diff --git a/Cargo.lock b/Cargo.lock index 2404d2c1f..cd7ef3fb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-str" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e19f68b180ebff43d6d42005c4b5f046c65fcac28369ba8b3beaad633f9ec0" + [[package]] name = "convert_case" version = "0.7.1" @@ -764,6 +770,7 @@ dependencies = [ "cc", "chrono", "clap", + "const-str", "derive_builder", "dsc-lib-jsonschema", "dsc-lib-osinfo", @@ -775,6 +782,7 @@ dependencies = [ "murmurhash64", "num-traits", "path-absolutize", + "pretty_assertions", "regex", "rt-format", "rust-i18n", @@ -783,6 +791,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "test-case", "thiserror 2.0.17", "tokio", "tracing", @@ -2942,6 +2951,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3236,6 +3249,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "test_group_resource" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c33ba1a1f..68f6e1a37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,8 @@ chrono = { version = "0.4" } clap = { version = "4.5", features = ["derive"] } # dsc clap_complete = { version = "4.5" } +# dsc-lib +const-str = {version = "1.0" } # dsc, registry, dsc-lib-registry, sshdconfig crossterm = { version = "0.29" } # dsc @@ -255,8 +257,10 @@ cc = { version = "1.2" } tonic-prost-build = { version = "0.14" } # test-only dependencies -# dsc-lib-jsonschema +# dsc-lib-jsonschema, dsc-lib pretty_assertions = { version = "1.4.1" } +# dsc-lib +test-case = { version = "3.3" } # Workspace crates as dependencies dsc-lib = { path = "lib/dsc-lib" } diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index f00193ec2..8add26ce0 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -12,6 +12,7 @@ base32 = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["alloc"] } clap = { workspace = true } +const-str = { workspace = true } derive_builder = { workspace = true } indicatif = { workspace = true } jsonschema = { workspace = true } @@ -28,7 +29,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } -semver = { workspace = true } +semver = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = [ "io-util", "macros", @@ -38,7 +39,7 @@ tokio = { workspace = true, features = [ tracing = { workspace = true } tracing-indicatif = { workspace = true } tree-sitter = { workspace = true } -tree-sitter-rust = { workspace = true} +tree-sitter-rust = { workspace = true } uuid = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } @@ -52,6 +53,10 @@ tree-sitter-dscexpression = { workspace = true } [dev-dependencies] serde_yaml = { workspace = true } +# Helps review complex comparisons, like schemas +pretty_assertions = { workspace = true } +# Enables parameterized test cases +test-case = { workspace = true } [build-dependencies] cc = { workspace = true } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 8fb58b2da..98ef41f8d 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -750,9 +750,14 @@ parser = "Parser" progress = "Progress" resourceNotFound = "Resource not found" resourceManifestNotFound = "Resource manifest not found" +resourceVersionToSemverConversion = "Unable to convert arbitrary string resource version to semantic version" +resourceVersionReqToSemverConversion = "Unable to convert arbitrary string resource version requirement to semantic version requirement" schema = "Schema" schemaNotAvailable = "No Schema found and `validate` is not supported" securityContext = "Security context" +semverReqWithBuildMetadataPrefix = "Invalid semantic version requirement: version" +semverReqWithBuildMetadataInfix = "contains the build metadata segment" +semverReqWithBuildMetadataSuffix = "DSC semantic version requirements must not include build metadata." utf8Conversion = "UTF-8 conversion" unknown = "Unknown" validation = "Validation" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index 2f50f02d6..ce9b16320 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -33,3 +33,240 @@ schemas: a slash, like `Microsoft/OSInfo`. Type names may optionally include the group, area, and subarea segments to namespace the resource under the owner, like `Microsoft.Windows/Registry`. + + resourceVersion: + title: + en-us: Resource version + description: + en-us: >- + Defines the version of a DSC resource as a semantic version or arbitrary string. + markdownDescription: + en-us: |- + Defines the version of a DSC resource. + + DSC supports both semantic versioning and arbitrary versioning for resources. Semantic + versioning is the preferred and recommended versioning strategy. DSC only supports + arbitrary versioning for compatibility scenarios. + + When the version is defined as a valid [semantic version][01], DSC can correctly compare + versions to determine the latest version or match a [semantic version requirement][02]. + Where possible, resource and extension authors should follow semantic versioning for the + best user experience. + + When the version is an arbitrary string, DSC compares the strings + [lexicographically][03]. Arbitrary string versions are only equivalent when they contain + exactly the same characters - the comparison is case-sensitive. If you're defining a + resource that doesn't follow semantic versioning, consider defining the version as an + [ISO 8601 date][04], like `2026-01-15`. When you do, DSC can correctly determine that a + later date should be treated as a newer version. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semver + [02]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq + [03]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + [04]: https://www.iso.org/iso-8601-date-and-time-format.html + semanticVariant: + title: + en-us: Semantic resource version + description: + en-us: >- + Defines the resource's version as a valid semantic version. + markdownDescription: + en-us: |- + Defines the resource's version as a valid semantic version. This is + the preferred and recommended versioning scheme for DSC resources. + + For more information about defining semantic versions, see + [Semantic version JSON Schema reference][01]. For more information + about semantic versioning, see [semver.org][02]. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semver + [02]: https://semver.org + arbitraryVariant: + title: + en-us: Arbitrary string resource version + description: + en-us: >- + Defines the resource's version as an arbitrary string. + deprecationMessage: + en-us: >- + Defining a resource version as an arbitrary string is supported only for compatibility + purposes. If possible, define your resource version as a valid semantic version. For + more information about defining a semantic version, see [semver.org](https://semver.org). + markdownDescription: + en-us: |- + Defines the resource's version as an arbitrary string. + + DSC uses this variant for the version of any DSC resource that defines its version as a + string that can't be parsed as a semantic version. This variant remains supported for + compatibility purposes but is _not_ recommended for production usage. + + When a resource defines the version as an arbitrary string: + + 1. You can only use exact match version requirements for that resource. + 1. When a resource defines the version as an arbitrary string, DSC uses Rust's + [lexicographic comparison][01] logic to determine the "latest" version of the + resource to use as the default version when no version requirement is specified. + 1. When DSC discovers a multiple manifests for a resource, DSC always treats + semantically versioned resources as newer than resources with an arbitrary string + version. + + [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + + resourceVersionReq: + title: + en-us: Resource version requirement + description: + en-us: >- + Defines one or more limitations for a resource version to enable version pinning. + markdownDescription: + en-us: |- + Defines one or more limitations for a [resource version][01] to enable version pinning. + + DSC supports both semantic versioning and arbitrary versioning for resources. Semantic + versioning is the preferred and recommended versioning strategy. DSC only supports + arbitrary versioning for compatibility scenarios. + + Because DSC supports arbitrary string versions for compatibility, version requirements + must also support arbitrary string versions. + + When a resource version requirement is semantic, it behaves like a + [semantic version requirement][02] and only matches resource versions that are semantic + _and_ valid for the given requirement. Arbitrary string versions never match a semantic + resource version requirement. + + Similarly, when a resource version requirement is an arbitrary string, it can never match + a semantically versioned resource. Instead, it matches an arbitrary resource version when + the arbitrary string version is _exactly_ the same as the arbitrary resource version + requirement. + + Arbitrary resource versions and resource version requirements are only defined for + compatibility scenarios. You should use semantic versions for resources and resource + version requirements. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/resourceVersion + [02]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq + semanticVariant: + title: + en-us: Semantic resource version requirement + description: + en-us: >- + Defines the required version range for a resource using the Rust version requirement + syntax. + markdownDescription: + en-us: |- + Defines the required version range for a resource using the Rust version requirement + syntax. This is the preferred and recommended way to pin versions for DSC resources. + + For more information about defining semantic version requirements with DSC, see + [Defining semantic version requirements][01]. + + [01]: https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md + arbitraryVariant: + title: + en-us: Arbitrary resource version requirement + description: + en-us: >- + Defines the required version for the resource as an arbitrary string. + deprecationMessage: + en-us: >- + Defining a resource version requirement as an arbitrary string is supported only for + compatibility purposes. If possible, define your version requirement as a valid + semantic version requirement. For more information about defining semantic version + requirements with DSC, see + [Defining semantic version requirements](https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md) + markdownDescription: + en-us: |- + Defines the required version for the resource as an arbitrary string. + + DSC considers any requirement that can't be parsed as a semantic version requirement as + an arbitrary resource version requirement. This kind of requirement remains supported + for compatibility purposes but is _not_ recommended for production usage. + + When a resource version requirement is defined as an arbitrary string: + + 1. It can never match a semantically versioned resource. + 1. It only matches a resource with an [arbitrary string version][01] when the resource + version and this version requirement are exactly the same. The comparison is + case-sensitive. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/resourceVersion + + semver: + title: + en-us: Semantic version + description: + en-us: |- + Defines a valid semantic version (semver) as a string. + + For reference, see https://semver.org/ + markdownDescription: + en-us: |- + Defines a valid semantic version ([semver][01]) as a string. + + This value uses the [suggested regular expression][02] to validate whether the string is + valid semver. This is the same pattern, made multi-line for easier readability: + + ```regex + ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*) + (?:-( + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)) + *))? + (?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ + ``` + + The first line matches the `major.minor.patch` components of the version. The middle + lines match the pre-release components. The last line matches the build metadata + component. + + [01]: https://semver.org/ + [02]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + patternErrorMessage: + en-us: |- + Invalid value, must be a semantic version like `..`, such as `1.2.3`. + + The value may also include pre-release version information and build metadata. + + semverReq: + title: + en-us: Semantic version requirement + description: + en-us: >- + Defines one or more limitations for a semantic version to enable version pinning. + markdownDescription: + en-us: |- + Defines one or more limitations for a semantic version to enable version pinning. + + DSC uses the semantic version requirements for Rust, as documented in the + ["Specifying dependencies" section of the Cargo Book][01]. DSC adheres closely to the + Rust syntax for defining semantic version requirements, with the following exceptions: + + 1. DSC semantic version requirements _forbid_ the inclusion of build metadata. + + Rust allows and ignores them. DSC forbids the inclusion of build metadata to limit confusion + and unexpected behavior when specifying a version requirement for a DSC resource or + extension. + 1. DSC semantic version requirements _must_ define a major version segment. All other segments + are optional. + + Rust technically supports specifying a wildcard-only version requirement (`*`). DSC forbids + specifying this version requirement as it maps to the default version selection and is + discouraged when specifying version requirements for production systems. + 1. DSC semantic version requirements only support the asterisk (`*`) character for wildcards, + not `x` or `X`. + + DSC forbids specifying wildcards as non-asterisks to reduce ambiguity and unexpected + behavior for prerelease segments, where `1.2.3-X` and `1.2.3-rc.x` are _valid_ requirements + but where the characters are _not_ interpreted as wildcards. Forbidding the use of these + characters prevents users from accidentally defining a requirement they _believe_ will + wildcard match on a prerelease segment but actually won't. + + For more information about defining semantic version requirements with DSC, see + [Defining semantic version requirements][01]. + + [01]: https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md + patternErrorMessage: + en-us: >- + Invalid semantic version requirement. A semantic version requirement must define one or + more valid comparators. For more information, see + [Defining semantic version requirements](https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md) \ No newline at end of file diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 5051c6cd5..942816b89 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -115,6 +115,12 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.resourceManifestNotFound"))] ResourceManifestNotFound(String), + #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionToSemverConversion"))] + ResourceVersionToSemverConversion(String), + + #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionReqToSemverConversion"))] + ResourceVersionReqToSemverConversion(String), + #[error("{t}: {0}", t = t!("dscerror.schema"))] Schema(String), @@ -127,6 +133,14 @@ pub enum DscError { #[error("semver: {0}")] SemVer(#[from] semver::Error), + #[error( + "{t}: '{0}' {t2} '{1}' - {t3}", + t = t!("dscerror.semverReqWithBuildMetadataPrefix"), + t2 = t!("dscerror.semverReqWithBuildMetadataInfix"), + t3 = t!("dscerror.semverReqWithBuildMetadataSuffix") + )] + SemVerReqWithBuildMetadata(String, String), + #[error("{t}: {0}", t = t!("dscerror.utf8Conversion"))] Utf8Conversion(#[from] Utf8Error), diff --git a/lib/dsc-lib/src/types/fully_qualified_type_name.rs b/lib/dsc-lib/src/types/fully_qualified_type_name.rs index 8c19468e7..101f4bb8a 100644 --- a/lib/dsc-lib/src/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -86,6 +86,8 @@ impl FullyQualifiedTypeName { /// Creates a new instance of [`FullyQualifiedTypeName`] from a string if the input is valid for the /// [`VALIDATING_PATTERN`]. If the string is invalid, the method raises the /// [`DscError::InvalidTypeName`] error. + /// + /// [`VALIDATING_PATTERN`]: Self::VALIDATING_PATTERN pub fn new(name: &str) -> Result { Self::validate(name)?; Ok(Self(name.to_string())) diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index b046d479b..e7899a903 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -3,3 +3,11 @@ mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; +mod resource_version; +pub use resource_version::ResourceVersion; +mod resource_version_req; +pub use resource_version_req::ResourceVersionReq; +mod semantic_version; +pub use semantic_version::SemanticVersion; +mod semantic_version_req; +pub use semantic_version_req::SemanticVersionReq; diff --git a/lib/dsc-lib/src/types/resource_version.rs b/lib/dsc-lib/src/types/resource_version.rs new file mode 100644 index 000000000..ef33325d4 --- /dev/null +++ b/lib/dsc-lib/src/types/resource_version.rs @@ -0,0 +1,541 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{convert::Infallible, fmt::Display, str::FromStr}; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{SemanticVersion, SemanticVersionReq}}; + +/// Defines the version of a DSC resource. +/// +/// DSC supports both semantic versioning and arbitrary versioning for resources. Semantic +/// versioning is the preferred and recommended versioning strategy. DSC only supports arbitrary +/// versioning for compatibility scenarios. +/// +/// When the version is defined as a valid semantic version ([`ResourceVersion::Semantic`]), DSC +/// can correctly compare versions to determine the latest version or match a +/// [`SemanticVersionReq`]. Where possible, resource and extension authors should follow semantic +/// versioning for the best user experience. +/// +/// When the version is an arbitrary string (`ResourceVersion::Arbitrary`]), DSC compares the strings +/// using Rust's default string comparison logic. This means that arbitrary string versions are +/// compared [lexicographically][01]. Arbitrary string versions are only equivalent when they +/// contain exactly the same characters - the comparison is case-sensitive. If you're defining a +/// resource that doesn't follow semantic versioning, consider defining the version as an +/// [ISO 8601 date][02], like `2026-01-15`. When you do, DSC can correctly determine that a later +/// date should be treated as a newer version. +/// +/// # Examples +/// +/// The following example shows how different instances of [`ResourceVersion`] compare to other +/// instances of `ResourceVersion`, [`SemanticVersion`], [`String`], and [`str`]. +/// +/// ```rust +/// use dsc_lib::types::{ResourceVersion, SemanticVersion}; +/// +/// let semantic = ResourceVersion::new("1.2.3"); +/// let arbitrary = ResourceVersion::new("Foo"); +/// let date = ResourceVersion::new("2026-01-15"); +/// +/// // You can compare instances of `ResourceVersion::Semantic` to strings, string slices, and +/// // semantic versions. +/// assert_eq!(semantic, SemanticVersion::parse("1.2.3").unwrap()); +/// assert_eq!(semantic, "1.2.3"); +/// assert_ne!(semantic, "1.2.*".to_string()); +/// +/// // When comparing arbitrary string versions to strings, you can compare `String` instances and +/// // literal strings. The comparisons are case-sensitive. +/// assert_eq!(arbitrary, "Foo"); +/// assert_ne!(arbitrary, "foo".to_string()); +/// +/// // When a semantic version is compared to an arbitrary string version, the semantic version is +/// // always treated as being higher: +/// assert!(semantic > arbitrary); +/// assert!(semantic > date); +/// assert!(arbitrary < SemanticVersion::parse("0.1.0").unwrap()); +/// +/// // Semantic version comparisons work as expected. +/// assert!(semantic < SemanticVersion::parse("1.2.4").unwrap()); +/// assert!(semantic >= SemanticVersion::parse("1.0.0").unwrap()); +/// +/// // When comparing a semantic version to a string, the comparison uses semantic version ordering +/// // if the string can be parsed as a semantic version. +/// assert!(semantic < "1.2.4"); +/// assert!(semantic > "foo".to_string()); +/// +/// // Arbitrary string version comparisons are lexicographic. DSC has no way of knowing whether +/// // `Bar` should be treated as a newer version than `Foo`: +/// assert!(arbitrary <= "foo"); +/// assert_ne!(arbitrary < "Bar", true); +/// +/// // String version comparisons for ISO 8601 dates are deterministic: +/// assert!(date < "2026-02-01"); +/// assert!(date >= "2026-01"); +/// ``` +/// +/// You can freely convert between strings and `ResourceVersion`: +/// +/// ```rust +/// use dsc_lib::types::ResourceVersion; +/// +/// let semantic: ResourceVersion = "1.2.3".parse().unwrap(); +/// let arbitrary = ResourceVersion::from("foo"); +/// let date = ResourceVersion::new("2026-01-15"); +/// +/// let stringified_semantic = String::from(semantic.clone()); +/// +/// // Define a function that expects a string: +/// fn expects_string(input: &str) { +/// println!("Input was: '{input}'") +/// } +/// +/// // You can pass the `ResourceVersion` in a few ways: +/// expects_string(&semantic.to_string()); +/// expects_string(date.to_string().as_str()); +/// ``` +/// +/// [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison +/// [02]: https://www.iso.org/iso-8601-date-and-time-format.html +#[derive(Debug, Clone, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "resourceVersion", folder_path = "definitions")] +#[serde(untagged)] +#[schemars( + title = t!("schemas.definitions.resourceVersion.title"), + description = t!("schemas.definitions.resourceVersion.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersion.markdownDescription") + ) +)] +pub enum ResourceVersion { + /// Defines the resource's version as a semantic version, containing an inner [`SemanticVersion`]. + /// This is the preferred and recommended versioning scheme for DSC resources. + /// + /// For more information about defining semantic versions, see [`SemanticVersion`]. For more + /// information about semantic versioning, see [semver.org][01]. + /// + /// [01]: https://semver.org + #[schemars( + title = t!("schemas.definitions.resourceVersion.semanticVariant.title"), + description = t!("schemas.definitions.resourceVersion.semanticVariant.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersion.semanticVariant.markdownDescription") + ) + )] + Semantic(SemanticVersion), + /// Defines the resource's version as an arbitrary string. + /// + /// DSC uses this variant for the version of any DSC resource that defines its + /// version as a string that can't be parsed as a semantic version. This variant remains + /// supported for compatibility purposes but is _not_ recommended for production usage. + /// + /// When a resource defines the version as an arbitrary string: + /// + /// 1. You can only use exact match version requirements for that resource. + /// 1. When a resource defines the version as an arbitrary string, DSC uses Rust's + /// [lexicographic comparison][01] logic to determine the "latest" version of the resource + /// to use as the default version when no version requirement is specified. + /// 1. When DSC discovers a multiple manifests for a resource, DSC always treats semantically + /// versioned resources as newer than resources with an arbitrary string version. + /// + /// [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + #[schemars( + title = t!("schemas.definitions.resourceVersion.arbitraryVariant.title"), + description = t!("schemas.definitions.resourceVersion.arbitraryVariant.description"), + extend( + "deprecated" = true, + "deprecationMessage" = t!("schemas.definitions.resourceVersion.arbitraryVariant.deprecationMessage"), + "markdownDescription" = t!("schemas.definitions.resourceVersion.arbitraryVariant.markdownDescription"), + ) + )] + Arbitrary(String), +} + +impl ResourceVersion { + /// Creates a new instance of [`ResourceVersion`]. + /// + /// If the input string is a valid semantic version, the function returns the [`Semantic`] + /// variant. Otherwise, the function returns the [`Arbitrary`] variant for arbitrary version + /// strings. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersion; + /// + /// fn print_version_message(version: ResourceVersion) { + /// match ResourceVersion::new("1.2.3") { + /// ResourceVersion::Semantic(v) => println!("Semantic version: {v}"), + /// ResourceVersion::Arbitrary(s) => println!("Arbitrary string version: '{s}'"), + /// } + /// } + /// + /// // Print for semantic version + /// print_version_message(ResourceVersion::new("1.2.3")); + /// + /// // Print for arbitrary version + /// print_version_message(ResourceVersion::new("2026-01")); + /// ``` + /// + /// [`Semantic`]: ResourceVersion::Semantic + /// [`Arbitrary`]: ResourceVersion::Arbitrary + pub fn new(version_string: &str) -> Self { + match SemanticVersion::parse(version_string) { + Ok(v) => Self::Semantic(v), + Err(_) => Self::Arbitrary(version_string.to_string()), + } + } + + /// Indicates whether the resource version is semantic. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersion; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let arbitrary = ResourceVersion::new("2026-01"); + /// + /// assert_eq!(semantic.is_semver(), true); + /// assert_eq!(arbitrary.is_semver(), false); + /// ``` + pub fn is_semver(&self) -> bool { + match self { + Self::Semantic(_) => true, + _ => false, + } + } + + /// Indicates whether the resource version is an arbitrary string. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersion; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let arbitrary = ResourceVersion::new("2026-01"); + /// + /// assert_eq!(semantic.is_semver(), true); + /// assert_eq!(arbitrary.is_semver(), false); + /// ``` + pub fn is_arbitrary(&self) -> bool { + match self { + Self::Arbitrary(_) => true, + _ => false, + } + } + + /// Returns the version as a reference to the underlying [`SemanticVersion`] if possible. + /// + /// If the underlying version is [`Semantic`], this method returns some semantic version. + /// Otherwise, it returns [`None`]. + /// + /// # Examples + /// + /// The following examples show how `as_semver()` behaves for different versions. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, SemanticVersion}; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let date = ResourceVersion::new("2026-01-15"); + /// let arbitrary = ResourceVersion::new("arbitrary_version"); + /// + /// assert_eq!( + /// semantic.as_semver(), + /// Some(&SemanticVersion::new(1, 2, 3)) + /// ); + /// assert_eq!( + /// date.as_semver(), + /// None + /// ); + /// assert_eq!( + /// arbitrary.as_semver(), + /// None + /// ); + /// ``` + /// + /// [`Semantic`]: ResourceVersion::Semantic + pub fn as_semver(&self) -> Option<&SemanticVersion> { + match self { + Self::Semantic(v) => Some(v), + _ => None, + } + } + + /// Compares an instance of [`ResourceVersion`] with [`SemanticVersionReq`]. + /// + /// When the instance is [`ResourceVersion::Semantic`], this method applies the canonical matching + /// logic from [`SemanticVersionReq`] for the version. When the instance is + /// [`ResourceVersion::Arbitrary`], this method always returns `false`. + /// + /// For more information about semantic version requirements and syntax, see + /// ["Specifying Dependencies" in _The Cargo Book_][semver-req]. + /// + /// # Examples + /// + /// The following example shows how comparisons work for different instances of + /// [`ResourceVersion`]. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, SemanticVersionReq}; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let date = ResourceVersion::new("2026-01-15"); + /// + /// let ref le_v2_0: SemanticVersionReq = "<=2.0".parse().unwrap(); + /// assert!(semantic.matches_semver_req(le_v2_0)); + /// assert!(!date.matches_semver_req(le_v2_0)); + /// let ref tilde_v1: SemanticVersionReq = "~1".parse().unwrap(); + /// assert!(semantic.matches_semver_req(tilde_v1)); + /// assert!(!date.matches_semver_req(tilde_v1)); + /// ``` + /// + /// [semver-req]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#version-requirement-syntax + pub fn matches_semver_req(&self, requirement: &SemanticVersionReq) -> bool { + match self { + Self::Semantic(v) => requirement.matches(v), + Self::Arbitrary(_) => false, + } + } +} + +// Default to semantic version `0.0.0` rather than an empty string. +impl Default for ResourceVersion { + fn default() -> Self { + Self::Semantic(SemanticVersion::default()) + } +} + +// Enable using `ResourceVersion` in `format!` and similar macros. +impl Display for ResourceVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Semantic(v) => write!(f, "{}", v), + Self::Arbitrary(s) => write!(f, "{}", s), + } + } +} + +// Parsing from a string is just calling `Self::new()` +impl FromStr for ResourceVersion { + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok(Self::new(s)) + } +} + +// Implemented various conversion traits to move between `ResourceVersion`, `SemanticVersion`, +// `String`, and string slice (`str`). +impl From<&String> for ResourceVersion { + fn from(value: &String) -> Self { + match SemanticVersion::parse(value) { + Ok(v) => ResourceVersion::Semantic(v), + Err(_) => ResourceVersion::Arbitrary(value.clone()), + } + } +} + +impl From for ResourceVersion { + fn from(value: String) -> Self { + match SemanticVersion::parse(&value) { + Ok(v) => ResourceVersion::Semantic(v), + Err(_) => ResourceVersion::Arbitrary(value), + } + } +} + +impl From for String { + fn from(value: ResourceVersion) -> Self { + value.to_string() + } +} + +// We can't bidirectionally convert string slices, because we can't return a temporary reference. +// We can still convert _from_ string slices, but not _into_ them. +impl From<&str> for ResourceVersion { + fn from(value: &str) -> Self { + ResourceVersion::from(value.to_string()) + } +} + +impl From for ResourceVersion { + fn from(value: SemanticVersion) -> Self { + Self::Semantic(value) + } +} + +impl From<&SemanticVersion> for ResourceVersion { + fn from(value: &SemanticVersion) -> Self { + Self::Semantic(value.clone()) + } +} + +// Creating an instance of `SemanticVersion` from `ResourceVersion` is the only fallible +// conversion, since `ResourceVersion` can define non-semantic versions. +impl TryFrom for SemanticVersion { + type Error = DscError; + + fn try_from(value: ResourceVersion) -> Result { + match value { + ResourceVersion::Semantic(v) => Ok(v), + ResourceVersion::Arbitrary(s) => Err(DscError::ResourceVersionToSemverConversion(s)), + } + } +} + +// Implement traits for comparing `ResourceVersion` to strings and semantic versions bi-directionally. +impl PartialEq for ResourceVersion { + fn eq(&self, other: &Self) -> bool { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version == other_version, + Self::Arbitrary(_) => false, + }, + Self::Arbitrary(string) => !other.is_semver() && *string == other.to_string(), + } + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &SemanticVersion) -> bool { + match self { + Self::Semantic(v) => v == other, + Self::Arbitrary(_) => false, + } + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &ResourceVersion) -> bool { + match other { + ResourceVersion::Semantic(v) => self == v, + ResourceVersion::Arbitrary(_) => false, + } + } +} + +impl PartialEq<&str> for ResourceVersion { + fn eq(&self, other: &&str) -> bool { + self == &Self::new(*other) + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ResourceVersion) -> bool { + &ResourceVersion::new(self) == other + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &String) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for String { + fn eq(&self, other: &ResourceVersion) -> bool { + &ResourceVersion::new(self) == other + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &str) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for str { + fn eq(&self, other: &ResourceVersion) -> bool { + &ResourceVersion::new(self) == other + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &Self) -> Option { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version.partial_cmp(other_version), + Self::Arbitrary(_) => Some(std::cmp::Ordering::Greater), + }, + Self::Arbitrary(string) => match other { + Self::Semantic(_) => Some(std::cmp::Ordering::Less), + Self::Arbitrary(other_string) => string.partial_cmp(other_string), + }, + } + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + match self { + Self::Semantic(v) => v.partial_cmp(other), + Self::Arbitrary(_) => Some(std::cmp::Ordering::Less), + } + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + match other { + ResourceVersion::Semantic(v) => self.partial_cmp(v), + ResourceVersion::Arbitrary(_) => Some(std::cmp::Ordering::Greater), + } + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &String) -> Option { + self.partial_cmp(&ResourceVersion::new(other.as_str())) + } +} + +impl PartialOrd for String { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + ResourceVersion::new(self.as_str()).partial_cmp(other) + } +} + +impl PartialOrd<&str> for ResourceVersion { + fn partial_cmp(&self, other: &&str) -> Option { + self.partial_cmp(&Self::new(other)) + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &str) -> Option { + self.partial_cmp(&Self::new(other)) + } +} + +impl PartialOrd for &str { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + ResourceVersion::new(self).partial_cmp(other) + } +} + +impl PartialOrd for str { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + ResourceVersion::new(self).partial_cmp(other) + } +} + +// Manually implement total ordering, because partial and total ordering are different for semantic +// versions. See the implementation on `semver::Version` for details. +impl Ord for ResourceVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version.cmp(other_version), + Self::Arbitrary(_) => std::cmp::Ordering::Greater, + }, + Self::Arbitrary(version) => match other { + Self::Semantic(_) => std::cmp::Ordering::Less, + Self::Arbitrary(other_version) => version.cmp(other_version), + } + } + } +} diff --git a/lib/dsc-lib/src/types/resource_version_req.rs b/lib/dsc-lib/src/types/resource_version_req.rs new file mode 100644 index 000000000..15025771a --- /dev/null +++ b/lib/dsc-lib/src/types/resource_version_req.rs @@ -0,0 +1,415 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{convert::Infallible, fmt::Display, str::FromStr}; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{ResourceVersion, SemanticVersionReq}}; + +/// Defines one or more limitations for a [`ResourceVersion`] to enable version pinning. +/// +/// DSC supports both semantic versioning and arbitrary versioning for resources. Semantic +/// versioning is the preferred and recommended versioning strategy. DSC only supports arbitrary +/// versioning for compatibility scenarios. +/// +/// Because DSC supports arbitrary string versions for compatibility, version requirements must +/// also support arbitrary string versions. +/// +/// When a [`ResourceVersionReq`] is semantic, it behaves like a [`SemanticVersionReq`] and only +/// matches resource versions that are semantic _and_ valid for the given requirement. Arbitrary +/// string versions never match a semantic resource version requirement. +/// +/// Similarly, when a [`ResourceVersionReq`] is an arbitrary string, it can never match a +/// semantically versioned [`ResourceVersion`]. Instead, it matches an arbitrary `ResourceVersion` +/// when the arbitrary string version is _exactly_ the same as the arbitrary resource version +/// requirement. +/// +/// Arbitrary resource versions and resource version requirements are only defined for +/// compatibility scenarios. You should use semantic versions for resources and resource version +/// requirements. +/// +/// ## Defining a resource version requirement +/// +/// All strings are valid resource version requirements. However, to usefully define a resource +/// version requirement that supports correctly matching semantic versions, you must define the +/// requirement as valid `SemanticVersionReq`. See the [`SemanticVersionReq` documentation][01] for +/// full details on defining semantic version requirements. +/// +/// ## Examples +/// +/// When you create a new instance of [`ResourceVersionReq`], the variant is `Semantic` when the +/// input string parses as a [`SemanticVersionReq`]. Otherwise, the new instance is `Arbitrary`. +/// +/// ```rust +/// use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; +/// +/// let semantic_req = ResourceVersionReq::new("^1.2, <1.5"); +/// let arbitrary_req = ResourceVersionReq::new("foo"); +/// +/// let v1_2_3 = &ResourceVersion::new("1.2.3"); +/// let v1_5_1 = &ResourceVersion::new("1.5.1"); +/// let v_arbitrary = &ResourceVersion::new("foo"); +/// +/// // Semantic requirement uses underlying semantic version requirement logic: +/// assert!(semantic_req.matches(v1_2_3)); +/// assert!(!semantic_req.matches(v1_5_1)); +/// // Semantic requirements never match arbitrary versions: +/// assert!(!semantic_req.matches(v_arbitrary)); +/// +/// // Arbitrary requirements only match arbitrary versions _exactly_: +/// assert!(arbitrary_req.matches(v_arbitrary)); +/// // Differing casing causes the match to fail: +/// assert!(!arbitrary_req.matches(&ResourceVersion::new("FOO"))); +/// ``` +/// +/// [01]: SemanticVersionReq +#[derive(Debug, Clone, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "resourceVersionReq", folder_path = "definitions")] +#[serde(untagged)] +#[schemars( + title = t!("schemas.definitions.resourceVersionReq.title"), + description = t!("schemas.definitions.resourceVersionReq.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.markdownDescription") + ) +)] +pub enum ResourceVersionReq { + /// Defines the version requirement for the resource as a semantic version requirement, + /// containing an inner [`SemanticVersionReq`]. This is the preferred and recommended way to pin + /// versions for DSC resources. + /// + /// For more information about defining semantic version requirements, see + /// [`SemanticVersionReq`]. For more information about semantic versioning, see + /// [semver.org][01]. + /// + /// [01]: https://semver.org + #[schemars( + title = t!("schemas.definitions.resourceVersionReq.semanticVariant.title"), + description = t!("schemas.definitions.resourceVersionReq.semanticVariant.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.semanticVariant.markdownDescription") + ) + )] + Semantic(SemanticVersionReq), + /// Defines the required version for the resource as an arbitrary string. + /// + /// DSC uses this variant for any requirement that can't be parsed as a semantic version + /// requirement. This variant remains supported for compatibility purposes but is _not_ + /// recommended for production usage. + /// + /// When a resource version requirement is defined as an arbitrary string: + /// + /// 1. It can never match a semantically versioned resource. + /// 1. It only matches a resource with an arbitrary string version + /// ([`ResourceVersion::Arbitrary`]) when the resource version and this version requirement + /// are exactly the same. The comparison is case-sensitive. + #[schemars( + title = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.title"), + description = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.description"), + extend( + "deprecated" = true, + "deprecationMessage" = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.deprecationMessage"), + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.markdownDescription"), + "examples" = [ + "2026-02", + "1.2.0.0" + ] + ) + )] + Arbitrary(String), +} + +impl ResourceVersionReq { + /// Creates a new instance of [`ResourceVersionReq`]. + /// + /// If the input string is a valid semantic version requirement, the function returns the + /// [`Semantic`] variant. Otherwise, the function returns the [`Arbitrary`] variant for + /// arbitrary version requirement strings. + /// + /// [`Semantic`]: ResourceVersionReq::Semantic + /// [`Arbitrary`]: ResourceVersionReq::Arbitrary + pub fn new(requirement_string: &str) -> Self { + match SemanticVersionReq::parse(requirement_string) { + Ok(req) => Self::Semantic(req), + Err(_) => Self::Arbitrary(requirement_string.to_string()), + } + } + + /// Indicates whether the resource version requirement is semantic. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersionReq; + /// + /// let semantic = ResourceVersionReq::new("^1.2, <1.5"); + /// let arbitrary = ResourceVersionReq::new("2026-01"); + /// + /// assert_eq!(semantic.is_semver(), true); + /// assert_eq!(arbitrary.is_semver(), false); + /// ``` + pub fn is_semver(&self) -> bool { + match self { + Self::Semantic(_) => true, + _ => false, + } + } + + /// Indicates whether the resource version requirement is an arbitrary string. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersionReq; + /// + /// let arbitrary = ResourceVersionReq::new("2026-01"); + /// let semantic = ResourceVersionReq::new("^1.2, <1.5"); + /// + /// assert_eq!(arbitrary.is_arbitrary(), true); + /// assert_eq!(semantic.is_arbitrary(), false); + /// ``` + pub fn is_arbitrary(&self) -> bool { + match self { + Self::Arbitrary(_) => true, + _ => false, + } + } + + /// Returns the requirement as a reference to the underlying [`SemanticVersionReq`] if possible. + /// + /// If the underlying requirement is [`Semantic`], this method returns some semantic version + /// requirement. Otherwise, it returns [`None`]. + /// + /// # Examples + /// + /// The following examples show how `as_semver_req()` behaves for different requirements. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + /// + /// let semantic = ResourceVersionReq::new("1.2.3"); + /// let date = ResourceVersionReq::new("2026-01-15"); + /// let arbitrary = ResourceVersionReq::new("arbitrary_version"); + /// + /// assert_eq!( + /// semantic.as_semver_req(), + /// Some(&SemanticVersionReq::parse("^1.2.3").unwrap()) + /// ); + /// assert_eq!( + /// date.as_semver_req(), + /// None + /// ); + /// assert_eq!( + /// arbitrary.as_semver_req(), + /// None + /// ); + /// ``` + /// + /// [`Semantic`]: ResourceVersionReq::Semantic + pub fn as_semver_req(&self) -> Option<&SemanticVersionReq> { + match self { + Self::Semantic(req) => Some(req), + _ => None, + } + } + + /// Compares an instance of [`ResourceVersion`] to the requirement, returning `true` if the + /// version is valid for the requirement and otherwise `false`. + /// + /// The comparison depends on whether the requirement and version are semantic or arbitrary: + /// + /// - When both the requirement and version are semantic, this function uses the logic for + /// comparing versions and requirements defined by [`SemanticVersionReq`]. + /// - When both the requirement and version are arbitrary, the version is only valid for the + /// requirement when it is exactly the same string as the requirement. + /// - Otherwise, this function returns `false` because an arbitrary version can never match a + /// semantic requirement and a semantic version can never match an arbitrary requirement. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; + /// + /// let semantic_req = ResourceVersionReq::new("^1.2.3, <1.5"); + /// assert!(semantic_req.matches(&ResourceVersion::new("1.2.3"))); + /// assert!(semantic_req.matches(&ResourceVersion::new("1.3.0"))); + /// assert!(!semantic_req.matches(&ResourceVersion::new("1.0.0"))); + /// assert!(!semantic_req.matches(&ResourceVersion::new("1.5.0"))); + /// assert!(!semantic_req.matches(&ResourceVersion::new("2026-02"))); + /// + /// let arbitrary_req = ResourceVersionReq::new("February 2026"); + /// assert!(arbitrary_req.matches(&ResourceVersion::new("February 2026"))); + /// assert!(!arbitrary_req.matches(&ResourceVersion::new("February2026"))); + /// assert!(!arbitrary_req.matches(&ResourceVersion::new("february 2026"))); + /// ``` + pub fn matches(&self, resource_version: &ResourceVersion) -> bool { + match self { + Self::Semantic(req) => { + match resource_version { + ResourceVersion::Semantic(version) => req.matches(version), + ResourceVersion::Arbitrary(_) => false, + } + }, + Self::Arbitrary(req) => { + match resource_version { + ResourceVersion::Semantic(_) => false, + ResourceVersion::Arbitrary(version) => req == version, + } + } + } + } +} + +// Default to matching any stable semantic version rather than an empty string. +impl Default for ResourceVersionReq { + fn default() -> Self { + Self::Semantic(SemanticVersionReq::default()) + } +} + +// Enable using `ResourceVersionReq` in `format!` and similar macros. +impl Display for ResourceVersionReq { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Semantic(req) => write!(f, "{}", req), + Self::Arbitrary(s) => write!(f, "{}", s), + } + } +} + +// Parsing from a string is just calling `Self::new()` +impl FromStr for ResourceVersionReq { + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok(Self::new(s)) + } +} + +// Implemented various conversion traits to move between `ResourceVersionReq`, `SemanticVersionReq`, +// `String`, and string slice (`str`). +impl From for ResourceVersionReq { + fn from(value: String) -> Self { + match SemanticVersionReq::parse(&value) { + Ok(req) => ResourceVersionReq::Semantic(req), + Err(_) => ResourceVersionReq::Arbitrary(value), + } + } +} + +impl From for String { + fn from(value: ResourceVersionReq) -> Self { + value.to_string() + } +} + +impl From<&String> for ResourceVersionReq { + fn from(value: &String) -> Self { + match SemanticVersionReq::parse(value) { + Ok(req) => ResourceVersionReq::Semantic(req), + Err(_) => ResourceVersionReq::Arbitrary(value.clone()), + } + } +} +// We can't bidirectionally convert string slices, because we can't return a temporary reference. +// We can still convert _from_ string slices, but not _into_ them. +impl From<&str> for ResourceVersionReq { + fn from(value: &str) -> Self { + ResourceVersionReq::from(value.to_string()) + } +} + +impl From for ResourceVersionReq { + fn from(value: SemanticVersionReq) -> Self { + Self::Semantic(value) + } +} + +impl From<&SemanticVersionReq> for ResourceVersionReq { + fn from(value: &SemanticVersionReq) -> Self { + Self::Semantic(value.clone()) + } +} + +// Creating an instance of `SemanticVersionReq` from `ResourceVersionReq` is the only fallible +// conversion, since `ResourceVersionReq` can define non-semantic version requirements. +impl TryFrom for SemanticVersionReq { + type Error = DscError; + + fn try_from(value: ResourceVersionReq) -> Result { + match value { + ResourceVersionReq::Semantic(req) => Ok(req), + ResourceVersionReq::Arbitrary(s) => Err(DscError::ResourceVersionReqToSemverConversion(s)) + } + } +} + +// Implement traits for comparing `ResourceVersionReq` to strings and semantic version requirements +// bi-directionally. +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &Self) -> bool { + match self { + Self::Semantic(req) => match other { + Self::Semantic(other_req) => req == other_req, + Self::Arbitrary(_) => false, + }, + Self::Arbitrary(string) => !other.is_semver() && *string == other.to_string(), + } + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match self { + Self::Semantic(req) => req == other, + Self::Arbitrary(_) => false, + } + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &ResourceVersionReq) -> bool { + match other { + ResourceVersionReq::Semantic(req) => self == req, + ResourceVersionReq::Arbitrary(_) => false, + } + } +} + +impl PartialEq<&str> for ResourceVersionReq { + fn eq(&self, other: &&str) -> bool { + self == &Self::new(*other) + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ResourceVersionReq) -> bool { + &ResourceVersionReq::new(self) == other + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &str) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for str { + fn eq(&self, other: &ResourceVersionReq) -> bool { + &ResourceVersionReq::new(self) == other + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &String) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for String { + fn eq(&self, other: &ResourceVersionReq) -> bool { + &ResourceVersionReq::new(self) == other + } +} diff --git a/lib/dsc-lib/src/types/semantic_version.rs b/lib/dsc-lib/src/types/semantic_version.rs new file mode 100644 index 000000000..f116252fe --- /dev/null +++ b/lib/dsc-lib/src/types/semantic_version.rs @@ -0,0 +1,659 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fmt::Display, ops::Deref, str::FromStr}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema}; +use rust_i18n::t; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Serialize}; + +/// Defines a semantic version for use with DSC. +/// +/// This type is a wrapper around the [`semver::Version`] type that enables DSC to provide a more +/// complete JSON Schema for semantic versions. +/// +/// A semantic version adheres to the specification defined at [semver.org][01]. +/// +/// ## Syntax +/// +/// A semantic version is composed of the mandatory major, minor, and patch version segments. +/// Optionally, a semantic version may define the prerelease and build metadata segments. The +/// string defining a semantic version must not include any spacing characters. +/// +/// The major, minor, and patch version segments must parse as zero or a positive integer ([`u64`]). +/// These segments must not contain leading zeroes. The string `01.020.0034` isn't a valid semantic +/// version and should be written as `1.20.34` instead. +/// +/// The prerelease segment must be prefixed with a single hyphen (`-`) and define one or more +/// identifiers. The build metadata segment must be prefixed with a single plus sign (`+`) and +/// define one or more identifiers. +/// +/// An identifier for prerelease and build metadata segments must be a string consisting of only +/// ASCII alphanumeric characters underscores (regex `\w`). Identifiers in prerelease and build +/// metadata segments must be separated by a single period (`.`), like `rc.1` or +/// `dev.mac_os.sha123`. +/// +/// ### Syntax parsing examples +/// +/// Stable versions are defined as `..`: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_2_3 = SemanticVersion::parse("1.2.3").unwrap(); +/// assert!(v1_2_3.major == 1); +/// assert!(v1_2_3.minor == 2); +/// assert!(v1_2_3.patch == 3); +/// ``` +/// +/// Omitting a version segment, specifying a non-digit for a version segment, and specifying a +/// leading zero for a version segment all cause parsing errors: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2").is_err()); +/// assert!(SemanticVersion::parse("1.x.3").is_err()); +/// assert!(SemanticVersion::parse("1.2.03").is_err()); +/// ``` +/// +/// Prerelease segments immediately follow the patch version segment and are prefixed with a hyphen +/// (`-`): +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_2_3_rc = SemanticVersion::parse("1.2.3-rc").unwrap(); +/// assert!(v1_2_3_rc.pre.as_str() == "rc"); +/// ``` +/// +/// Build metadata immediately follows either the patch version segment or prerelease segment and +/// are prefixed with a plus sign (`+`): +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_2_3_ci = SemanticVersion::parse("1.2.3+ci").unwrap(); +/// let v1_2_3_rc_ci = SemanticVersion::parse("1.2.3-rc+ci").unwrap(); +/// assert!(v1_2_3_ci.build.as_str() == "ci"); +/// assert!(v1_2_3_rc_ci.build.as_str() == "ci"); +/// ``` +/// +/// Putting build metadata before prerelease causes the intended prerelease segment to parse as +/// part of the build metadata: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let build_first = SemanticVersion::parse("1.2.3+ci-rc").unwrap(); +/// assert!(build_first.build.as_str() == "ci-rc"); +/// assert!(build_first.pre.as_str() == ""); +/// +/// let pre_first = SemanticVersion::parse("1.2.3-rc+ci").unwrap(); +/// assert!(pre_first.build.as_str() == "ci"); +/// assert!(pre_first.pre.as_str() == "rc"); +/// ``` +/// +/// Build metadata and prerelease segments may contain multiple components with a separating +/// period: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3-rc.1").is_ok()); +/// ``` +/// +/// Build metadata and prerelease segments and subsegments may start with a hyphen: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3--rc.-1").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+-ci.-1").is_ok()); +/// ``` +/// +/// Digit-only identifiers for prerelease segments must not have leading zeroes but may consist of +/// a single zero: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3-rc.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3-rc.01").is_err()); +/// assert!(SemanticVersion::parse("1.2.3-0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3-01").is_err()); +/// assert!(SemanticVersion::parse("1.2.3-rc.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3-rc.000").is_err()); +/// ``` +/// +/// Digit-only identifiers for build metadata segments can have leading zeroes and consist of +/// multiple zeroes: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3+ci.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+ci.01").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+01").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+ci.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+ci.000").is_ok()); +/// ``` +/// +/// Specifying any character other than a hyphen (`-`), digit (`0-9`), ASCII alphabetic (`a-z` and +/// `A-Z`), or period (`.`) for a prerelease or build metadata segment causes a parsing error: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3-rc@4").is_err()); +/// assert!(SemanticVersion::parse("1.2.3+ci!4").is_err()); +/// ``` +/// +/// ## Semantic version ordering +/// +/// The comparison for semantic versions is performed segment by segment where the segment for the +/// left hand side of the comparison may be equal to, greater than, or less than the segment for +/// the right hand side of the comparison. The comparison logic follows these steps: +/// +/// 1. If the major version segments of the semantic versions are unequal, the version with a +/// higher major version segment is greater. If the major version segments are equal, compare +/// the minor version segments. +/// 1. If the minor version segments of the semantic versions are unequal, the version with a +/// higher minor version segment is greater. If the minor version segments are equal, compare +/// the patch version segments. +/// 1. If the patch version segments of the semantic versions are unequal, the version with a +/// higher patch version segment is greater. If the patch version segments are equal, compare +/// the prerelease segments. +/// 1. If only one version defines a prerelease segment then the stable version, which doesn't +/// define a prerelease segment, is greater. If both versions define a prerelease segment, +/// compare the identifiers for each prerelease segment in their defined order: +/// +/// - If both identifiers contain only digits then the identifiers are compared numerically. +/// - If both identifiers contain non-digit characters then the identifiers are compared +/// in ASCII sort order (hyphen < digits < uppercase letters < lowercase letters). +/// - If the identifiers are identical for both versions, continue to the next identifier, if +/// any. +/// - If only one version defines the next identifier, that version is greater than the other +/// version. For example, `1.2.3-rc.1.a` is greater than `1.2.3-rc.1`. +/// +/// If the prerelease segments are equal, compare the build metadata segments. +/// 1. If only one version defines a build metadata segment then the version with build metadata +/// is greater. If both versions define a build metadata segment, compare the identifiers for +/// each segment in their defined order. The comparison logic for build metadata identifiers is +/// is the same as for prerelease identifiers. +/// 1. If all segments are equal then the versions are equal. +/// +/// Note that build metadata is _always_ ignored when matching against a [`SemanticVersionReq`]. +/// This comparison is _only_ for distinguishing precedence between versions to find the latest +/// version. This behavior can be surprising for end-users, since versions with build metadata +/// are typically seen with development builds, not release builds. Prefer omitting build metadata +/// when defining semantic versions for DSC to make a more consistent experience for users. +/// +/// ### Ordering examples +/// +/// ```rust +/// use dsc_lib::types::SemanticVersion; +/// +/// let v1_0_0: SemanticVersion = "1.0.0".parse().unwrap(); +/// let v2_0_0: SemanticVersion = "2.0.0".parse().unwrap(); +/// let v1_2_3: SemanticVersion = "1.2.3".parse().unwrap(); +/// let v1_2_3_pre: SemanticVersion = "1.2.3-rc.1".parse().unwrap(); +/// let v1_2_3_build: SemanticVersion = "1.2.3+ci.1".parse().unwrap(); +/// let v1_2_3_pre_build: SemanticVersion = "1.2.3-rc.1+ci.1".parse().unwrap(); +/// +/// // Comparisons of stable versions work as expected +/// assert!(v1_0_0 < v1_2_3); +/// assert!(v2_0_0 > v1_2_3); +/// // Stable versions is always greater than prerelease for same version +/// assert!(v1_0_0 < v1_2_3_pre); +/// assert!(v1_2_3 > v1_2_3_pre); +/// // Version with build metadata is greater than same version +/// assert!(v1_2_3_build > v1_2_3); +/// assert!(v1_2_3_pre_build > v1_2_3_pre); +/// // Build metadata is ignored when versions aren't the same +/// assert!(v2_0_0 > v1_2_3_build); +/// +/// let rc: SemanticVersion = "1.2.3-rc".parse().unwrap(); +/// let rc_1: SemanticVersion = "1.2.3-rc.1".parse().unwrap(); +/// let rc_1_2: SemanticVersion = "1.2.3-rc.1.2".parse().unwrap(); +/// // When the first identifier is identical, the version with an extra +/// // identifier is greater +/// assert!(rc < rc_1); +/// assert!(rc_1 < rc_1_2); +/// +/// // To correct sort prerelease and build versions, make sure to separate +/// // the alpha segment like `rc` or `ci`from the numeric. Otherwise, the +/// // ordering may be unexpected, like `rc11` < `rc2` +/// let rc11: SemanticVersion = "1.2.3-rc11".parse().unwrap(); +/// let rc2: SemanticVersion = "1.2.3-rc2".parse().unwrap(); +/// let rc_11: SemanticVersion = "1.2.3-rc.11".parse().unwrap(); +/// let rc_2: SemanticVersion = "1.2.3-rc.2".parse().unwrap(); +/// assert!(rc2 > rc11); +/// assert!(rc_11 > rc_2); +/// +/// // For identifiers, hyphen < digit < uppercase alpha < lowercase alpha +/// // Showing for build but ordering applies to prerelease identifiers too +/// let middle_hyphen: SemanticVersion = "1.2.3+a-a".parse().unwrap(); +/// let middle_digit: SemanticVersion = "1.2.3+a0a".parse().unwrap(); +/// let middle_upper: SemanticVersion = "1.2.3+aAa".parse().unwrap(); +/// let middle_lower: SemanticVersion = "1.2.3+aaa".parse().unwrap(); +/// assert!(middle_hyphen < middle_digit); +/// assert!(middle_digit < middle_upper); +/// assert!(middle_upper < middle_lower); +/// ``` +/// +/// # Determining latest version +/// +/// DSC uses the default ordering for semantic versions where: +/// +/// - A higher version supercedes a lower version, regardless of prerelease and build metadata. +/// - A stable version supercedes the same version with a prerelease segment. +/// - A stable version with build metadata supercedes the same version without build metadata. +/// - Prerelease and build metadata segments are compared lexicographically. +/// +/// Consider the following example: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_0_0 = SemanticVersion::parse("1.0.0").unwrap(); +/// let v1_2_3 = SemanticVersion::parse("1.2.3").unwrap(); +/// let v2_0_0 = SemanticVersion::parse("2.0.0").unwrap(); +/// let v1_2_3_rc_1 = SemanticVersion::parse("1.2.3-rc.1").unwrap(); +/// let v1_2_3_ci_1 = SemanticVersion::parse("1.2.3+ci.1").unwrap(); +/// +/// let mut versions = vec![ +/// v1_0_0.clone(), +/// v1_2_3.clone(), +/// v2_0_0.clone(), +/// v1_2_3_rc_1.clone(), +/// v1_2_3_ci_1.clone() +/// ]; +/// versions.sort(); +/// +/// assert_eq!( +/// versions, +/// vec![ +/// v1_0_0, +/// v1_2_3_rc_1, +/// v1_2_3, +/// v1_2_3_ci_1, +/// v2_0_0 +/// ] +/// ); +/// ``` +/// +/// When the versions are sorted, the latest version is `2.0.0`. When considering the versions +/// `1.2.3`, `1.2.3-rc.1`, and `1.2.3+ci.1`, they sort as `1.2.3+ci.1 > 1.2.3 > 1.2.3-rc.1`. +/// +/// Versions sorting with build metadata as later than typical stable versions may be surprising to +/// end users. When publishing DSC resources and extensions, authors should always set the version +/// in a manifest to _exclude_ build metadata. +/// +/// # Matching version requirements +/// +/// DSC uses the [`SemanticVersionReq`] type for defining version ranges. This enables users to +/// pin to specific versions or a range of supported versions. For more information, see +/// [`SemanticVersionReq`]. +/// +/// [01]: https://semver.org +/// [`SemanticVersionReq`]: crate::types::SemanticVersionReq +#[derive(Debug, Clone, Hash, Eq, Serialize, Deserialize, DscRepoSchema)] +#[dsc_repo_schema(base_name = "semver", folder_path = "definitions")] +pub struct SemanticVersion(semver::Version); + +impl SemanticVersion { + /// Create an instance of [`SemanticVersion`] with empty prerelease and build segments. + /// + /// # Examples + /// + /// ```rust + /// pub use dsc_lib::types::SemanticVersion; + /// + /// let version = &SemanticVersion::new(1, 2, 3); + /// + /// assert!( + /// semver::VersionReq::parse(">1.0").unwrap().matches(version) + /// ); + /// ``` + pub fn new(major: u64, minor: u64, patch: u64) -> Self { + Self(semver::Version::new(major, minor, patch)) + } + + /// Create an instance of [`SemanticVersion`] from a string representation. + /// + /// # Errors + /// + /// Parsing fails when the input string isn't a valid semantic version. Common parse errors + /// include: + /// + /// - Not specifying major, minor, and patch segments, like `1` or `1.0` instead of `1.0.0`. + /// - Specifying a leading zero before a non-zero digit in a version segment, like `01.02.03` + /// instead of `1.2.3`. + /// - Specifying a non-digit character in a major, minor, or patch version segment, like + /// `1a.2.3`, `1.2b.3`, or `1.2.c3`. + /// - Specifying a hyphen after the version without a prelease segment, like `1.2.3-`. + /// - Specifying a plus sign after the version without a build metadata segment, like `1.2.3+`. + /// - Invalid characters in prerelease or build metadata segments, which only allow the + /// characters `a-z`, `a-Z`, `0-9`, `-`, and `.`, such as `1.2.3-rc_1` or `1.2.3+ci@sha`. + pub fn parse(value: &str) -> Result { + match semver::Version::parse(value) { + Ok(v) => Ok(Self(v)), + Err(e) => Err(DscError::SemVer(e)), + } + } + + /// Defines the validating regular expression for semantic versions. + /// + /// This regular expression is adapted from the officially recommended pattern on [semver.org] + /// and is used for the `pattern` keyword in the JSON Schema for the [`SemanticVersion`] type. + /// + /// The pattern isn't used for validating an instance during or after deserialization. Instead, + /// it provides author-time feedback to manifest maintainers so they can avoid runtime failures. + /// + /// During deserialization, the library first tries to parse the string as a semantic version. + /// If the value parses successfully, it's deserialized as a [`SemanticVersion`] instance. + /// Otherwise, deserialization fails. + /// + /// [semver.org]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + pub const VALIDATING_PATTERN: &str = const_str::concat!( + "^", // Anchor pattern to start of string. + SemanticVersion::CAPTURING_VERSION_MAJOR_PATTERN, // Must include major version segment, + r"\.", // then a period (`.`), + SemanticVersion::CAPTURING_VERSION_MINOR_PATTERN, // then the minor version segment, + r"\.", // then a period (`.`), + SemanticVersion::CAPTURING_VERSION_PATCH_PATTERN, // then the patch version segment. + "(?:", // Open non-capturing group for prerelease segment. + "-", // Prerelease follows patch version with a hyphen + SemanticVersion::CAPTURING_PRERELEASE_PATTERN, // then the actual segment. + ")", // Close the non-capturing group for prerelease. + "?", // Mark the prerelease segment as optional. + "(?:", // Open non-capturing group for build metadata segment. + r"\+", // Build follows patch or prerelease with a plus + SemanticVersion::CAPTURING_BUILD_METADATA_PATTERN, // then the actual segment. + ")", // Close the non-capturing group for build metadata. + "?", // Mark the build metadata segment as optional. + "$" // Anchor pattern to end of string. + ); + + pub(crate) const VERSION_SEGMENT_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group for version segment + "0", // segments can be either zero + "|", // or + r"[1-9]\d*", // any integer greater than zero + ")", // Close non-capturing group for version segment + ); + pub(crate) const CAPTURING_VERSION_MAJOR_PATTERN: &str = const_str::concat!( + "(?", // Open the named capture group + SemanticVersion::VERSION_SEGMENT_PATTERN, // Capture the version segment + ")" // Close the named capture group + ); + pub(crate) const CAPTURING_VERSION_MINOR_PATTERN: &str = const_str::concat!( + "(?", // Open the named capture group + SemanticVersion::VERSION_SEGMENT_PATTERN, // Capture the version segment + ")" // Close the named capture group + ); + pub(crate) const CAPTURING_VERSION_PATCH_PATTERN: &str = const_str::concat!( + "(?", // Open the named capture group + SemanticVersion::VERSION_SEGMENT_PATTERN, // Capture the version segment + ")" // Close the named capture group + ); + pub(crate) const PRERELEASE_SUBSEGMENT_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group to avoid cluttering results. + SemanticVersion::VERSION_SEGMENT_PATTERN, // Subsegment can either be a version segment + "|", // or + r"\d*[a-zA-Z-]", // any number of digits followed by a letter or hyphen, then + "[0-9a-zA-Z-]*", // any number of digits, letters, or hyphens. + ")" // Close the non-capturing group. + ); + pub(crate) const PRERELEASE_SEGMENT_PATTERN: &str = const_str::concat!( + SemanticVersion::PRERELEASE_SUBSEGMENT_PATTERN, // Start with a valid prerelease subsegment + "(?:", // Open a non-capturing group to avoid cluttering. + r"\.", // First character after prior subsegment must be a `.`, + SemanticVersion::PRERELEASE_SUBSEGMENT_PATTERN, // followed by another valid prerelease segment. + ")", // Close the non-capturing group for extra subsegments. + "*" // Match additional subsegments zero or more times. + ); + pub(crate) const CAPTURING_PRERELEASE_PATTERN: &str = const_str::concat!( + "(?", // Open named capture group. + SemanticVersion::PRERELEASE_SEGMENT_PATTERN, // Capture the segment. + ")" // Close the named capture group. + ); + /// A subsegment of build metadata consists of one or more digits, letters, and hyphens. + pub(crate) const BUILD_METADATA_SUBSEGMENT_PATTERN: &str = "[0-9a-zA-Z-]+"; + pub(crate) const BUILD_METADATA_SEGMENT_PATTERN: &str = const_str::concat!( + SemanticVersion::BUILD_METADATA_SUBSEGMENT_PATTERN, // Start with a valid build metadata subsegment + "(?:", // Open a non-capturing group to avoid cluttering. + r"\.", // First character after prior subsegment must be a `.`, + SemanticVersion::BUILD_METADATA_SUBSEGMENT_PATTERN, // followed by another valid subsegment. + ")", // Close the non-capturing group for extra subsegments. + "*" // Match additional subsegments zero or more times. + ); + pub(crate) const CAPTURING_BUILD_METADATA_PATTERN: &str = const_str::concat!( + "(?", // Open named capture group. + SemanticVersion::BUILD_METADATA_SEGMENT_PATTERN, // Capture the segment. + ")" // Close the named capture group. + ); +} + +impl JsonSchema for SemanticVersion { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": t!("schemas.definitions.semver.title"), + "description": t!("schemas.definitions.semver.description"), + "markdownDescription": t!("schemas.definitions.semver.markdownDescription"), + "type": "string", + "pattern": Self::VALIDATING_PATTERN, + "patternErrorMessage": t!("schemas.definitions.semver.patternErrorMessage"), + }) + } +} + +impl Default for SemanticVersion { + fn default() -> Self { + Self(semver::Version::new(0, 0, 0)) + } +} + +impl Display for SemanticVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +// Infallible conversions +impl From for SemanticVersion { + fn from(value: semver::Version) -> Self { + Self(value) + } +} + +impl From for semver::Version { + fn from(value: SemanticVersion) -> Self { + value.0 + } +} + +impl From<&semver::Version> for SemanticVersion { + fn from(value: &semver::Version) -> Self { + Self(value.clone()) + } +} + +impl From<&SemanticVersion> for semver::Version { + fn from(value: &SemanticVersion) -> Self { + value.0.clone() + } +} + +impl From for String { + fn from(value: SemanticVersion) -> Self { + value.to_string() + } +} + +// Fallible conversions +impl FromStr for SemanticVersion { + type Err = DscError; + fn from_str(s: &str) -> Result { + SemanticVersion::parse(s) + } +} + +impl TryFrom for SemanticVersion { + type Error = DscError; + fn try_from(value: String) -> Result { + match semver::Version::parse(value.as_str()) { + Ok(v) => Ok(Self(v)), + Err(e) => Err(DscError::SemVer(e)), + } + } +} + +impl TryFrom<&str> for SemanticVersion { + type Error = DscError; + fn try_from(value: &str) -> Result { + SemanticVersion::from_str(value) + } +} + +// Referencing and dereferencing +impl AsRef for SemanticVersion { + fn as_ref(&self) -> &semver::Version { + &self.0 + } +} + +impl Deref for SemanticVersion { + type Target = semver::Version; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// Comparison traits +impl PartialEq for SemanticVersion { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &semver::Version) -> bool { + &self.0 == other + } +} + +impl PartialEq for semver::Version { + fn eq(&self, other: &SemanticVersion) -> bool { + self == &other.0 + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &String) -> bool { + match Self::parse(other.as_str()) { + Ok(other_version) => self.eq(&other_version), + Err(_) => false, + } + } +} + +impl PartialEq for String { + fn eq(&self, other: &SemanticVersion) -> bool { + match SemanticVersion::parse(self.as_str()) { + Ok(version) => version.eq(other), + Err(_) => false, + } + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &str) -> bool { + match Self::parse(other) { + Ok(other_version) => self.eq(&other_version), + Err(_) => false, + } + } +} + +impl PartialEq for str { + fn eq(&self, other: &SemanticVersion) -> bool { + match SemanticVersion::parse(self) { + Ok(version) => version.eq(other), + Err(_) => false, + } + } +} + +impl PartialEq<&str> for SemanticVersion { + fn eq(&self, other: &&str) -> bool { + match Self::parse(*other) { + Ok(other_version) => self.eq(&other_version), + Err(_) => false, + } + } +} + +impl PartialEq for &str { + fn eq(&self, other: &SemanticVersion) -> bool { + match SemanticVersion::parse(*self) { + Ok(version) => version.eq(other), + Err(_) => false, + } + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &Self) -> Option { + self.0.partial_cmp(&other.0) + } +} + +impl Ord for SemanticVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &semver::Version) -> Option { + self.0.partial_cmp(other) + } +} + +impl PartialOrd for semver::Version { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + self.partial_cmp(&other.0) + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &String) -> Option { + match Self::parse(other.as_str()) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } + } +} + +impl PartialOrd for String { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + match SemanticVersion::parse(self.as_str()) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &str) -> Option { + match Self::parse(other) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } + } +} + +impl PartialOrd for str { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + match SemanticVersion::parse(self) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } + } +} diff --git a/lib/dsc-lib/src/types/semantic_version_req.rs b/lib/dsc-lib/src/types/semantic_version_req.rs new file mode 100644 index 000000000..8c52d32e2 --- /dev/null +++ b/lib/dsc-lib/src/types/semantic_version_req.rs @@ -0,0 +1,888 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fmt::Display, ops::Deref, str::FromStr, sync::OnceLock}; + +use regex::Regex; +use rust_i18n::t; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Serialize}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::SemanticVersion}; + +/// Defines one or more limitations for a semantic version to enable version pinning. +/// +/// DSC uses the semantic version requirements for Rust, as documented in the +/// ["Specifying dependencies" section of the Cargo Book][01]. DSC adheres closely to the +/// Rust syntax for defining semantic version requirements, with the following exceptions: +/// +/// 1. DSC semantic version requirements _forbid_ the inclusion of build metadata. +/// +/// Rust allows and ignores them. DSC forbids the inclusion of build metadata to limit confusion +/// and unexpected behavior when specifying a version requirement for a DSC resource or +/// extension. +/// 1. DSC semantic version requirements _must_ define a major version segment. All other segments +/// are optional. +/// +/// Rust technically supports specifying a wildcard-only version requirement (`*`). DSC forbids +/// specifying this version requirement as it maps to the default version selection and is +/// discouraged when specifying version requirements for production systems. +/// 1. DSC semantic version requirements only support the asterisk (`*`) character for wildcards, +/// not `x` or `X`. +/// +/// DSC forbids specifying wildcards as non-asterisks to reduce ambiguity and unexpected +/// behavior for prerelease segments, where `1.2.3-X` and `1.2.3-rc.x` are _valid_ requirements +/// but where the characters are _not_ interpreted as wildcards. Forbidding the use of these +/// characters prevents users from accidentally defining a requirement they _believe_ will +/// wildcard match on a prerelease segment but actually won't. +/// +/// # Default requirement +/// +/// The default requirement matches every possible stable version. It only rejects versions with a +/// prerelease segment, like `1.2.3-rc.1`. For DSC, the default requirement is used only when no +/// explicit requirement is given. +/// +/// Effectively, the default requirement is `>=0.0.0`. +/// +/// # Comparators +/// +/// Every requirement defines one or more comparators. A comparator defines an operator and a +/// version for comparing against an instance of [`SemanticVersion`]. A requirement with multiple +/// comparators must separate each pair of comparators with a comma (`,`), like `^1.2, <=1.4`. +/// +/// When a requirement specifies multiple comparators, a given instance of [`SemanticVersion`] only +/// matches the requirement when it matches _every_ comparator. Requirements with multiple +/// comparators effectively apply a logical `AND` for each comparator. If a requirement is defined +/// with incompatible comparators then _no_ version will ever match that requirement. For example, +/// the requirement `<1.2, >=2.3` can never match a version because no version can be less than +/// `1.2.0` _and_ greater than or equal to `2.3.0`. +/// +/// There is no way to define a requirement using logical `OR` for multiple comparators. Instead, +/// define multiple requirements and check them independently in your code. +/// +/// The following example shows how multiple comparators work in practice. +/// +/// ```rust +/// use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; +/// +/// // The requirement acts as a logical AND, matching versions between 1.2.0 and 1.4.0: +/// let valid_req = SemanticVersionReq::parse(">=1.2, <1.4").unwrap(); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 1, 0)), false); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 2, 0)), true); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 3, 0)), true); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 4, 0)), false); +/// +/// // The invalid requirement never matches any versions: +/// let invalid_req = SemanticVersionReq::parse("<=1.2, >1.4").unwrap(); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 1, 0)), false); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 2, 0)), false); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 3, 0)), false); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 4, 0)), false); +/// +/// // To match two or more incompatible version requirements, use an or statement: +/// let le_req = SemanticVersionReq::parse("<=1.2").unwrap(); +/// let gt_req = SemanticVersionReq::parse(">1.4").unwrap(); +/// let v1_0_0 = &SemanticVersion::new(1, 0, 0); +/// let v1_3_0 = &SemanticVersion::new(1, 3, 0); +/// let v1_5_0 = &SemanticVersion::new(1, 5, 0); +/// assert_eq!( +/// le_req.matches(v1_0_0) || gt_req.matches(v1_0_0), +/// true +/// ); +/// assert_eq!( +/// le_req.matches(v1_3_0) || gt_req.matches(v1_3_0), +/// false +/// ); +/// assert_eq!( +/// le_req.matches(v1_5_0) || gt_req.matches(v1_5_0), +/// true +/// ); +/// ``` +/// +/// ## Specifying comparator versions +/// +/// Every comparator in a version requirement must define a version. Only the major version segment +/// is required. The minor, patch, and prerelease segments are optional. The build metadata segment +/// is forbidden. +/// +/// ### Omitting version segments +/// +/// When defining a version for a comparator, you must define the major version segment. You can +/// omit either or both the minor and version segments. The following comparators define valid +/// versions: +/// +/// - `>=1` - Matches all versions greater than or equal to `1.0.0`. +/// - `>=1.2` - Matches all versions greater than or equal to `1.2.0`. +/// +/// ### Wildcard version segments +/// +/// You can specify the minor and patch version segments as a wildcard with the asterisk (`*`) +/// character, indicating that it should match any version for that segment. If the minor version +/// segment is a wildcard, the patch version segment must either be a wildcard or omitted. +/// +/// When specifying an explicit operator, specifying the version for a comparator with wildcards is +/// equivalent to omitting those version segments. When you define a comparator without an explicit +/// operator and with a version that defines one or more wildcard segments, the implicit operator +/// for that comparator is the _wildcard operator_ instead of the _caret operator_. For more +/// information about the behavior of comparators without an explicit operator, see +/// [Specifying comparators with implicit operators](#specifying-comparators-with-implicit-operators). +/// +/// The following table shows how comparators behave depending on whether they specify an operator, +/// omit version segments, and use wildcards. Each row defines a literal comparator, the effective +/// requirement for that comparator, and a set of equivalent comparators. +/// +/// | Comparator | Effective requirement | Equivalent comparators | +/// |:----------:|:---------------------:|:---------------------------------------------------------------| +/// | `1` | `>=1.0.0, <2.0.0` | `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `1.2` | `>=1.2.0, <2.0.0` | `^1.2`, `^1.2.*` | +/// | `1.*` | `>=1.0.0, <2.0.0` | `1`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `1.2.*` | `>=1.2.0, <1.3.0` | `=1.2`, `=1.2.*` | +/// | `^1` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `^1.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `^1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `^1.2` | `>=1.2.0, <2.0.0` | `1.2`, `^1.2.*` | +/// | `^1.2.*` | `>=1.2.0, <2.0.0` | `1.2` | +/// | `=1` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1.*`, `=1.*.*` | +/// | `=1.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*.*` | +/// | `=1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*` | +/// | `=1.2` | `>=1.2.0, <1.3.0` | `1.2.*`, `=1.2.*` | +/// | `=1.2.*` | `>=1.2.0, <1.3.0` | `1.2.*`, `=1.2` | +/// +/// Effectively, not specifying the minor or patch version segments is equivalent to specifying +/// the missing segments as wildcards in most cases. That means that the comparators `1`, `1.*`, +/// and `1.*.*` are equivalent. +/// +/// The exception to this rule is when the comparator defines a version with literal major and minor +/// version segments, a wildcard for the patch version segment, and no explicit operator, like +/// `1.2.*`. In that case, because the implicit operator is the wildcard operator, the effective +/// requirement becomes `>=1.2.0, <1.3.0` instead of `>=1.2.0, <2.0.0`. +/// +/// To reduce ambiguity and unexpected version matching, _always_ specify an explicit operator. +/// +/// ### Prerelease version segments +/// +/// A comparator only ever matches a version with a prerelease segment when the comparator version +/// also defines a prerelease segment. Prerelease segments are only compared when the comparator +/// version and the target version have identical major, minor, and patch version segments. +/// Prerelease segments are compared as strings for ordering. +/// +/// The comparator `^1` can never match `1.2.3-rc.1` or `1.3.0-pre`. To define a prerelease +/// segment, you must define the major, minor, and patch version segments as literals without any +/// wildcards, like `1.2.3-rc`. +/// +/// To define a comparator with a version that matches any valid prerelease for that version, +/// specify the prerelease segment as `0`, like `1.2.3-0`. +/// +/// To help show how prerelease segments affect version matching, the following table defines +/// a series of comparators and whether different versions match those comparators. +/// +/// | Comparator version | Matching versions | Non-matching versions | +/// |:------------------:|:--------------------------------------------------------------------------|:------------------------------------------------------| +/// | `>=2.0.0` | `2.0.0`, `2.1.0`, `3.0.0` | `1.2.3`, `2.0.0-alpha.1`, `2.1.0-beta.2`, `3.0.0-rc1` | +/// | `>=2.0.0-alpha` | `2.0.0`, `2.1.0`, `3.0.0`, `2.0.0-alpha`, `2.0.0-alpha.1`, `2.0.0-beta.1` | `1.2.3`, `2.0.0-0` | +/// | `>=2.0.0-beta` | `2.0.0`, `2.1.0`, `3.0.0`, `2.0.0-beta`, `2.0.0-beta.1` | `1.2.3`, `2.0.0-alpha` | +/// | `>=2.0.0-0` | `2.0.0`, `2.1.0`, `3.0.0`, `2.0.0-1`, `2.0.0-alpha`, `2.0.0-beta` | `1.2.3` | +/// +/// ### Forbidding build metadata in comparator versions +/// +/// DSC forbids the inclusion of build metadata in comparator versions to reduce ambiguity. While +/// the underlying Rust implementation for version requirements ([`semver::VersionReq`]) allows +/// versions with build metadata, like `1.2.3+sha123` or `1.2.3-rc.1+dev.debug.linux`, it ignores +/// those segments entirely when matching a semantic version against the requirement. +/// +/// To prevent users from assuming that a version requirement might operate on the build metadata, +/// DSC forbids its inclusion in a version requirement string and raises the +/// [`SemVerReqWithBuildMetadata`] error during parsing if one is specified. +/// +/// ### Examples of invalid comparator versions +/// +/// The following list enumerates a series of invalid versions for comparators with the reasons why +/// each version is invalid for a comparator: +/// +/// - `*.*.*` - The major version segment must be a literal number, not a wildcard, like `1.*.*`. +/// If you want to allow any version, do not specify a version requirement explicitly. +/// - `1.*.3` - When the minor version segment is a wildcard, the patch version segment must either +/// be a wildcard or omitted, like `1.*.*` or `1.*`. +/// - `1.2-rc` - A prerelease segment is only valid when the major, minor, and patch version +/// segments are all defined as literals, like `1.2.0-rc`. +/// - `1.2.*-rc` - A prerelease segment is only valid when the major, minor, and patch version +/// segments are literals without any wildcards, like `1.2.0-rc`. +/// - `1.2.3-*` - Wildcards aren't permitted for prerelease version segments. To effectively +/// specify a prerelease segment that matches any prerelease versions for a given version, +/// define the prerelease segment as `0`. +/// - `1.2.3+sha123` - Build metadata segments aren't permitted for versions in comparators. +/// +/// ## Specifying comparator operators +/// +/// An operator defines how to compare a given [`SemanticVersion`] against the version component +/// of the comparator. The operator for a comparator is optional. For more information about how +/// comparators behave without an explicit operator, see +/// [Specifying comparators with implicit operators](#specifying-comparators-with-implicit-operators). +/// +/// The following list enumerates the available operators. Each definition includes a table of +/// examples demonstrating how the operator behaves. +/// +/// - Caret (`^`) - Indicates that the [`SemanticVersion`] must be +/// semantically compatible with the version for this comparator. The version must be equal to or +/// greater than the given version in the comparator and less than the next major version. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:----------------------:|:---------------------------------------------|:---------------------------------------------| +/// | `^1` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2` | `>=1.2.0, <2.0.0` | `1.2.0`, `1.2.3`, `1.3.0` | `1.0.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2.*` | `>=1.2.0, <2.0.0` | `1.2.0`, `1.2.3`, `1.3.0` | `1.0.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2.3` | `>=1.2.3, <2.0.0` | `1.2.3`, `1.2.4`, `1.3.0` | `1.2.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2.3-rc.2` | `>=1.2.3-rc.2, <2.0.0` | `1.2.3`, `1.3.0`, `1.2.3-rc.2`, `1.2.3-rc.3` | `1.2.0`, `2.0.0`, `1.2.3-rc.1`, `1.3.0-rc.2` | +/// +/// - Tilde (`~`) - Indicates that the [`SemanticVersion`] must be +/// greater than or equal to the version for this comparator. The upper bound of matching +/// versions depends on how many components the version of the comparator defines: +/// +/// - If the comparator defines only the major version segment, like `~ 1`, the comparator +/// matches any version less than the next major version. +/// - If the comparator defines the major and minor version segments, like `~ 1.2` or `~ 1.2.3`, +/// the comparator matches any version less than the next minor version. +/// +/// The patch and prerelease segments of the version for the comparator only affect the minimum +/// version bound for the requirement. They don't affect the upper bound. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:----------------------:|:---------------------------------------------|:-------------------------------| +/// | `~1` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `~1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `~1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `~1.2` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.1` | +/// | `~1.2.*` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.1` | +/// | `~1.2.3` | `>=1.2.3, <1.3.0` | `1.2.3`, `1.2.9` | `1.2.0`, `1.3.0`, `1.2.3-rc.1` | +/// | `~1.2.3-rc.2` | `>=1.2.3-rc.2, <1.3.0` | `1.2.3`, `1.2.9`, `1.2.3-rc.2`, `1.2.3-rc.3` | `1.2.0`, `1.3.0`, `1.2.3-rc.1` | +/// +/// - Less than (`<`) - Indicates that the [`SemanticVersion`] must +/// be less than the version for this comparator. Versions equal to or greater than the +/// comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:----------------------:|:----------------------------------------|:---------------------------------------------| +/// | `<1` | `<1.0.0` | `0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.*` | `<1.0.0` | `0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.*.*` | `<1.0.0` | `0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.2` | `<1.2.0` | `0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | +/// | `<1.2.*` | `<1.2.0` | `0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | +/// | `<1.2.3` | `<1.2.3` | `0.1.0`, `1.0.0`, `1.2.0` | `1.2.3`, `1.3.0`, `1.2.3-rc.1` | +/// | `<1.2.3-rc.2` | `<1.2.3-rc.2` | `0.1.0`, `1.0.0`, `1.2.0`, `1.2.3-rc.1` | `1.2.3`, `1.3.0`, `1.0.0-rc.1`, `1.2.3-rc.2` | +/// +/// - Less than or equal to (`<=`) - Indicates that the +/// [`SemanticVersion`] must be any version up to the version for this comparator. Versions +/// greater than the comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:------------------------------------|:---------------------------------------------| +/// | `<=1` | `<2.0.0` | `0.1.0`, `1.0.0`, `1.2.3` | `2.0.0`, `0.1.0-rc.1`, `1.0.0-rc.1` | +/// | `<=1.*` | `<2.0.0` | `0.1.0`, `1.0.0`, `1.2.3` | `2.0.0`, `0.1.0-rc.1`, `1.0.0-rc.1` | +/// | `<=1.*.*` | `<2.0.0` | `0.1.0`, `1.0.0`, `1.2.3` | `2.0.0`, `0.1.0-rc.1`, `1.0.0-rc.1` | +/// | `<=1.2` | `<1.3.0` | `0.1.0`, `1.0.0`, `1.2.0`, `1.2.3` | `1.3.0`, `1.0.0-rc.1`, `1.2.0-rc.1` | +/// | `<=1.2.*` | `<1.3.0` | `0.1.0`, `1.0.0`, `1.2.0`, `1.2.3` | `1.3.0`, `1.0.0-rc.1`, `1.2.0-rc.1` | +/// | `<=1.2.3` | `<=1.2.3` | `0.1.0`, `1.0.0`, `1.2.3` | `1.2.4`, `1.3.0`, `1.2.0-rc.1`, `1.2.3-rc.1` | +/// | `<=1.2.3-rc.2` | `<=1.2.3-rc.2` | `0.1.0`, `1.2.3-rc.1`, `1.2.3-rc.2` | `1.2.3`, `1.3.0`, `1.0.0-rc.1`, `1.2.3-rc.3` | +/// +/// - Exact (`=`) - Indicates that the [`SemanticVersion`] must be +/// the same as the given version for this comparator. If the comparator version omits +/// version segments or specifies them as wildcards, then the comparator matches a range of +/// versions. A comparator that defines a literal patch version only matches that exact +/// version. A comparator that defines a prerelease segment only matches that exact patch version +/// and prerelease segment. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:-----------------|:--------------------------------| +/// | `=1` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.0.0-rc.2` | +/// | `=1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.0.0-rc.2` | +/// | `=1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.0.0-rc.2` | +/// | `=1.2` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.2` | +/// | `=1.2.*` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.2` | +/// | `=1.2.3` | `=1.2.3` | `1.2.3` | `1.2.0`, `1.3.0`, `1.2.3-rc.2` | +/// | `=1.2.3-rc.2` | `=1.2.3-rc.2` | `1.2.3-rc.2` | `1.2.3`, `1.3.0`, `1.2.3-rc.1` | +/// +/// - Greater than (`>`) - Indicates that the +/// [`SemanticVersion`] must be greater than the version for this comparator. Versions equal to +/// or less than the comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:------------------------------|:------------------------------------| +/// | `>1` | `>=2.0.0` | `2.0.0`, `2.3.4` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.*` | `>=2.0.0` | `2.0.0`, `2.3.4` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.*.*` | `>=2.0.0` | `2.0.0`, `2.3.4` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2` | `>=1.3.0` | `1.3.0`, `2.0.0` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2.*` | `>=1.3.0` | `1.3.0`, `2.0.0` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2.3` | `>=1.2.4` | `1.2.4`, `2.0.0` | `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2.3-rc.1` | `>=1.2.3-rc.2` | `1.2.3`,`2.0.0`, `1.2.3-rc.3` | `1.2.0`, `1.2.3-rc.1`, `2.0.0-rc.2` | +/// +/// - Greater than or equal to (>=) - Indicates that +/// the [`SemanticVersion`] must be the same as the version for this comparator or newer. +/// Versions less than the comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:---------------------------------------------|:------------------------------------| +/// | `>=1` | `>=1.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `1.2.3-rc.2` | +/// | `>=1.*` | `>=1.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `1.2.3-rc.2` | +/// | `>=1.*.*` | `>=1.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `1.2.3-rc.2` | +/// | `>=1.2` | `>=1.2.0` | `1.2.0`, `1.2.3` | `1.1.1`, `1.2.3-rc.2` | +/// | `>=1.2.*` | `>=1.2.0` | `1.2.0`, `1.2.3` | `1.1.1`, `1.2.3-rc.2` | +/// | `>=1.2.3` | `>=1.2.3` | `1.2.3`, `1.3.0` | `1.2.2`, `1.2.3-rc.2`, `2.0.0-rc.2` | +/// | `>=1.2.3-rc.1` | `>=1.2.3-rc.2` | `1.2.3`, `2.0.0`, `1.2.3-rc.2`, `1.2.3-rc.3` | `1.2.0`, `1.2.3-rc.1`, `2.0.0-rc.2` | +/// +/// - Wildcard - The wildcard operator is a purely implicit operator. +/// A comparator uses the wildcard operator when it defines a version that includes at least one +/// wildcard without an explicit operator. +/// +/// The wildcard operator is equivalent to the [exact operator (`=`)](#operator-exact). +/// +/// Because a comparator with a wildcard operator _always_ defines a version with one or more +/// wildcard segments, these comparators can _never_ match a prerelease version. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:-----------------|:-------------------------------| +/// | `1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `1.2.*` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.1.1`, `1.3.0`, `1.2.3-rc.1` | +/// +/// ### Specifying comparators with implicit operators +/// +/// When you don't specify an explicit operator, the version requirement implicitly defaults to one +/// of two operators: +/// +/// 1. If the version doesn't define any wildcards, the implicit operator for the comparator is +/// the caret operator. The following sets of comparators are parsed identically: +/// +/// - `1` and `^1` +/// - `1.2` and `^1.2` +/// - `1.2.3` and `^1.2.3` +/// - `1.2.3-rc.1` and `^1.2.3-rc.1` +/// +/// 1. If the version defines one or more wildcards, the implicit operator for the comparator is +/// the wildcard operator, which behaves like the exact operator (`=`). The following pairs of +/// comparators are equivalent: +/// +/// - `1.*` and `=1.*` +/// - `1.*.*` and `=1.*.*` +/// - `1.2.*` and `=1.2.*` +/// +/// A potentially confusing and ambiguous effect of the underlying implementation is that, except +/// for one case, omitting a version segment and specifying it as a wildcard have identical +/// behaviors. The exception is for defining a version with an implicit operator. The comparators +/// `1.2` and `1.2.*` are _not_ equivalent. +/// +/// The comparator `1.2` effectively expands to the comparator pair `>=1.2.0, <2.0.0` while the +/// comparator `1.2.*` effectively expands to `>=1.2.0, <1.3.0`. +/// +/// To avoid this ambiguity and potentially unexpected matching (or _not_ matching) of versions, +/// always explicitly define an operator for your comparators. +/// +/// # Serialization +/// +/// Note that during serialization instances of [`SemanticVersionReq`]: +/// +/// 1. If the originally parsed requirement uses an implicit operator and a version without any +/// wildcards, like `1.2.3`, it serializes with the caret operator as `^1.2.3`. +/// 1. If the originally parsed requirement defines an explicit operator and a version with any +/// wildcards, it serializes with the wildcard segments omitted. For example, consider the +/// following table showing how different comparators serialize: +/// +/// | Originally parsed comparator | Serialized comparator | +/// |:----------------------------:|:---------------------:| +/// | `^1.*` | `~1` | +/// | `^1.*.*` | `~1` | +/// | `^1.2.*` | `~1.2` | +/// +/// 1. If the originally parsed requirement has any separating spaces between an operator and +/// version, like `>= 1.2` or `>= 1.2`, it serializes without any spaces as `>= 1.2`. +/// 1. If the originally parsed requirement defines a pair of comparators, it always serializes the +/// pair separated by a comma followed by a single space. For example, all of the originally +/// parsed requirements in the following list serialize as `>=1.2, <1.5`: +/// +/// - `>=1.2,<1.5` +/// - `>=1.2 ,<1.5` +/// - `>=1.2, <1.5` +/// - `>=1.2 , <1.5` +/// +/// This can make it difficult to effectively round-trip a requirement when deserializing and +/// reserializing. To define a version requirement that will round-trip without any changes: +/// +/// 1. Always define an operator for each comparator. +/// 1. Always omit version segments rather than specifying a wildcard. +/// 1. Never separate operators and versions in a comparator with any spaces. +/// 1. When defining a requirement with multiple comparators, always follow the preceding +/// comparator with a comma followed by a single space before the succeeding comparator. +/// +/// The following table shows requirements that won't correctly round-trip with an equivalent +/// requirement that _does_ round trip. +/// +/// | Non-round-tripping requirement | Round-tripping requirement | +/// |:------------------------------:|:--------------------------:| +/// | `1` | `^1` | +/// | `1.2` | `^1.2` | +/// | `1.2.3` | `^1.2.3` | +/// | `^1.2.*` | `^1.2` | +/// | `> 1.2 , <= 1.5.*` | `>1.2, <=1.5` | +/// +/// # Best practices for defining version requirements +/// +/// When defining a comparator for a version requirement, always: +/// +/// 1. Define an explicit operator for every comparator, like `^1` or `^1.2` instead of `1` or +/// `1.2`. +/// +/// This reduces ambiguity in the behavior for the comparators and reduces the likelihood of +/// changing the requirement string when round-tripping through serialization and +/// deserialization. +/// 1. Immediately follow the explicit operator with the version, like `>1.2` instead of `> 1.2`. +/// +/// This reduces the likelihood of changing the requirement string when round-tripping through +/// serialization and deserialization. +/// 1. Omit version segments instead of using wildcards, like `>1.2` instead of `>1.2.*`. +/// +/// This reduces the likelihood of changing the requirement string when round-tripping through +/// serialization and deserialization. +/// 1. Separate subsequent comparators from previous comparators in the requirement with a comma +/// followed by a single space, like `>=1.2, <1.5` instead of `>=1.2,<1.5`, `>=1.2 ,<1.5`, +/// or `>=1.2 , <1.5`. +/// +/// This reduces the likelihood of changing the requirement string when round-tripping through +/// serialization and deserialization. +/// 1. Define the requirement without leading or trailing spaces, like `^1.2` instead of +/// ` ^1.2 `. +/// +/// This reduces the likelihood of changing the requirement when round-tripping through +/// serialization and deserialization. +/// +/// [01]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#version-requirement-syntax +/// [`SemVerReqWithBuildMetadata`]: DscError::SemVerReqWithBuildMetadata +#[derive(Debug, Clone, Hash, Eq, Serialize, Deserialize, DscRepoSchema)] +#[dsc_repo_schema(base_name = "semverRequirement", folder_path = "definitions")] +pub struct SemanticVersionReq(semver::VersionReq); + +/// This static lazily defines the validating regex for [`SemanticVersionReq`]. It enables the +/// [`Regex`] instance to be constructed once, the first time it's used, and then reused on all +/// subsequent validation calls. It's kept private, since the API usage is to invoke the +/// [`SemanticVersionReq::parse()`] method to validate and parse a string into a version requirement. +/// +/// This pattern is used to forbid the inclusion of build metadata in a version requirement for DSC, +/// since Rust allows but ignores that segment of a semantic version. +static FORBIDDING_BUILD_METADATA_REGEX: OnceLock = OnceLock::new(); + +impl SemanticVersionReq { + /// Returns the [`Regex`] for [`FORBIDDING_BUILD_METADATA_PATTERN`]. + /// + /// This private method is used to initialize the [`FORBIDDING_BUILD_METADATA_REGEX`] private + /// static to reduce the number of times the regular expression is compiled from the pattern + /// string. + fn init_pattern() -> Regex { + Regex::new(Self::FORBIDDING_BUILD_METADATA_PATTERN).expect("pattern is valid") + } + + /// Parses a given string into a semantic version requirement. + /// + /// # Errors + /// + /// The parse function returns an error when the string isn't a valid version requirement. + /// Common parse failures include: + /// + /// - Specifying a literal version segment after a wildcard, like `*.1` or `1.x.3`. + /// - Specifying a wildcard in the prerelease segment, like `1.2.3-*` or `2.0.0-rc.*`. Note that + /// specifying an `x` or `X` as a wildcard for the prerelease segment parses but is treated + /// as a literal `x` or `X` in the comparison logic because singular alphabetic characters are + /// valid prerelease segments. + /// - Specifying the build metadata segment, like `1.2.3+dev` or `1.2.3-rc.1+dev`. + /// - Specifying an invalid comparison operator, like `!3.0.0`. + /// - Specifying an invalid character for a version segment, like `>a.b`. + /// - Not specifying an additional comparator after a comma, like `>=1.*,`, + /// - Not specifying a comma between comparators, like `>=1.2 <1.9`. + pub fn parse(text: &str) -> Result { + // Check first for build metadata and error if discovered + let pattern = FORBIDDING_BUILD_METADATA_REGEX.get_or_init(Self::init_pattern); + if let Some(captures) = pattern.captures(text) { + let version = captures.get_match().as_str().to_string(); + let build = captures + .name("buildmetadata") + .map_or("", |m| m.as_str()) + .to_string(); + + return Err(DscError::SemVerReqWithBuildMetadata(version, build)); + } + + // Parse as underlying type and raise wrapped error if invalid + match semver::VersionReq::parse(text) { + Ok(requirement) => Ok(Self(requirement)), + Err(e) => Err(DscError::SemVer(e)), + } + } + + /// Checks whether a given [`SemanticVersion`] is valid for defined requirement. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; + /// + /// let requirement = SemanticVersionReq::parse("^1.2.3").unwrap(); + /// + /// // 1.3.0 is compatible with the requirement. + /// assert!(requirement.matches(&SemanticVersion::new(1, 3, 0))); + /// // 2.0.0 isn't compatible with the requirement. + /// assert!(!requirement.matches(&SemanticVersion::new(2, 0, 0))); + /// ``` + pub fn matches(&self, version: &SemanticVersion) -> bool { + self.0.matches(version.as_ref()) + } + + /// Defines the validating regular expression for semantic version requirements. + /// + /// This regular expression is used for the `pattern` keyword in the JSON Schema for the + /// [`SemanticVersionReq`] type. + /// + /// The pattern is also used for validating an instance during parsing and deserialization. DSC + /// uses a stricter subset of valid syntax for a version requirement: + /// + /// - DSC forbids the inclusion of build metadata, which the underlying version requirement + /// silently ignores. + /// - DSC forbids the use of `x` and `X` as wildcards for version segments. Only an asterisk + /// (`*`) is a valid wildcard. + pub const VALIDATING_PATTERN: &str = const_str::concat!( + "^", // Anchor to start of string + SemanticVersionReq::COMPARATOR_PATTERN, // Capture first comparator + "(?:", // Open non-capturing group for additional comparators + r"\s*,\s*", // Additional comparators must follow a comma with optional spacing around it + SemanticVersionReq::COMPARATOR_PATTERN, // Capture the additional comparator + ")", // Close the non-capturing group for additional comparators + "*", // Mark additional comparators as allowed any number of times + "$", // Anchor to end of string + ); + + /// Defines the regular expression for matching a literal version with build metadata. + /// + /// DSC forbids the inclusion of build metadata in a version requirement. To provide better + /// error messaging, DSC uses this pattern to discover the inclusion of build metadata during + /// parsing and report it to the user. + pub const FORBIDDING_BUILD_METADATA_PATTERN: &str = const_str::concat!( + SemanticVersionReq::LITERAL_VERSION_PATTERN, // Match a literal version + "(?:", // Open non-capturing group for build metadata and prefix + r"\+", // Build metadata is always preceded by a plus sign + SemanticVersion::CAPTURING_BUILD_METADATA_PATTERN, // Capture the build metadata + ")", // Close non-capturing group for build metadata and prefix + ); + + /// Defines the regular expression for matching a wildcard instead of a version segment. + /// + /// While Rust supports specifying the wildcard as `x`, `X`, or `*`, DSC only supports `*` to + /// minimize ambiguity. + pub const WILDCARD_SYMBOL_PATTERN: &str = r"\*"; + + /// Defines the regular expression for matching a version requirement operator. + /// + /// Rust and DSC both support the following table of operators: + /// + /// | Operator | Name | + /// |:--------:|:------------------------:| + /// | `^` | Caret | + /// | `~` | Tilde | + /// | `=` | Equals | + /// | `<` | Less than | + /// | `<=` | Less than or equal to | + /// | `>` | Greater than | + /// | `>=` | Greater than or equal to | + pub const OPERATOR_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group + ">=", // Requirements can be greater than or equal to + "|", // or + ">", // greater than + "|", // or + "<", // less than + "|", // or + "<=", // less than or equal to + "|", // or + "=", // exactly equal + "|", // or + r"\^", // semver-compatible (caret, also default when no prefix defined) + "|", // or + "~", // minimal-version (tilde) + ")", // Close the non-capturing group + "?", // Mark the operator as optional + ); + + /// Defines the regular expression for matching a comparator with optional leading operator + /// followed by a literal or wildcard version. + pub const COMPARATOR_PATTERN: &str = const_str::concat!( + SemanticVersionReq::OPERATOR_PATTERN, // Match the operator, if any + r"\s*", // allow any number of spaces after operator + "(?:", // Open non-capturing group for wildcard-literal version selection + SemanticVersionReq::LITERAL_VERSION_PATTERN, // Match literal version + "|", // or + SemanticVersionReq::WILDCARD_VERSION_PATTERN, // Match version with wildcard + ")", // Close non-capturing group for wildcard-literal version selection + ); + + /// Defines the regular expression for matching a literal version. + /// + /// Literal versions must define the major version segment. The minor, patch, and prerelease + /// segments are optional. The build metadata segment is forbidden. + pub const LITERAL_VERSION_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group for literal version + SemanticVersion::VERSION_SEGMENT_PATTERN, // Must define the major version. + "(?:", // Open non-capturing group for optional minor and patch segments + r"\.", // Major version must be followed by a period if minor is specified. + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match the minor version. + "(?:", // Open non-capturing group for optional patch segment + r"\.", // Minor version must be followed by a period if patch is specified. + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match the patch version. + SemanticVersionReq::PRERELEASE_PATTERN, // Match prerelease, if any - only valid with patch + ")?", // Open non-capturing group for optional patch segment + ")?", // Close non-capturing group for optional minor and patch segments + ")", // Close non-capturing group for literal version + ); + + /// Defines the regular expression for matching a version with a wildcard segment. + /// + /// Wildcard versions must define the major version segment. The minor and patch segments are + /// optional. The prerelease and build metadata segments are forbidden. + /// + /// If the wildcard version defines the minor version segment as a wildcard, it must not define + /// the patch segment. If the wildcard version defines the minor version segment as a literal + /// version segment, it may define the patch version segment as a wildcard. + /// + /// The following table shows a few example wildcard versions, whether they are valid, and why + /// an example version is invalid. + /// + /// | Wildcard version | Valid | Notes | + /// |:----------------:|:-----:|:------------------------------------------------------------------------------------| + /// | `1.*` | Yes | Defines a literal major version segment followed by a wildcard minor version. | + /// | `1.2.*` | Yes | Defines literal major and minor segments followed by a wildcard patch version. | + /// | `1.*.*` | Yes | Equivalent to `1.*` - both wildcards match any minor and patch version. | + /// | `1.*.3` | No | If the version includes any wildcards, it must be the last defined version segment. | + /// | `1.2.3-*` | No | Defines the prerelease segment as a wildcard, which is forbidden. | + pub const WILDCARD_VERSION_PATTERN: &str = const_str::concat!( + SemanticVersion::VERSION_SEGMENT_PATTERN, // Must match the (literal) major version + "(?:", // Open non-capturing group for optional minor and patch segments + r"\.", // Must follow major version with period before minor version + "(?:", // Open non-capturing group for literal-or-wildcard minor + "(?:", // Open non-capturing group for literal minor followed by optional patch + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match literal minor version + "(?:", // Open non-capturing group for optional literal-or-wildcard patch + r"\.", // Must follow minor version with period before patch version + "(?:", // Open non-capturing group to select between wildcard-or-literal patch + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match literal patch version + "|", // or + SemanticVersionReq::WILDCARD_SYMBOL_PATTERN, // Match patch version as wildcard + ")", // Close non-capturing group to select between wildcard-or-literal patch + ")?", // Close non-capturing group for optional literal-or-wildcard patch + ")", // Close non-capturing group for literal minor followed by optional patch + "|", // or + SemanticVersionReq::WILDCARD_SYMBOL_PATTERN, // Match minor version as wildcard, must not have following patch + ")", // Close non-capturing group for literal-or-wildcard minor + ")?", // Close non-capturing group for optional minor and patch segments + ); + + /// Defines the regular expression for matching a literal prerelease segment. + /// + /// Prerelease segments are only valid after the patch version in a literal version. A version + /// requirement must not specify a prerelease segment with any wildcards in it or after a + /// wildcard version. + pub const PRERELEASE_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group for optional prerelease + "-", // Must precede prerelease segment with a hyphen + SemanticVersion::PRERELEASE_SEGMENT_PATTERN, // Match literal prerelease segment, wildcards not allowed + ")?", // Close non-capturing group for optional prerelease + ); +} + +impl JsonSchema for SemanticVersionReq { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": t!("schemas.definitions.semverReq.title"), + "description": t!("schemas.definitions.semverReq.description"), + "markdownDescription": t!("schemas.definitions.semverReq.markdownDescription"), + "type": "string", + "pattern": SemanticVersionReq::VALIDATING_PATTERN, + "patternErrorMessage": t!("schemas.definitions.semverReq.patternErrorMessage"), + "examples": [ + "1.2.3", + ">=1.2.3, <2.0.0", + "^1.2", + "~2.3", + ] + }) + } +} + +impl Default for SemanticVersionReq { + fn default() -> Self { + Self(semver::VersionReq::default()) + } +} + +impl Display for SemanticVersionReq { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +// Infallible conversions +impl From for SemanticVersionReq { + fn from(value: semver::VersionReq) -> Self { + Self(value) + } +} + +impl From for semver::VersionReq { + fn from(value: SemanticVersionReq) -> Self { + value.0 + } +} + +impl From<&semver::VersionReq> for SemanticVersionReq { + fn from(value: &semver::VersionReq) -> Self { + Self(value.clone()) + } +} + +impl From<&SemanticVersionReq> for semver::VersionReq { + fn from(value: &SemanticVersionReq) -> Self { + value.0.clone() + } +} + +impl From for String { + fn from(value: SemanticVersionReq) -> Self { + value.to_string() + } +} + +// Fallible conversions +impl FromStr for SemanticVersionReq { + type Err = DscError; + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom for SemanticVersionReq { + type Error = DscError; + fn try_from(value: String) -> Result { + Self::parse(value.as_str()) + } +} + +impl TryFrom<&str> for SemanticVersionReq { + type Error = DscError; + fn try_from(value: &str) -> Result { + SemanticVersionReq::from_str(value) + } +} + +// Referencing and dereferencing +impl AsRef for SemanticVersionReq { + fn as_ref(&self) -> &semver::VersionReq { + &self.0 + } +} + +impl Deref for SemanticVersionReq { + type Target = semver::VersionReq; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// Comparison traits +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &semver::VersionReq) -> bool { + &self.0 == other + } +} + +impl PartialEq for semver::VersionReq { + fn eq(&self, other: &SemanticVersionReq) -> bool { + self == &other.0 + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &String) -> bool { + match Self::parse(other.as_str()) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for String { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(self.as_str()) { + Ok(s) => &s == other, + Err(_) => false + } + } +} + +impl PartialEq<&String> for SemanticVersionReq { + fn eq(&self, other: &&String) -> bool { + match Self::parse(other.as_str()) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for &String { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(self.as_str()) { + Ok(s) => &s == other, + Err(_) => false + } + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &str) -> bool { + match Self::parse(other) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for str { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(self) { + Ok(s) => &s == other, + Err(_) => false + } + } +} + +impl PartialEq<&str> for SemanticVersionReq { + fn eq(&self, other: &&str) -> bool { + match Self::parse(*other) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for &str { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(*self) { + Ok(s) => &s == other, + Err(_) => false + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index c45605926..8a07e79cb 100644 --- a/lib/dsc-lib/tests/integration/types/mod.rs +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -3,3 +3,11 @@ #[cfg(test)] mod fully_qualified_type_name; +#[cfg(test)] +mod resource_version; +#[cfg(test)] +mod resource_version_req; +#[cfg(test)] +mod semantic_version; +#[cfg(test)] +mod semantic_version_req; diff --git a/lib/dsc-lib/tests/integration/types/resource_version.rs b/lib/dsc-lib/tests/integration/types/resource_version.rs new file mode 100644 index 000000000..d6f1e1f62 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/resource_version.rs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::types::{ResourceVersion, SemanticVersion, SemanticVersionReq}; + use test_case::test_case; + + #[test_case("1.2.3" => matches ResourceVersion::Semantic(_); "for valid semantic version")] + #[test_case("1.2.3a" => matches ResourceVersion::Arbitrary(_); "for invalid semantic version")] + #[test_case("2026-01-15" => matches ResourceVersion::Arbitrary(_); "for full ISO8601 date")] + #[test_case("2026-01" => matches ResourceVersion::Arbitrary(_); "for partial ISO8601 date")] + #[test_case("arbitrary_string" => matches ResourceVersion::Arbitrary(_); "for arbitrary string")] + fn new(version_string: &str) -> ResourceVersion { + ResourceVersion::new(version_string) + } + + #[test_case("1.2.3" => true; "for valid semantic version")] + #[test_case("1.2.3a" => false; "for invalid semantic version")] + #[test_case("2026-01-15" => false; "for full ISO8601 date")] + #[test_case("2026-01" => false; "for partial ISO8601 date")] + #[test_case("arbitrary_string" => false; "for arbitrary string")] + fn is_semver(version_string: &str) -> bool { + ResourceVersion::new(version_string).is_semver() + } + + #[test_case("1.2.3" => false; "for valid semantic version")] + #[test_case("1.2.3a" => true; "for invalid semantic version")] + #[test_case("2026-01-15" => true; "for full ISO8601 date")] + #[test_case("2026-01" => true; "for partial ISO8601 date")] + #[test_case("arbitrary_string" => true; "for arbitrary string")] + fn is_arbitrary(version_string: &str) -> bool { + ResourceVersion::new(version_string).is_arbitrary() + } + + #[test_case(ResourceVersion::new("1.2.3") => matches Some(_); "for valid semantic version")] + #[test_case(ResourceVersion::new("1.2.3a") => matches None; "for invalid semantic version")] + #[test_case(ResourceVersion::new("2026-01-15") => matches None; "for full ISO8601 date")] + #[test_case(ResourceVersion::new("2026-01") => matches None; "for partial ISO8601 date")] + #[test_case(ResourceVersion::new("arbitrary_string") => matches None; "for arbitrary string")] + fn as_semver(version: ResourceVersion) -> Option { + version.as_semver().cloned() + } + + #[test_case("1.2.3", ">1.0" => true; "semantic version matches gt req")] + #[test_case("1.2.3", "<=1.2.2" => false; "semantic version not matches le req")] + #[test_case("1.2.3", "~1" => true; "semantic version matches tilde req")] + #[test_case("arbitrary", "*" => false; "arbitrary string version never matches")] + fn matches_semver_req(version_string: &str, requirement_string: &str) -> bool { + ResourceVersion::new(version_string) + .matches_semver_req(&SemanticVersionReq::parse(requirement_string).unwrap()) + } +} + +#[cfg(test)] +mod schema { + use std::{ops::Index, sync::LazyLock}; + + use dsc_lib::types::ResourceVersion; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static ROOT_SCHEMA: LazyLock = LazyLock::new(|| schema_for!(ResourceVersion)); + static SEMVER_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(0) + .as_object() + .unwrap() + .clone() + .into() + }); + static STRING_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(1) + .as_object() + .unwrap() + .clone() + .into() + }); + + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); + + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title", &*ROOT_SCHEMA; "title")] + #[test_case("description", &*ROOT_SCHEMA; "description")] + #[test_case("markdownDescription", &*ROOT_SCHEMA; "markdownDescription")] + #[test_case("title", &*SEMVER_VARIANT_SCHEMA; "semver.title")] + #[test_case("description", &*SEMVER_VARIANT_SCHEMA; "semver.description")] + #[test_case("markdownDescription", &*SEMVER_VARIANT_SCHEMA; "semver.markdownDescription")] + #[test_case("title", &*STRING_VARIANT_SCHEMA; "arbitrary.title")] + #[test_case("description", &*STRING_VARIANT_SCHEMA; "arbitrary.description")] + #[test_case("markdownDescription", &*STRING_VARIANT_SCHEMA; "arbitrary.markdownDescription")] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test] + fn semver_subschema_is_reference() { + assert!( + (&*SEMVER_VARIANT_SCHEMA).get_keyword_as_string("$ref").is_some_and(|kv| !kv.is_empty()) + ) + } + + #[test_case(&json!("1.2.3") => true ; "valid semantic version string value is valid")] + #[test_case(&json!("1.2.3a") => true ; "invalid semantic version string value is valid")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 date full string value is valid")] + #[test_case(&json!("2026-01") => true ; "iso8601 date year month string value is valid")] + #[test_case(&json!("arbitrary_string") => true ; "arbitrary string value is valid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"version": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::ResourceVersion; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn serializing_resource_version_to_string(version_string: &str) { + let actual = serde_json::to_string(&ResourceVersion::new(version_string)) + .expect("serialization should never fail"); + let expected = format!(r#""{version_string}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn serializing_to_json_value_returns_string(version_string: &str) { + let expected = Value::String(version_string.to_string()); + let actual = serde_json::to_value(&ResourceVersion::new(version_string)) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!(actual, expected); + } + + + #[test_case(json!(true); "boolean value fails")] + #[test_case(json!(1); "integer value fails")] + #[test_case(json!(1.2); "float value fails")] + #[test_case(json!({"version": "1.2.3"}); "object value fails")] + #[test_case(json!(["1.2.3"]); "array value fails")] + #[test_case(serde_json::Value::Null; "null value fails")] + fn deserializing_invalid(input_value: Value) { + serde_json::from_value::(input_value) + .expect_err("json value '{input_value}' should be invalid"); + } + + #[test_case(json!("1.2.3") => matches ResourceVersion::Semantic(_); "valid semantic version string value succeeds")] + #[test_case(json!("1.2.3a") => matches ResourceVersion::Arbitrary(_) ; "invalid semantic version string value succeeds")] + #[test_case(json!("2026-01-15") => matches ResourceVersion::Arbitrary(_) ; "iso8601 date full string value succeeds")] + #[test_case(json!("2026-01") => matches ResourceVersion::Arbitrary(_) ; "iso8601 date year month string value succeeds")] + #[test_case(json!("arbitrary_string") => matches ResourceVersion::Arbitrary(_) ; "arbitrary string value succeeds")] + fn deserializing_valid(input_value: Value) -> ResourceVersion { + serde_json::from_value::(input_value) + .expect("deserialization for '{input_value}' should never fail") + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::ResourceVersion; + + #[test] + fn default() { + pretty_assertions::assert_eq!( + ResourceVersion::default(), + ResourceVersion::new("0.0.0") + ); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::ResourceVersion; + use test_case::test_case; + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn format(version_string: &str) { + pretty_assertions::assert_eq!( + format!("version: {}", ResourceVersion::new(version_string)), + format!("version: {version_string}") + ) + } + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn to_string(version_string: &str) { + pretty_assertions::assert_eq!( + ResourceVersion::new(version_string).to_string(), + version_string.to_string() + ) + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::types::ResourceVersion; + use test_case::test_case; + + #[test_case("1.2.3" => ResourceVersion::new("1.2.3"); "valid semantic version")] + #[test_case("1.2.3a" => ResourceVersion::new("1.2.3a"); "invalid semantic version")] + #[test_case("2026-01-15" => ResourceVersion::new("2026-01-15"); "ISO8601 date full")] + #[test_case("2026-01" => ResourceVersion::new("2026-01"); "ISO8601 date year and month only")] + #[test_case("arbitrary_string" => ResourceVersion::new("arbitrary_string"); "arbitrary string")] + fn parse(input: &str) -> ResourceVersion { + input.parse().expect("parse should be infallible") + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use test_case::test_case; + + #[test] + fn semantic_version() { + let semantic_version = SemanticVersion::parse("1.2.3").unwrap(); + match ResourceVersion::from(semantic_version.clone()) { + ResourceVersion::Semantic(v) => pretty_assertions::assert_eq!(v, semantic_version), + ResourceVersion::Arbitrary(_) => { + panic!("should never fail to convert as Semantic version") + } + } + } + + #[test_case("1.2.3" => matches ResourceVersion::Semantic(_); "valid semantic version")] + #[test_case("1.2.3a" => matches ResourceVersion::Arbitrary(_); "invalid semantic version")] + #[test_case("2026-01-15" => matches ResourceVersion::Arbitrary(_); "ISO8601 date full")] + #[test_case("2026-01" => matches ResourceVersion::Arbitrary(_); "ISO8601 date year and month only")] + #[test_case("arbitrary_string" => matches ResourceVersion::Arbitrary(_); "arbitrary string")] + fn string(version_string: &str) -> ResourceVersion { + ResourceVersion::from(version_string.to_string()) + } + } + + // While technically we implemented the traits as `From for `, it's easier to + // reason about what we're converting _into_ - otherwise the functions would have names like + // `resource_version_for_semver_version`. When you implement `From`, you automatically implement + // `Into` for the reversing of the type pair. + #[cfg(test)] + mod into { + use dsc_lib::types::ResourceVersion; + use test_case::test_case; + + #[test_case("1.2.3"; "semantic version")] + #[test_case("arbitrary_version"; "arbitrary string version")] + fn string(version_string: &str) { + let actual: String = ResourceVersion::new(version_string).into(); + let expected = version_string.to_string(); + + pretty_assertions::assert_eq!(actual, expected) + } + } + + #[cfg(test)] + mod try_into { + use dsc_lib::{dscerror::DscError, types::{ResourceVersion, SemanticVersion}}; + use test_case::test_case; + + #[test_case("1.2.3" => matches Ok(_); "valid semantic version converts")] + #[test_case("1.2.3a" => matches Err(_); "invalid semantic version fails")] + #[test_case("2026-01-15" => matches Err(_); "ISO8601 date full fails")] + #[test_case("2026-01" => matches Err(_); "ISO8601 date year and month only fails")] + #[test_case("arbitrary_string" => matches Err(_); "arbitrary string fails")] + fn semantic_version(version_string: &str) -> Result { + TryInto::::try_into(ResourceVersion::new(version_string)) + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", true; "equal semantic versions")] + #[test_case("1.2.3", "3.2.1", false; "unequal semantic versions")] + #[test_case("Arbitrary", "Arbitrary", true; "identical string versions")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased string versions")] + #[test_case("foo", "bar", false; "unequal string versions")] + fn resource_version(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!(ResourceVersion::new(lhs), ResourceVersion::new(rhs)) + } else { + pretty_assertions::assert_ne!(ResourceVersion::new(lhs), ResourceVersion::new(rhs)) + } + } + + #[test_case("1.2.3", "1.2.3", true; "equal semantic versions")] + #[test_case("1.2.3", "3.2.1", false; "unequal semantic versions")] + #[test_case("arbitrary_string", "3.2.1", false; "arbitrary string with semantic version")] + fn semantic_version( + resource_version_string: &str, + semantic_version_string: &str, + should_be_equal: bool, + ) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let semantic: SemanticVersion = semantic_version_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == semantic, + should_be_equal, + "expected comparison of {version} and {semantic} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semantic == version, + should_be_equal, + "expected comparison of {semantic} and {version} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "semantic version and equivalent string")] + #[test_case("1.2.3", "3.2.1", false; "semantic version and differing string")] + #[test_case("Arbitrary", "Arbitrary", true; "arbitrary string version and identical string")] + #[test_case("Arbitrary", "arbitrary", false; "arbitrary string version and string with differing case")] + #[test_case("foo", "bar", false; "arbitrary string version and different string")] + fn str(resource_version_string: &str, string_slice: &str, should_be_equal: bool) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == string_slice, + should_be_equal, + "expected comparison of {version} and {string_slice} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == version, + should_be_equal, + "expected comparison of {string_slice} and {version} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "semantic version and equivalent string")] + #[test_case("1.2.3", "3.2.1", false; "semantic version and differing string")] + #[test_case("Arbitrary", "Arbitrary", true; "arbitrary string version and identical string")] + #[test_case("Arbitrary", "arbitrary", false; "arbitrary string version and string with differing case")] + #[test_case("foo", "bar", false; "arbitrary string version and different string")] + fn string(resource_version_string: &str, string_slice: &str, should_be_equal: bool) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let string = string_slice.to_string(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == string, + should_be_equal, + "expected comparison of {version} and {string} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == version, + should_be_equal, + "expected comparison of {string} and {version} to be #{should_be_equal}" + ); + } + } + + #[cfg(test)] + mod partial_ord { + use std::cmp::Ordering; + + use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "semantic versions with newer rhs")] + #[test_case("1.2.3", "arbitrary", Ordering::Greater; "semantic version to string version")] + #[test_case("arbitrary", "1.2.3", Ordering::Less; "string version to semantic version")] + #[test_case("arbitrary", "arbitrary", Ordering::Equal; "string version to same string version")] + #[test_case("arbitrary", "ARBITRARY", Ordering::Greater; "lowercased string version to uppercased string version")] + #[test_case("foo", "bar", Ordering::Greater; "string version to earlier alphabetic string version")] + #[test_case("a", "b", Ordering::Less; "string version to later alphabetic string version")] + #[test_case("2026-01-15", "2026-01-15", Ordering::Equal; "full date string version to same string version")] + #[test_case("2026-01", "2026-01", Ordering::Equal; "partial date string version to same string version")] + #[test_case("2026-01-15", "2026-02-15", Ordering::Less; "full date string version to later full date")] + #[test_case("2026-01-15", "2026-02", Ordering::Less; "full date string version to later partial date")] + #[test_case("2026-01", "2026-02-15", Ordering::Less; "partial date string version to later full date")] + #[test_case("2026-01", "2026-02", Ordering::Less; "partial date string version to later partial date")] + fn resource_version(lhs: &str, rhs: &str, expected_order: Ordering) { + pretty_assertions::assert_eq!( + ResourceVersion::new(lhs) + .partial_cmp(&ResourceVersion::new(rhs)) + .unwrap(), + expected_order, + "expected '{lhs}' compared to '{rhs}' to be {expected_order:#?}" + ) + } + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "semantic versions with newer rhs")] + #[test_case("arbitrary", "1.2.3", Ordering::Less; "string version to semantic version")] + fn semantic_version( + resource_version_string: &str, + semantic_version_string: &str, + expected_order: Ordering, + ) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let semantic: SemanticVersion = semantic_version_string.parse().unwrap(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&semantic).unwrap(), + expected_order, + "expected comparison of {version} and {semantic} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Less, + Ordering::Less => Ordering::Greater, + }; + + pretty_assertions::assert_eq!( + semantic.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {semantic} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "semantic versions with newer rhs")] + #[test_case("1.2.3", "arbitrary", Ordering::Greater; "semantic version to string version")] + #[test_case("arbitrary", "1.2.3", Ordering::Less; "string version to semantic version")] + #[test_case("arbitrary", "arbitrary", Ordering::Equal; "string version to same string version")] + #[test_case("arbitrary", "ARBITRARY", Ordering::Greater; "lowercased string version to uppercased string version")] + #[test_case("foo", "bar", Ordering::Greater; "string version to earlier alphabetic string version")] + #[test_case("a", "b", Ordering::Less; "string version to later alphabetic string version")] + #[test_case("2026-01-15", "2026-01-15", Ordering::Equal; "full date string version to same string version")] + #[test_case("2026-01", "2026-01", Ordering::Equal; "partial date string version to same string version")] + #[test_case("2026-01-15", "2026-02-15", Ordering::Less; "full date string version to later full date")] + #[test_case("2026-01-15", "2026-02", Ordering::Less; "full date string version to later partial date")] + #[test_case("2026-01", "2026-02-15", Ordering::Less; "partial date string version to later full date")] + #[test_case("2026-01", "2026-02", Ordering::Less; "partial date string version to later partial date")] + fn string(resource_version_string: &str, string_slice: &str, expected_order: Ordering) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let string = string_slice.to_string(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&string).unwrap(), + expected_order, + "expected comparison of {version} and {string} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Less, + Ordering::Less => Ordering::Greater, + }; + + pretty_assertions::assert_eq!( + string.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {string} and {version} to be #{expected_inverted_order:#?}" + ); + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/resource_version_req.rs b/lib/dsc-lib/tests/integration/types/resource_version_req.rs new file mode 100644 index 000000000..2f66ac860 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/resource_version_req.rs @@ -0,0 +1,477 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use test_case::test_case; + + #[cfg(test)] + mod new { + use dsc_lib::types::ResourceVersionReq; + use dsc_lib::types::ResourceVersionReq::*; + use test_case::test_case; + + #[test_case("1" => matches Semantic(_); "major is semantic")] + #[test_case("1.2" => matches Semantic(_); "major.minor is semantic")] + #[test_case("1.2.3" => matches Semantic(_); "major.minor.patch is semantic")] + #[test_case("1.2.3-pre" => matches Semantic(_); "major.minor.patch-pre is semantic")] + #[test_case("1-pre" => matches Arbitrary(_); "major-pre is arbitrary")] + #[test_case("1.2-pre" => matches Arbitrary(_); "major.minor-pre is arbitrary")] + #[test_case("1.2.3+build" => matches Arbitrary(_); "major.minor.patch+build is arbitrary")] + #[test_case("1.2.3-pre+build" => matches Arbitrary(_); "major.minor.patch-pre+build is arbitrary")] + #[test_case("a" => matches Arbitrary(_); "invalid_char is arbitrary")] + #[test_case("1.b" => matches Arbitrary(_); "major.invalid_char is arbitrary")] + #[test_case("1.2.c" => matches Arbitrary(_); "major.minor.invalid_char is arbitrary")] + fn literal_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("1.*" => matches Semantic(_); "major.wildcard is semantic")] + #[test_case("1.*.*" => matches Semantic(_); "major.wildcard.wildcard is semantic")] + #[test_case("1.2.*" => matches Semantic(_); "major.minor.wildcard is semantic")] + #[test_case("1.*.3" => matches Arbitrary(_); "major.wildcard.patch is arbitrary")] + #[test_case("1.2.*-pre" => matches Arbitrary(_); "major.minor.wildcard-pre is arbitrary")] + #[test_case("1.*.*-pre" => matches Arbitrary(_); "major.wildcard.wildcard-pre is arbitrary")] + #[test_case("1.2.3-*" => matches Arbitrary(_); "major.minor.patch-wildcard is arbitrary")] + #[test_case("1.2.3-pre.*" => matches Arbitrary(_); "major.minor.patch-pre.wildcard is arbitrary")] + fn wildcard_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("1.2.3" => matches Semantic(_); "implicit operator is semantic")] + #[test_case("^ 1.2.3" => matches Semantic(_); "caret operator is semantic")] + #[test_case("~ 1.2.3" => matches Semantic(_); "tilde operator is semantic")] + #[test_case("= 1.2.3" => matches Semantic(_); "exact operator is semantic")] + #[test_case("> 1.2.3" => matches Semantic(_); "greater than operator is semantic")] + #[test_case(">= 1.2.3" => matches Semantic(_); "greater than or equal to operator is semantic")] + #[test_case("< 1.2.3" => matches Semantic(_); "less than operator is semantic")] + #[test_case("<= 1.2.3" => matches Semantic(_); "less than or equal to operator is semantic")] + #[test_case("== 1.2.3" => matches Arbitrary(_); "invalid operator is arbitrary")] + fn operators_in_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("1.2.3, < 1.5" => matches Semantic(_); "pair with separating comma is semantic")] + #[test_case("1, 1.2, 1.2.3" => matches Semantic(_); "triple with separating comma is semantic")] + #[test_case("<= 1, >= 2" => matches Semantic(_); "incompatible pair is semantic")] + #[test_case(", 1, 1.2" => matches Arbitrary(_); "leading comma is arbitrary")] + #[test_case("1, 1.2," => matches Arbitrary(_); "trailing comma is arbitrary")] + #[test_case("1 1.2" => matches Arbitrary(_); "omitted separating comma is arbitrary")] + #[test_case("1.*, < 1.3.*" => matches Semantic(_); "multiple comparators with wildcard is semantic")] + fn multiple_comparator_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("^1.2" => matches Semantic(_); "operator and version without spacing is semantic")] + #[test_case("^ 1.2" => matches Semantic(_); "operator and version with extra spacing is semantic")] + #[test_case(" ^ 1.2" => matches Semantic(_); "leading space is semantic")] + #[test_case("^ 1.2 " => matches Semantic(_); "trailing space is semantic")] + #[test_case("^1.2,<1.5" => matches Semantic(_); "pair of comparators without spacing is semantic")] + #[test_case(" ^ 1.2 , < 1.5 " => matches Semantic(_); "pair of comparators with extra spacing is semantic")] + fn spacing_in_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + } + + #[test_case("1.2.3" => true; "single comparator is semver")] + #[test_case("^1.2, >1.5" => true; "multi comparator is semver")] + #[test_case("2026-02-01" => false; "date string is not semver")] + #[test_case("arbitrary" => false; "arbitrary string is not semver")] + fn is_semver(requirement_string: &str) -> bool { + ResourceVersionReq::new(requirement_string).is_semver() + } + + #[test_case("1.2.3" => false; "single comparator is not arbitrary")] + #[test_case("^1.2, >1.5" => false; "multi comparator is not arbitrary")] + #[test_case("2026-02-01" => true; "date string is arbitrary")] + #[test_case("arbitrary" => true; "arbitrary string is arbitrary")] + fn is_arbitrary(requirement_string: &str) -> bool { + ResourceVersionReq::new(requirement_string).is_arbitrary() + } + + #[test_case("1.2.3" => matches Some(_); "single comparator returns some")] + #[test_case("^1.2, >1.5" => matches Some(_); "multi comparator returns some")] + #[test_case("2026-02-01" => matches None; "date string returns none")] + #[test_case("arbitrary" => matches None; "arbitrary string returns none")] + fn as_semver_req(requirement_string: &str) -> Option { + ResourceVersionReq::new(requirement_string).as_semver_req().cloned() + } + + #[cfg(test)] + mod matches { + use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; + use test_case::test_case; + + fn check(requirement: &str, versions: Vec<&str>, should_match: bool) { + let req = ResourceVersionReq::new(requirement); + let expected = if should_match { "match" } else { "not match" }; + for version in versions { + pretty_assertions::assert_eq!( + req.matches(&ResourceVersion::new(version)), + should_match, + "expected version '{version}' to {expected} requirement '{requirement}'" + ); + } + } + + // Only test a subset of valid semantic reqs since the matches method for SemanticVersionReq + // more thoroughly covers these cases + #[test_case("1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1", "2026-02-01", "arbitrary"], false; "not matching major")] + #[test_case("1.2", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor")] + #[test_case("1.2", vec!["1.0.0", "2.0.0", "1.2.3-rc.1", "2026-02-01", "arbitrary"], false; "not matching major.minor")] + #[test_case("1.2.3", vec!["1.2.3", "1.2.4", "1.3.0"], true; "matching major.minor.patch")] + #[test_case("1.2.3", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "2026-02-01", "arbitrary"], false; "not matching major.minor.patch")] + #[test_case("1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("1.2.3-rc.2", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "1.3.0-rc.2", "2026-02-01", "arbitrary"], false; "not matching major.minor.patch-pre")] + fn semantic(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("2026-02-01", vec!["2026-02-01"], true; "matching version as date")] + #[test_case("2026-02-01", vec!["2026-02-02", "2026-02", "arbitrary", "2026.02.01", "1.2.3"], false; "not matching version as date")] + #[test_case("Arbitrary", vec!["Arbitrary"], true; "matching version as arbitrary string")] + #[test_case("Arbitrary", vec!["arbitrary", " Arbitrary", "Arbitrary ", "2026-02-01", "1.2.3"], false; "not matching version as arbitrary string")] + fn arbitrary(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + } +} + +#[cfg(test)] +mod schema { + use std::{ops::Index, sync::LazyLock}; + + use dsc_lib::types::ResourceVersionReq; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static ROOT_SCHEMA: LazyLock = LazyLock::new(|| schema_for!(ResourceVersionReq)); + static SEMANTIC_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(0) + .as_object() + .unwrap() + .clone() + .into() + }); + static ARBITRARY_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(1) + .as_object() + .unwrap() + .clone() + .into() + }); + + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); + + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title", &*ROOT_SCHEMA; "title")] + #[test_case("description", &*ROOT_SCHEMA; "description")] + #[test_case("markdownDescription", &*ROOT_SCHEMA; "markdownDescription")] + #[test_case("title", &*SEMANTIC_VARIANT_SCHEMA; "semver.title")] + #[test_case("description", &*SEMANTIC_VARIANT_SCHEMA; "semver.description")] + #[test_case("markdownDescription", &*SEMANTIC_VARIANT_SCHEMA; "semver.markdownDescription")] + #[test_case("title", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.title")] + #[test_case("description", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.description")] + #[test_case("deprecationMessage", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.deprecationMessage")] + #[test_case("markdownDescription", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.markdownDescription")] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test] + fn semver_subschema_is_reference() { + assert!( + (&*SEMANTIC_VARIANT_SCHEMA).get_keyword_as_string("$ref").is_some_and(|kv| !kv.is_empty()) + ) + } + + #[test_case(&json!("^1.2.3") => true ; "single comparator semantic version req string value is valid")] + #[test_case(&json!("^1.2.3, <1.5") => true ; "multi comparator semantic version req string value is valid")] + #[test_case(&json!("=1.2.3a") => true ; "invalid semantic version req string value is valid")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 date full string value is valid")] + #[test_case(&json!("2026-01") => true ; "iso8601 date year month string value is valid")] + #[test_case(&json!("arbitrary_string") => true ; "arbitrary string value is valid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"version": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::ResourceVersionReq; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("^1.2.3"; "single comparator semantic req string serializes to string")] + #[test_case("^1.2.3, <1.4"; "multi comparator semantic req serializes to string")] + #[test_case("2026-02-1"; "arbitrary req formatted as date serializes to string")] + #[test_case("arbitrary"; "arbitrary req serializes to string")] + fn serializing(requirement: &str) { + let actual = serde_json::to_string( + &ResourceVersionReq::new(requirement) + ).expect("serialization should never fail"); + + let expected = format!(r#""{requirement}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.2.3") => matches Ok(_); "valid req string value succeeds")] + #[test_case(json!("a.b") => matches Ok(_); "invalid req string value succeeds")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + + #[test] + fn default() { + pretty_assertions::assert_eq!( + ResourceVersionReq::default(), + SemanticVersionReq::default(), + ) + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::ResourceVersionReq; + use test_case::test_case; + + #[test_case("1.2", "^1.2"; "semantic req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "semantic req with multiple comparators")] + #[test_case("1.*", "1.*"; "semantic req with a wildcard")] + #[test_case("2020-02-01", "2020-02-01"; "arbitrary req as date")] + #[test_case("Arbitrary", "Arbitrary"; "arbitrary req as string")] + fn format(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + format!("req: '{}'", ResourceVersionReq::new(requirement)), + format!("req: '{}'", expected) + ) + } + + #[test_case("1.2", "^1.2"; "semantic req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "semantic req with multiple comparators")] + #[test_case("1.*", "1.*"; "semantic req with a wildcard")] + #[test_case("2020-02-01", "2020-02-01"; "arbitrary req as date")] + #[test_case("Arbitrary", "Arbitrary"; "arbitrary req as string")] + fn to_string(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + ResourceVersionReq::new(requirement).to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use dsc_lib::types::ResourceVersionReq::*; + use test_case::test_case; + + #[test] + fn semantic_version_req() { + let semantic = SemanticVersionReq::parse("^1.2.3").unwrap(); + match ResourceVersionReq::from(semantic.clone()) { + Semantic(req) => pretty_assertions::assert_eq!(req, semantic), + Arbitrary(_) => { + panic!("should never fail to convert as Semantic version requirement") + } + } + } + #[test_case("^1.2.3" => matches Semantic(_); "single comparator semantic req")] + #[test_case("^1.2, <1.5" => matches Semantic(_); "multi comparator semantic req")] + #[test_case("2020-02-01" => matches Arbitrary(_); "date-formatted arbitrary req")] + #[test_case("arbitrary" => matches Arbitrary(_); "arbitrary string req")] + fn string(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::from(requirement_string.to_string()) + } + + #[test_case("^1.2.3" => matches Semantic(_); "single comparator semantic req")] + #[test_case("^1.2, <1.5" => matches Semantic(_); "multi comparator semantic req")] + #[test_case("2020-02-01" => matches Arbitrary(_); "date-formatted arbitrary req")] + #[test_case("arbitrary" => matches Arbitrary(_); "arbitrary string req")] + fn str(string_slice: &str) -> ResourceVersionReq { + ResourceVersionReq::from(string_slice) + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::types::ResourceVersionReq; + use dsc_lib::types::ResourceVersionReq::*; + use test_case::test_case; + + #[test_case("^1.2.3" => matches Semantic(_); "single comparator semantic req")] + #[test_case("^1.2, <1.5" => matches Semantic(_); "multi comparator semantic req")] + #[test_case("2020-02-01" => matches Arbitrary(_); "date-formatted arbitrary req")] + #[test_case("arbitrary" => matches Arbitrary(_); "arbitrary string req")] + fn parse(input: &str) -> ResourceVersionReq { + input.parse().expect("parse should be infallible") + } + } + + #[cfg(test)] + mod into { + use dsc_lib::types::ResourceVersionReq; + use test_case::test_case; + + #[test_case("^1.2.3"; "single comparator semantic req")] + #[test_case("^1.2, <1.5"; "multi comparator semantic req")] + #[test_case("2020-02-01"; "date-formatted arbitrary req")] + #[test_case("arbitrary"; "arbitrary string req")] + fn string(requirement_string: &str) { + let actual: String = ResourceVersionReq::new(requirement_string).into(); + let expected = requirement_string.to_string(); + + pretty_assertions::assert_eq!(actual, expected) + } + } + + #[cfg(test)] + mod try_into { + use dsc_lib::{dscerror::DscError, types::{ResourceVersionReq, SemanticVersionReq}}; + use test_case::test_case; + + #[test_case("^1.2.3" => matches Ok(_); "single comparator semantic req converts")] + #[test_case("^1.2, <1.5" => matches Ok(_); "multi comparator semantic req converts")] + #[test_case("2020-02-01" => matches Err(_); "date-formatted arbitrary req fails")] + #[test_case("arbitrary" => matches Err(_); "arbitrary string req fails")] + fn semantic_version_req(requirement: &str) -> Result { + TryInto::::try_into(ResourceVersionReq::new(requirement)) + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use test_case::test_case; + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "Arbitrary", true; "identical arbitrary reqs")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased arbitrary reqs")] + #[test_case("foo", "bar", false; "different arbitrary reqs")] + fn resource_version_req(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!(ResourceVersionReq::new(lhs), ResourceVersionReq::new(rhs)) + } else { + pretty_assertions::assert_ne!(ResourceVersionReq::new(lhs), ResourceVersionReq::new(rhs)) + } + } + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "1.2.3", false; "arbitrary req and semantic req")] + fn semantic_version_req( + resource_version_req_string: &str, + semantic_version_req_string: &str, + should_be_equal: bool, + ) { + let req = ResourceVersionReq::new(resource_version_req_string); + let semantic = SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == semantic, + should_be_equal, + "expected comparison of {req} and {semantic} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semantic == req, + should_be_equal, + "expected comparison of {semantic} and {req} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "1.2.3", false; "arbitrary req and semantic req")] + #[test_case("Arbitrary", "Arbitrary", true; "identical arbitrary reqs")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased arbitrary reqs")] + #[test_case("foo", "bar", false; "different arbitrary reqs")] + fn str(resource_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let req: ResourceVersionReq = resource_version_req_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == string_slice, + should_be_equal, + "expected comparison of {req} and {string_slice} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == req, + should_be_equal, + "expected comparison of {string_slice} and {req} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "1.2.3", false; "arbitrary req and semantic req")] + #[test_case("Arbitrary", "Arbitrary", true; "identical arbitrary reqs")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased arbitrary reqs")] + #[test_case("foo", "bar", false; "different arbitrary reqs")] + fn string(resource_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let req: ResourceVersionReq = resource_version_req_string.parse().unwrap(); + let string = string_slice.to_string(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == string, + should_be_equal, + "expected comparison of {req} and {string} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == req, + should_be_equal, + "expected comparison of {string} and {req} to be #{should_be_equal}" + ); + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/semantic_version.rs b/lib/dsc-lib/tests/integration/types/semantic_version.rs new file mode 100644 index 000000000..d4b42cd80 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/semantic_version.rs @@ -0,0 +1,539 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use test_case::test_case; + + #[test] + fn new() { + let actual = SemanticVersion::new(1, 0, 0); + + pretty_assertions::assert_eq!(actual.to_string(), "1.0.0".to_string()); + } + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit is invalid")] + fn parse(value: &str) -> Result { + SemanticVersion::parse(value) + } +} + +#[cfg(test)] +mod schema { + use std::sync::LazyLock; + + use dsc_lib::types::SemanticVersion; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static SCHEMA: LazyLock = LazyLock::new(|| schema_for!(SemanticVersion)); + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*SCHEMA).as_value()).unwrap()); + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title")] + #[test_case("description")] + #[test_case("markdownDescription")] + #[test_case("patternErrorMessage")] + fn has_documentation_keyword(keyword: &str) { + let schema = &*SCHEMA; + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test_case(&json!("1.0.0") => true; "valid stable semantic version string value is valid")] + #[test_case(&json!("1.0.0-rc.1") => true; "valid prerelease semantic version string value is valid")] + #[test_case(&json!("1.0.0+ci.123") => true; "valid stable semantic version with build metadata string value is valid")] + #[test_case(&json!("1.0.0-rc.1+ci.123") => true; "valid prerelease semantic version with build metadata string value is valid")] + #[test_case(&json!("1") => false; "major version only string value is invalid")] + #[test_case(&json!("1.0") => false; "missing patch version string value is invalid")] + #[test_case(&json!("1.2.c") => false; "version segment as non-digit string value is invalid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"req": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::SemanticVersion; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("1.0.0"; "stable semantic version")] + #[test_case("1.0.0-rc.1"; "prerelease semantic version")] + #[test_case("1.0.0+ci.123"; "stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123"; "prerelease semantic version with build metadata")] + fn serializing(version: &str) { + let actual = serde_json::to_string( + &SemanticVersion::parse(version).expect("parse should never fail"), + ) + .expect("serialization should never fail"); + + let expected = format!(r#""{version}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.0.0") => matches Ok(_); "stable semantic version string value is valid")] + #[test_case(json!("1.0.0-rc.1") => matches Ok(_); "prerelease semantic version string value is valid")] + #[test_case(json!("1.0.0+ci.123") => matches Ok(_); "stable semantic version with build metadata string value is valid")] + #[test_case(json!("1.0.0-rc.1+ci.123") => matches Ok(_); "prerelease semantic version with build metadata string value is valid")] + #[test_case(json!("1") => matches Err(_); "major version only string value is invalid")] + #[test_case(json!("1.0") => matches Err(_); "missing patch version string value is invalid")] + #[test_case(json!("1.2.c") => matches Err(_); "version segment as non-digit string value is invalid")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::SemanticVersion; + + #[test] + fn default() { + let actual = SemanticVersion::default(); + let expected = SemanticVersion::new(0, 0, 0); + + pretty_assertions::assert_eq!(actual, expected); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::SemanticVersion; + use test_case::test_case; + + #[test_case("1.0.0", "1.0.0"; "valid stable semantic version")] + #[test_case("1.0.0-rc.1", "1.0.0-rc.1"; "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123", "1.0.0+ci.123"; "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123", "1.0.0-rc.1+ci.123"; "valid prerelease semantic version with build metadata")] + fn format(version: &str, expected: &str) { + pretty_assertions::assert_eq!( + format!("req: '{}'", SemanticVersion::parse(version).unwrap()), + format!("req: '{}'", expected) + ) + } + + #[test_case("1.0.0", "1.0.0"; "valid stable semantic version")] + #[test_case("1.0.0-rc.1", "1.0.0-rc.1"; "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123", "1.0.0+ci.123"; "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123", "1.0.0-rc.1+ci.123"; "valid prerelease semantic version with build metadata")] + fn to_string(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + SemanticVersion::parse(requirement).unwrap().to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::SemanticVersion; + use semver::Version; + + #[test] + fn semver_version() { + let _ = SemanticVersion::from(Version::new(1, 0, 0)); + } + } + + #[cfg(test)] + mod from_str { + use std::str::FromStr; + + use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use test_case::test_case; + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn from_str(text: &str) -> Result { + SemanticVersion::from_str(text) + } + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn parse(text: &str) -> Result { + text.parse() + } + } + + #[cfg(test)] + mod try_from { + use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use test_case::test_case; + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn string(text: &str) -> Result { + SemanticVersion::try_from(text.to_string()) + } + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn str(text: &str) -> Result { + SemanticVersion::try_from(text) + } + } + + #[cfg(test)] + mod into { + use dsc_lib::types::SemanticVersion; + use semver::Version; + + #[test] + fn semver_version() { + let _: Version = SemanticVersion::new(1, 0, 0).into(); + } + + #[test] + fn string() { + let _: String = SemanticVersion::new(1, 0, 0).into(); + } + } + + #[cfg(test)] + mod as_ref { + use dsc_lib::types::SemanticVersion; + use semver::Version; + + #[test] + fn semver_version() { + let _: &Version = SemanticVersion::new(1, 0, 0).as_ref(); + } + } + + #[cfg(test)] + mod deref { + use dsc_lib::types::SemanticVersion; + + #[test] + fn semver_version() { + let v = SemanticVersion::new(1, 2, 3); + + pretty_assertions::assert_eq!(v.major, 1u64); + pretty_assertions::assert_eq!(v.minor, 2u64); + pretty_assertions::assert_eq!(v.patch, 3u64); + pretty_assertions::assert_eq!(v.pre, semver::Prerelease::EMPTY); + pretty_assertions::assert_eq!(v.build, semver::BuildMetadata::EMPTY); + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::SemanticVersion; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", true; "identical semantic versions")] + #[test_case("1.2.3", "3.2.1", false; "different semantic versions")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "semantic version and prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "semantic version and build metadata")] + fn semantic_version(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!( + SemanticVersion::parse(lhs).unwrap(), + SemanticVersion::parse(rhs).unwrap() + ) + } else { + pretty_assertions::assert_ne!( + SemanticVersion::parse(lhs).unwrap(), + SemanticVersion::parse(rhs).unwrap() + ) + } + } + + #[test_case("1.2.3", "1.2.3", true; "SemanticVersion and identical semver::Version")] + #[test_case("1.2.3", "3.2.1", false; "different versions")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "SemanticVersion and semver::Version with prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "SemanticVersion and semver::Version with build metadata")] + fn semver_version( + semantic_version_string: &str, + semver_version_string: &str, + should_be_equal: bool, + ) { + let semantic_version = SemanticVersion::parse(semantic_version_string).unwrap(); + let semver_version = semver::Version::parse(semver_version_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version == semver_version, + should_be_equal, + "expected comparison of {semantic_version} and {semver_version} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semver_version == semantic_version, + should_be_equal, + "expected comparison of {semver_version} and {semantic_version} to be {should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "SemanticVersion and identical string")] + #[test_case("1.2.3", "3.2.1", false; "SemanticVersion and different version string")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "SemanticVersion and version string with prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "SemanticVersion and version string with build metadata")] + fn string(semantic_version_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version = SemanticVersion::parse(semantic_version_string).unwrap(); + let string = string_slice.to_string(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version == string, + should_be_equal, + "expected comparison of {semantic_version} and {string} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == semantic_version, + should_be_equal, + "expected comparison of {string} and {semantic_version} to be {should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "SemanticVersion and identical string slice")] + #[test_case("1.2.3", "3.2.1", false; "SemanticVersion and different version string slice")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "SemanticVersion and version string slice with prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "SemanticVersion and version string slice with build metadata")] + fn str(semantic_version_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version = SemanticVersion::parse(semantic_version_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version == string_slice, + should_be_equal, + "expected comparison of {semantic_version} and {string_slice} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == semantic_version, + should_be_equal, + "expected comparison of {string_slice} and {semantic_version} to be {should_be_equal}" + ); + } + } + + #[cfg(test)] + mod partial_ord { + use std::cmp::Ordering; + + use dsc_lib::types::SemanticVersion; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal stable versions")] + #[test_case("1.2.3-rc.1", "1.2.3-rc.1", Ordering::Equal; "equal prerelease versions")] + #[test_case("1.2.3+ci.1", "1.2.3+ci.1", Ordering::Equal; "equal stable versions with build metadata")] + #[test_case("1.2.3-rc.1+ci.1", "1.2.3-rc.1+ci.1", Ordering::Equal; "equal prerelease versions with build metadata")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "newer stable is greater than older stable")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "older stable is less than newer stable")] + #[test_case("1.2.3", "1.2.3-rc.1", Ordering::Greater; "stable is greater than prerelease")] + #[test_case("1.2.3", "1.2.3+ci.1", Ordering::Less; "stable without build is less than stable with build")] + #[test_case("1.2.3-rc.1", "1.2.3-rc.1+ci.1", Ordering::Less; "prerelease without build is less than prerelease with build")] + #[test_case("1.2.3-A-0", "1.2.3-A00", Ordering::Less; "prerelease hyphen is less than digit")] + #[test_case("1.2.3-A0A", "1.2.3-AAA", Ordering::Less; "prerelease digit is less than uppercase")] + #[test_case("1.2.3-AAA", "1.2.3-AaA", Ordering::Less; "prerelease uppercase is less than lowercase")] + #[test_case("1.2.3+A-0", "1.2.3+A00", Ordering::Less; "build metadata hyphen is less than digit")] + #[test_case("1.2.3+A0A", "1.2.3+AAA", Ordering::Less; "build metadata digit is less than uppercase")] + #[test_case("1.2.3+AAA", "1.2.3+AaA", Ordering::Less; "build metadata uppercase is less than lowercase")] + fn semantic_version(lhs: &str, rhs: &str, expected_order: Ordering) { + pretty_assertions::assert_eq!( + SemanticVersion::parse(lhs) + .expect("parsing for lhs should not fail") + .partial_cmp(&SemanticVersion::parse(rhs).expect("parsing for rhs should not fail")) + .expect("comparison should always be an ordering"), + expected_order, + "expected '{lhs}' compared to '{rhs}' to be {expected_order:#?}" + ) + } + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "newer rhs")] + #[test_case("1.2.3", "1.2.3-rc.1", Ordering::Greater; "stable lhs and prerelease rhs")] + #[test_case("1.2.3", "1.2.3+ci.1", Ordering::Less; "stable lhs and rhs with build metadata")] + fn semver_version( + resource_version_string: &str, + semver_string: &str, + expected_order: Ordering, + ) { + let version: SemanticVersion = resource_version_string.parse().unwrap(); + let semantic: semver::Version = semver_string.parse().unwrap(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&semantic).unwrap(), + expected_order, + "expected comparison of {version} and {semantic} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Less, + Ordering::Less => Ordering::Greater, + }; + + pretty_assertions::assert_eq!( + semantic.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {semantic} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Some(Ordering::Equal); "equal version and string")] + #[test_case("3.2.1", "1.2.3", Some(Ordering::Greater); "newer version and older string")] + #[test_case("1.2.3", "3.2.1", Some(Ordering::Less); "older version and newer string")] + #[test_case("1.2.3", "1.2.3-rc.1", Some(Ordering::Greater); "stable version and prerelease string")] + #[test_case("1.2.3", "1.2.3+ci.1", Some(Ordering::Less); "stable version and string with build metadata")] + #[test_case("1.2.3", "not a version", None; "stable version and non-version string")] + fn string( + resource_version_string: &str, + string_slice: &str, + expected_order: Option, + ) { + let version: SemanticVersion = resource_version_string.parse().unwrap(); + let string = string_slice.to_string(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&string), + expected_order, + "expected comparison of {version} and {string} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + None => None, + Some(o) => match o { + Ordering::Equal => Some(Ordering::Equal), + Ordering::Greater => Some(Ordering::Less), + Ordering::Less => Some(Ordering::Greater), + }, + }; + + pretty_assertions::assert_eq!( + string.partial_cmp(&version), + expected_inverted_order, + "expected comparison of {string} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Some(Ordering::Equal); "equal version and string")] + #[test_case("3.2.1", "1.2.3", Some(Ordering::Greater); "newer version and older string")] + #[test_case("1.2.3", "3.2.1", Some(Ordering::Less); "older version and newer string")] + #[test_case("1.2.3", "1.2.3-rc.1", Some(Ordering::Greater); "stable version and prerelease string")] + #[test_case("1.2.3", "1.2.3+ci.1", Some(Ordering::Less); "stable version and string with build metadata")] + #[test_case("1.2.3", "not a version", None; "stable version and non-version string")] + fn str( + resource_version_string: &str, + string_slice: &str, + expected_order: Option, + ) { + let version: SemanticVersion = resource_version_string.parse().unwrap(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(string_slice), + expected_order, + "expected comparison of {version} and {string_slice} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + None => None, + Some(o) => match o { + Ordering::Equal => Some(Ordering::Equal), + Ordering::Greater => Some(Ordering::Less), + Ordering::Less => Some(Ordering::Greater), + }, + }; + + pretty_assertions::assert_eq!( + string_slice.partial_cmp(&version), + expected_inverted_order, + "expected comparison of {string_slice} and {version} to be #{expected_inverted_order:#?}" + ); + } + } + + mod ord { + use dsc_lib::types::SemanticVersion; + + #[test] + fn semantic_version() { + let v1_0_0 = SemanticVersion::parse("1.0.0").unwrap(); + let v1_2_3 = SemanticVersion::parse("1.2.3").unwrap(); + let v2_0_0 = SemanticVersion::parse("2.0.0").unwrap(); + let v1_2_3_rc_1 = SemanticVersion::parse("1.2.3-rc.1").unwrap(); + let v1_2_3_ci_1 = SemanticVersion::parse("1.2.3+ci.1").unwrap(); + + let mut versions = vec![ + v1_0_0.clone(), + v1_2_3.clone(), + v2_0_0.clone(), + v1_2_3_rc_1.clone(), + v1_2_3_ci_1.clone() + ]; + versions.sort(); + + pretty_assertions::assert_eq!( + versions, + vec![ + v1_0_0, + v1_2_3_rc_1, + v1_2_3, + v1_2_3_ci_1, + v2_0_0 + ] + ); + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/semantic_version_req.rs b/lib/dsc-lib/tests/integration/types/semantic_version_req.rs new file mode 100644 index 000000000..de4167e27 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/semantic_version_req.rs @@ -0,0 +1,647 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + #[cfg(test)] + mod parse { + use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use test_case::test_case; + + #[test_case("1" => matches Ok(_); "major is valid")] + #[test_case("1.2" => matches Ok(_); "major.minor is valid")] + #[test_case("1.2.3" => matches Ok(_); "major.minor.patch is valid")] + #[test_case("1.2.3-pre" => matches Ok(_); "major.minor.patch-pre is valid")] + #[test_case("1-pre" => matches Err(_); "major-pre is invalid")] + #[test_case("1.2-pre" => matches Err(_); "major.minor-pre is invalid")] + #[test_case("1.2.3+build" => matches Err(_); "major.minor.patch+build is invalid")] + #[test_case("1.2.3-pre+build" => matches Err(_); "major.minor.patch-pre+build is invalid")] + #[test_case("a" => matches Err(_); "invalid_char is invalid")] + #[test_case("1.b" => matches Err(_); "major.invalid_char is invalid")] + #[test_case("1.2.c" => matches Err(_); "major.minor.invalid_char is invalid")] + fn literal_version(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("1.*" => matches Ok(_); "major.wildcard is valid")] + #[test_case("1.*.*" => matches Ok(_); "major.wildcard.wildcard is valid")] + #[test_case("1.2.*" => matches Ok(_); "major.minor.wildcard is valid")] + #[test_case("1.*.3" => matches Err(_); "major.wildcard.patch is invalid")] + #[test_case("1.2.*-pre" => matches Err(_); "major.minor.wildcard-pre is invalid")] + #[test_case("1.*.*-pre" => matches Err(_); "major.wildcard.wildcard-pre is invalid")] + #[test_case("1.2.3-*" => matches Err(_); "major.minor.patch-wildcard is invalid")] + #[test_case("1.2.3-pre.*" => matches Err(_); "major.minor.patch-pre.wildcard is invalid")] + fn wildcard_version(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("1.2.3" => matches Ok(_); "implicit operator is valid")] + #[test_case("^ 1.2.3" => matches Ok(_); "caret operator is valid")] + #[test_case("~ 1.2.3" => matches Ok(_); "tilde operator is valid")] + #[test_case("= 1.2.3" => matches Ok(_); "exact operator is valid")] + #[test_case("> 1.2.3" => matches Ok(_); "greater than operator is valid")] + #[test_case(">= 1.2.3" => matches Ok(_); "greater than or equal to operator is valid")] + #[test_case("< 1.2.3" => matches Ok(_); "less than operator is valid")] + #[test_case("<= 1.2.3" => matches Ok(_); "less than or equal to operator is valid")] + #[test_case("== 1.2.3" => matches Err(_); "invalid operator is invalid")] + fn operators(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("1.2.3, < 1.5" => matches Ok(_); "pair with separating comma is valid")] + #[test_case("1, 1.2, 1.2.3" => matches Ok(_); "triple with separating comma is valid")] + #[test_case("<= 1, >= 2" => matches Ok(_); "incompatible pair is valid")] + #[test_case(", 1, 1.2" => matches Err(_); "leading comma is invalid")] + #[test_case("1, 1.2," => matches Err(_); "trailing comma is invalid")] + #[test_case("1 1.2" => matches Err(_); "omitted separating comma is invalid")] + #[test_case("1.*, < 1.3.*" => matches Ok(_); "multiple comparators with wildcard is valid")] + fn multiple_comparators(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("^1.2" => matches Ok(_); "operator and version without spacing is valid")] + #[test_case("^ 1.2" => matches Ok(_); "operator and version with extra spacing is valid")] + #[test_case(" ^ 1.2" => matches Ok(_); "leading space is valid")] + #[test_case("^ 1.2 " => matches Ok(_); "trailing space is valid")] + #[test_case("^1.2,<1.5" => matches Ok(_); "pair of comparators without spacing is valid")] + #[test_case(" ^ 1.2 , < 1.5 " => matches Ok(_); "pair of comparators with extra spacing is valid")] + fn spacing(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + } + + #[cfg(test)] + mod matches { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + fn check(requirement: &str, versions: Vec<&str>, should_match: bool) { + let req = SemanticVersionReq::parse(requirement).unwrap(); + let expected = if should_match { "match" } else { "not match" }; + for version in versions { + pretty_assertions::assert_eq!( + req.matches(&version.parse().unwrap()), + should_match, + "expected version '{version}' to {expected} requirement '{requirement}'" + ); + } + } + + #[test_case("1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major")] + #[test_case("1.2", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor")] + #[test_case("1.2", vec!["1.0.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor")] + #[test_case("1.2.3", vec!["1.2.3", "1.2.4", "1.3.0"], true; "matching major.minor.patch")] + #[test_case("1.2.3", vec!["1.2.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("1.2.3-rc.2", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "1.3.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn implicit(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("^1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("^1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major")] + #[test_case("^1.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard")] + #[test_case("^1.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard")] + #[test_case("^1.*.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard.wildcard")] + #[test_case("^1.*.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("^1.2", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor")] + #[test_case("^1.2", vec!["1.0.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor")] + #[test_case("^1.2.*", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor.wildcard")] + #[test_case("^1.2.*", vec!["1.0.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor.wildcard")] + #[test_case("^1.2.3", vec!["1.2.3", "1.2.4", "1.3.0"], true; "matching major.minor.patch")] + #[test_case("^1.2.3", vec!["1.2.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("^1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("^1.2.3-rc.2", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "1.3.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn caret(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("~1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("~1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major")] + #[test_case("~1.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard")] + #[test_case("~1.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard")] + #[test_case("~1.*.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard.wildcard")] + #[test_case("~1.*.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("~1.2", vec!["1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case("~1.2", vec!["1.0.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor")] + #[test_case("~1.2.*", vec!["1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case("~1.2.*", vec!["1.0.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.wildcard")] + #[test_case("~1.2.3", vec!["1.2.3", "1.2.9"], true; "matching major.minor.patch")] + #[test_case("~1.2.3", vec!["1.2.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("~1.2.3-rc.2", vec!["1.2.3", "1.2.9", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("~1.2.3-rc.2", vec!["1.2.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch-pre")] + fn tilde(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("<1", vec!["0.1.0"], true; "matching major")] + #[test_case("<1", vec!["1.0.0", "1.2.3", "0.1.0-rc.1"], false; "not matching major")] + #[test_case("<1.*", vec!["0.1.0"], true; "matching major.wildcard")] + #[test_case("<1.*", vec!["1.0.0", "1.2.3", "0.1.0-rc.1"], false; "not matching major.wildcard")] + #[test_case("<1.*.*", vec!["0.1.0"], true; "matching major.wildcard.wildcard")] + #[test_case("<1.*.*", vec!["1.0.0", "1.2.3", "0.1.0-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("<1.2", vec!["0.1.0", "1.0.0", "1.1.1"], true; "matching major.minor")] + #[test_case("<1.2", vec!["1.2.0", "1.2.3", "1.3.0", "1.2.0-rc.1"], false; "not matching major.minor")] + #[test_case("<1.2.3", vec!["0.1.0", "1.0.0", "1.2.0"], true; "matching major.minor.patch")] + #[test_case("<1.2.3", vec!["1.2.3", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("<1.2.3-rc.2", vec!["0.1.0", "1.2.0", "1.2.3-rc.1"], true; "matching major.minor.patch-pre")] + #[test_case("<1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.0.0-rc.1", "1.2.3-rc.2"], false; "not matching major.minor.patch-pre")] + fn less_than(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("<=1", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major")] + #[test_case("<=1", vec!["2.0.0", "0.1.0-rc.1", "1.0.0-rc.1"], false; "not matching major")] + #[test_case("<=1.*", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major.wildcard")] + #[test_case("<=1.*", vec!["2.0.0", "0.1.0-rc.1", "1.0.0-rc.1"], false; "not matching major.wildcard")] + #[test_case("<=1.*.*", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major.wildcard.wildcard")] + #[test_case("<=1.*.*", vec!["2.0.0", "0.1.0-rc.1", "1.0.0-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("<=1.2", vec!["0.1.0", "1.0.0", "1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case("<=1.2", vec!["1.3.0", "1.0.0-rc.1", "1.2.0-rc.1"], false; "not matching major.minor")] + #[test_case("<=1.2.*", vec!["0.1.0", "1.0.0", "1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case("<=1.2.*", vec!["1.3.0", "1.0.0-rc.1", "1.2.0-rc.1"], false; "not matching major.minor.wildcard")] + #[test_case("<=1.2.3", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major.minor.patch")] + #[test_case("<=1.2.3", vec!["1.2.4", "1.3.0", "1.2.0-rc.1", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("<=1.2.3-rc.2", vec!["0.1.0", "1.2.3-rc.1", "1.2.3-rc.2"], true; "matching major.minor.patch-pre")] + #[test_case("<=1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.0.0-rc.1", "1.2.3-rc.3"], false; "not matching major.minor.patch-pre")] + fn less_than_or_equal_to(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("=1", vec!["1.0.0", "1.2.3"], true; "matching major")] + #[test_case("=1", vec!["0.1.0", "2.0.0", "1.0.0-rc.2"], false; "not matching major")] + #[test_case("=1.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard")] + #[test_case("=1.*", vec!["0.1.0", "2.0.0", "1.0.0-rc.2"], false; "not matching major.wildcard")] + #[test_case("=1.*.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard.wildcard")] + #[test_case("=1.*.*", vec!["0.1.0", "2.0.0", "1.0.0-rc.2"], false; "not matching major.wildcard.wildcard")] + #[test_case("=1.2", vec!["1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case("=1.2", vec!["1.0.0", "1.3.0", "1.2.3-rc.2"], false; "not matching major.minor")] + #[test_case("=1.2.*", vec!["1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case("=1.2.*", vec!["1.0.0", "1.3.0", "1.2.3-rc.2"], false; "not matching major.minor.wildcard")] + #[test_case("=1.2.3", vec!["1.2.3"], true; "matching major.minor.patch")] + #[test_case("=1.2.3", vec!["1.2.0", "1.3.0", "1.2.3-rc.2"], false; "not matching major.minor.patch")] + #[test_case("=1.2.3-rc.2", vec!["1.2.3-rc.2"], true; "matching major.minor.patch-pre")] + #[test_case("=1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch-pre")] + fn equal_to(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">1", vec!["2.0.0", "2.3.4"], true; "matching major")] + #[test_case(">1", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major")] + #[test_case(">1.*", vec!["2.0.0", "2.3.4"], true; "matching major.wildcard")] + #[test_case(">1.*", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.wildcard")] + #[test_case(">1.*.*", vec!["2.0.0", "2.3.4"], true; "matching major.wildcard.wildcard")] + #[test_case(">1.*.*", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.wildcard.wildcard")] + #[test_case(">1.2", vec!["1.3.0", "2.0.0"], true; "matching major.minor")] + #[test_case(">1.2", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.minor")] + #[test_case(">1.2.*", vec!["1.3.0", "2.0.0"], true; "matching major.minor.wildcard")] + #[test_case(">1.2.*", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.minor.wildcard")] + #[test_case(">1.2.3", vec!["1.2.4", "2.0.0"], true; "matching major.minor.patch")] + #[test_case(">1.2.3", vec!["1.2.3", "2.0.0-rc.2"], false; "not matching major.minor.patch")] + #[test_case(">1.2.3-rc.2", vec!["1.2.3","2.0.0", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case(">1.2.3-rc.2", vec!["1.2.0", "1.2.3-rc.1", "2.0.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn greater_than(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">=1", vec!["1.0.0", "1.2.3"], true; "matching major")] + #[test_case(">=1", vec!["0.1.0", "1.2.3-rc.2"], false; "not matching major")] + #[test_case(">=1.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard")] + #[test_case(">=1.*", vec!["0.1.0", "1.2.3-rc.2"], false; "not matching major.wildcard")] + #[test_case(">=1.*.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard.wildcard")] + #[test_case(">=1.*.*", vec!["0.1.0", "1.2.3-rc.2"], false; "not matching major.wildcard.wildcard")] + #[test_case(">=1.2", vec!["1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case(">=1.2", vec!["1.1.1", "1.2.3-rc.2"], false; "not matching major.minor")] + #[test_case(">=1.2.*", vec!["1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case(">=1.2.*", vec!["1.1.1", "1.2.3-rc.2"], false; "not matching major.minor.wildcard")] + #[test_case(">=1.2.3", vec!["1.2.3", "1.3.0"], true; "matching major.minor.patch")] + #[test_case(">=1.2.3", vec!["1.2.2", "1.2.3-rc.2", "2.0.0-rc.2"], false; "not matching major.minor.patch")] + #[test_case(">=1.2.3-rc.2", vec!["1.2.3", "2.0.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case(">=1.2.3-rc.2", vec!["1.2.0", "1.2.3-rc.1", "2.0.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn greater_than_or_equal_to(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">= 2.0.0", vec!["2.0.0", "2.1.0", "3.0.0"], true; "matching major.minor.patch")] + #[test_case(">= 2.0.0", vec!["1.2.3", "2.0.0-0", "2.0.0-alpha.1", "2.1.0-beta.2", "3.0.0-rc.1"], false; "not matching major.minor.patch")] + #[test_case(">= 2.0.0-alpha", vec!["2.0.0", "2.1.0", "3.0.0", "2.0.0-alpha","2.0.0-alpha.1", "2.0.0-beta.1"], true; "matching major.minor.patch-alpha")] + #[test_case(">= 2.0.0-alpha", vec!["1.2.3", "2.0.0-0", "3.0.0-alpha.1"], false; "not matching major.minor.patch-alpha")] + #[test_case(">= 2.0.0-beta.2", vec!["2.0.0", "2.1.0", "3.0.0", "2.0.0-beta.2", "2.0.0-beta.3", "2.0.0-rc.1"], true; "matching major.minor.patch-beta.2")] + #[test_case(">= 2.0.0-beta.2", vec!["1.2.3", "2.0.0-alpha", "2.0.0-beta.1", "3.0.0-rc.1"], false; "not matching major.minor.patch-beta.2")] + #[test_case(">= 2.0.0-0", vec!["2.0.0", "2.1.0", "3.0.0", "2.0.0-0", "2.0.0-1", "2.0.0-alpha", "2.0.0-beta.1"], true; "matching major.minor.patch-0")] + #[test_case(">= 2.0.0-0", vec!["1.2.3", "3.0.0-rc.1"], false; "not matching major.minor.patch-0")] + fn prerelease(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("1.*", vec!["1.0.0", "1.2.3"], true; "matches major.wildcard")] + #[test_case("1.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matches major.wildcard")] + #[test_case("1.*.*", vec!["1.0.0", "1.2.3"], true; "matches major.wildcard.wildcard")] + #[test_case("1.*.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matches major.wildcard.wildcard")] + #[test_case("1.2.*", vec!["1.2.0", "1.2.3"], true; "matches major.minor.wildcard")] + #[test_case("1.2.*", vec!["1.1.1", "1.3.0", "1.2.3-rc.1"], false; "not matches major.minor.wildcard")] + fn wildcard(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">=1.2, <1.4.0", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching multiple compatible requirements")] + #[test_case(">=1.2, <1.4.0", vec!["1.1.0", "1.4.0", "1.3.0-rc.1"], false; "not matching multiple compatible requirements")] + #[test_case("<=1.2, >1.4.0", vec!["1.0.0", "1.2.3", "1.3.0", "1.4.0", "2.0.0", "2.3.4-rc.1"], false; "never matching multiple incompatible requirements")] + fn multiple(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + } +} + +#[cfg(test)] +mod patterns { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + #[test_case(SemanticVersionReq::PRERELEASE_PATTERN; "PRERELEASE_PATTERN")] + #[test_case(SemanticVersionReq::WILDCARD_VERSION_PATTERN; "WILDCARD_VERSION_PATTERN")] + #[test_case(SemanticVersionReq::LITERAL_VERSION_PATTERN; "LITERAL_VERSION_PATTERN")] + #[test_case(SemanticVersionReq::COMPARATOR_PATTERN; "COMPARATOR_PATTERN")] + #[test_case(SemanticVersionReq::OPERATOR_PATTERN; "OPERATOR_PATTERN")] + #[test_case(SemanticVersionReq::WILDCARD_SYMBOL_PATTERN; "WILDCARD_SYMBOL_PATTERN")] + fn partial_pattern_compiles(pattern: &str) { + regex::Regex::new(pattern).unwrap(); + } + + #[test_case("1"; "major")] + #[test_case("1.2"; "major.minor")] + #[test_case("1.2.3"; "major.minor.patch")] + #[test_case("1.*"; "major.wildcard_asterisk")] + #[test_case("1.2.*"; "major.minor.wildcard_asterisk")] + #[test_case("1.2.3-alpha"; "major.minor.patch-prerelease")] + #[test_case("^1"; "caret operator")] + #[test_case("~1"; "tilde operator")] + #[test_case("=1"; "equals operator")] + #[test_case(">1"; "greater than operator")] + #[test_case("<1"; "less than operator")] + #[test_case(">=1"; "greater than or equal to operator")] + #[test_case("<=1"; "less than or equal to operator")] + #[test_case("~1,1.2.3,<2"; "multiple comparators without spacing")] + #[test_case("~ 1 , 1.2.3 , < 2"; "multiple comparators with extra spacing")] + fn validating_pattern(requirement: &str) { + let pattern = SemanticVersionReq::VALIDATING_PATTERN; + let r = regex::Regex::new(pattern).unwrap(); + + if r.is_match(requirement) { + match semver::VersionReq::parse(requirement) { + Ok(_) => {} + Err(e) => panic!("failed to parse '{requirement}': {e}"), + } + } else { + panic!("Expected '{requirement}' to be valid requirement pattern but didn't match regex '{pattern}'") + } + } +} + +#[cfg(test)] +mod schema { + use std::sync::LazyLock; + + use dsc_lib::types::SemanticVersionReq; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static SCHEMA: LazyLock = LazyLock::new(|| schema_for!(SemanticVersionReq)); + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*SCHEMA).as_value()).unwrap()); + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title")] + #[test_case("description")] + #[test_case("markdownDescription")] + #[test_case("patternErrorMessage")] + fn has_documentation_keyword(keyword: &str) { + let schema = &*SCHEMA; + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test_case(&json!("1") => true; "major is valid")] + #[test_case(&json!("1.2") => true; "major.minor is valid")] + #[test_case(&json!("1.2.3") => true; "major.minor.patch is valid")] + #[test_case(&json!("1.2.3-pre") => true; "major.minor.patch-pre is valid")] + #[test_case(&json!("1.*") => true; "major.wildcard is valid")] + #[test_case(&json!("1.2.*") => true; "major.minor.wildcard is valid")] + #[test_case(&json!("^1") => true; "caret operator is valid")] + #[test_case(&json!("~1") => true; "tilde operator is valid")] + #[test_case(&json!("=1") => true; "equals operator is valid")] + #[test_case(&json!(">1") => true; "greater than operator is valid")] + #[test_case(&json!("<1") => true; "less than operator is valid")] + #[test_case(&json!(">=1") => true; "greater than or equal to operator is valid")] + #[test_case(&json!("<=1") => true; "less than or equal to operator is valid")] + #[test_case(&json!("~1,1.2.3,<2") => true; "multiple comparators without spacing is valid")] + #[test_case(&json!("~ 1 , 1.2.3 , < 2") => true; "multiple comparators with extra spacing is valid")] + #[test_case(&json!("1.2.3+build") => false; "major.minor.patch+build is invalid")] + #[test_case(&json!("1.2.3-pre+build") => false; "major.minor.patch-pre+build is invalid")] + #[test_case(&json!("!3.0.0") => false; "unknown operator is invalid")] + #[test_case(&json!("3.0.0.0") => false; "non-semantic version is invalid")] + #[test_case(&json!("1.a") => false; "version with alphabetic segment is invalid")] + #[test_case(&json!("*.2") => false; "wildcard.major is invalid")] + #[test_case(&json!("1.*.3") => false; "major.wildcard.patch is invalid")] + #[test_case(&json!("1.2.3-*") => false; "major.minor.patch-wildcard is invalid")] + #[test_case(&json!("1.2.3-pre.*") => false; "major.minor.patch-pre.wildcard is invalid")] + #[test_case(&json!(">=1.2,") => false; "comma without following comparator is invalid")] + #[test_case(&json!(">=1.2 < 1.4") => false; "multiple comparators without separating comma is invalid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"req": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::SemanticVersionReq; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("^1.2.3"; "single comparator req string serializes to string")] + #[test_case("^1.2.3, <1.4"; "multi comparator req serializes to string")] + fn serializing(requirement: &str) { + let actual = serde_json::to_string( + &SemanticVersionReq::parse(requirement).expect("parse should never fail"), + ) + .expect("serialization should never fail"); + let expected = format!(r#""{requirement}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.2.3") => matches Ok(_); "valid req string value succeeds")] + #[test_case(json!("a.b") => matches Err(_); "invalid req string value fails")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } + + #[test_case("^1", true; "major with explicit operator round trips")] + #[test_case("1", false; "major with implicit operator does not round trip")] + #[test_case("^1.2", true; "major.minor with explicit operator round trips")] + #[test_case("1.2", false; "major.minor with implicit operator does not round trip")] + #[test_case("^1.2.3", true; "major.minor.patch with explicit operator round trips")] + #[test_case("1.2.3", false; "major.minor.patch with implicit operator does not round trip")] + #[test_case("^1.2.3-pre", true; "major.minor.patch-pre with explicit operator round trips")] + #[test_case("1.2.3-pre", false; "major.minor.patch-pre with implicit operator does not round trip")] + #[test_case("^1.*", false; "major.wildcard with explicit operator does not round trip")] + #[test_case("1.*", true; "major.wildcard with implicit operator round trips")] + #[test_case("^1.*.*", false; "major.wildcard.wildcard with explicit operator does not round trip")] + #[test_case("1.*.*", false; "major.wildcard.wildcard with implicit operator does not round trip")] + #[test_case("^1.2.*", false; "major.minor.wildcard version with explicit operator round trips")] + #[test_case("1.2.*", true; "major.minor.wildcard version with implicit operator round trips")] + #[test_case(" ^1.2.3", false; "requirement with leading spaces does not round trip")] + #[test_case("^1.2.3 ", false; "requirement with trailing spaces does not round trip")] + #[test_case("^1.2.3, <1.5", true; "multi-comparators with single space after comma round trips")] + #[test_case("^1.2.3,<1.5", false; "multi-comparators without space after comma does not round trip")] + #[test_case("^1.2.3 , <1.5", false; "multi-comparators with space before and after comma does not round trip")] + #[test_case("^1.2.3, <1.5", false; "multi-comparators with multiple spaces after comma does not round trip")] + fn round_tripping(requirement: &str, should_round_trip: bool) { + let json_value = json!(requirement); + // let json_string = json_value.clone().to_string(); + let serialized: SemanticVersionReq = serde_json::from_value(json_value.clone()).unwrap(); + let deserialized = serde_json::to_value(&serialized).unwrap(); + + if should_round_trip { + pretty_assertions::assert_eq!( + json_value, + deserialized, + "expected requirement '{requirement}' to roundtrip without munging" + ); + } else { + pretty_assertions::assert_ne!( + json_value, + deserialized, + "expected requirement '{requirement}' serialize as a munged string" + ); + } + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::SemanticVersionReq; + + #[test] + fn default() { + pretty_assertions::assert_eq!( + SemanticVersionReq::default().as_ref(), + &semver::VersionReq::default(), + ) + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + #[test_case("1.2", "^1.2"; "valid req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "valid req with multiple comparators")] + #[test_case("1.*", "1.*"; "valid req with a wildcard")] + fn format(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + format!("req: '{}'", SemanticVersionReq::parse(requirement).unwrap()), + format!("req: '{}'", expected) + ) + } + + #[test_case("1.2", "^1.2"; "valid req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "valid req with multiple comparators")] + #[test_case("1.*", "1.*"; "valid req with a wildcard")] + fn to_string(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + SemanticVersionReq::parse(requirement).unwrap().to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; + + #[test] + fn semver_version_req() { + let semver_req = semver::VersionReq::parse("1.2.3").unwrap(); + SemanticVersionReq::from(semver_req).matches(&SemanticVersion::new(1, 2, 3)); + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use test_case::test_case; + + // Minimal test suite, since full parsing tests are on the associated `parse` function. + #[test_case("1.2.3" => matches Ok(_); "valid requirement returns ok")] + #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] + fn parse(input: &str) -> Result { + input.parse() + } + } + + #[cfg(test)] + mod try_from { + use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use test_case::test_case; + + // Minimal test suite, since full parsing tests are on the associated `parse` function. + #[test_case("1.2.3" => matches Ok(_); "valid requirement returns ok")] + #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] + fn string(input: &str) -> Result { + SemanticVersionReq::try_from(input.to_string()) + } + + // Minimal test suite, since full parsing tests are on the associated `parse` function. + #[test_case("1.2.3" => matches Ok(_); "valid requirement returns ok")] + #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] + fn string_slice(input: &str) -> Result { + SemanticVersionReq::try_from(input) + } + } + + // While technically we implemented the traits as `From for `, it's easier to + // reason about what we're converting _into_ - otherwise the functions would have names like + // `type_version_for_semver_version`. When you implement `From`, you automatically implement + // `Into` for the reversing of the type pair. + #[cfg(test)] + mod into { + use dsc_lib::types::SemanticVersionReq; + + #[test] + fn semver_version_req() { + let _: semver::VersionReq = SemanticVersionReq::parse("1.2").unwrap().into(); + } + + #[test] + fn string() { + let _: String = SemanticVersionReq::parse("1.2").unwrap().into(); + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + fn semantic_version_req(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!( + SemanticVersionReq::parse(lhs).unwrap(), + SemanticVersionReq::parse(rhs).unwrap() + ) + } else { + pretty_assertions::assert_ne!( + SemanticVersionReq::parse(lhs).unwrap(), + SemanticVersionReq::parse(rhs).unwrap() + ) + } + } + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + fn semver_version_req( + semantic_version_req_string: &str, + semver_version_req_string: &str, + should_be_equal: bool, + ) { + let semantic_version_req = + SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + let semver_version_req = semver::VersionReq::parse(semver_version_req_string).unwrap(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version_req == semver_version_req, + should_be_equal, + "expected comparison of {semantic_version_req} and {semver_version_req} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semver_version_req == semantic_version_req, + should_be_equal, + "expected comparison of {semver_version_req} and {semantic_version_req} to be {should_be_equal}" + ); + } + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + #[test_case("1.2", "invalid", false; "requirement and arbitrary string")] + fn string(semantic_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version_req = + SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + let string = string_slice.to_string(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version_req == string, + should_be_equal, + "expected comparison of {semantic_version_req} and {string} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == semantic_version_req, + should_be_equal, + "expected comparison of {string} and {semantic_version_req} to be {should_be_equal}" + ); + } + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + #[test_case("1.2", "invalid", false; "requirement and arbitrary string")] + fn str(semantic_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version_req = + SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version_req == string_slice, + should_be_equal, + "expected comparison of {semantic_version_req} and {string_slice} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == semantic_version_req, + should_be_equal, + "expected comparison of {string_slice} and {semantic_version_req} to be {should_be_equal}" + ); + } + } +}