From f0d2a577057671cf3f37d88b215392ed4b4c6c12 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Wed, 5 Jun 2024 15:27:56 -0700 Subject: [PATCH] Grab bag refactor (#5) --- Cargo.toml | 10 +- src/error.rs | 30 +- src/fetcher.rs | 134 -------- src/lib.rs | 546 ++++++++++++++++--------------- src/locator.rs | 708 +++++++++++++++++++++++++++++++++++++++++ src/locator_package.rs | 420 ++++++++++++++++++++++++ src/locator_strict.rs | 408 ++++++++++++++++++++++++ src/test.rs | 288 ----------------- 8 files changed, 1848 insertions(+), 696 deletions(-) delete mode 100644 src/fetcher.rs create mode 100644 src/locator.rs create mode 100644 src/locator_package.rs create mode 100644 src/locator_strict.rs delete mode 100644 src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 5f15a12..13ea3de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,25 @@ [package] name = "locator" -version = "1.0.0" +version = "2.0.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +alphanumeric-sort = "1.5.3" getset = "0.1.2" lazy_static = "1.4.0" +pretty_assertions = "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" +utoipa = "4.2.3" +serde_json = "1.0.95" +documented = "0.4.1" +semver = "1.0.23" [dev-dependencies] assert_matches = "1.5.0" itertools = "0.10.5" proptest = "1.0.0" -serde_json = "1.0.95" diff --git a/src/error.rs b/src/error.rs index e2d3f0d..6cb1d85 100644 --- a/src/error.rs +++ b/src/error.rs @@ -45,38 +45,38 @@ pub enum ParseError { error: strum::ParseError, }, - /// An unsupported value for the "project" field was provided. - #[error("invalid project '{project}' in input '{input}'")] - Project { + /// An unsupported value for the "package" field was provided. + #[error("invalid package '{package}' in input '{input}'")] + Package { /// The input originally provided to the parser. input: String, - /// The project that was attempted to parse. - project: String, + /// The package that was attempted to parse. + package: String, /// The error returned by the parser. #[source] - error: ProjectParseError, + error: PackageParseError, }, } -/// Errors encountered when parsing the project field +/// Errors encountered when parsing the package 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, +pub enum PackageParseError { + /// An unsupported value for the "package" field was provided. + #[error("package did not match required syntax: {package}")] + Package { + /// The package input. + package: String, }, /// The "named" field was missing from the input. - #[error("field '{field}' missing from input: {project}")] + #[error("field '{field}' missing from input: {package}")] Field { /// The input originally provided to the parser. - project: String, + package: String, /// The field that was missing. field: String, diff --git a/src/fetcher.rs b/src/fetcher.rs deleted file mode 100644 index 3e06624..0000000 --- a/src/fetcher.rs +++ /dev/null @@ -1,134 +0,0 @@ -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 979bcaf..9ca1f33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,338 +3,372 @@ #![deny(missing_docs)] #![warn(rust_2018_idioms)] -use std::fmt::Display; +use std::{borrow::Cow, str::FromStr}; -use getset::{CopyGetters, Getters}; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; -use typed_builder::TypedBuilder; +use strum::{AsRefStr, Display, EnumIter, EnumString}; +use utoipa::ToSchema; mod error; -mod fetcher; +mod locator; +mod locator_package; +mod locator_strict; + 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. +pub use locator::*; +pub use locator_package::*; +pub use locator_strict::*; + +/// [`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, -/// 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. +/// refer to [Fetchers and Locators](https://go/fetchers-doc). +#[derive( + Copy, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Debug, + Display, + EnumString, + EnumIter, + AsRefStr, + Serialize, + Deserialize, + ToSchema, +)] +#[non_exhaustive] +#[serde(rename_all = "snake_case")] +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. /// - /// 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, + /// 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 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"); - } +/// Identifies the organization to which this locator is namespaced. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct OrgId(usize); - 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(), - })?; +impl TryFrom<&str> for OrgId { + type Error = ::Err; - 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, - })), - } + fn try_from(value: &str) -> Result { + Ok(OrgId(value.parse()?)) } +} - /// 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 std::fmt::Display for OrgId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) } } -impl Display for Locator { +impl std::fmt::Debug for OrgId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let fetcher = &self.fetcher; - write!(f, "{fetcher}+")?; + write!(f, "{self}") + } +} - let project = &self.project; - if let Some(org_id) = &self.org_id { - write!(f, "{org_id}/")?; - } - write!(f, "{project}")?; +/// The package section of the locator. +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct Package(String); - if let Some(revision) = &self.revision { - write!(f, "${revision}")?; - } +impl Package { + /// View the item as a string. + pub fn as_str(&self) -> &str { + &self.0 + } +} - Ok(()) +impl From for Package { + fn from(value: String) -> Self { + Self(value) } } -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 From<&str> for Package { + fn from(value: &str) -> Self { + Self::from(value.to_string()) } } -impl Serialize for Locator { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.to_string().serialize(serializer) +impl std::fmt::Display for Package { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) } } -/// 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 std::fmt::Debug for Package { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self}") + } } -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, - }) +impl std::cmp::Ord for Package { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + alphanumeric_sort::compare_str(&self.0, &other.0) } +} - /// 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 std::cmp::PartialOrd for Package { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } -impl Display for PackageLocator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let converted = Locator::from(self); - write!(f, "{converted}") +/// The revision section of the locator. +#[derive(Clone, Eq, PartialEq, Hash)] +pub enum Revision { + /// The revision is valid semver. + Semver(semver::Version), + + /// The revision is an opaque string. + Opaque(String), +} + +impl Revision { + /// View the item as a string. + pub fn as_str(&self) -> Cow<'_, str> { + match self { + Revision::Semver(v) => Cow::Owned(v.to_string()), + Revision::Opaque(v) => Cow::Borrowed(v), + } } } -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 From for Revision { + fn from(value: String) -> Self { + match semver::Version::parse(&value) { + Ok(v) => Self::Semver(v), + Err(_) => Self::Opaque(value), + } } } -impl Serialize for PackageLocator { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.to_string().serialize(serializer) +impl From<&str> for Revision { + fn from(value: &str) -> Self { + Self::from(value.to_string()) } } -impl From for PackageLocator { - fn from(full: Locator) -> Self { - Self { - fetcher: full.fetcher, - org_id: full.org_id, - project: full.project, +impl std::fmt::Display for Revision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Revision::Semver(v) => write!(f, "{v}"), + Revision::Opaque(v) => write!(f, "{v}"), } } } -impl From for Locator { - fn from(package: PackageLocator) -> Self { - Self { - fetcher: package.fetcher, - org_id: package.org_id, - project: package.project, - revision: None, +impl std::fmt::Debug for Revision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self}") + } +} + +impl std::cmp::Ord for Revision { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + let cmp = alphanumeric_sort::compare_str; + match (self, other) { + (Revision::Semver(a), Revision::Semver(b)) => a.cmp(b), + (Revision::Semver(a), Revision::Opaque(b)) => cmp(&a.to_string(), b), + (Revision::Opaque(a), Revision::Semver(b)) => cmp(a, &b.to_string()), + (Revision::Opaque(a), Revision::Opaque(b)) => cmp(a, b), } } } -impl From<&PackageLocator> for Locator { - fn from(package: &PackageLocator) -> Self { - package.clone().into() +impl std::cmp::PartialOrd for Revision { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } -/// Optionally parse an org ID and trimmed project out of a project string. -fn parse_org_project(project: &str) -> Result<(Option, &str), ProjectParseError> { +/// Optionally parse an org ID and trimmed package out of a package string. +fn parse_org_package(package: &str) -> Result<(Option, Package), PackageParseError> { lazy_static! { - static ref RE: Regex = Regex::new(r"^(?:(?P\d+)/)?(?P.+)") - .expect("Project parsing expression must compile"); + static ref RE: Regex = Regex::new(r"^(?:(?P\d+)/)?(?P.+)") + .expect("Package 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 mut captures = RE.captures_iter(package); + let capture = captures.next().ok_or_else(|| PackageParseError::Package { + package: package.to_string(), })?; - let trimmed_project = + let trimmed_package = capture - .name("project") + .name("package") .map(|m| m.as_str()) - .ok_or_else(|| ProjectParseError::Field { - project: project.to_string(), - field: String::from("project"), + .ok_or_else(|| PackageParseError::Field { + package: package.to_string(), + field: String::from("package"), })?; // 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) { + match capture + .name("org_id") + .map(|m| m.as_str()) + .map(OrgId::try_from) + { // An org ID was provided and validly parsed, use it. - Some(Ok(org_id)) => Ok((Some(org_id), trimmed_project)), + Some(Ok(org_id)) => Ok((Some(org_id), Package::from(trimmed_package))), // 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)), + // just use the package as-is. + _ => Ok((None, Package::from(package))), } } #[cfg(test)] -mod test; +mod tests { + use itertools::izip; + + use super::*; + + impl Package { + fn new(value: &str) -> Self { + Self(value.to_string()) + } + } + + #[test] + fn parses_org_package() { + let orgs = [OrgId(0usize), OrgId(1), OrgId(9809572)]; + let names = [Package::new("name"), Package::new("name/foo")]; + + for (org, name) in izip!(orgs, names) { + let test = format!("{org}/{name}"); + let Ok((Some(org_id), package)) = parse_org_package(&test) else { + panic!("must parse '{test}'") + }; + assert_eq!(org_id, org, "'org_id' must match in '{test}'"); + assert_eq!(package, name, "'package' must match in '{test}"); + } + } + + #[test] + fn parses_org_package_no_org() { + let names = [ + Package::new("/name/foo"), + Package::new("/name"), + Package::new("abcd/1234/name"), + Package::new("1abc2/name"), + ]; + for test in names { + let input = &format!("{test}"); + let Ok((org_id, package)) = parse_org_package(input) else { + panic!("must parse '{test}'") + }; + assert_eq!(org_id, None, "'org_id' must be None in '{test}'"); + assert_eq!(package, test, "'package' must match in '{test}"); + } + } +} diff --git a/src/locator.rs b/src/locator.rs new file mode 100644 index 0000000..420e49d --- /dev/null +++ b/src/locator.rs @@ -0,0 +1,708 @@ +use std::fmt::Display; + +use documented::Documented; +use getset::{CopyGetters, Getters}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use typed_builder::TypedBuilder; +use utoipa::{ + openapi::{ObjectBuilder, SchemaType}, + ToSchema, +}; + +use crate::{ + parse_org_package, Error, Fetcher, OrgId, Package, PackageLocator, ParseError, Revision, + StrictLocator, +}; + +/// 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). +/// +/// ## Ordering +/// +/// Locators order by: +/// 1. Fetcher, alphanumerically. +/// 2. Organization ID, alphanumerically; missing organizations are sorted higher. +/// 3. The package field, alphanumerically. +/// 4. The revision field: +/// If both comparing locators use semver, these are compared using semver rules; +/// otherwise these are compared alphanumerically. +/// Missing revisions are sorted higher. +/// +/// Importantly, there may be other metrics for ordering using the actual code host +/// which contains the package (for example, ordering by release date). +/// This library does not perform such ordering. +/// +/// ## Parsing +/// +/// The input string must be in one of the following forms: +/// - `{fetcher}+{package}` +/// - `{fetcher}+{package}$` +/// - `{fetcher}+{package}${revision}` +/// +/// Packages may also be namespaced to a specific organization; +/// in such cases the organization ID is at the start of the `{package}` field +/// separated by a slash. The ID can be any non-negative integer. +/// This yields the following formats: +/// - `{fetcher}+{org_id}/{package}` +/// - `{fetcher}+{org_id}/{package}$` +/// - `{fetcher}+{org_id}/{package}${revision}` +/// +/// This parse function is based on the function used in FOSSA Core for maximal compatibility. +#[derive( + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Debug, + TypedBuilder, + Getters, + CopyGetters, + Documented, +)] +pub struct Locator { + /// Determines which fetcher is used to download this package. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this package is namespaced. + #[builder(default, setter(transform = |id: usize| Some(OrgId(id))))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the package by fetcher. + /// + /// For example, the `git` fetcher fetching a github package + /// uses a value in the form of `{user_name}/{package_name}`. + #[builder(setter(transform = |package: impl ToString| Package(package.to_string())))] + #[getset(get = "pub")] + package: Package, + + /// Specifies the version for the package by fetcher. + /// + /// For example, the `git` fetcher fetching a github package + /// 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::from(revision.to_string()))))] + #[getset(get = "pub")] + revision: Option, +} + +impl Locator { + /// Parse a `Locator`. + /// For details, see the parsing section on [`Locator`]. + 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 package = capture + .name("package") + .map(|m| m.as_str().to_owned()) + .ok_or_else(|| ParseError::Field { + input: locator.to_owned(), + field: "package".to_string(), + })?; + + let revision = capture.name("revision").map(|m| m.as_str()).and_then(|s| { + if s.is_empty() { + None + } else { + Some(Revision::from(s)) + } + }); + + match parse_org_package(&package) { + Ok((org_id @ Some(_), package)) => Ok(Locator { + fetcher, + org_id, + package, + revision, + }), + Ok((org_id @ None, _)) => Ok(Locator { + fetcher, + org_id, + package: Package::from(package.as_str()), + revision, + }), + Err(error) => Err(Error::Parse(ParseError::Package { + input: locator.to_owned(), + package, + error, + })), + } + } + + /// Promote a `Locator` to a [`StrictLocator`] by providing the default value to use + /// for the `revision` component, if one is not specified in the locator already. + /// + /// The `ToString` implementation is lazily evaluated if the locator doesn't already contain a revision. + pub fn promote_strict(self, revision: impl ToString) -> StrictLocator { + let locator = StrictLocator::builder() + .fetcher(self.fetcher) + .package(self.package) + .revision( + self.revision + .unwrap_or_else(|| Revision::from(revision.to_string())), + ); + + match self.org_id { + None => locator.build(), + Some(OrgId(id)) => locator.org_id(id).build(), + } + } + + /// Promote a `Locator` to a [`StrictLocator`] by providing the default value to use + /// for the `revision` component, if one is not specified in the locator already. + /// + /// The revision is lazily evaluated if the locator doesn't already contain a revision. + pub fn promote_strict_with String>(self, revision: F) -> StrictLocator { + let locator = StrictLocator::builder() + .fetcher(self.fetcher) + .package(self.package) + .revision(self.revision.unwrap_or_else(|| Revision::from(revision()))); + + match self.org_id { + None => locator.build(), + Some(OrgId(id)) => locator.org_id(id).build(), + } + } + + /// 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() + } + + /// Explodes the locator into its (owned) parts. + /// Used for conversions without cloning. + pub(crate) fn explode(self) -> (Fetcher, Option, Package, Option) { + (self.fetcher, self.org_id, self.package, self.revision) + } +} + +impl Display for Locator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fetcher = &self.fetcher; + write!(f, "{fetcher}+")?; + + let package = &self.package; + if let Some(org_id) = &self.org_id { + write!(f, "{org_id}/")?; + } + write!(f, "{package}")?; + + 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) + } +} + +impl<'a> ToSchema<'a> for Locator { + fn schema() -> ( + &'a str, + utoipa::openapi::RefOr, + ) { + ( + "Locator", + ObjectBuilder::new() + .description(Some(Self::DOCS)) + .example(Some(json!("git+github.com/fossas/example$1234"))) + .min_length(Some(3)) + .schema_type(SchemaType::String) + .build() + .into(), + ) + } +} + +impl From for Locator { + fn from(package: PackageLocator) -> Self { + let (fetcher, org_id, package) = package.explode(); + Self { + fetcher, + org_id, + package, + revision: None, + } + } +} + +impl From<&PackageLocator> for Locator { + fn from(package: &PackageLocator) -> Self { + Self { + fetcher: package.fetcher(), + org_id: package.org_id(), + package: package.package().clone(), + revision: None, + } + } +} + +impl From for Locator { + fn from(strict: StrictLocator) -> Self { + let (fetcher, org_id, package, revision) = strict.explode(); + Self { + fetcher, + org_id, + package, + revision: Some(revision), + } + } +} + +impl From<&StrictLocator> for Locator { + fn from(strict: &StrictLocator) -> Self { + Self { + fetcher: strict.fetcher(), + org_id: strict.org_id(), + package: strict.package().clone(), + revision: Some(strict.revision().clone()), + } + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use assert_matches::assert_matches; + use itertools::{izip, Itertools}; + use pretty_assertions::assert_eq; + 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) + .package("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) + .package("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_package() { + 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 = [ + OrgId(0usize), + OrgId(1), + OrgId(1234), + OrgId(2385028), + OrgId(19847938492847928), + ]; + let packages = ["github.com/foo/bar", "some-name"]; + let revisions = ["", "$", "$1", "$1234abcd1234"]; + + for (fetcher, org, package, revision) in izip!(fetchers, orgs, packages, revisions) { + let input = format!("{fetcher}+{org}/{package}{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.package().as_str(), + package, + "'package' in '{input}' must match" + ); + + let revision = if revision.is_empty() || revision == "$" { + None + } else { + Some(Cow::Borrowed(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) + .package("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) + .package("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar$123abc", rendered); + } + + #[test] + fn render_package() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo/bar") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar", rendered); + } + + #[test] + fn roundtrip_serialization() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .package("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) + .package("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}"); + } + + #[test] + fn demotes() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("abcd") + .build(); + + let expected = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + let demoted = input.clone().into_package(); + assert_eq!(expected, demoted, "demote {input}"); + } + + #[test] + fn promotes_strict() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("bar") + .build(); + let promoted = input.clone().promote_strict("bar"); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn promotes_strict_existing() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .revision("1234") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("1234") + .build(); + + let promoted = input.clone().promote_strict("bar"); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn promotes_strict_existing_function() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("bar") + .build(); + + let promoted = input.clone().promote_strict_with(|| String::from("bar")); + assert_eq!(expected, promoted, "promote {input}"); + } + #[test] + fn promotes_strict_existing_lazy() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .revision("1234") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("1234") + .build(); + + let promoted = input + .clone() + .promote_strict_with(|| panic!("should not be called")); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn ordering() { + let locators = vec![ + "git+github.com/foo/bar", + "git+github.com/foo/bar$1234", + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + "custom+2/bam", + ] + .into_iter() + .map(Locator::parse) + .collect::, _>>() + .expect("must parse locators"); + + let expected = vec![ + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam", + "custom+2/bam$1234", + "git+github.com/foo/bar", + "git+github.com/foo/bar$1234", + ]; + let sorted = locators + .iter() + .sorted() + .map(Locator::to_string) + .collect_vec(); + assert_eq!(expected, sorted, "sort {locators:?}"); + } + + /// 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 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 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); + } + } +} diff --git a/src/locator_package.rs b/src/locator_package.rs new file mode 100644 index 0000000..310023d --- /dev/null +++ b/src/locator_package.rs @@ -0,0 +1,420 @@ +use std::fmt::Display; + +use documented::Documented; +use getset::{CopyGetters, Getters}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use typed_builder::TypedBuilder; +use utoipa::{ + openapi::{ObjectBuilder, SchemaType}, + ToSchema, +}; + +use crate::{Error, Fetcher, Locator, OrgId, Package, StrictLocator}; + +/// 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. +/// +/// ## Ordering +/// +/// Locators order by: +/// 1. Fetcher, alphanumerically. +/// 2. Organization ID, alphanumerically; missing organizations are sorted higher. +/// 3. The package field, alphanumerically. +/// +/// Importantly, there may be other metrics for ordering using the actual code host +/// which contains the package (for example, ordering by release date). +/// This library does not perform such ordering. +/// +/// ## Parsing +/// +/// The input string must be in one of the following forms: +/// - `{fetcher}+{package}` +/// - `{fetcher}+{package}$` +/// - `{fetcher}+{package}${revision}` +/// +/// Packages may also be namespaced to a specific organization; +/// in such cases the organization ID is at the start of the `{package}` field +/// separated by a slash. The ID can be any non-negative integer. +/// This yields the following formats: +/// - `{fetcher}+{org_id}/{package}` +/// - `{fetcher}+{org_id}/{package}$` +/// - `{fetcher}+{org_id}/{package}${revision}` +/// +/// This implementation ignores the `revision` segment if it exists. If this is not preferred, use [`Locator`] instead. +#[derive( + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Debug, + TypedBuilder, + Getters, + CopyGetters, + Documented, +)] +pub struct PackageLocator { + /// Determines which fetcher is used to download this package. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this package is namespaced. + #[builder(default, setter(transform = |id: usize| Some(OrgId(id))))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the package by fetcher. + /// + /// For example, the `git` fetcher fetching a github package + /// uses a value in the form of `{user_name}/{package_name}`. + #[builder(setter(transform = |package: impl ToString| Package(package.to_string())))] + #[getset(get = "pub")] + package: Package, +} + +impl PackageLocator { + /// Parse a `PackageLocator`. + /// For details, see the parsing section on [`PackageLocator`]. + pub fn parse(locator: &str) -> Result { + let full = Locator::parse(locator)?; + Ok(full.into_package()) + } + + /// Promote a `PackageLocator` to a [`Locator`] by providing the value to use for the `revision` component. + pub fn promote(self, revision: Option) -> Locator { + let locator = Locator::builder() + .fetcher(self.fetcher) + .package(self.package); + + match (self.org_id, revision) { + (None, None) => locator.build(), + (None, Some(revision)) => locator.revision(revision).build(), + (Some(OrgId(id)), None) => locator.org_id(id).build(), + (Some(OrgId(id)), Some(revision)) => locator.org_id(id).revision(revision).build(), + } + } + + /// Promote a `PackageLocator` to a [`StrictLocator`] by providing the value to use for the `revision` component. + pub fn promote_strict(self, revision: impl ToString) -> StrictLocator { + let locator = StrictLocator::builder() + .fetcher(self.fetcher) + .package(self.package) + .revision(revision); + + match self.org_id { + None => locator.build(), + Some(OrgId(id)) => locator.org_id(id).build(), + } + } + + /// Explodes the locator into its (owned) parts. + /// Used for conversions without cloning. + pub(crate) fn explode(self) -> (Fetcher, Option, Package) { + (self.fetcher, self.org_id, self.package) + } +} + +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<'a> ToSchema<'a> for PackageLocator { + fn schema() -> ( + &'a str, + utoipa::openapi::RefOr, + ) { + ( + "PackageLocator", + ObjectBuilder::new() + .description(Some(Self::DOCS)) + .example(Some(json!("git+github.com/fossas/example"))) + .min_length(Some(3)) + .schema_type(SchemaType::String) + .build() + .into(), + ) + } +} + +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 { + let (fetcher, org_id, package, _) = full.explode(); + Self { + fetcher, + org_id, + package, + } + } +} + +impl From<&Locator> for PackageLocator { + fn from(full: &Locator) -> Self { + Self { + fetcher: full.fetcher(), + org_id: full.org_id(), + package: full.package().clone(), + } + } +} + +impl From for PackageLocator { + fn from(strict: StrictLocator) -> Self { + let (fetcher, org_id, package, _) = strict.explode(); + Self { + fetcher, + org_id, + package, + } + } +} + +impl From<&StrictLocator> for PackageLocator { + fn from(strict: &StrictLocator) -> Self { + Self { + fetcher: strict.fetcher(), + org_id: strict.org_id(), + package: strict.package().clone(), + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use itertools::{izip, Itertools}; + use pretty_assertions::assert_eq; + use serde::Deserialize; + use strum::IntoEnumIterator; + + use crate::ParseError; + + use super::*; + + #[test] + fn parse_render_successful() { + let input = "git+github.com/foo/bar"; + let parsed = PackageLocator::parse(input).expect("must parse locator"); + let expected = PackageLocator::builder() + .fetcher(Fetcher::Git) + .package("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + } + + #[test] + fn parse_drops_revision() { + let input = "git+github.com/foo/bar$abcd"; + let parsed = PackageLocator::parse(input).expect("must parse locator"); + let expected = PackageLocator::builder() + .fetcher(Fetcher::Git) + .package("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + } + + #[test] + fn parse_invalid_fetcher() { + let input = "foo+github.com/foo/bar"; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); + } + + #[test] + fn parse_missing_package() { + let input = "git+"; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_invalid_syntax() { + let input = ""; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); + + let input = "git+$"; + let parsed = PackageLocator::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 = [ + OrgId(0usize), + OrgId(1), + OrgId(1234), + OrgId(2385028), + OrgId(19847938492847928), + ]; + let packages = ["github.com/foo/bar", "some-name"]; + let revisions = ["", "$", "$1", "$1234abcd1234"]; + + for (fetcher, org, package, revision) in izip!(fetchers, orgs, packages, revisions) { + let input = format!("{fetcher}+{org}/{package}{revision}"); + let Ok(parsed) = PackageLocator::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.package().as_str(), + package, + "'package' in '{input}' must match" + ); + } + } + + #[test] + fn render_with_org() { + let locator = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .org_id(1234) + .package("foo/bar") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+1234/foo/bar", rendered); + } + + #[test] + fn roundtrip_serialization() { + let input = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .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: PackageLocator, + } + + let input = r#"{ "locator": "custom+1/foo" }"#; + let locator = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + + let expected = Test { locator }; + let deserialized = serde_json::from_str(input).expect("must deserialize"); + assert_eq!(expected, deserialized, "deserialize {input}"); + } + + #[test] + fn promotes_locator() { + let input = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + let promoted = input.clone().promote(None); + assert_eq!(expected, promoted, "promote {input}"); + + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("bar") + .build(); + let promoted = input.clone().promote(Some(String::from("bar"))); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn promotes_strict() { + let input = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("bar") + .build(); + let promoted = input.clone().promote_strict("bar"); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn ordering() { + let locators = vec![ + "git+github.com/foo/bar", + "git+github.com/foo/bar$1234", + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + ] + .into_iter() + .map(PackageLocator::parse) + .collect::, _>>() + .expect("must parse locators"); + + let expected = vec![ + "custom+baz", + "custom+1/bam", + "custom+2/bam", + "git+github.com/foo/bar", + "git+github.com/foo/bar", + ]; + let sorted = locators + .iter() + .sorted() + .map(PackageLocator::to_string) + .collect_vec(); + assert_eq!(expected, sorted, "sort {locators:?}"); + } +} diff --git a/src/locator_strict.rs b/src/locator_strict.rs new file mode 100644 index 0000000..31e3397 --- /dev/null +++ b/src/locator_strict.rs @@ -0,0 +1,408 @@ +use std::fmt::Display; + +use documented::Documented; +use getset::{CopyGetters, Getters}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use typed_builder::TypedBuilder; +use utoipa::{ + openapi::{ObjectBuilder, SchemaType}, + ToSchema, +}; + +use crate::{Error, Fetcher, Locator, OrgId, Package, PackageLocator, ParseError, Revision}; + +/// A [`Locator`] specialized to **require** the `revision` component. +/// +/// ## Ordering +/// +/// Locators order by: +/// 1. Fetcher, alphanumerically. +/// 2. Organization ID, alphanumerically; missing organizations are sorted higher. +/// 3. The package field, alphanumerically. +/// 4. The revision field: +/// If both comparing locators use semver, these are compared using semver rules; +/// otherwise these are compared alphanumerically. +/// +/// Importantly, there may be other metrics for ordering using the actual code host +/// which contains the package (for example, ordering by release date). +/// This library does not perform such ordering. +/// +/// ## Parsing +/// +/// The input string must be in the following format: +/// ```ignore +/// {fetcher}+{package}${revision} +/// ``` +/// +/// Packages may also be namespaced to a specific organization; +/// in such cases the organization ID is at the start of the `{package}` field +/// separated by a slash. The ID can be any non-negative integer. +/// This yields the following format: +/// ```ignore +/// {fetcher}+{org_id}/{package}${revision} +/// ``` +#[derive( + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Debug, + TypedBuilder, + Getters, + CopyGetters, + Documented, +)] +pub struct StrictLocator { + /// Determines which fetcher is used to download this package. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this package is namespaced. + #[builder(default, setter(transform = |id: usize| Some(OrgId(id))))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the package by fetcher. + /// + /// For example, the `git` fetcher fetching a github package + /// uses a value in the form of `{user_name}/{package_name}`. + #[builder(setter(transform = |package: impl ToString| Package(package.to_string())))] + #[getset(get = "pub")] + package: Package, + + /// Specifies the version for the package by fetcher. + /// + /// For example, the `git` fetcher fetching a github package + /// uses a value in the form of `{git_sha}` or `{git_tag}`, + /// and the fetcher disambiguates. + #[builder(setter(transform = |revision: impl ToString| Revision::from(revision.to_string())))] + #[getset(get = "pub")] + revision: Revision, +} + +impl StrictLocator { + /// Parse a `StrictLocator`. + /// For details, see the parsing section on [`StrictLocator`]. + pub fn parse(locator: &str) -> Result { + let (fetcher, org_id, package, revision) = Locator::parse(locator)?.explode(); + + let Some(revision) = revision else { + return Err(Error::Parse(ParseError::Field { + input: locator.to_owned(), + field: String::from("revision"), + })); + }; + + Ok(Self { + fetcher, + org_id, + package, + revision, + }) + } + + /// Converts the instance 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() + } + + /// Converts the instance into a [`Locator`]. + /// Equivalent to the `From` implementation, but offered as a method for convenience. + pub fn into_locator(self) -> Locator { + self.into() + } + + /// Explodes the locator into its (owned) parts. + /// Used for conversions without cloning. + pub(crate) fn explode(self) -> (Fetcher, Option, Package, Revision) { + (self.fetcher, self.org_id, self.package, self.revision) + } +} + +impl Display for StrictLocator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fetcher = &self.fetcher; + write!(f, "{fetcher}+")?; + + if let Some(org_id) = &self.org_id { + write!(f, "{org_id}/")?; + } + + let package = &self.package; + let revision = &self.revision; + write!(f, "{package}${revision}")?; + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for StrictLocator { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + StrictLocator::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for StrictLocator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl<'a> ToSchema<'a> for StrictLocator { + fn schema() -> ( + &'a str, + utoipa::openapi::RefOr, + ) { + ( + "StrictLocator", + ObjectBuilder::new() + .description(Some(Self::DOCS)) + .example(Some(json!("git+github.com/fossas/example$1234"))) + .min_length(Some(3)) + .schema_type(SchemaType::String) + .build() + .into(), + ) + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use itertools::{izip, Itertools}; + use pretty_assertions::assert_eq; + use serde::Deserialize; + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn parse_render_successful() { + let input = "git+github.com/foo/bar$abcd"; + let parsed = StrictLocator::parse(input).expect("must parse locator"); + let expected = StrictLocator::builder() + .fetcher(Fetcher::Git) + .package("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 = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); + } + + #[test] + fn parse_missing_package() { + let input = "git+"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_missing_revision() { + let input = "git+package"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_empty_revision() { + let input = "git+package$"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_invalid_syntax() { + let input = ""; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); + + let input = "git+$"; + let parsed = StrictLocator::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 = [ + OrgId(0usize), + OrgId(1), + OrgId(1234), + OrgId(2385028), + OrgId(19847938492847928), + ]; + let packages = ["github.com/foo/bar", "some-name"]; + let revisions = ["1", "1234abcd1234"]; + + for (fetcher, org, package, revision) in izip!(fetchers, orgs, packages, revisions) { + let input = format!("{fetcher}+{org}/{package}${revision}"); + let Ok(parsed) = StrictLocator::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.package().as_str(), + package, + "'package' in '{input}' must match" + ); + assert_eq!( + parsed.revision().as_str(), + revision, + "'revision' in '{input}' must match", + ); + } + } + + #[test] + fn render_with_org() { + let locator = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .org_id(1234) + .package("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+1234/foo/bar$123abc", rendered); + } + + #[test] + fn render_with_revision() { + let locator = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar$123abc", rendered); + } + + #[test] + fn roundtrip_serialization() { + let input = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("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: StrictLocator, + } + + let input = r#"{ "locator": "custom+1/foo$bar" }"#; + let locator = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .revision("bar") + .org_id(1) + .build(); + + let expected = Test { locator }; + let deserialized = serde_json::from_str(input).expect("must deserialize"); + assert_eq!(expected, deserialized, "deserialize {input}"); + } + + #[test] + fn demotes_package() { + let input = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .revision("bar") + .org_id(1) + .build(); + + let expected = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .build(); + let demoted = input.clone().into_package(); + assert_eq!(expected, demoted, "demote {input}"); + } + + #[test] + fn demotes_locator() { + let input = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .revision("bar") + .org_id(1) + .build(); + + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .package("foo") + .org_id(1) + .revision("bar") + .build(); + let demoted = input.clone().into_locator(); + assert_eq!(expected, demoted, "demote {input}"); + } + + #[test] + fn ordering() { + let locators = vec![ + "git+github.com/foo/bar$abcd10", + "git+github.com/foo/bar$abcd11", + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + ] + .into_iter() + .map(StrictLocator::parse) + .collect::, _>>() + .expect("must parse locators"); + + let expected = vec![ + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + "git+github.com/foo/bar$abcd10", + "git+github.com/foo/bar$abcd11", + ]; + let sorted = locators + .iter() + .sorted() + .map(StrictLocator::to_string) + .collect_vec(); + assert_eq!(expected, sorted, "sort {locators:?}"); + } +} diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 6148279..0000000 --- a/src/test.rs +++ /dev/null @@ -1,288 +0,0 @@ -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}"); - } -}