From 81f7cc2efc1ef93053cce6146d11fb489958cec2 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Thu, 7 Dec 2023 14:26:42 -0800 Subject: [PATCH] Initial migration --- .github/CODEOWNERS | 1 + Cargo.toml | 17 ++- README.md | 59 ++++---- src/error.rs | 84 +++++++++++ src/fetcher.rs | 134 ++++++++++++++++++ src/lib.rs | 343 ++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 9 -- src/test.rs | 288 +++++++++++++++++++++++++++++++++++++ 8 files changed, 892 insertions(+), 43 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 src/error.rs create mode 100644 src/fetcher.rs delete mode 100644 src/main.rs create mode 100644 src/test.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..bc06713 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +@fossas/analysis diff --git a/Cargo.toml b/Cargo.toml index 6184e61..5f15a12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,21 @@ [package] -name = "template-rust" -version = "0.1.0" +name = "locator" +version = "1.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +getset = "0.1.2" +lazy_static = "1.4.0" +regex = "1.6.0" +serde = { version = "1.0.140", features = ["derive"] } +strum = { version = "0.24.1", features = ["derive"] } +thiserror = "1.0.31" +typed-builder = "0.10.0" + +[dev-dependencies] +assert_matches = "1.5.0" +itertools = "0.10.5" +proptest = "1.0.0" +serde_json = "1.0.95" diff --git a/README.md b/README.md index 9cbcc40..e2a7eb5 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,31 @@ -# template-rust - -Template repository for a Rust project. - -TODOs for a new project: -- [ ] Change the license if MPL2 is not appropriate for the project. Make sure to do this before adding any code. -- [ ] Set [CODEOWNERS] to the team that owns the repository. -- [ ] Create an API user in [FOSSA] and store it as a secret named `FOSSA_API_KEY`. - - Consider naming it with the pattern `ci-{REPO_NAME}`. For example, `ci-template-rust`. -- [ ] Update repository permissions as appropriate. Generally, the CODEOWNER team is set as admin. -- [ ] Update branch protection rules as appropriate. -- [ ] Update repository features and settings. Recommended defaults: - - [ ] Turn off all features (Wikis, Issues, Sponsorships, Discussions, Projects); FOSSA uses other systems for these. - - [ ] Only allow squash merging. - - [ ] Always suggest updating PR branches. - - [ ] Allow auto-merge. - - [ ] Automatically delete head branches. - -Then just edit the included Rust project, or remove it and `cargo init` your project, and get going! - -[codeowners]: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -[fossa]: https://app.fossa.com - -# recommendations - -- Prefer [cross compilation](./docs/dev/reference/cross-compile.md) over running workflows in distinct runners when possible. -- If publishing a Linux binary, consider providing two: one that [statically links libc](./docs/dev/reference/static-binary.md), and one that doesn't. -- If publishing a macOS binary, consider providing two: one for [Intel and one for M-series CPUs](./docs/dev/reference/macos-arch.md). +# locator + +This library provides the ability to parse and format "Locator" strings. +FOSSA uses locators to indicate specific libraries at specific versions. + +For more detail, FOSSA employees can reference the +[Fetchers & Locators doc](https://go/fetchers-doc). + +# Format + +Locators are in the following basic format: + +``` +{fetcher}+{package}${version} +``` + +There is some nuance to this. For more details, see the library documentation. + +## Example + +Some example locators: +``` +// The FOSSA CLI on GitHub, referencing the tag 'v3.8.24'. +git+github.com/fossas/fossa-cli$v3.8.24 + +// The 'lodash' library on NPM, referencing the version '4.17.21'. +npm+lodash$4.17.21 + +// The 'lodash' library on NPM without specifying a version. +npm+lodash +``` diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e2d3f0d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,84 @@ +use thiserror::Error; + +/// Records all errors reported by this library. +#[derive(Error, Clone, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum Error { + /// Errors encountered while parsing a [`Locator`](crate::Locator). + #[error(transparent)] + Parse(#[from] ParseError), +} + +/// Errors encountered when parsing a [`Locator`](crate::Locator) from a string. +#[derive(Error, Clone, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum ParseError { + /// The input did not match the required syntax. + #[error("input did not match required syntax: {input}")] + Syntax { + /// The input originally provided to the parser. + input: String, + }, + + /// The "named" field was missing from the input. + #[error("field '{field}' missing from input: {input}")] + Field { + /// The input originally provided to the parser. + input: String, + + /// The field that was missing. + field: String, + }, + + /// An unsupported value for the "fetcher" field was provided. + /// Often this means that it is simply missing from this package. + #[error("invalid fetcher '{fetcher}' in input '{input}'")] + Fetcher { + /// The input originally provided to the parser. + input: String, + + /// The fetcher that was attempted to parse. + fetcher: String, + + /// The error returned by the parser. + #[source] + error: strum::ParseError, + }, + + /// An unsupported value for the "project" field was provided. + #[error("invalid project '{project}' in input '{input}'")] + Project { + /// The input originally provided to the parser. + input: String, + + /// The project that was attempted to parse. + project: String, + + /// The error returned by the parser. + #[source] + error: ProjectParseError, + }, +} + +/// Errors encountered when parsing the project field +/// when parsing a [`Locator`](crate::Locator) from a string. +#[derive(Error, Clone, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum ProjectParseError { + /// An unsupported value for the "project" field was provided. + #[error("project did not match required syntax: {project}")] + Project { + /// The project input. + project: String, + }, + + /// The "named" field was missing from the input. + #[error("field '{field}' missing from input: {project}")] + Field { + /// The input originally provided to the parser. + project: String, + + /// The field that was missing. + field: String, + }, +} diff --git a/src/fetcher.rs b/src/fetcher.rs new file mode 100644 index 0000000..3e06624 --- /dev/null +++ b/src/fetcher.rs @@ -0,0 +1,134 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use strum::{AsRefStr, Display, EnumIter, EnumString}; + +/// [`Locator`](crate::Locator) is closely tied with the concept of Core's "fetchers", +/// which are asynchronous jobs tasked with downloading the code +/// referred to by a [`Locator`](crate::Locator) so that Core or some other service +/// may analyze it. +/// +/// For more information on the background of `Locator` and fetchers generally, +/// refer to [Fetchers and Locators](https://go/fetchers-doc). +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Display, EnumString, EnumIter, AsRefStr)] +#[non_exhaustive] +pub enum Fetcher { + /// Archive locators are FOSSA specific. + #[strum(serialize = "archive")] + Archive, + + /// Interacts with Bower. + #[strum(serialize = "bower")] + Bower, + + /// Interacts with Carthage. + #[strum(serialize = "cart")] + Cart, + + /// Interacts with Cargo. + #[strum(serialize = "cargo")] + Cargo, + + /// Interacts with Composer. + #[strum(serialize = "comp")] + Comp, + + /// Interacts with Conan. + #[strum(serialize = "conan")] + Conan, + + /// Interacts with Conda. + #[strum(serialize = "conda")] + Conda, + + /// Interacts with CPAN. + #[strum(serialize = "cpan")] + Cpan, + + /// Interacts with CRAN. + #[strum(serialize = "cran")] + Cran, + + /// The `custom` fetcher describes first party projects in FOSSA. + /// + /// These projects aren't really _fetched_; + /// they're stored in FOSSA's database. + #[strum(serialize = "custom")] + Custom, + + /// Interacts with RubyGems. + #[strum(serialize = "gem")] + Gem, + + /// Interacts with git VCS hosts. + #[strum(serialize = "git")] + Git, + + /// Resolves 'git' dependencies in the same manner as Go modules. + #[strum(serialize = "go")] + Go, + + /// Interacts with Hackage. + #[strum(serialize = "hackage")] + Hackage, + + /// Interacts with Hex. + #[strum(serialize = "hex")] + Hex, + + /// Interacts with Maven. + #[strum(serialize = "mvn")] + Maven, + + /// Interacts with NPM. + #[strum(serialize = "npm")] + Npm, + + /// Interacts with Nuget. + #[strum(serialize = "nuget")] + Nuget, + + /// Interacts with PyPI. + #[strum(serialize = "pip")] + Pip, + + /// Interacts with CocoaPods. + #[strum(serialize = "pod")] + Pod, + + /// Interacts with Dart's package manager. + #[strum(serialize = "pub")] + Pub, + + /// Interact with Swift's package manager. + #[strum(serialize = "swift")] + Swift, + + /// Specifies an arbitrary URL, + /// which is downloaded and treated like an `Archive` variant. + #[strum(serialize = "url")] + Url, + + /// A user-specified package. + #[strum(serialize = "user")] + User, +} + +impl<'de> Deserialize<'de> for Fetcher { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Fetcher::from_str(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for Fetcher { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} diff --git a/src/lib.rs b/src/lib.rs index 4ee70a2..979bcaf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,340 @@ -#[cfg(test)] -mod tests { - #[test] - fn lib_works() {} +#![doc = include_str!("../README.md")] +#![deny(unsafe_code)] +#![deny(missing_docs)] +#![warn(rust_2018_idioms)] + +use std::fmt::Display; + +use getset::{CopyGetters, Getters}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use typed_builder::TypedBuilder; + +mod error; +mod fetcher; +pub use error::*; +pub use fetcher::*; + +/// Core, and most services that interact with Core, +/// refer to open source packages via the `Locator` type. +/// +/// This type is nearly universally rendered to a string +/// before being serialized to the database or sent over the network. +/// +/// This type represents a _validly-constructed_ `Locator`, but does not +/// validate whether a `Locator` is actually valid. This means that a +/// given `Locator` is guaranteed to be correctly formatted data, +/// but that the actual repository or revision to which the `Locator` +/// refers is _not_ guaranteed to exist or be accessible. +/// Currently the canonical method for validating whether a given `Locator` is +/// accessible is to run it through the Core fetcher system. +/// +/// For more information on the background of `Locator` and fetchers generally, +/// FOSSA employees may refer to +/// [Fetchers and Locators](https://go/fetchers-doc). +#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder, Getters, CopyGetters)] +pub struct Locator { + /// Determines which fetcher is used to download this project. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this project is namespaced. + #[builder(default, setter(strip_option))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{user_name}/{project_name}`. + #[builder(setter(transform = |project: impl ToString| project.to_string()))] + #[getset(get = "pub")] + project: String, + + /// Specifies the version for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{git_sha}` or `{git_tag}`, + /// and the fetcher disambiguates. + #[builder(default, setter(transform = |revision: impl ToString| Some(revision.to_string())))] + #[getset(get = "pub")] + revision: Option, +} + +impl Locator { + /// Parse a `Locator`. + /// + /// The input string must be in one of the following forms: + /// - `{fetcher}+{project}` + /// - `{fetcher}+{project}$` + /// - `{fetcher}+{project}${revision}` + /// + /// Projects may also be namespaced to a specific organization; + /// in such cases the organization ID is at the start of the `{project}` field + /// separated by a slash. The ID can be any non-negative integer. + /// This yields the following formats: + /// - `{fetcher}+{org_id}/{project}` + /// - `{fetcher}+{org_id}/{project}$` + /// - `{fetcher}+{org_id}/{project}${revision}` + /// + /// This parse function is based on the function used in FOSSA Core for maximal compatibility. + pub fn parse(locator: &str) -> Result { + lazy_static! { + static ref RE: Regex = Regex::new( + r"^(?:(?P[a-z-]+)\+|)(?P[^$]+)(?:\$|)(?P.+|)$" + ) + .expect("Locator parsing expression must compile"); + } + + let mut captures = RE.captures_iter(locator); + let capture = captures.next().ok_or_else(|| ParseError::Syntax { + input: locator.to_string(), + })?; + + let fetcher = + capture + .name("fetcher") + .map(|m| m.as_str()) + .ok_or_else(|| ParseError::Field { + input: locator.to_owned(), + field: "fetcher".to_string(), + })?; + + let fetcher = Fetcher::try_from(fetcher).map_err(|error| ParseError::Fetcher { + input: locator.to_owned(), + fetcher: fetcher.to_string(), + error, + })?; + + let project = capture + .name("project") + .map(|m| m.as_str().to_owned()) + .ok_or_else(|| ParseError::Field { + input: locator.to_owned(), + field: "project".to_string(), + })?; + + let revision = capture.name("revision").map(|m| m.as_str()).and_then(|s| { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }); + + match parse_org_project(&project) { + Ok((org_id @ Some(_), project)) => Ok(Locator { + fetcher, + org_id, + project: String::from(project), + revision, + }), + Ok((org_id @ None, _)) => Ok(Locator { + fetcher, + org_id, + project, + revision, + }), + Err(error) => Err(Error::Parse(ParseError::Project { + input: locator.to_owned(), + project, + error, + })), + } + } + + /// Converts the locator into a [`PackageLocator`] by discarding the `revision` component. + /// Equivalent to the `From` implementation, but offered as a method for convenience. + pub fn into_package(self) -> PackageLocator { + self.into() + } +} + +impl Display for Locator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fetcher = &self.fetcher; + write!(f, "{fetcher}+")?; + + let project = &self.project; + if let Some(org_id) = &self.org_id { + write!(f, "{org_id}/")?; + } + write!(f, "{project}")?; + + if let Some(revision) = &self.revision { + write!(f, "${revision}")?; + } + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for Locator { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Locator::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for Locator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +/// A [`Locator`] specialized to not include the `revision` component. +/// +/// Any [`Locator`] may be converted to a `PackageLocator` by simply discarding the `revision` component. +/// To create a [`Locator`] from a `PackageLocator`, the value for `revision` must be provided; see [`Locator`] for details. +#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder)] +pub struct PackageLocator { + /// Determines which fetcher is used to download this dependency + /// from the internet. + fetcher: Fetcher, + + /// Specifies the organization ID to which this project is namespaced. + org_id: Option, + + /// Specifies the unique identifier for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{user_name}/{project_name}`. + #[builder(setter(transform = |project: impl ToString| project.to_string()))] + project: String, +} + +impl PackageLocator { + /// Parse a `PackageLocator`. + /// + /// The input string must be in one of the following forms: + /// - `{fetcher}+{project}` + /// - `{fetcher}+{project}$` + /// - `{fetcher}+{project}${revision}` + /// + /// Projects may also be namespaced to a specific organization; + /// in such cases the organization ID is at the start of the `{project}` field + /// separated by a slash. The ID can be any non-negative integer. + /// This yields the following formats: + /// - `{fetcher}+{org_id}/{project}` + /// - `{fetcher}+{org_id}/{project}$` + /// - `{fetcher}+{org_id}/{project}${revision}` + /// + /// This parse function is based on the function used in FOSSA Core for maximal compatibility. + /// + /// This implementation ignores the `revision` segment if it exists. If this is not preferred, use [`Locator`] instead. + pub fn parse(locator: &str) -> Result { + let full = Locator::parse(locator)?; + Ok(Self { + fetcher: full.fetcher, + org_id: full.org_id, + project: full.project, + }) + } + + /// Promote a `PackageLocator` to a [`Locator`] by providing the value to use for the `revision` component. + pub fn promote(self, revision: Option) -> Locator { + Locator { + fetcher: self.fetcher, + org_id: self.org_id, + project: self.project, + revision, + } + } +} + +impl Display for PackageLocator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let converted = Locator::from(self); + write!(f, "{converted}") + } } + +impl<'de> Deserialize<'de> for PackageLocator { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + PackageLocator::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PackageLocator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl From for PackageLocator { + fn from(full: Locator) -> Self { + Self { + fetcher: full.fetcher, + org_id: full.org_id, + project: full.project, + } + } +} + +impl From for Locator { + fn from(package: PackageLocator) -> Self { + Self { + fetcher: package.fetcher, + org_id: package.org_id, + project: package.project, + revision: None, + } + } +} + +impl From<&PackageLocator> for Locator { + fn from(package: &PackageLocator) -> Self { + package.clone().into() + } +} + +/// Optionally parse an org ID and trimmed project out of a project string. +fn parse_org_project(project: &str) -> Result<(Option, &str), ProjectParseError> { + lazy_static! { + static ref RE: Regex = Regex::new(r"^(?:(?P\d+)/)?(?P.+)") + .expect("Project parsing expression must compile"); + } + + let mut captures = RE.captures_iter(project); + let capture = captures.next().ok_or_else(|| ProjectParseError::Project { + project: project.to_string(), + })?; + + let trimmed_project = + capture + .name("project") + .map(|m| m.as_str()) + .ok_or_else(|| ProjectParseError::Field { + project: project.to_string(), + field: String::from("project"), + })?; + + // If we fail to parse the org_id as a valid number, don't fail the overall parse; + // just don't namespace to org ID and return the input unmodified. + match capture.name("org_id").map(|m| m.as_str()).map(str::parse) { + // An org ID was provided and validly parsed, use it. + Some(Ok(org_id)) => Ok((Some(org_id), trimmed_project)), + + // Otherwise, if we either didn't get an org ID section, + // or it wasn't a valid org ID, + // just use the project as-is. + _ => Ok((None, project)), + } +} + +#[cfg(test)] +mod test; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index ae16713..0000000 --- a/src/main.rs +++ /dev/null @@ -1,9 +0,0 @@ -fn main() { - println!("Hello, world!"); -} - -#[cfg(test)] -mod tests { - #[test] - fn bin_works() {} -} diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..6148279 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,288 @@ +use assert_matches::assert_matches; +use itertools::izip; +use proptest::prelude::*; +use serde::Deserialize; +use strum::IntoEnumIterator; + +use super::*; + +#[test] +fn parse_render_successful() { + let input = "git+github.com/foo/bar"; + let parsed = Locator::parse(input).expect("must parse locator"); + let expected = Locator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + + let input = "git+github.com/foo/bar"; + let parsed = Locator::parse(input).expect("must parse locator"); + let expected = Locator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + + let input = "git+github.com/foo/bar$abcd"; + let parsed = Locator::parse(input).expect("must parse locator"); + let expected = Locator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .revision("abcd") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); +} + +#[test] +fn parse_invalid_fetcher() { + let input = "foo+github.com/foo/bar"; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); +} + +#[test] +fn parse_missing_project() { + let input = "git+"; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); +} + +#[test] +fn parse_invalid_syntax() { + let input = ""; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); + + let input = "git+$"; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); +} + +#[test] +fn parse_with_org() { + let fetchers = Fetcher::iter().map(|fetcher| format!("{fetcher}")); + let orgs = [0usize, 1, 1234, 2385028, 19847938492847928]; + let projects = ["github.com/foo/bar", "some-name"]; + let revisions = ["", "$", "$1", "$1234abcd1234"]; + + for (fetcher, org, project, revision) in izip!(fetchers, orgs, projects, revisions) { + let input = format!("{fetcher}+{org}/{project}{revision}"); + let Ok(parsed) = Locator::parse(&input) else { + panic!("must parse '{input}'") + }; + + assert_eq!( + parsed.fetcher().to_string(), + fetcher, + "'fetcher' in '{input}' must match" + ); + assert_eq!( + parsed.org_id(), + Some(org), + "'org_id' in '{input}' must match" + ); + assert_eq!( + parsed.project().as_str(), + project, + "'project' in '{input}' must match" + ); + + let revision = if revision.is_empty() || revision == "$" { + None + } else { + Some(revision) + }; + assert_eq!( + parsed.revision().as_ref().map(|r| r.as_str()), + revision, + "'revision' in '{input}' must match", + ); + } +} + +#[test] +fn render_with_org() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .org_id(1234) + .project("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+1234/foo/bar$123abc", rendered); + + let package_only = locator.into_package(); + let rendered = package_only.to_string(); + assert_eq!("custom+1234/foo/bar", rendered); +} + +#[test] +fn render_with_revision() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar$123abc", rendered); + + let package_only = locator.into_package(); + let rendered = package_only.to_string(); + assert_eq!("custom+foo/bar", rendered); +} + +#[test] +fn render_project() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo/bar") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar", rendered); + + let package_only = locator.into_package(); + let rendered = package_only.to_string(); + assert_eq!("custom+foo/bar", rendered); +} + +#[test] +fn roundtrip_serialization() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + + let serialized = serde_json::to_string(&input).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(input, deserialized); +} + +#[test] +fn serde_deserialization() { + #[derive(Debug, Deserialize, PartialEq)] + struct Test { + locator: Locator, + } + + let input = r#"{ "locator": "custom+1/foo$bar" }"#; + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + let expected = Test { locator: expected }; + + let deserialized = serde_json::from_str(input).expect("must deserialize"); + assert_eq!(expected, deserialized, "deserialize {input}"); +} + +/// Regular expression that matches any unicode string that is: +/// - Prefixed with `git+` +/// - Contains at least one character that is not a control character and not the literal `$` +/// - Contains a literal `$` +/// - Contains at least one character that is not a control character and not the literal `$` +const VALID_INPUTS_GIT: &str = r"git\+[^\pC$]+\$[^\pC$]+"; + +proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_git(input in VALID_INPUTS_GIT) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } +} + +/// Regular expression that matches any unicode string that is: +/// - Prefixed with `git+` +/// - Contains a literal `/` +/// - Contains zero or more digits +/// - Contains a literal `/` +/// - Contains at least one character that is not a control character and not the literal `$` +/// - Contains a literal `$` +/// - Contains at least one character that is not a control character and not the literal `$` +const VALID_INPUTS_GIT_WITH_ORG: &str = r"git\+/\d*/[^\pC$]+\$[^\pC$]+"; + +proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_git_with_org(input in VALID_INPUTS_GIT_WITH_ORG) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } +} + +/// Regular expression that matches any unicode string that is: +/// - Prefixed with `custom+` +/// - Contains at least one character that is not a control character and not the literal `$` +/// - Contains a literal `$` +/// - Contains at least one character that is not a control character and not the literal `$` +const VALID_INPUTS_CUSTOM: &str = r"custom\+[^\pC$]+\$[^\pC$]+"; + +proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_custom(input in VALID_INPUTS_CUSTOM) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } +} + +/// Regular expression that matches any unicode string that is: +/// - Prefixed with `custom+` +/// - Contains a literal `/` +/// - Contains zero or more digits +/// - Contains a literal `/` +/// - Contains at least one character that is not a control character and not the literal `$` +/// - Contains a literal `$` +/// - Contains at least one character that is not a control character and not the literal `$` +const VALID_INPUTS_CUSTOM_WITH_ORG: &str = r"custom\+/\d*/[^\pC$]+\$[^\pC$]+"; + +proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_custom_with_org(input in VALID_INPUTS_CUSTOM_WITH_ORG) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } +} + +#[test] +fn parses_org_project() { + let orgs = [0usize, 1, 9809572]; + let names = ["name", "name/foo"]; + + for (org, name) in izip!(orgs, names) { + let test = format!("{org}/{name}"); + let Ok((Some(org_id), project)) = parse_org_project(&test) else { + panic!("must parse '{test}'") + }; + assert_eq!(org_id, org, "'org_id' must match in '{test}'"); + assert_eq!(project, name, "'project' must match in '{test}"); + } +} + +#[test] +fn parses_org_project_no_org() { + let names = ["/name/foo", "/name", "abcd/1234/name", "1abc2/name"]; + for test in names { + let Ok((org_id, project)) = parse_org_project(test) else { + panic!("must parse '{test}'") + }; + assert_eq!(org_id, None, "'org_id' must be None in '{test}'"); + assert_eq!(project, test, "'project' must match in '{test}"); + } +}