diff --git a/Cargo.lock b/Cargo.lock index e43bffb6b..356f1822c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,6 +788,7 @@ dependencies = [ "ipnetwork", "jsonschema", "linked-hash-map", + "miette", "murmurhash64", "num-traits", "path-absolutize", @@ -1811,6 +1812,28 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index d16c4be72..268ac4343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,6 +172,8 @@ jsonschema = { version = "0.38", default-features = false } # dsc-lib linked-hash-map = { version = "0.5" } # dsc-lib +miette = { version = "7.6.0" } +# dsc-lib murmurhash64 = { version = "0.3" } # dsc-lib-security_context::not_windows nix = { version = "0.31" } diff --git a/dsc/tests/dsc_version.tests.ps1 b/dsc/tests/dsc_version.tests.ps1 index 3ef528367..9d2af8218 100644 --- a/dsc/tests/dsc_version.tests.ps1 +++ b/dsc/tests/dsc_version.tests.ps1 @@ -22,7 +22,7 @@ Describe 'tests for metadata versioning' { $config_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json directives: - version: 999.0.0 + version: '=999.0.0' resources: - name: Echo type: Microsoft.DSC.Debug/Echo @@ -31,7 +31,7 @@ Describe 'tests for metadata versioning' { "@ $null = $config_yaml | dsc config get -f - 2>$testdrive/error.log $errorLog = Get-Content -Path $testdrive/error.log -Raw - $errorLog | Should -BeLike "*Validation*Configuration requires DSC version '999.0.0', but the current version is '*" + $errorLog | Should -BeLike "*Validation*Configuration requires DSC version '=999.0.0', but the current version is '*" $LASTEXITCODE | Should -Be 2 } diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index c4ef36de8..eac6abd15 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -17,6 +17,7 @@ derive_builder = { workspace = true } indicatif = { workspace = true } jsonschema = { workspace = true } linked-hash-map = { workspace = true } +miette = { workspace = true } murmurhash64 = { workspace = true } num-traits = { workspace = true } path-absolutize = { workspace = true } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 617aac17c..149dbfcb8 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -792,14 +792,9 @@ 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" @@ -827,13 +822,39 @@ executableNotFoundInWorkingDirectory = "Executable '%{executable}' not found wit executableNotFound = "Executable '%{executable}' not found" [types.date_version] -invalidDay = "Day `%{day}` for month `%{month}` is invalid - the day for that month must be between `01` and `%{max_days}`." -invalidLeapDay = "Invalid version date '%{year}-%{month}-%{day}' - the specified date is for a leap day in a year that doesn't have a leap day. Only leap years, like 2024 and 2028, have February 29th. %{year} is not a leap year." -invalidMonth = "Month `%{month}` is invalid. Date version months must be between `01` and `12` inclusive." -invalidYear = "Year `%{year}` is invalid. Date version years must be between `1000` and `9999` inclusive." -notMatchPattern = "Input string '%{text}' didn't match the validating pattern '%{pattern}'" -parseError = "Unable to parse '%{text}' as a date version: %{details}" +invalidDay = "day `%{day}` for month `%{month}` is invalid - must be between `01` and `%{max_days}`" +invalidLeapDay = "specified date '%{year}-02-29' is a leap day but %{year} isn't a leap year" +invalidMonth = "month `%{month}` is invalid - must be between `01` and `12` inclusive" +invalidYear = "year `%{year}` is invalid - must be between `1000` and `9999` inclusive" +notMatchPattern = "input string '%{text}' didn't match the validating pattern '%{pattern}'" +invalidDate = "unable to parse '%{text}' as a date version - %{errors}" [types.exit_codes_map] successText = "Success" failureText = "Error" + +[types.resource_version] +unparseableVersion = "unable to parse '%{text}' as resource version - input doesn't seem to be a semantic or date version" +invalidDateVersion = "invalid date resource version: %{err}" +invalidSemanticVersion = "invalid semantic resource version: %{err}" +invalidConversionToSemanticVersion = "unable to convert date resource version '%{version}' to semantic version" +invalidConversionToDateVersion = "unable to convert semantic resource version '%{version}' to date version" + +[types.resource_version_req] +invalidConversionToSemanticVersionReq = "unable to convert date resource version requirement to semantic version requirement" +invalidConversionToDateVersion = "unable to convert semantic resource version requirement to date version requirement" +invalidDateVersionRequirement = "invalid date resource version requirement: %{err}" +invalidSemanticVersionRequirement = "invalid semantic resource version requirement: %{err}" +unparseableRequirement = "unable to parse '%{text}' as a resource version requirement - input doesn't seem to be a semantic or date version requirement" + +[types.semantic_version] +invalidSemanticVersion = "invalid semantic version '%{text}': %{err}" + +[types.semantic_version_req] +unparseableReq = "unable to parse semantic version requirement: %{err}" +invalidReq = "invalid semantic version requirement '%{requirement}': %{err}" +invalidComparator = "invalid comparator '%{comparator}': %{err}" +forbiddenBuildMetadata = "comparator '%{comparator}' is defined with forbidden build metadata segment '%{build}'" +missingOperator = "comparator '%{comparator}' doesn't define an operator" +invalidWildcards = "comparator '%{comparator}' has invalid wildcard characters - must define wildcards as asterisks (`*`), not `x` or `X`" +wildcardMajorVersion = "comparator '%{comparator}' defines the major version segment as a wildcard `%{wildcard}` instead of a literal number" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index ebb0d73e5..e1562c968 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -76,7 +76,7 @@ schemas: You can't use alternate formats for the exit code. For example, instead of the hexadecimal value `0x80070005` for "Access denied", specify the exit code as `-2147024891`. - + If you're authoring your resource manifest in YAML, be sure to wrap the exit code in single quotes, like `'0': Success` instead of `0: Success` to ensure the YAML file can be parsed correctly. @@ -130,26 +130,24 @@ schemas: en-us: |- Defines the version of a DSC resource. - DSC supports both semantic versioning and arbitrary versioning for resources. Semantic + DSC supports both semantic versioning and date versioning for resources. Semantic versioning is the preferred and recommended versioning strategy. DSC only supports - arbitrary versioning for compatibility scenarios. + date 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. + When the version is defined as a valid [date version][03], DSC compares the dates to see + which one is newer. Date versions are only equivalent when they define the same date and + optional prerelease segment. Both versions must define or omit the prerelease segment. If + the prerelease segment is defined, the segments must be identical - the comparison is + case sensitive. [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 + [02]: https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md + [03]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/dateVersion semanticVariant: title: en-us: Semantic resource version @@ -167,36 +165,31 @@ schemas: [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semver [02]: https://semver.org - arbitraryVariant: + dateVariant: title: - en-us: Arbitrary string resource version + en-us: Date resource version description: en-us: >- - Defines the resource's version as an arbitrary string. + Defines the resource's version as a date version. deprecationMessage: en-us: >- - Defining a resource version as an arbitrary string is supported only for compatibility + Defining a resource version as a date version 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. + Defines the resource's version as a date version. - 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. + This variant remains supported for compatibility purposes but is _not_ recommended for + production usage. - When a resource defines the version as an arbitrary string: + When a resource defines the version as a date version: - 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. The resource only matches a resource version requirement that is defined as a date + version when the date and optional prerelease segments are exactly the same. The + comparison is case-sensitive for the prerelease segment. 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 + semantically versioned resources as newer than resources with a date version. resourceVersionReq: title: @@ -208,24 +201,23 @@ schemas: 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 + DSC supports both semantic versioning and date versioning for resources. Semantic versioning is the preferred and recommended versioning strategy. DSC only supports - arbitrary versioning for compatibility scenarios. + date versioning for compatibility scenarios. - Because DSC supports arbitrary string versions for compatibility, version requirements - must also support arbitrary string versions. + Because DSC supports date versions for compatibility, version requirements + must also support date versions as a valid requirement. 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 + _and_ valid for the given requirement. Date 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. + Similarly, when a resource version requirement is a date version, it can never match + a semantically versioned resource. Instead, it matches a date resource version when + the date version is _exactly_ the same as the date resource version requirement. - Arbitrary resource versions and resource version requirements are only defined for + Date resource versions and date resource version requirements are only defined for compatibility scenarios. You should use semantic versions for resources and resource version requirements. @@ -247,33 +239,33 @@ schemas: [Defining semantic version requirements][01]. [01]: https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md - arbitraryVariant: + dateVariant: title: - en-us: Arbitrary resource version requirement + en-us: Date resource version requirement description: en-us: >- - Defines the required version for the resource as an arbitrary string. + Defines the required version for the resource as a date version. deprecationMessage: en-us: >- - Defining a resource version requirement as an arbitrary string is supported only for + Defining a resource version requirement as a date version 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. + Defines the required version for the resource as a date version. - 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. + Date version requirements are supported for compatibility purposes but are _not_ + recommended for production usage. - When a resource version requirement is defined as an arbitrary string: + When a resource version requirement is defined as a date version: 1. It can never match a semantically versioned resource. - 1. It only matches a resource with an [arbitrary string version][01] when the resource + 1. It only matches a resource with a [date resource version][01] when the resource version and this version requirement are exactly the same. The comparison is - case-sensitive. + case-sensitive when comparing the prerelease segments for date versions and the + date version requirement. [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/resourceVersion @@ -338,6 +330,22 @@ schemas: 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 _must_ explicitly define an operator for every comparator. + + DSC forbids defining a comparator without an operator, like `1.*` or `1.2.3, <1.5`, to + reduce ambiguity and unexpected behavior for version pinning. For example, in all other + cases, omitting version segments and specifying them as a wildcard has the same behavior + _except_ for the comparators `1.2` and `1.2.*`: + + - `1`, `1.*`, and `1.*.*` all have an effective requirement of `>=1.0.0, <2.0.0`. + - `>1.2` and `>1.2.*` both have an effective requirement of `>1.2.0`. + - `1.2` has an effective requirement of `>=1.2.0, <2.0.0` but `1.2.*` has an effective + requirement of `>=1.2.0, <1.3.0`. + + Similarly, it is not immediately obvious to a user who isn't familiar with Rust semantic + version requirements that `1.2.3` will match `1.5.7`. It's more common across version + requirements to expect an exactly specified version to be an exact match requirement, not + a semantically compatible requirement. 1. DSC semantic version requirements only support the asterisk (`*`) character for wildcards, not `x` or `X`. diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index f66e0b518..17cd040d2 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use miette::Diagnostic; use rust_i18n::t; use std::str::Utf8Error; @@ -8,7 +9,7 @@ use indicatif::style::TemplateError; use thiserror::Error; use tree_sitter::LanguageError; -#[derive(Error, Debug)] +#[derive(Error, Debug, Diagnostic)] pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.adapterNotFound"))] AdapterNotFound(String), @@ -52,8 +53,8 @@ pub enum DscError { #[error("{t} '{0}', {t2} {1}, {t3} {2}", t = t!("dscerror.invalidFunctionParameterCount"), t2 = t!("dscerror.expected"), t3 = t!("dscerror.got"))] InvalidFunctionParameterCount(String, usize, usize), - #[error("{0}")] - InvalidDateVersion(String), + #[error(transparent)] + DateVersion(#[from] crate::types::DateVersionError), #[error("{t} '{0}': {1}", t = t!("dscerror.invalidExitCode"))] InvalidExitCode(String, core::num::ParseIntError), @@ -139,11 +140,11 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.resourceManifestNotFound"))] ResourceManifestNotFound(String), - #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionToSemverConversion"))] - ResourceVersionToSemverConversion(String), + #[error(transparent)] + ResourceVersion(#[from] crate::types::ResourceVersionError), - #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionReqToSemverConversion"))] - ResourceVersionReqToSemverConversion(String), + #[error(transparent)] + ResourceVersionReq(#[from] crate::types::ResourceVersionReqError), #[error("{t}: {0}", t = t!("dscerror.schema"))] Schema(String), @@ -154,16 +155,11 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.securityContext"))] SecurityContext(String), - #[error("semver: {0}")] - SemVer(#[from] semver::Error), + #[error(transparent)] + SemVer(#[from] crate::types::SemanticVersionError), - #[error( - "{t}: '{0}' {t2} '{1}' - {t3}", - t = t!("dscerror.semverReqWithBuildMetadataPrefix"), - t2 = t!("dscerror.semverReqWithBuildMetadataInfix"), - t3 = t!("dscerror.semverReqWithBuildMetadataSuffix") - )] - SemVerReqWithBuildMetadata(String, String), + #[error(transparent)] + SemverReq(#[from] crate::types::SemanticVersionReqError), #[error("{t}: {0}", t = t!("dscerror.utf16Conversion"))] Utf16Conversion(#[from] std::string::FromUtf16Error), diff --git a/lib/dsc-lib/src/types/date_version.rs b/lib/dsc-lib/src/types/date_version.rs index f231299be..732eeb6fc 100644 --- a/lib/dsc-lib/src/types/date_version.rs +++ b/lib/dsc-lib/src/types/date_version.rs @@ -8,15 +8,14 @@ use std::{ }; use chrono::{Datelike, NaiveDate}; +use miette::Diagnostic; use regex::Regex; use rust_i18n::t; use schemars::{JsonSchema, json_schema}; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::{ - dscerror::{DscError, DscError::InvalidDateVersion}, - schemas::dsc_repo::DscRepoSchema, -}; +use crate::schemas::dsc_repo::DscRepoSchema; /// Defines a version as an ISO8601 formatted date string for compatibility scenarios. /// @@ -40,11 +39,111 @@ use crate::{ /// /// If the date version is for a prerelease, the prerelease segment must be a string of ASCII /// alphabetic characters (`[a-zA-Z]`). -#[derive(Debug, Clone, Serialize, Deserialize, DscRepoSchema)] +#[derive(Debug, Clone, Hash, Serialize, Deserialize, DscRepoSchema)] #[serde(try_from = "String", into = "String")] #[dsc_repo_schema(base_name = "dateVersion", folder_path = "definitions")] pub struct DateVersion(NaiveDate, Option); +/// Indicates an error with parsing or converting a [`DateVersion`]. +#[derive(Debug, Error, Diagnostic)] +#[non_exhaustive] +pub enum DateVersionError { + /// Indicates that the input string for a date version doesn't match the validating pattern for + /// date versions. + /// + /// The input string must match the regular expression defined by + /// [`DateVersion::VALIDATING_PATTERN`] to be parsed as a date version. + #[error("{t}", t = t!( + "types.date_version.notMatchPattern", + "text" => text, + "pattern" => DateVersion::VALIDATING_PATTERN, + ))] + NotMatchPattern { + /// The text input that failed to match the validating pattern. + text: String + }, + + /// Indicates that the input string for a date version matches the validating pattern for date + /// versions but defines an invalid date. + /// + /// The validating pattern isn't able to verify whether a given date is valid. For example, the + /// date string `2026-04-31` isn't valid because the maximum number of days in April is 30. + /// Similarly, `2028-02-29` defines a leap day in a valid leap year, but the date `2026-02-29` + /// is invalid because 2026 isn't a leap year. + #[error("{t}", t = t!( + "types.date_version.invalidDate", + "text" => text, + "errors" => errors.iter().map(|e| e.to_string()).collect::>().join(", "), + ))] + InvalidDate { + /// The text input that defines an invalid date. + /// + /// This text is guaranteed to match the validating pattern for date versions, but it + /// defines an invalid date for one or more reasons. The `errors` field includes more + /// details about why the date is invalid. + text: String, + /// A list of specific errors that explain why the date defined by `text` is invalid. + #[related] + errors: Vec, + }, + + /// Indicates that the year segment of a date version defines an invalid year. + /// + /// This can occur for years greater than `9999` or less than `1000`. + #[error("{t}", t = t!( + "types.date_version.invalidYear", + "year" => year : {:04}, + ))] + InvalidYear{ + /// The invalid year defined in the input string. + year: i32 + }, + + /// Indicates that the month segment of a date version defines an invalid month. + /// + /// This can occur for months less than `1` or greater than `12`. + #[error("{t}", t = t!( + "types.date_version.invalidMonth", + "month" => month : {:02}, + ))] + InvalidMonth{ + /// The invalid month defined in the input string. + month: u32 + }, + + /// Indicates that the day segment of a date version defines an invalid leap day for February. + /// + /// This can occur when the month is defined as `02`, the day is defined as `29`, but the year + /// isn't a leap year. For example, `2026-02-29` defines an invalid leap day. + #[error("{t}", t = t!( + "types.date_version.invalidLeapDay", + "year" => year : {:04}, + ))] + InvalidLeapDay { + /// The year defined in the input string. + year: i32 + }, + + /// Indicates that the day segment of a date version defines an invalid day for the month. + /// + /// This can occur when the day is less than `1` or greater than the maximum number of days in + /// the month. For example, `2026-04-31` defines an invalid day because April has only 30 days. + #[error("{t}", t = t!( + "types.date_version.invalidDay", + "day" => day : {:02}, + "month" => month : {:02}, + "max_days" => max_days : {:02}, + ))] + InvalidDay { + /// The invalid day defined in the input string. + day: u32, + /// The month defined in the input string. + month: u32, + /// The maximum number of days in the month defined by `month`. + max_days: u32, + } +} + /// This static lazily defines the validating regex for [`DateVersion`]. 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 @@ -113,16 +212,10 @@ impl DateVersion { /// February 29 isn't a valid date for a non-leap year. /// /// [`VALIDATING_PATTERN`]: DateVersion::VALIDATING_PATTERN - pub fn parse(text: &str) -> Result { + pub fn parse(text: &str) -> Result { let pattern = VALIDATING_PATTERN_REGEX.get_or_init(Self::init_pattern); let Some(captures) = pattern.captures(text) else { - return Err(InvalidDateVersion( - t!( - "types.date_version.notMatchPattern", - "text" => text, - "pattern" => DateVersion::VALIDATING_PATTERN, - ).to_string() - )); + return Err(DateVersionError::NotMatchPattern { text: text.to_string() }); }; let year: i32 = captures @@ -251,7 +344,7 @@ impl DateVersion { /// 1. Forbids leading and trailing spacing characters. /// 1. Requires the first segment of the version to be a four-digit year, like `2026`. It /// forbids the year from starting with a leading zero. - /// 1. Requires a hyphen before the second segment. The second segment but be a two-digit + /// 1. Requires a hyphen before the second segment. The second segment must be a two-digit /// month, like `02` or `11`. The first nine months require a leading zero. Defining the /// month as either zero or greater than twelve is invalid. /// 1. Requires a hyphen before the third segment. The third segment must be a two-digit day of @@ -339,7 +432,7 @@ impl DateVersion { /// segments. /// /// [`VALIDATING_PATTERN`]: Self::VALIDATING_PATTERN - fn validate_date(year: i32, month: u32, day: u32, text: &str) -> Result<(), DscError> { + fn validate_date(year: i32, month: u32, day: u32, text: &str) -> Result<(), DateVersionError> { let max_days_in_month = match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31u32, 4 | 6 | 9 | 11 => 30u32, @@ -353,46 +446,32 @@ impl DateVersion { }, _ => unreachable!() }; + let mut errors: Vec = vec![]; - let details = if year > 9999 || year < 1000 { - Some(t!( - "types.date_version.invalidYear", - "year" => year - ).to_string()) - } else if month > 12 { - Some(t!( - "types.date_version.invalidMonth", - "month" => month : {:02}, - ).to_string()) - } else if day > max_days_in_month { + if year > 9999 || year < 1000 { + errors.push(DateVersionError::InvalidYear { year }); + } + + if month > 12 { + errors.push(DateVersionError::InvalidMonth { month }); + } + + if day > max_days_in_month { if month == 2 && day == 29 { - Some(t!( - "types.date_version.invalidLeapDay", - "year" => year, - "month" => month : {:02}, - "day" => day : {:02}, - ).to_string()) + errors.push(DateVersionError::InvalidLeapDay { year }); } else { - Some(t!( - "types.date_version.invalidDay", - "day" => day : {:02}, - "month" => month : {:02}, - "max_days" => max_days_in_month, - ).to_string()) + errors.push(DateVersionError::InvalidDay { + day, + month, + max_days: max_days_in_month + }); } - } else { - None - }; + } - match details { - None => Ok(()), - Some(details) => Err(InvalidDateVersion( - t!( - "types.date_version.parseError", - "text" => text, - "details" => details, - ).to_string() - )), + if errors.is_empty() { + Ok(()) + } else { + Err(DateVersionError::InvalidDate { text: text.to_string(), errors }) } } } @@ -505,21 +584,21 @@ impl AsRef for DateVersion { } impl FromStr for DateVersion { - type Err = DscError; + type Err = DateVersionError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom<&str> for DateVersion { - type Error = DscError; + type Error = DateVersionError; fn try_from(value: &str) -> Result { Self::parse(value) } } impl TryFrom for DateVersion { - type Error = DscError; + type Error = DateVersionError; fn try_from(value: String) -> Result { Self::parse(value.as_str()) } @@ -532,7 +611,7 @@ impl From for String { } impl TryFrom for DateVersion { - type Error = DscError; + type Error = DateVersionError; fn try_from(value: NaiveDate) -> Result { Self::validate_date(value.year(), value.month(), value.day(), value.to_string().as_str())?; diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index 937b58096..a555e71e8 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. mod date_version; -pub use date_version::DateVersion; +pub use date_version::{DateVersion, DateVersionError}; mod exit_code; pub use exit_code::ExitCode; mod exit_codes_map; @@ -10,13 +10,13 @@ pub use exit_codes_map::ExitCodesMap; mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; mod resource_version; -pub use resource_version::ResourceVersion; +pub use resource_version::{ResourceVersion, ResourceVersionError}; mod resource_version_req; -pub use resource_version_req::ResourceVersionReq; +pub use resource_version_req::{ResourceVersionReq, ResourceVersionReqError}; mod semantic_version; -pub use semantic_version::SemanticVersion; +pub use semantic_version::{SemanticVersion, SemanticVersionError}; mod semantic_version_req; -pub use semantic_version_req::SemanticVersionReq; +pub use semantic_version_req::{SemanticVersionReq, SemanticVersionReqError}; mod tag; pub use tag::Tag; mod tag_list; diff --git a/lib/dsc-lib/src/types/resource_version.rs b/lib/dsc-lib/src/types/resource_version.rs index ef33325d4..00afa1115 100644 --- a/lib/dsc-lib/src/types/resource_version.rs +++ b/lib/dsc-lib/src/types/resource_version.rs @@ -1,13 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::{convert::Infallible, fmt::Display, str::FromStr}; +use std::{fmt::Display, str::FromStr, sync::OnceLock}; +use miette::Diagnostic; +use regex::Regex; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; - -use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{SemanticVersion, SemanticVersionReq}}; +use thiserror::Error; + +use crate::{ + schemas::dsc_repo::DscRepoSchema, + types::{ + DateVersion, + DateVersionError, + SemanticVersion, + SemanticVersionError, + SemanticVersionReq, + }, +}; /// Defines the version of a DSC resource. /// @@ -31,14 +43,12 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{Semant /// # Examples /// /// The following example shows how different instances of [`ResourceVersion`] compare to other -/// instances of `ResourceVersion`, [`SemanticVersion`], [`String`], and [`str`]. +/// instances of `ResourceVersion`, [`SemanticVersion`], [`DateVersion`], [`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"); +/// # use dsc_lib::types::{DateVersion, ResourceVersion, SemanticVersion}; +/// let semantic = ResourceVersion::parse("1.2.3").unwrap(); +/// let date = ResourceVersion::parse("2026-01-15").unwrap(); /// /// // You can compare instances of `ResourceVersion::Semantic` to strings, string slices, and /// // semantic versions. @@ -46,34 +56,45 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{Semant /// 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()); +/// // You can compare instances of `ResourceVersion::Date` to strings, string slices, and +/// // date versions. +/// assert_eq!(date, DateVersion::parse("2026-01-15").unwrap()); +/// assert_eq!(date, "2026-01-15"); +/// assert_ne!(date, "2026-02-03".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); +/// // When a semantic version is compared to a date version, the semantic version is always +/// // treated as being higher: /// assert!(semantic > date); -/// assert!(arbitrary < SemanticVersion::parse("0.1.0").unwrap()); +/// assert!(date < 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. +/// // if the string can be parsed as a semantic version. If the string can be parsed as a date +/// // version, the semantic version is always greater. /// 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); +/// assert!(semantic > "2026-01-15"); /// -/// // String version comparisons for ISO 8601 dates are deterministic: +/// // Date version comparisons are deterministic: Comparing _first_ the date. If the date is +/// // identical, a stable date version sorts as higher than a prerelease date version. If the +/// // dates are identical and both versions are prerelease, the prerelease segments are compared +/// // lexicographically: /// assert!(date < "2026-02-01"); -/// assert!(date >= "2026-01"); +/// assert!(date >= "2026-01-15"); +/// assert!(date > "2026-01-15-preview"); +/// let alpha = ResourceVersion::parse("2026-01-15-alpha").unwrap(); +/// let beta = ResourceVersion::parse("2026-01-15-beta").unwrap(); +/// assert!(alpha < beta); +/// +/// // Comparing a resource version to a string that doesn't parse as a valid resource version +/// // always returns `false`: +/// assert_eq!(semantic > "foo", false); +/// assert_eq!(semantic < "foo", false); +/// assert_eq!(date <= "foo", false); +/// assert_eq!(date >= "foo", false); +/// assert_eq!(date == "foo", false); /// ``` /// /// You can freely convert between strings and `ResourceVersion`: @@ -82,10 +103,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{Semant /// 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()); +/// let date = ResourceVersion::parse("2026-01-15").unwrap(); /// /// // Define a function that expects a string: /// fn expects_string(input: &str) { @@ -99,9 +117,10 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{Semant /// /// [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)] +#[derive(Debug, Clone, Hash, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] #[dsc_repo_schema(base_name = "resourceVersion", folder_path = "definitions")] -#[serde(untagged)] +#[serde(untagged, try_from = "String", into = "String")] +#[schemars(!try_from, !into)] #[schemars( title = t!("schemas.definitions.resourceVersion.title"), description = t!("schemas.definitions.resourceVersion.description"), @@ -125,66 +144,172 @@ pub enum ResourceVersion { ) )] Semantic(SemanticVersion), - /// Defines the resource's version as an arbitrary string. + /// Defines the resource's version as a date version, containing an inner [`DateVersion`]. This + /// variant remains supported for compatibility purposes but is _not_ recommended for + /// production usage. /// - /// 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 a date version: /// - /// 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. You can only use exact match version requirements for that resource. You can't define + /// a range of valid date versions to support. You must specify the exact date version of + /// the resource. /// 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. + /// versioned resources as newer than resources with a date version. + /// + /// For more information about defining date versions, see [`DateVersion`]. For more information + /// about defining a version requirement for a resource with a date version, see + /// [`ResourceVersionReq`]. /// + /// [`ResourceVersionReq`]: crate::types::ResourceVersionReq /// [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"), + title = t!("schemas.definitions.resourceVersion.dateVariant.title"), + description = t!("schemas.definitions.resourceVersion.dateVariant.description"), extend( "deprecated" = true, - "deprecationMessage" = t!("schemas.definitions.resourceVersion.arbitraryVariant.deprecationMessage"), - "markdownDescription" = t!("schemas.definitions.resourceVersion.arbitraryVariant.markdownDescription"), + "deprecationMessage" = t!("schemas.definitions.resourceVersion.dateVariant.deprecationMessage"), + "markdownDescription" = t!("schemas.definitions.resourceVersion.dateVariant.markdownDescription"), ) )] - Arbitrary(String), + Date(DateVersion), } +/// Defines errors that can occur when parsing or working with [`ResourceVersion`]. +#[derive(Debug, Error, Diagnostic)] +#[non_exhaustive] +pub enum ResourceVersionError { + /// Indicates that the input string didn't match the approximate shape of either a semantic + /// version or a date version, so it couldn't be parsed as a resource version at all. + #[error("{t}", t = t!( + "types.resource_version.unparseableVersion", + "text" => text, + ))] + UnparseableVersion{ + /// The input string that failed to parse as a resource version. + text: String + }, + + /// Indicates that the input string was recognized as a [`DateVersion`] but failed to parse. + #[error("{t}", t = t!( + "types.resource_version.invalidDateVersion", + "err" => source + ))] + InvalidDateVersion{ + #[from] source: DateVersionError, + }, + + /// Indicates that the input string was recognized as a [`SemanticVersion`] but failed to parse. + #[error("{t}", t = t!( + "types.resource_version.invalidSemanticVersion", + "err" => source + ))] + InvalidSemanticVersion{ + #[from] source: SemanticVersionError, + }, + + /// Indicates that the [`ResourceVersion`] couldn't convert into a [`SemanticVersion`] because the + /// underlying variant was a [`DateVersion`]. + #[error("{t}", t = t!( + "types.resource_version.invalidConversionToSemanticVersion", + "version" => version, + ))] + InvalidConversionToSemanticVersion{ + /// The inner [`DateVersion`] for a [`ResourceVersion`] that failed to convert into a + /// [`SemanticVersion`]. + version: DateVersion, + }, + + /// Indicates that the [`ResourceVersion`] couldn't convert into a [`DateVersion`] because the + /// underlying variant was a [`SemanticVersion`]. + #[error("{t}", t = t!( + "types.resource_version.invalidConversionToDateVersion", + "version" => version, + ))] + InvalidConversionToDateVersion{ + /// The inner [`SemanticVersion`] for a [`ResourceVersion`] that failed to convert into a + /// [`DateVersion`]. + version: SemanticVersion, + }, +} + +/// Defines a regular expression for approximating whether a string looks like a semantic version. +/// This is used during parsing to determine whether to parse the input string as a semantic +/// version and forward the appropriate parsing errors, if any. +/// +/// This regex is kept as a static to enable lazy initialization and prevent needing to recompile +/// the regex pattern every time we parse a resource version. +static APPROXIMATE_SEMVER_REGEX: OnceLock = OnceLock::new(); + +/// Defines a regular expression for approximating whether a string looks like a date version. This +/// is used during parsing to determine whether to parse the input string as a date version and +/// forward the appropriate parsing errors, if any. +/// +/// This regex is kept as a static to enable lazy initialization and prevent needing to recompile +/// the regex pattern every time we parse a resource version. +static APPROXIMATE_DATEVER_REGEX: OnceLock = OnceLock::new(); + impl ResourceVersion { - /// Creates a new instance of [`ResourceVersion`]. + /// Parses a string into 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. + /// If the input string can be parsed as a [`SemanticVersion`], this function returns the + /// [`Semantic`] variant. If the input string can be parsed as a [`DateVersion`], this function + /// returns the [`Date`] variant. If the input string can't be parsed as either a semantic or + /// date version, this function raises an error. /// /// # Examples /// + /// The following snippet shows how you can parse a resource version from input strings: + /// /// ```rust + /// use chrono::Datelike; /// 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")); + /// let semantic = ResourceVersion::parse("1.2.3").unwrap(); + /// let date = ResourceVersion::parse("2026-02-15").unwrap(); /// - /// // Print for arbitrary version - /// print_version_message(ResourceVersion::new("2026-01")); + /// assert_eq!(semantic.as_semver().unwrap().major, 1); + /// assert_eq!(date.as_date_version().unwrap().year(), 2026); /// ``` /// + /// # Error + /// + /// When the input string can't be parsed as either a semantic version or a date version, this + /// function raises a [`ResourceVersionError`]. Parsing can fail for a few reasons: + /// + /// - When the input string doesn't match the general shape of either a semantic version or a + /// date version, it fails with [`ResourceVersionError::UnparseableVersion`]. For example, the + /// string `foo` would fail with this error. + /// - When the input string matches the general shape of a semantic version but fails to parse + /// as a valid semantic version, it raises [`ResourceVersionError::InvalidSemanticVersion`]. + /// - When the input string matches the general shape of a date version but fails to parse as a + /// valid date version, it raises [`ResourceVersionError::InvalidDateVersion`]. + /// + /// [`Date`]: ResourceVersion::Date /// [`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()), + pub fn parse(text: &str) -> Result { + let apparent_semver = APPROXIMATE_SEMVER_REGEX.get_or_init( + Self::init_approximate_semver_pattern + ); + let apparent_date = APPROXIMATE_DATEVER_REGEX.get_or_init( + Self::init_approximate_datever_pattern + ); + + if apparent_semver.is_match(text) { + match SemanticVersion::parse(text) { + Ok(v) => Ok(Self::Semantic(v)), + Err(e) => Err(ResourceVersionError::InvalidSemanticVersion { + source: e, + }) + } + } else if apparent_date.is_match(text) { + match DateVersion::parse(text) { + Ok(v) => Ok(Self::Date(v)), + Err(e) => Err(ResourceVersionError::InvalidDateVersion { + source: e, + }) + } + } else { + Err(ResourceVersionError::UnparseableVersion { text: text.to_string() }) } } @@ -195,11 +320,11 @@ impl ResourceVersion { /// ```rust /// use dsc_lib::types::ResourceVersion; /// - /// let semantic = ResourceVersion::new("1.2.3"); - /// let arbitrary = ResourceVersion::new("2026-01"); + /// let semantic = ResourceVersion::parse("1.2.3").unwrap(); + /// let date = ResourceVersion::parse("2026-01-02").unwrap(); /// /// assert_eq!(semantic.is_semver(), true); - /// assert_eq!(arbitrary.is_semver(), false); + /// assert_eq!(date.is_semver(), false); /// ``` pub fn is_semver(&self) -> bool { match self { @@ -208,22 +333,22 @@ impl ResourceVersion { } } - /// Indicates whether the resource version is an arbitrary string. + /// Indicates whether the resource version is a date version. /// /// # Examples /// /// ```rust /// use dsc_lib::types::ResourceVersion; /// - /// let semantic = ResourceVersion::new("1.2.3"); - /// let arbitrary = ResourceVersion::new("2026-01"); + /// let semantic = ResourceVersion::parse("1.2.3").unwrap(); + /// let date = ResourceVersion::parse("2026-01-02").unwrap(); /// - /// assert_eq!(semantic.is_semver(), true); - /// assert_eq!(arbitrary.is_semver(), false); + /// assert_eq!(semantic.is_date_version(), false); + /// assert_eq!(date.is_date_version(), true); /// ``` - pub fn is_arbitrary(&self) -> bool { + pub fn is_date_version(&self) -> bool { match self { - Self::Arbitrary(_) => true, + Self::Date(_) => true, _ => false, } } @@ -240,9 +365,8 @@ impl ResourceVersion { /// ```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"); + /// let semantic = ResourceVersion::parse("1.2.3").unwrap(); + /// let date = ResourceVersion::parse("2026-01-15").unwrap(); /// /// assert_eq!( /// semantic.as_semver(), @@ -252,10 +376,6 @@ impl ResourceVersion { /// date.as_semver(), /// None /// ); - /// assert_eq!( - /// arbitrary.as_semver(), - /// None - /// ); /// ``` /// /// [`Semantic`]: ResourceVersion::Semantic @@ -266,14 +386,47 @@ impl ResourceVersion { } } + /// Returns the version as a reference to the underlying [`DateVersion`] if possible. + /// + /// If the underlying version is [`Date`], this method returns some date version. + /// Otherwise, it returns [`None`]. + /// + /// # Examples + /// + /// The following examples show how `as_date_version()` behaves for different versions. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, DateVersion}; + /// + /// let semantic = ResourceVersion::parse("1.2.3").unwrap(); + /// let date = ResourceVersion::parse("2026-01-15").unwrap(); + /// + /// assert_eq!( + /// semantic.as_date_version(), + /// None, + /// ); + /// assert_eq!( + /// date.as_date_version(), + /// Some(&DateVersion::parse("2026-01-15").unwrap()) + /// ); + /// ``` + /// + /// [`Date`]: ResourceVersion::Date + pub fn as_date_version(&self) -> Option<&DateVersion> { + match self { + Self::Date(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`. + /// When the instance is [`ResourceVersion::Semantic`], this method applies the canonical + /// matching logic from [`SemanticVersionReq`] for the version. When the instance is + /// [`ResourceVersion::Date`], this method always returns `false`. /// /// For more information about semantic version requirements and syntax, see - /// ["Specifying Dependencies" in _The Cargo Book_][semver-req]. + /// [`SemanticVersionReq`]. /// /// # Examples /// @@ -281,26 +434,100 @@ impl ResourceVersion { /// [`ResourceVersion`]. /// /// ```rust - /// use dsc_lib::types::{ResourceVersion, SemanticVersionReq}; - /// - /// let semantic = ResourceVersion::new("1.2.3"); - /// let date = ResourceVersion::new("2026-01-15"); + /// # use dsc_lib::types::{ResourceVersion, SemanticVersionReq}; + /// let semantic = ResourceVersion::parse("1.2.3").unwrap(); + /// let date = ResourceVersion::parse("2026-01-15").unwrap(); /// /// 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, + _ => false, } } + + /// Compares an instance of [`ResourceVersion`] with [`DateVersion`]. + /// + /// When the instance is [`ResourceVersion::Date`], this method checks whether the version is + /// exactly the same as a given [`DateVersion`]. When the instance is + /// [`ResourceVersion::Semantic`], this method always returns `false`. + /// + /// # Examples + /// + /// The following example shows how comparisons work for different instances of + /// [`ResourceVersion`]. + /// + /// ```rust + /// # use dsc_lib::types::{DateVersion, ResourceVersion}; + /// let semantic_version = ResourceVersion::parse("1.2.3").unwrap(); + /// let date_version = ResourceVersion::parse("2026-01-15").unwrap(); + /// + /// let ref stable_req = DateVersion::parse("2026-01-15").unwrap(); + /// assert!(!semantic_version.matches_date_req(stable_req)); + /// assert!(date_version.matches_date_req(stable_req)); + /// + /// let ref prerelease_req = DateVersion::parse("2026-01-15-rc").unwrap(); + /// assert!(!semantic_version.matches_date_req(prerelease_req)); + /// assert!(!date_version.matches_date_req(prerelease_req)); + /// ``` + pub fn matches_date_req(&self, requirement: &DateVersion) -> bool { + match self { + Self::Date(v) => v == requirement, + _ => false, + } + } + + /// Defines the regular expression pattern that approximates the shape of a semantic version. + /// This is used to quickly determine whether a string might be a semantic version before + /// attempting to parse it as one. This allows us to provide better error messages for invalid + /// versions by distinguishing between strings that look like semantic versions and those that + /// might be date versions or completely arbitrary strings. + const APPROXIMATE_SEMVER_PATTERN: &str = const_str::concat!( + "^", // Anchor to start of string + "(?:", // Open non-capturing group for the alternatives + r"\d+", // Match any number of digits alone + "|", // or + r"\d+\.\d+.*", // Match an apparent major.minor with anything after it + "|", // or + r"\d+[^-]+", // Match a number followed by anything but a hyphen + ")$", // Close non-capturing group and anchor to end of string + ); + + /// Returns the [`Regex`] for [`APPROXIMATE_SEMVER_PATTERN`]. + /// + /// This private method is used to initialize the [`APPROXIMATE_SEMVER_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`APPROXIMATE_SEMVER_PATTERN`]: ResourceVersion::APPROXIMATE_SEMVER_PATTERN + fn init_approximate_semver_pattern() -> Regex { + Regex::new(Self::APPROXIMATE_SEMVER_PATTERN).expect("pattern is valid") + } + + /// Defines the regular expression pattern that approximates the shape of a date version. This + /// is used to quickly determine whether a string might be a date version before attempting to + /// parse it as one. This allows us to provide better error messages for invalid versions by + /// distinguishing between strings that look like date versions and those that might be + /// semantic versions or completely arbitrary strings. + const APPROXIMATE_DATEVER_PATTERN: &str = r"^\d+-\d+"; + + /// Returns the [`Regex`] for [`APPROXIMATE_DATEVER_PATTERN`]. + /// + /// This private method is used to initialize the [`APPROXIMATE_DATEVER_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`APPROXIMATE_DATEVER_PATTERN`]: ResourceVersion::APPROXIMATE_DATEVER_PATTERN + fn init_approximate_datever_pattern() -> Regex { + Regex::new(Self::APPROXIMATE_DATEVER_PATTERN).expect("pattern is valid") + } } // Default to semantic version `0.0.0` rather than an empty string. @@ -315,36 +542,32 @@ 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), + Self::Date(s) => write!(f, "{}", s), } } } -// Parsing from a string is just calling `Self::new()` +// Parsing from a string is just calling `Self::parse()` impl FromStr for ResourceVersion { - type Err = Infallible; + type Err = ResourceVersionError; fn from_str(s: &str) -> Result { - Ok(Self::new(s)) + Self::parse(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()), - } +// `DateVersion`, `String`, and string slice (`str`). +impl TryFrom<&String> for ResourceVersion { + type Error = ResourceVersionError; + fn try_from(value: &String) -> Result { + Self::parse(value.as_str()) } } -impl From for ResourceVersion { - fn from(value: String) -> Self { - match SemanticVersion::parse(&value) { - Ok(v) => ResourceVersion::Semantic(v), - Err(_) => ResourceVersion::Arbitrary(value), - } +impl TryFrom for ResourceVersion { + type Error = ResourceVersionError; + fn try_from(value: String) -> Result { + Self::parse(value.as_str()) } } @@ -356,9 +579,10 @@ impl From for 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 TryFrom<&str> for ResourceVersion { + type Error = ResourceVersionError; + fn try_from(value: &str) -> Result { + Self::parse(value) } } @@ -374,28 +598,61 @@ impl From<&SemanticVersion> for ResourceVersion { } } -// Creating an instance of `SemanticVersion` from `ResourceVersion` is the only fallible -// conversion, since `ResourceVersion` can define non-semantic versions. +// Creating an instance of `SemanticVersion` from `ResourceVersion` is a fallible conversion, +// since `ResourceVersion` can define non-semantic versions. impl TryFrom for SemanticVersion { - type Error = DscError; + type Error = ResourceVersionError; fn try_from(value: ResourceVersion) -> Result { match value { ResourceVersion::Semantic(v) => Ok(v), - ResourceVersion::Arbitrary(s) => Err(DscError::ResourceVersionToSemverConversion(s)), + ResourceVersion::Date(version) => Err( + ResourceVersionError::InvalidConversionToSemanticVersion { version } + ), } } } -// Implement traits for comparing `ResourceVersion` to strings and semantic versions bi-directionally. +impl From for ResourceVersion { + fn from(value: DateVersion) -> Self { + Self::Date(value) + } +} + +impl From<&DateVersion> for ResourceVersion { + fn from(value: &DateVersion) -> Self { + Self::Date(value.clone()) + } +} + +// Creating an instance of `DateVersion` from `ResourceVersion` is a fallible conversion, +// since `ResourceVersion` can define non-semantic versions. +impl TryFrom for DateVersion { + type Error = ResourceVersionError; + + fn try_from(value: ResourceVersion) -> Result { + match value { + ResourceVersion::Date(d) => Ok(d), + ResourceVersion::Semantic(version) => Err( + ResourceVersionError::InvalidConversionToDateVersion { version } + ), + } + } +} + +// Implement traits for comparing `ResourceVersion` to strings, semantic versions, and date +// 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, + _ => false, }, - Self::Arbitrary(string) => !other.is_semver() && *string == other.to_string(), + Self::Date(date) => match other { + Self::Date(other_date) => date == other_date, + _ => false, + } } } } @@ -404,7 +661,7 @@ impl PartialEq for ResourceVersion { fn eq(&self, other: &SemanticVersion) -> bool { match self { Self::Semantic(v) => v == other, - Self::Arbitrary(_) => false, + _ => false, } } } @@ -413,67 +670,115 @@ impl PartialEq for SemanticVersion { fn eq(&self, other: &ResourceVersion) -> bool { match other { ResourceVersion::Semantic(v) => self == v, - ResourceVersion::Arbitrary(_) => false, + _ => false, + } + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &DateVersion) -> bool { + match self { + Self::Date(v) => v == other, + _ => false, + } + } +} + +impl PartialEq for DateVersion { + fn eq(&self, other: &ResourceVersion) -> bool { + match other { + ResourceVersion::Date(v) => self == v, + _ => false, } } } impl PartialEq<&str> for ResourceVersion { fn eq(&self, other: &&str) -> bool { - self == &Self::new(*other) + if let Ok(other_version) = Self::parse(*other) { + self == &other_version + } else { + false + } } } impl PartialEq for &str { fn eq(&self, other: &ResourceVersion) -> bool { - &ResourceVersion::new(self) == other + if let Ok(version) = ResourceVersion::parse(*self) { + &version == other + } else { + false + } } } impl PartialEq for ResourceVersion { fn eq(&self, other: &String) -> bool { - self == &Self::new(other) + if let Ok(other_version) = Self::parse(other.as_str()) { + self == &other_version + } else { + false + } } } impl PartialEq for String { fn eq(&self, other: &ResourceVersion) -> bool { - &ResourceVersion::new(self) == other + if let Ok(version) = ResourceVersion::parse(self.as_str()) { + &version == other + } else { + false + } } } impl PartialEq for ResourceVersion { fn eq(&self, other: &str) -> bool { - self == &Self::new(other) + if let Ok(other_version) = Self::parse(other) { + self == &other_version + } else { + false + } } } impl PartialEq for str { fn eq(&self, other: &ResourceVersion) -> bool { - &ResourceVersion::new(self) == other + if let Ok(version) = ResourceVersion::parse(self) { + &version == other + } else { + false + } } } -impl PartialOrd for ResourceVersion { - fn partial_cmp(&self, other: &Self) -> Option { - match self { +impl Ord for ResourceVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self { Self::Semantic(version) => match other { - Self::Semantic(other_version) => version.partial_cmp(other_version), - Self::Arbitrary(_) => Some(std::cmp::Ordering::Greater), + Self::Semantic(other_version) => version.cmp(other_version), + Self::Date(_) => 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), + Self::Date(date) => match other { + Self::Semantic(_) => std::cmp::Ordering::Less, + Self::Date(other_date) => date.cmp(other_date), }, } } } +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + 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), + Self::Date(_) => Some(std::cmp::Ordering::Less), } } } @@ -482,60 +787,79 @@ 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), + ResourceVersion::Date(_) => Some(std::cmp::Ordering::Greater), + } + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &DateVersion) -> Option { + match self { + Self::Semantic(_) => Some(std::cmp::Ordering::Greater), + Self::Date(v) => v.partial_cmp(other), + } + } +} + +impl PartialOrd for DateVersion { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + match other { + ResourceVersion::Date(v) => self.partial_cmp(v), + ResourceVersion::Semantic(_) => Some(std::cmp::Ordering::Less), } } } impl PartialOrd for ResourceVersion { fn partial_cmp(&self, other: &String) -> Option { - self.partial_cmp(&ResourceVersion::new(other.as_str())) + match ResourceVersion::parse(other.as_str()) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } } } impl PartialOrd for String { fn partial_cmp(&self, other: &ResourceVersion) -> Option { - ResourceVersion::new(self.as_str()).partial_cmp(other) + match ResourceVersion::parse(self.as_str()) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } } } impl PartialOrd<&str> for ResourceVersion { fn partial_cmp(&self, other: &&str) -> Option { - self.partial_cmp(&Self::new(other)) + match ResourceVersion::parse(*other) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } } } impl PartialOrd for ResourceVersion { fn partial_cmp(&self, other: &str) -> Option { - self.partial_cmp(&Self::new(other)) + match ResourceVersion::parse(other) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } } } impl PartialOrd for &str { fn partial_cmp(&self, other: &ResourceVersion) -> Option { - ResourceVersion::new(self).partial_cmp(other) + match ResourceVersion::parse(*self) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } } } 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), - } - } + match ResourceVersion::parse(self) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } } } diff --git a/lib/dsc-lib/src/types/resource_version_req.rs b/lib/dsc-lib/src/types/resource_version_req.rs index 15025771a..f18198272 100644 --- a/lib/dsc-lib/src/types/resource_version_req.rs +++ b/lib/dsc-lib/src/types/resource_version_req.rs @@ -1,74 +1,87 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::{convert::Infallible, fmt::Display, str::FromStr}; +use std::{fmt::Display, str::FromStr, sync::OnceLock}; +use miette::Diagnostic; +use regex::Regex; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{ResourceVersion, SemanticVersionReq}}; +use crate::{ + schemas::dsc_repo::DscRepoSchema, + types::{DateVersion, DateVersionError, ResourceVersion, SemanticVersionReq, SemanticVersionReqError} +}; /// 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. +/// DSC supports both semantic versioning and date versioning for resources. Semantic versioning is +/// the preferred and recommended versioning strategy. DSC only supports date versioning for +/// compatibility scenarios. /// -/// Because DSC supports arbitrary string versions for compatibility, version requirements must -/// also support arbitrary string versions. +/// Because DSC supports date versions for compatibility, version requirements must also support +/// date 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. +/// matches resource versions that are semantic _and_ valid for the given requirement. Date +/// 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. +/// Similarly, when a [`ResourceVersionReq`] is a date version, it can never match a semantically +/// versioned [`ResourceVersion`]. Instead, it matches an `ResourceVersion` when the date version +/// is _exactly_ the same as the 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. +/// Date 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 +/// Not every string is a valid resource version requirement. 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. /// +/// If the string can't be parsed as a semantic version requirement, it _must_ parse as a valid +/// date version. For more information on defining date versions, see [`DateVersion`]. +/// +/// If the string doesn't parse as either a semantic version requirement or a date version, the +/// value is invalid. +/// /// ## 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`. +/// input string parses as a [`SemanticVersionReq`]. If it parses as a [`DateVersion`], the variant +/// is `Date`. Parsing fails if the input string doesn't parse as either a [`SemanticVersionReq`] +/// or [`DateVersion`]. /// /// ```rust /// use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; /// -/// let semantic_req = ResourceVersionReq::new("^1.2, <1.5"); -/// let arbitrary_req = ResourceVersionReq::new("foo"); +/// let semantic_req = ResourceVersionReq::parse("^1.2, <1.5").unwrap(); +/// let date_req = ResourceVersionReq::parse("2026-01-15-rc").unwrap(); /// -/// let v1_2_3 = &ResourceVersion::new("1.2.3"); -/// let v1_5_1 = &ResourceVersion::new("1.5.1"); -/// let v_arbitrary = &ResourceVersion::new("foo"); +/// let v1_2_3 = &ResourceVersion::parse("1.2.3").unwrap(); +/// let v1_5_1 = &ResourceVersion::parse("1.5.1").unwrap(); +/// let v_date = &ResourceVersion::parse("2026-01-15-rc").unwrap(); /// /// // 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)); +/// // Semantic requirements never match date versions: +/// assert!(!semantic_req.matches(v_date)); /// -/// // Arbitrary requirements only match arbitrary versions _exactly_: -/// assert!(arbitrary_req.matches(v_arbitrary)); +/// // Date requirements only match date versions _exactly_: +/// assert!(date_req.matches(v_date)); /// // Differing casing causes the match to fail: -/// assert!(!arbitrary_req.matches(&ResourceVersion::new("FOO"))); +/// assert!(!date_req.matches(&ResourceVersion::parse("2026-01-15-RC").unwrap())); /// ``` /// /// [01]: SemanticVersionReq -#[derive(Debug, Clone, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[derive(Debug, Clone, Hash, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] #[dsc_repo_schema(base_name = "resourceVersionReq", folder_path = "definitions")] -#[serde(untagged)] +#[serde(untagged, try_from = "String", into = "String")] +#[schemars(!try_from, !into)] #[schemars( title = t!("schemas.definitions.resourceVersionReq.title"), description = t!("schemas.definitions.resourceVersionReq.description"), @@ -94,47 +107,179 @@ pub enum ResourceVersionReq { ) )] Semantic(SemanticVersionReq), - /// Defines the required version for the resource as an arbitrary string. + /// Defines the required version for the resource as a specific [`DateVersion`]. /// - /// 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. + /// DSC uses this variant for pinning resources that use date versioning. 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: + /// When a resource version requirement is defined as a date version: /// /// 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. + /// 1. It only matches a resource with a date version ([`ResourceVersion::Date`]) when the + /// resource version and this version requirement are exactly the same. The comparison is + /// case-sensitive for the prerelease segment, if present. #[schemars( - title = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.title"), - description = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.description"), + title = t!("schemas.definitions.resourceVersionReq.dateVariant.title"), + description = t!("schemas.definitions.resourceVersionReq.dateVariant.description"), extend( "deprecated" = true, - "deprecationMessage" = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.deprecationMessage"), - "markdownDescription" = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.markdownDescription"), + "deprecationMessage" = t!("schemas.definitions.resourceVersionReq.dateVariant.deprecationMessage"), + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.dateVariant.markdownDescription"), "examples" = [ - "2026-02", - "1.2.0.0" + "2026-02-03", + "2026-11-27-preview" ] ) )] - Arbitrary(String), + Date(DateVersion), } +/// Defines errors that can occur when parsing a string into a [`ResourceVersionReq`] or converting +/// between a [`ResourceVersionReq`] and another type (e.g. [`SemanticVersionReq`] or +/// [`DateVersion`]). +#[derive(Debug, Error, Diagnostic)] +#[non_exhaustive] +pub enum ResourceVersionReqError { + /// Indicates that a string couldn't be parsed as a valid resource version requirement because + /// it didn't match the expected format for either a semantic version requirement or a date + /// version. + #[error("{t}", t = t!( + "types.resource_version_req.unparseableRequirement", + "text" => text, + ))] + UnparseableRequirement{ + /// The input string that couldn't be parsed as a resource version requirement. + text: String, + }, + + /// Indicates that a string matched the general shape of a semantic version requirement but + /// failed to parse as a valid semantic version requirement. + #[error("{t}", t = t!( + "types.resource_version_req.invalidSemanticVersionRequirement", + "err" => source + ))] + InvalidSemanticVersionRequirement{ + /// The error raised when attempting to parse a semantic version requirement from a string + /// that matched the general shape of a semantic version requirement but failed to parse as + /// a valid semantic version requirement. + #[from] source: SemanticVersionReqError, + }, + + /// Indicates that a string matched the general shape of a date version but failed to parse as a + /// valid date version. + #[error("{t}", t = t!( + "types.resource_version_req.invalidDateVersionRequirement", + "err" => source + ))] + InvalidDateVersionRequirement{ + /// The error raised when attempting to parse a date version requirement from a string that + /// matched the general shape of a date version but failed to parse as a valid date version. + #[from] source: DateVersionError, + }, + + /// Indicates that an attempt was made to convert a [`ResourceVersionReq`] to a + /// [`SemanticVersionReq`] but the conversion was invalid. + /// + /// This can only occur when the resource version requirement is a date version requirement. + #[error("{t}", t = t!( + "types.resource_version_req.invalidConversionToSemanticVersionReq", + "req" => req + ))] + InvalidConversionToSemanticVersionReq{ + /// The inner date version for a resource version requirement that failed to convert to a + /// semantic version requirement. + req: DateVersion + }, + + /// Indicates that an attempt was made to convert a [`ResourceVersionReq`] to a [`DateVersion`] + /// but the conversion was invalid. + /// + /// This can only occur when the resource version requirement is a semantic version requirement. + #[error("{t}", t = t!( + "types.resource_version_req.invalidConversionToDateVersion", + "req" => req + ))] + InvalidConversionToDateVersion{ + /// The inner semantic version requirement for a resource version requirement that failed + /// to convert to a date version. + req: SemanticVersionReq + }, +} + +/// Defines a regular expression for approximating whether a string looks like a semantic version +/// requirement. This is used during parsing to determine whether to parse the input string as a +/// semantic version requirement and forward the appropriate parsing errors, if any. +/// +/// This regex is kept as a static to enable lazy initialization and prevent needing to recompile +/// the regex pattern every time we parse a resource version requirement. +static APPROXIMATE_SEMVER_REGEX: OnceLock = OnceLock::new(); + +/// Defines a regular expression for approximating whether a string looks like a date version. This +/// is used during parsing to determine whether to parse the input string as a date version and +/// forward the appropriate parsing errors, if any. +/// +/// This regex is kept as a static to enable lazy initialization and prevent needing to recompile +/// the regex pattern every time we parse a resource version requirement. +static APPROXIMATE_DATEVER_REGEX: OnceLock = OnceLock::new(); + impl ResourceVersionReq { - /// Creates a new instance of [`ResourceVersionReq`]. + /// Parses a string into a new instance of [`ResourceVersionReq`]. + /// + /// If the input string can be parsed as a [`SemanticVersionReq`], this function returns the + /// [`Semantic`] variant. If the input string can be parsed as a [`DateVersion`], this function + /// returns the [`Date`] variant. If the input string can't be parsed as either a semantic + /// version requirement or a date version, this function raises an error. + /// + /// # Examples + /// + /// The following snippet shows how you can parse a resource version requirement from input + /// strings: + /// + /// ```rust + /// # use chrono::Datelike; + /// # use dsc_lib::types::ResourceVersionReq; + /// let semantic = ResourceVersionReq::parse("^1.2, <1.5").unwrap(); + /// assert!(semantic.matches( + /// &dsc_lib::types::ResourceVersion::parse("1.3.0").unwrap() + /// )); + /// + /// let date = ResourceVersionReq::parse("2026-02-15").unwrap(); + /// assert!(date.matches( + /// &dsc_lib::types::ResourceVersion::parse("2026-02-15").unwrap() + /// )); + /// ``` /// - /// 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. + /// # Error + /// + /// This function raises a [`ResourceVersionReqError`] when the input string can't be parsed as + /// either a [`SemanticVersionReq`] or [`DateVersion`]. /// /// [`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()), + /// [`Date`]: ResourceVersionReq::Date + pub fn parse(text: &str) -> Result { + let apparent_semver = APPROXIMATE_SEMVER_REGEX.get_or_init( + Self::init_approximate_semver_pattern + ); + let apparent_date = APPROXIMATE_DATEVER_REGEX.get_or_init( + Self::init_approximate_datever_pattern + ); + + if apparent_date.is_match(text) { + match DateVersion::parse(text) { + Ok(date) => Ok(Self::Date(date)), + Err(e) => Err( + ResourceVersionReqError::InvalidDateVersionRequirement { source: e } + ), + } + } else if apparent_semver.is_match(text) { + match SemanticVersionReq::parse(text) { + Ok(req) => Ok(Self::Semantic(req)), + Err(e) => Err( + ResourceVersionReqError::InvalidSemanticVersionRequirement { source: e } + ), + } + } else { + Err(ResourceVersionReqError::UnparseableRequirement { text: text.to_string() }) } } @@ -145,11 +290,11 @@ impl ResourceVersionReq { /// ```rust /// use dsc_lib::types::ResourceVersionReq; /// - /// let semantic = ResourceVersionReq::new("^1.2, <1.5"); - /// let arbitrary = ResourceVersionReq::new("2026-01"); + /// let semantic = ResourceVersionReq::parse("^1.2, <1.5").unwrap(); + /// let date = ResourceVersionReq::parse("2026-01-15").unwrap(); /// /// assert_eq!(semantic.is_semver(), true); - /// assert_eq!(arbitrary.is_semver(), false); + /// assert_eq!(date.is_semver(), false); /// ``` pub fn is_semver(&self) -> bool { match self { @@ -158,22 +303,22 @@ impl ResourceVersionReq { } } - /// Indicates whether the resource version requirement is an arbitrary string. + /// Indicates whether the resource version requirement is for a specific [`DateVersion`]. /// /// # Examples /// /// ```rust /// use dsc_lib::types::ResourceVersionReq; /// - /// let arbitrary = ResourceVersionReq::new("2026-01"); - /// let semantic = ResourceVersionReq::new("^1.2, <1.5"); + /// let date = ResourceVersionReq::parse("2026-01-15").unwrap(); + /// let semantic = ResourceVersionReq::parse("^1.2, <1.5").unwrap(); /// - /// assert_eq!(arbitrary.is_arbitrary(), true); - /// assert_eq!(semantic.is_arbitrary(), false); + /// assert_eq!(date.is_date_version(), true); + /// assert_eq!(semantic.is_date_version(), false); /// ``` - pub fn is_arbitrary(&self) -> bool { + pub fn is_date_version(&self) -> bool { match self { - Self::Arbitrary(_) => true, + Self::Date(_) => true, _ => false, } } @@ -190,9 +335,8 @@ impl ResourceVersionReq { /// ```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"); + /// let semantic = ResourceVersionReq::parse("^1.2.3").unwrap(); + /// let date = ResourceVersionReq::parse("2026-01-15").unwrap(); /// /// assert_eq!( /// semantic.as_semver_req(), @@ -202,10 +346,6 @@ impl ResourceVersionReq { /// date.as_semver_req(), /// None /// ); - /// assert_eq!( - /// arbitrary.as_semver_req(), - /// None - /// ); /// ``` /// /// [`Semantic`]: ResourceVersionReq::Semantic @@ -216,6 +356,39 @@ impl ResourceVersionReq { } } + /// Returns the requirement as a reference to the underlying [`DateVersion`] if possible. + /// + /// If the underlying requirement is [`Date`], this method returns some date version. + /// Otherwise, it returns [`None`]. + /// + /// # Examples + /// + /// The following examples show how `as_date_version()` behaves for different requirements. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersionReq, DateVersion}; + /// + /// let semantic = ResourceVersionReq::parse("^1.2.3").unwrap(); + /// let date = ResourceVersionReq::parse("2026-01-15").unwrap(); + /// + /// assert_eq!( + /// semantic.as_date_version(), + /// None, + /// ); + /// assert_eq!( + /// date.as_date_version(), + /// Some(&DateVersion::parse("2026-01-15").unwrap()) + /// ); + /// ``` + /// + /// [`Date`]: ResourceVersionReq::Date + pub fn as_date_version(&self) -> Option<&DateVersion> { + match self { + Self::Date(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`. /// @@ -223,44 +396,81 @@ impl ResourceVersionReq { /// /// - 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. + /// - When both the requirement and version are date versions, the version is only valid for the + /// requirement when it is exactly the same date version as the requirement. + /// - Otherwise, this function returns `false` because a date version can never match a + /// semantic requirement and a semantic version can never match a date version 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"))); + /// let semantic_req = ResourceVersionReq::parse("^1.2.3, <1.5").unwrap(); + /// assert!(semantic_req.matches(&ResourceVersion::parse("1.2.3").unwrap())); + /// assert!(semantic_req.matches(&ResourceVersion::parse("1.3.0").unwrap())); + /// assert!(!semantic_req.matches(&ResourceVersion::parse("1.0.0").unwrap())); + /// assert!(!semantic_req.matches(&ResourceVersion::parse("1.5.0").unwrap())); + /// assert!(!semantic_req.matches(&ResourceVersion::parse("2026-02-15").unwrap())); + /// + /// let date_req = ResourceVersionReq::parse("2026-02-15").unwrap(); + /// assert!(date_req.matches(&ResourceVersion::parse("2026-02-15").unwrap())); + /// assert!(!date_req.matches(&ResourceVersion::parse("2026-02-15-preview").unwrap())); + /// assert!(!date_req.matches(&ResourceVersion::parse("2026-02-01").unwrap())); /// ``` 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, + ResourceVersion::Date(_) => false, } }, - Self::Arbitrary(req) => { + Self::Date(req) => { match resource_version { ResourceVersion::Semantic(_) => false, - ResourceVersion::Arbitrary(version) => req == version, + ResourceVersion::Date(version) => req == version, } } } } + + /// Defines the regex pattern that the [`ResourceVersionReq::parse()`] method uses to check + /// whether to parse an input string as a semantic version requirement. If the input string + /// defines a comparator operator or contains a string that _looks like_ a version, this + /// pattern matches. + const APPROXIMATE_SEMVER_PATTERN: &str = const_str::concat!( + SemanticVersionReq::OPERATOR_PATTERN, // Any operator + "|", // Or + r"(?:\d+|[\*xX])(?:\.(?:\d+|[\*xX]))?" // Shaped like a version + ); + + /// Returns the [`Regex`] for [`APPROXIMATE_SEMVER_PATTERN`]. + /// + /// This private method is used to initialize the [`APPROXIMATE_SEMVER_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`APPROXIMATE_SEMVER_PATTERN`]: ResourceVersionReq::APPROXIMATE_SEMVER_PATTERN + fn init_approximate_semver_pattern() -> Regex { + Regex::new(Self::APPROXIMATE_SEMVER_PATTERN).expect("pattern is valid") + } + + /// Defines the regex pattern that the [`ResourceVersionReq::parse()`] method uses to check + /// whether to parse an input string as a date version requirement. If the input string + /// starts with numbers followed by a hyphen and more numbers, this pattern matches. + const APPROXIMATE_DATEVER_PATTERN: &str = r"^\d+-\d+"; + + /// Returns the [`Regex`] for [`APPROXIMATE_DATEVER_PATTERN`]. + /// + /// This private method is used to initialize the [`APPROXIMATE_DATEVER_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`APPROXIMATE_DATEVER_PATTERN`]: ResourceVersionReq::APPROXIMATE_DATEVER_PATTERN + fn init_approximate_datever_pattern() -> Regex { + Regex::new(Self::APPROXIMATE_DATEVER_PATTERN).expect("pattern is valid") + } } // Default to matching any stable semantic version rather than an empty string. @@ -275,27 +485,25 @@ 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), + Self::Date(v) => write!(f, "{}", v), } } } // Parsing from a string is just calling `Self::new()` impl FromStr for ResourceVersionReq { - type Err = Infallible; + type Err = ResourceVersionReqError; fn from_str(s: &str) -> Result { - Ok(Self::new(s)) + Self::parse(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 TryFrom for ResourceVersionReq { + type Error = ResourceVersionReqError; + fn try_from(value: String) -> Result { + Self::parse(value.as_str()) } } @@ -305,19 +513,18 @@ impl From for 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()), - } +impl TryFrom<&String> for ResourceVersionReq { + type Error = ResourceVersionReqError; + fn try_from(value: &String) -> Result { + Self::parse(value.as_str()) } } // 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 TryFrom<&str> for ResourceVersionReq { + type Error = ResourceVersionReqError; + fn try_from(value: &str) -> Result { + Self::parse(value) } } @@ -333,15 +540,45 @@ impl From<&SemanticVersionReq> for ResourceVersionReq { } } -// Creating an instance of `SemanticVersionReq` from `ResourceVersionReq` is the only fallible -// conversion, since `ResourceVersionReq` can define non-semantic version requirements. +// Creating an instance of `SemanticVersionReq` from `ResourceVersionReq` is a fallible conversion, +// since `ResourceVersionReq` can define non-semantic version requirements. impl TryFrom for SemanticVersionReq { - type Error = DscError; + type Error = ResourceVersionReqError; fn try_from(value: ResourceVersionReq) -> Result { match value { ResourceVersionReq::Semantic(req) => Ok(req), - ResourceVersionReq::Arbitrary(s) => Err(DscError::ResourceVersionReqToSemverConversion(s)) + ResourceVersionReq::Date(req) => Err( + ResourceVersionReqError::InvalidConversionToSemanticVersionReq { req } + ), + } + } +} + + +impl From for ResourceVersionReq { + fn from(value: DateVersion) -> Self { + Self::Date(value) + } +} + +impl From<&DateVersion> for ResourceVersionReq { + fn from(value: &DateVersion) -> Self { + Self::Date(value.clone()) + } +} + +// Creating an instance of `DateVersion` from `ResourceVersionReq` is a fallible conversion, +// since `ResourceVersionReq` can define semantic version requirements. +impl TryFrom for DateVersion { + type Error = ResourceVersionReqError; + + fn try_from(value: ResourceVersionReq) -> Result { + match value { + ResourceVersionReq::Date(d) => Ok(d), + ResourceVersionReq::Semantic(req) => Err( + ResourceVersionReqError::InvalidConversionToDateVersion { req } + ), } } } @@ -353,9 +590,12 @@ impl PartialEq for ResourceVersionReq { match self { Self::Semantic(req) => match other { Self::Semantic(other_req) => req == other_req, - Self::Arbitrary(_) => false, + _ => false }, - Self::Arbitrary(string) => !other.is_semver() && *string == other.to_string(), + Self::Date(date) => match other { + Self::Date(other_date) => date == other_date, + _ => false, + } } } } @@ -364,7 +604,7 @@ impl PartialEq for ResourceVersionReq { fn eq(&self, other: &SemanticVersionReq) -> bool { match self { Self::Semantic(req) => req == other, - Self::Arbitrary(_) => false, + _ => false, } } } @@ -373,43 +613,79 @@ impl PartialEq for SemanticVersionReq { fn eq(&self, other: &ResourceVersionReq) -> bool { match other { ResourceVersionReq::Semantic(req) => self == req, - ResourceVersionReq::Arbitrary(_) => false, + _ => false, + } + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &DateVersion) -> bool { + match self { + Self::Date(req) => req == other, + _ => false, + } + } +} + +impl PartialEq for DateVersion { + fn eq(&self, other: &ResourceVersionReq) -> bool { + match other { + ResourceVersionReq::Date(req) => self == req, + _ => false, } } } impl PartialEq<&str> for ResourceVersionReq { fn eq(&self, other: &&str) -> bool { - self == &Self::new(*other) + match Self::parse(*other) { + Ok(other_req) => self == &other_req, + Err(_) => false, + } } } impl PartialEq for &str { fn eq(&self, other: &ResourceVersionReq) -> bool { - &ResourceVersionReq::new(self) == other + match ResourceVersionReq::parse(*self) { + Ok(req) => &req == other, + Err(_) => false, + } } } impl PartialEq for ResourceVersionReq { fn eq(&self, other: &str) -> bool { - self == &Self::new(other) + match Self::parse(other) { + Ok(other_req) => self == &other_req, + Err(_) => false, + } } } impl PartialEq for str { fn eq(&self, other: &ResourceVersionReq) -> bool { - &ResourceVersionReq::new(self) == other + match ResourceVersionReq::parse(self) { + Ok(req) => &req == other, + Err(_) => false, + } } } impl PartialEq for ResourceVersionReq { fn eq(&self, other: &String) -> bool { - self == &Self::new(other) + match Self::parse(other.as_str()) { + Ok(other_req) => self == &other_req, + Err(_) => false, + } } } impl PartialEq for String { fn eq(&self, other: &ResourceVersionReq) -> bool { - &ResourceVersionReq::new(self) == other + match ResourceVersionReq::parse(self.as_str()) { + Ok(req) => &req == other, + Err(_) => false, + } } } diff --git a/lib/dsc-lib/src/types/semantic_version.rs b/lib/dsc-lib/src/types/semantic_version.rs index f116252fe..dfee27fbc 100644 --- a/lib/dsc-lib/src/types/semantic_version.rs +++ b/lib/dsc-lib/src/types/semantic_version.rs @@ -3,10 +3,13 @@ use std::{fmt::Display, ops::Deref, str::FromStr}; -use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema}; +use miette::Diagnostic; use rust_i18n::t; use schemars::{json_schema, JsonSchema}; use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::schemas::dsc_repo::DscRepoSchema; /// Defines a semantic version for use with DSC. /// @@ -30,8 +33,8 @@ use serde::{Deserialize, Serialize}; /// 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 +/// ASCII alphanumeric characters and hyphens (regex `[a-zA-Z0-9-]`). 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 @@ -196,7 +199,7 @@ use serde::{Deserialize, Serialize}; /// // 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 +/// // Stable versions are 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 @@ -213,8 +216,8 @@ use serde::{Deserialize, Serialize}; /// 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 +/// // To correctly 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(); @@ -238,9 +241,9 @@ use serde::{Deserialize, Serialize}; /// /// 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. +/// - A higher version supersedes a lower version, regardless of prerelease and build metadata. +/// - A stable version supersedes the same version with a prerelease segment. +/// - A stable version with build metadata supersedes the same version without build metadata. /// - Prerelease and build metadata segments are compared lexicographically. /// /// Consider the following example: @@ -293,6 +296,26 @@ use serde::{Deserialize, Serialize}; #[dsc_repo_schema(base_name = "semver", folder_path = "definitions")] pub struct SemanticVersion(semver::Version); +/// Defines errors that can occur when parsing or working with [`SemanticVersion`]s. +#[derive(Debug, Error, Diagnostic)] +#[non_exhaustive] +pub enum SemanticVersionError { + /// Indicates an error that occurred while parsing a semantic version string. + #[error("{t}", t = t!( + "types.semantic_version.invalidSemanticVersion", + "err" => source, + "text" => text, + ))] + Parse { + /// The parsing error from the underlying [`semver`] crate. + #[source] + source: semver::Error, + + /// The input string that failed to parse as a semantic version. + text: String, + }, +} + impl SemanticVersion { /// Create an instance of [`SemanticVersion`] with empty prerelease and build segments. /// @@ -323,14 +346,17 @@ impl SemanticVersion { /// 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 hyphen after the version without a prerelease 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 { + /// 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)), + Err(e) => Err(SemanticVersionError::Parse { + source: e, + text: value.to_string(), + }), } } @@ -401,7 +427,7 @@ impl SemanticVersion { 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. + SemanticVersion::PRERELEASE_SUBSEGMENT_PATTERN, // followed by another valid prerelease subsegment. ")", // Close the non-capturing group for extra subsegments. "*" // Match additional subsegments zero or more times. ); @@ -488,26 +514,23 @@ impl From for String { // Fallible conversions impl FromStr for SemanticVersion { - type Err = DscError; + type Err = SemanticVersionError; fn from_str(s: &str) -> Result { SemanticVersion::parse(s) } } impl TryFrom for SemanticVersion { - type Error = DscError; + type Error = SemanticVersionError; fn try_from(value: String) -> Result { - match semver::Version::parse(value.as_str()) { - Ok(v) => Ok(Self(v)), - Err(e) => Err(DscError::SemVer(e)), - } + SemanticVersion::parse(value.as_str()) } } impl TryFrom<&str> for SemanticVersion { - type Error = DscError; + type Error = SemanticVersionError; fn try_from(value: &str) -> Result { - SemanticVersion::from_str(value) + SemanticVersion::parse(value) } } diff --git a/lib/dsc-lib/src/types/semantic_version_req.rs b/lib/dsc-lib/src/types/semantic_version_req.rs index 8c52d32e2..ebacb3053 100644 --- a/lib/dsc-lib/src/types/semantic_version_req.rs +++ b/lib/dsc-lib/src/types/semantic_version_req.rs @@ -3,12 +3,14 @@ use std::{fmt::Display, ops::Deref, str::FromStr, sync::OnceLock}; +use miette::Diagnostic; use regex::Regex; use rust_i18n::t; use schemars::{json_schema, JsonSchema}; use serde::{Deserialize, Serialize}; +use thiserror::Error; -use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::SemanticVersion}; +use crate::{schemas::dsc_repo::DscRepoSchema, types::SemanticVersion}; /// Defines one or more limitations for a semantic version to enable version pinning. /// @@ -27,6 +29,22 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// 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 _must_ explicitly define an operator for every comparator. +/// +/// DSC forbids defining a comparator without an operator, like `1.*` or `1.2.3, <1.5`, to +/// reduce ambiguity and unexpected behavior for version pinning. For example, in all other +/// cases, omitting version segments and specifying them as a wildcard has the same behavior +/// _except_ for the comparators `1.2` and `1.2.*`: +/// +/// - `1`, `1.*`, and `1.*.*` all have an effective requirement of `>=1.0.0, <2.0.0`. +/// - `>1.2` and `>1.2.*` both have an effective requirement of `>1.2.0`. +/// - `1.2` has an effective requirement of `>=1.2.0, <2.0.0` but `1.2.*` has an effective +/// requirement of `>=1.2.0, <1.3.0`. +/// +/// Similarly, it is not immediately obvious to a user who isn't familiar with Rust semantic +/// version requirements that `1.2.3` will match `1.5.7`. It's more common across version +/// requirements to expect an exactly specified version to be an exact match requirement, not +/// a semantically compatible requirement. /// 1. DSC semantic version requirements only support the asterisk (`*`) character for wildcards, /// not `x` or `X`. /// @@ -108,8 +126,8 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// ### 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: +/// omit either or both the minor and patch 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`. @@ -120,45 +138,24 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// 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. +/// For DSC semantic version requirements, specifying the version for a comparator with wildcards +/// is equivalent to omitting those version segments. +/// +/// The following table shows a how specifying wildcards for a version segment affects the effective +/// requirement for a comparator: +/// +/// | Comparator | Effective requirement | +/// |:----------:|:---------------------:| +/// | `^1` | `>=1.0.0, <2.0.0` | +/// | `^1.*` | `>=1.0.0, <2.0.0` | +/// | `^1.*.*` | `>=1.0.0, <2.0.0` | +/// | `^1.2` | `>=1.2.0, <2.0.0` | +/// | `^1.2.*` | `>=1.2.0, <2.0.0` | +/// | `=1` | `>=1.0.0, <2.0.0` | +/// | `=1.*` | `>=1.0.0, <2.0.0` | +/// | `=1.*.*` | `>=1.0.0, <2.0.0` | +/// | `=1.2` | `>=1.2.0, <1.3.0` | +/// | `=1.2.*` | `>=1.2.0, <1.3.0` | /// /// ### Prerelease version segments /// @@ -193,7 +190,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// /// 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. +/// [`ComparatorIncludesForbiddenBuildMetadata`] error during parsing if one is specified. /// /// ### Examples of invalid comparator versions /// @@ -216,9 +213,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// ## 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). +/// of the comparator. The operator for a comparator is required. /// /// The following list enumerates the available operators. Each definition includes a table of /// examples demonstrating how the operator behaves. @@ -241,10 +236,12 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// 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. +/// - If the comparator defines only the major version segment, like `~1`, the comparator +/// matches any version greater than or equal to the given major version and 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 greater than or equal to the given version and 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. @@ -318,7 +315,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// | `>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 +/// - 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. /// @@ -330,73 +327,24 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// | `>=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. +/// | `>=1.2.3-rc.1` | `>=1.2.3-rc.1` | `1.2.3`, `2.0.0`, `1.2.3-rc.1`, `1.2.3-rc.2` | `1.2.0`, `1.2.3-rc.0`, `2.0.0-rc.2` | /// /// # 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: +/// 1. If the originally parsed requirement defines 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.*` | `^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`. +/// 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`: @@ -409,7 +357,6 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// 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 @@ -420,23 +367,15 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// /// | 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` | +/// | `> 1` | `>1` | +/// | `>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`. +/// 1. Immediately follow the 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. @@ -457,30 +396,185 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// serialization and deserialization. /// /// [01]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#version-requirement-syntax -/// [`SemVerReqWithBuildMetadata`]: DscError::SemVerReqWithBuildMetadata +/// [`ComparatorIncludesForbiddenBuildMetadata`]: SemanticVersionReqError::ComparatorIncludesForbiddenBuildMetadata #[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(); +/// Defines the parsing errors and diagnostics for invalid string representations of a +/// [`SemanticVersionReq`]. +/// +/// This error type is surfaced through the [`DscError::SemverReq`] error. This error type primarily +/// distinguishes between two kinds of errors returned by [`SemanticVersionReq::parse()`]: +/// +/// 1. When the input string is unparseable as a [`semver::VersionReq`], the function raises the +/// [`UnparseableRequirement`] error, which passes the underlying parsing error through to the +/// user. +/// 1. When the input string is parseable as a [`semver::VersionReq`] but fails the more +/// restrictive syntax validation DSC requires, the function raises the [`InvalidRequirement`] +/// error, which collects the diagnostic errors for every mistake in the input string. +/// +/// This enables the function to return the full set of invalid components for a requirement +/// together, instead of requiring a user to iteratively discover their mistakes when each +/// error is raised separately and immediately halts execution. +/// +/// The remaining variants serve to collect validation errors for each comparator and to +/// distinguish between the different validation failures for a comparator. +/// +/// [`DscError::SemverReq`]: crate::dscerror::DscError::SemverReq +/// [`UnparseableRequirement`]: Self::UnparseableRequirement +/// [`InvalidRequirement`]: Self::InvalidRequirement +#[derive(Debug, Diagnostic, Error)] +#[non_exhaustive] +pub enum SemanticVersionReqError { + /// Indicates that the input string was unparseable by the underlying [`semver`] crate, which + /// allows a more relaxed syntax. Any string that fails to parse as a [`semver::VersionReq`] + /// can't parse as a [`SemanticVersionReq`]. + #[error("{t}", t = t!( + "types.semantic_version_req.unparseableReq", + "err" => source, + ))] + UnparseableRequirement{ + /// The underlying parsing error from the [`semver`] crate, which provides details about + /// why the input string couldn't be parsed as a valid semantic version requirement. + #[from] source: semver::Error, + }, -impl SemanticVersionReq { - /// Returns the [`Regex`] for [`FORBIDDING_BUILD_METADATA_PATTERN`]. + /// Indicates that the input string was invalid for the syntax that DSC supports. /// - /// 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") - } + /// When DSC raises this error, the input string was valid for the Rust syntax that [`semver`] + /// supports but had one or more errors specific to DSC's more restrictive syntax. The `errors` + /// field contains a collection of one or more errors that show more fully how the input + /// string failed validation during parsing. + #[error("{t}", t = t!( + "types.semantic_version_req.invalidReq", + "requirement" => requirement, + "err" => errors.iter().map(|e| e.to_string()).collect::>().join(", ") + ))] + InvalidRequirement{ + /// The input string for the requirement that failed validation during parsing. + requirement: String, + + /// Collected errors for every invalid comparator in the requirement. + #[related] + errors: Vec + }, + + /// Indicates that a specific comparator in the requirement was invalid for DSC. + /// + /// When DSC raises this error, the comparator was valid for the Rust syntax that [`semver`] + /// supports but had one or more errors specific to DSC's more restrictive syntax. The `errors` + /// field contains a collection of one or more errors that show more fully how the specific + /// comparator failed validation during parsing. + /// + /// [`InvalidRequirement`]: Self::InvalidRequirement + #[error("{t}", t = t!( + "types.semantic_version_req.invalidComparator", + "comparator" => comparator, + "err" => errors.iter().map(|e| e.to_string()).collect::>().join(", ") + ))] + InvalidComparator { + /// The input string for the comparator that failed validation during parsing. + comparator: String, + + /// Collected errors for every invalid syntax problem with the comparator. + #[related] + errors: Vec, + }, + /// Indicates that a comparator included the build metadata segment for a version, which DSC + /// forbids for clarity. + /// + /// While [`semver`] allows users to define build metadata for the version of a comparator, it + /// also ignores that segment entirely for the purposes of matching versions. DSC forbids the + /// build metadata segment in comparators to reduce the ambiguity and false expectations that + /// it will be used for version matching. + #[error("{t}", t = t!( + "types.semantic_version_req.forbiddenBuildMetadata", + "comparator" => comparator, + "build" => build_metadata, + ))] + ComparatorIncludesForbiddenBuildMetadata{ + /// The input string for the comparator that failed validation during parsing. + comparator: String, + /// The text of the forbidden build metadata segment + build_metadata: String + }, + + /// Indicates that a comparator was defined without an explicit operator, which DSC requires + /// for clarity and predictability. + /// + /// [`semver`] supports defining a comparator without an explicit operator, interpreting the + /// operator as the semantically compatible operator (`^`) unless the version specifies one + /// or more wildcards, in which case it interprets the comparator as a wildcard operator. + /// + /// DSC _requires_ an explicit operator to limit confusion when specifying version pinning in + /// a configuration document. + #[error("{t}", t = t!( + "types.semantic_version_req.missingOperator", + "comparator" => comparator + ))] + ComparatorMissingOperator{ + /// The input string for the comparator that failed validation during parsing. + comparator: String + }, + + /// Indicates that a comparator was defined with an invalid wildcard character (`x` or `X`). + /// + /// [`semver`] supports defining wildcards as asterisks (`*`), `x`, and `X`. DSC forbids using + /// letters as wildcards to reduce ambiguity and confusion when specifying wildcards and + /// prerelease version segments. + #[error("{t}", t = t!( + "types.semantic_version_req.invalidWildcards", + "comparator" => comparator + ))] + ComparatorWithInvalidWildcards{ + /// The input string for the comparator that failed validation during parsing. + comparator: String + }, + + /// Indicates that a comparator was defined with a wildcard for the major version segment, + /// which DSC forbids. + /// + /// [`semver`] supports defining the version for a comparator with the major version segment + /// as a wildcard. DSC forbids this construction, since it maps to "match any version," which + /// is the default behavior when no version requirement is defined. + #[error("{t}", t = t!( + "types.semantic_version_req.wildcardMajorVersion", + "comparator" => comparator, + "wildcard" => wildcard + ))] + ComparatorWithWildcardMajorVersion{ + /// The input string for the comparator that failed validation during parsing. + comparator: String, + /// The wildcard used for the major version segment. + wildcard: String, + }, +} + +/// This static lazily defines the regex for [`SemanticVersionReq`] that finds instances of the +/// forbidden build metadata segment in the version for any comparator. 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. +static COMPARATOR_HAS_BUILD_METADATA_REGEX: OnceLock = OnceLock::new(); + +/// This static lazily defines the regex for [`SemanticVersionReq`] that finds comparators that +/// are defined without the mandatory operator before a version. 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. +static COMPARATOR_STARTS_WITH_OPERATOR_REGEX: OnceLock = OnceLock::new(); + +/// This static lazily defines the regex for [`SemanticVersionReq`] that finds invalid wildcards +/// (`x` or `X`) that are defined for any comparator version. 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. +static COMPARATOR_HAS_INVALID_WILDCARD_REGEX: OnceLock = OnceLock::new(); + +impl SemanticVersionReq { /// Parses a given string into a semantic version requirement. /// /// # Errors @@ -498,23 +592,85 @@ impl SemanticVersionReq { /// - 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)); + pub fn parse(text: &str) -> Result { + // First verify whether the input can parse as a semantic version requirement at all; + // If not, error early. + let requirement = semver::VersionReq::parse(text)?; + // Next, collect parse errors to provide full feedback to user for invalid comparator + // definitions: + let mut errors: Vec = vec![]; + let comparators: Vec<&str> = text.split(',').map(|c| c.trim()).collect(); + let starts_with_operator = COMPARATOR_STARTS_WITH_OPERATOR_REGEX.get_or_init( + Self::init_operator_pattern + ); + let invalid_wildcard = COMPARATOR_HAS_INVALID_WILDCARD_REGEX.get_or_init( + Self::init_wildcard_pattern + ); + let has_build_metadata = COMPARATOR_HAS_BUILD_METADATA_REGEX.get_or_init( + Self::init_build_metadata_pattern + ); + + for comparator in comparators { + let mut comparator_errors: Vec = vec![]; + // Check for missing operators: + if !starts_with_operator.is_match(comparator) { + comparator_errors.push( + SemanticVersionReqError::ComparatorMissingOperator{ + comparator: comparator.into() + } + ); + } + // Check for invalid wildcards: + if let Some(captures) = invalid_wildcard.captures(comparator) { + if let Some(wildcard_major) = captures.name("wildcard_major") { + comparator_errors.push( + SemanticVersionReqError::ComparatorWithWildcardMajorVersion{ + comparator: comparator.into(), + wildcard: wildcard_major.as_str().to_string() + } + ); + } + if captures.name("invalid_minor_wildcard").is_some() || captures.name("invalid_patch_wildcard").is_some() { + comparator_errors.push( + SemanticVersionReqError::ComparatorWithInvalidWildcards{ + comparator: comparator.into() + } + ); + } + } + // Check for forbidden build metadata + if let Some(captures) = has_build_metadata.captures(comparator) { + let build_metadata = captures + .name("buildmetadata") + .expect("capture requires this group, should always exist") + .as_str() + .to_string(); + + comparator_errors.push( + SemanticVersionReqError::ComparatorIncludesForbiddenBuildMetadata{ + comparator: comparator.into(), + build_metadata, + } + ); + } + + if !comparator_errors.is_empty() { + errors.push( + SemanticVersionReqError::InvalidComparator { + comparator: comparator.into(), + errors: comparator_errors + } + ); + } } - // 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)), + if errors.is_empty() { + Ok(Self(requirement)) + } else { + Err(SemanticVersionReqError::InvalidRequirement { + requirement: text.to_string(), + errors + }) } } @@ -532,6 +688,29 @@ impl SemanticVersionReq { /// // 2.0.0 isn't compatible with the requirement. /// assert!(!requirement.matches(&SemanticVersion::new(2, 0, 0))); /// ``` + /// + /// The following example shows how the `matches` function treats prerelease versions as not + /// matching a requirement unless the requirement explicitly defines a prerelease segment. + /// + /// ```rust + /// # use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; + /// let v_stable = &SemanticVersion::parse("1.2.3").unwrap(); + /// let v_rc1 = &SemanticVersion::parse("1.2.3-rc.1").unwrap(); + /// let v_rc2 = &SemanticVersion::parse("1.2.3-rc.2").unwrap(); + /// + /// // Only the stable version matches the stable requirement + /// let stable_req = SemanticVersionReq::parse("^1.2.3").unwrap(); + /// assert!(!stable_req.matches(v_rc1)); + /// assert!(!stable_req.matches(v_rc2)); + /// assert!(stable_req.matches(v_stable)); + /// + /// // All three versions match the requirement that explicitly defines the prerelease segment + /// let prerelease_req = SemanticVersionReq::parse("^1.2.3-rc.1").unwrap(); + /// assert!(prerelease_req.matches(v_stable)); + /// assert!(prerelease_req.matches(v_rc1)); + /// assert!(prerelease_req.matches(v_rc2)); + /// + /// ``` pub fn matches(&self, version: &SemanticVersion) -> bool { self.0.matches(version.as_ref()) } @@ -559,6 +738,42 @@ impl SemanticVersionReq { "$", // Anchor to end of string ); + /// Returns the [`Regex`] for [`FORBIDDING_BUILD_METADATA_PATTERN`]. + /// + /// This private method is used to initialize the [`COMPARATOR_HAS_BUILD_METADATA_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`FORBIDDING_BUILD_METADATA_PATTERN`]: SemanticVersionReq::FORBIDDING_BUILD_METADATA_PATTERN + fn init_build_metadata_pattern() -> Regex { + Regex::new(Self::FORBIDDING_BUILD_METADATA_PATTERN).expect("pattern is valid") + } + + /// Returns the [`Regex`] for [`REQUIRE_OPERATOR_PATTERN`]. + /// + /// This private method is used to initialize the [`COMPARATOR_STARTS_WITH_OPERATOR_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`REQUIRE_OPERATOR_PATTERN`]: SemanticVersionReq::REQUIRE_OPERATOR_PATTERN + fn init_operator_pattern() -> Regex { + let pattern = SemanticVersionReq::REQUIRE_OPERATOR_PATTERN; + Regex::new(pattern).expect("pattern is valid") + } + + /// Returns the [`Regex`] for [`VALIDATING_WILDCARDS_PATTERN`]. + /// + /// This private method is used to initialize the [`COMPARATOR_HAS_INVALID_WILDCARD_REGEX`] + /// private static to reduce the number of times the regular expression is compiled from the + /// pattern string. + /// + /// [`VALIDATING_WILDCARDS_PATTERN`]: SemanticVersionReq::VALIDATING_WILDCARDS_PATTERN + fn init_wildcard_pattern() -> Regex { + let pattern = SemanticVersionReq::VALIDATING_WILDCARDS_PATTERN; + Regex::new(pattern).expect("pattern is valid") + } + + /// 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 @@ -572,6 +787,55 @@ impl SemanticVersionReq { ")", // Close non-capturing group for build metadata and prefix ); + /// Defines the regular expression for matching an operator at the beginning of any comparator. + /// + /// DSC requires every comparator to define an explicit operator instead of allowing the + /// implicit operator behavior that [`semver::VersionReq`] supports. To provide better error + /// messaging, DSC uses this pattern to verify each comparator during parsing and reports any + /// comparators that are missing their required operator to the user. + pub const REQUIRE_OPERATOR_PATTERN: &str = const_str::concat!( + "^", // Anchor to the start of the string + SemanticVersionReq::OPERATOR_PATTERN // Match any valid operator + ); + + /// Defines the regular expression for validating wildcards in a comparator. + /// + /// DSC forbids defining the major version segment as any wildcard character. DSC also forbids + /// defining any wildcards as `x` or `X` instead of `*`. To provide better error messaging, DSC + /// uses this pattern to discover the inclusion of invalid wildcards during parsing and report + /// it to the user. + pub const VALIDATING_WILDCARDS_PATTERN: &str = const_str::concat!( + "^", // Anchor to the start of the string + SemanticVersionReq::OPERATOR_PATTERN, // Match any valid operator + "?", // Make the operator optional + r"\s*", // Allow any whitespace after operator + "(?:", // Start non-capturing group for version. + r"(?[\*xX])", // Match any wildcard for major version + "|", // or + r"\d+", // Match literal major version + "(?:", // Start non-capturing group for optional segments + r"\.", // Require period after major and before minor + "(?:", // Start non-capture group for minor version + r"(?[xX])", // Capture invalid wildcard + "|", // or + r"\d+", // Match literal version + "|", // or + r"\*", // Match valid wildcard + ")", // Close non-capture group for minor version + "(?:", // Open non-capture group for optional patch segment + r"\.", // Require period after minor and before patch + "(?:", // Open non-capture group for patch version + r"(?[xX])", // Capture invalid wildcard + "|", // or + r"\d+", // match literal version + "|", // or + r"\*", // match valid wildcard + ")", // close non-capture group for patch version + ")?", // close non-capture group for optional patch segment + ")?", // close non-capture group for optional segments + ")" // Close non-capturing group for version + ); + /// 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 @@ -607,11 +871,10 @@ impl SemanticVersionReq { "|", // 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. + /// Defines the regular expression for matching a comparator with a 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 @@ -636,7 +899,7 @@ impl SemanticVersionReq { 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 patch segment ")?", // Close non-capturing group for optional minor and patch segments ")", // Close non-capturing group for literal version ); @@ -708,7 +971,7 @@ impl JsonSchema for SemanticVersionReq { "pattern": SemanticVersionReq::VALIDATING_PATTERN, "patternErrorMessage": t!("schemas.definitions.semverReq.patternErrorMessage"), "examples": [ - "1.2.3", + "=1.2.3", ">=1.2.3, <2.0.0", "^1.2", "~2.3", @@ -762,21 +1025,21 @@ impl From for String { // Fallible conversions impl FromStr for SemanticVersionReq { - type Err = DscError; + type Err = SemanticVersionReqError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for SemanticVersionReq { - type Error = DscError; + type Error = SemanticVersionReqError; fn try_from(value: String) -> Result { Self::parse(value.as_str()) } } impl TryFrom<&str> for SemanticVersionReq { - type Error = DscError; + type Error = SemanticVersionReqError; fn try_from(value: &str) -> Result { SemanticVersionReq::from_str(value) } diff --git a/lib/dsc-lib/tests/integration/types/date_version.rs b/lib/dsc-lib/tests/integration/types/date_version.rs index eb86b2310..745b8fc1f 100644 --- a/lib/dsc-lib/tests/integration/types/date_version.rs +++ b/lib/dsc-lib/tests/integration/types/date_version.rs @@ -4,7 +4,7 @@ #[cfg(test)] mod methods { use chrono::NaiveDate; - use dsc_lib::{dscerror::DscError, types::DateVersion}; + use dsc_lib::types::{DateVersion, DateVersionError}; use test_case::test_case; #[test_case("2026-02-28" => matches Ok(_); "ISO8601 date is valid")] @@ -15,8 +15,6 @@ mod methods { #[test_case("1234-2-03" => matches Err(_); "month with less than two digits is invalid")] #[test_case("1234-02-3" => matches Err(_); "day with less than two digits is invalid")] #[test_case("0123-02-03" => matches Err(_); "year with leading zero is invalid")] - #[test_case("1234-2-03" => matches Err(_); "month without leading zero is invalid")] - #[test_case("1234-02-3" => matches Err(_); "day without leading zero is invalid")] #[test_case("1234-00-03" => matches Err(_); "zero month is invalid")] #[test_case("1234-02-00" => matches Err(_); "zero day is invalid")] #[test_case("1234-14-21" => matches Err(_); "impossible month is invalid")] @@ -33,7 +31,7 @@ mod methods { #[test_case("1234-11-31" => matches Err(_); "impossible november date is invalid")] #[test_case("1234-12-32" => matches Err(_); "impossible december date is invalid")] #[test_case("2026-02-28-rc.1" => matches Err(_); "prerelease with non-ASCII alphabetic characters is invalid")] - fn parse(text: &str) -> Result { + fn parse(text: &str) -> Result { DateVersion::parse(text) } @@ -53,7 +51,7 @@ mod methods { } #[test_case("2026-02-03", None; "version without prerelease segment")] - #[test_case("2026-02-03-rc", Some(&"rc".to_string()); "version with prerelease segment")] + #[test_case("2026-02-03-rc", Some(&String::from("rc")); "version with prerelease segment")] fn prerelease_segment(text: &str, expected: Option<&String>) { pretty_assertions::assert_eq!( DateVersion::parse(text).unwrap().prerelease(), @@ -88,7 +86,7 @@ mod schema { let schema = &*SCHEMA; let value = schema .get_keyword_as_str(keyword) - .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + .expect(&format!("expected keyword '{keyword}' to be defined")); assert!( !(&*KEYWORD_PATTERN).is_match(value), @@ -320,7 +318,7 @@ mod traits { mod from_str { use std::str::FromStr; - use dsc_lib::{dscerror::DscError, types::DateVersion}; + use dsc_lib::types::{DateVersion, DateVersionError}; use test_case::test_case; #[test_case("2026-02-28" => matches Ok(_); "ISO8601 date is valid")] @@ -349,7 +347,7 @@ mod traits { #[test_case("1234-11-31" => matches Err(_); "impossible november date is invalid")] #[test_case("1234-12-32" => matches Err(_); "impossible december date is invalid")] #[test_case("2026-02-28-rc.1" => matches Err(_); "prerelease with non-ASCII alphabetic characters is invalid")] - fn from_str(text: &str) -> Result { + fn from_str(text: &str) -> Result { DateVersion::from_str(text) } @@ -379,7 +377,7 @@ mod traits { #[test_case("1234-11-31" => matches Err(_); "impossible november date is invalid")] #[test_case("1234-12-32" => matches Err(_); "impossible december date is invalid")] #[test_case("2026-02-28-rc.1" => matches Err(_); "prerelease with non-ASCII alphabetic characters is invalid")] - fn parse(text: &str) -> Result { + fn parse(text: &str) -> Result { text.parse() } } @@ -387,12 +385,12 @@ mod traits { #[cfg(test)] mod try_from { use chrono::NaiveDate; - use dsc_lib::{dscerror::DscError, types::DateVersion}; + use dsc_lib::types::{DateVersion, DateVersionError}; use test_case::test_case; #[test_case("2026-02-03".parse().unwrap() => matches Ok(_); "date with year greater than 999 is valid")] #[test_case("0999-02-03".parse().unwrap() => matches Err(_); "date with year less than 1000 is invalid")] - fn naive_date(date: NaiveDate) -> Result { + fn naive_date(date: NaiveDate) -> Result { DateVersion::try_from(date) } @@ -422,7 +420,7 @@ mod traits { #[test_case("1234-11-31" => matches Err(_); "impossible november date is invalid")] #[test_case("1234-12-32" => matches Err(_); "impossible december date is invalid")] #[test_case("2026-02-28-rc.1" => matches Err(_); "prerelease with non-ASCII alphabetic characters is invalid")] - fn string(text: &str) -> Result { + fn string(text: &str) -> Result { DateVersion::try_from(text.to_string()) } @@ -452,7 +450,7 @@ mod traits { #[test_case("1234-11-31" => matches Err(_); "impossible november date is invalid")] #[test_case("1234-12-32" => matches Err(_); "impossible december date is invalid")] #[test_case("2026-02-28-rc.1" => matches Err(_); "prerelease with non-ASCII alphabetic characters is invalid")] - fn str(text: &str) -> Result { + fn str(text: &str) -> Result { DateVersion::try_from(text) } } diff --git a/lib/dsc-lib/tests/integration/types/resource_version.rs b/lib/dsc-lib/tests/integration/types/resource_version.rs index d6f1e1f62..d157112e6 100644 --- a/lib/dsc-lib/tests/integration/types/resource_version.rs +++ b/lib/dsc-lib/tests/integration/types/resource_version.rs @@ -3,53 +3,76 @@ #[cfg(test)] mod methods { - use dsc_lib::types::{ResourceVersion, SemanticVersion, SemanticVersionReq}; + use dsc_lib::types::{ + DateVersion, + ResourceVersion, + ResourceVersionError, + 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" => matches Ok(_); "valid semantic version parses")] + #[test_case("1.2.3a" => matches Err(_); "invalid semantic version fails")] + #[test_case("2026-01-15" => matches Ok(_); "full ISO8601 date parses")] + #[test_case("2026-01-15-rc" => matches Ok(_); "full ISO8601 date with preview segment parses")] + #[test_case("2026-01" => matches Err(_); "partial ISO8601 date fails")] + #[test_case("arbitrary_string" => matches Err(_); "arbitrary string fails")] + fn parse(version_string: &str) -> Result { + ResourceVersion::parse(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")] + #[test_case("1.2.3" => true; "semantic version is semantic")] + #[test_case("2026-01-15" => false; "stable date version is not semantic")] + #[test_case("2026-01-15-rc" => false; "preview date version is not semantic")] fn is_semver(version_string: &str) -> bool { - ResourceVersion::new(version_string).is_semver() + ResourceVersion::parse(version_string).unwrap().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("1.2.3" => false; "semantic version is not date")] + #[test_case("2026-01-15" => true; "stable date version is date")] + #[test_case("2026-01-15-rc" => true; "preview date version is date")] + fn is_date_version(version_string: &str) -> bool { + ResourceVersion::parse(version_string).unwrap().is_date_version() } - #[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" => matches Some(_); "semantic version returns some")] + #[test_case("2026-01-15" => matches None; "stable date version returns none")] + #[test_case("2026-01-15-rc" => matches None; "preview date version returns none")] + fn as_semver(version: &str) -> Option { + ResourceVersion::parse(version).unwrap().as_semver().cloned() + } + + #[test_case("1.2.3" => matches None; "semantic version returns none")] + #[test_case("2026-01-15" => matches Some(_); "stable date version returns some")] + #[test_case("2026-01-15-rc" => matches Some(_); "preview date version returns some")] + fn as_date_version(version: &str) -> Option { + ResourceVersion::parse(version).unwrap().as_date_version().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")] + #[test_case("2026-01-15", "^1" => false; "date version never matches")] fn matches_semver_req(version_string: &str, requirement_string: &str) -> bool { - ResourceVersion::new(version_string) + ResourceVersion::parse(version_string) + .unwrap() .matches_semver_req(&SemanticVersionReq::parse(requirement_string).unwrap()) } + + #[test_case("2026-01-15", "2026-01-15" => true; "stable date version matches identical date req")] + #[test_case("2026-01-15", "2026-02-15" => false; "stable date version does not match different stable date req")] + #[test_case("2026-01-15", "2026-01-15-rc" => false; "stable date version does not match preview date req")] + #[test_case("2026-01-15-rc", "2026-01-15-rc" => true; "preview date version matches identical date req")] + #[test_case("2026-01-15-rc", "2026-02-15-rc" => false; "preview date version does not match different preview date req")] + #[test_case("2026-01-15-rc", "2026-02-15-preview" => false; "preview date version does not match preview date req with different prerelease segment")] + #[test_case("2026-01-15-rc", "2026-01-15-RC" => false; "preview date version does not match preview date req with different casing")] + #[test_case("1.2.3", "2026-01-15" => false; "semantic version does not match date req")] + fn matches_date_version(version_string: &str, requirement_string: &str) -> bool { + ResourceVersion::parse(version_string) + .unwrap() + .matches_date_req(&DateVersion::parse(requirement_string).unwrap()) + } } #[cfg(test)] @@ -75,7 +98,7 @@ mod schema { .clone() .into() }); - static STRING_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + static DATE_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { (&*ROOT_SCHEMA) .get_keyword_as_array("anyOf") .unwrap() @@ -98,13 +121,14 @@ mod schema { #[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")] + #[test_case("title", &*DATE_VARIANT_SCHEMA; "date.title")] + #[test_case("description", &*DATE_VARIANT_SCHEMA; "date.description")] + #[test_case("markdownDescription", &*DATE_VARIANT_SCHEMA; "date.markdownDescription")] + #[test_case("deprecationMessage", &*DATE_VARIANT_SCHEMA; "date.deprecationMessage")] 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()); + .expect(&format!("expected keyword '{keyword}' to be defined")); assert!( !(&*KEYWORD_PATTERN).is_match(value), @@ -119,11 +143,19 @@ mod schema { ) } + #[test] + fn date_version_subschema_is_reference() { + assert!( + (&*DATE_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!("1.2.a") => false ; "invalid semantic version string value is invalid")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 full date string value is valid")] + #[test_case(&json!("2026-01-15-rc") => true ; "iso8601 full date with prerelease segment string value is valid")] + #[test_case(&json!("2026-01") => false ; "iso8601 partial date is invalid")] + #[test_case(&json!("arbitrary_string") => false ; "arbitrary 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")] @@ -137,37 +169,35 @@ mod schema { #[cfg(test)] mod serde { - use dsc_lib::types::ResourceVersion; + use dsc_lib::types::{ResourceVersion, 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")] + #[test_case("1.2.3"; "semantic version")] + #[test_case("2026-01-15"; "stable date version")] + #[test_case("2026-01-15-rc"; "preview date version")] fn serializing_resource_version_to_string(version_string: &str) { - let actual = serde_json::to_string(&ResourceVersion::new(version_string)) + let actual = serde_json::to_string(&ResourceVersion::parse(version_string).unwrap()) .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")] + #[test_case("1.2.3"; "semantic version")] + #[test_case("2026-01-15"; "stable date version")] + #[test_case("2026-01-15-rc"; "preview date version")] 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)) + let actual = serde_json::to_value(&ResourceVersion::parse(version_string).unwrap()) .expect("serialization should never fail"); pretty_assertions::assert_eq!(actual, expected); } + #[test_case(json!("1.2.c"); "invalid semantic version fails")] + #[test_case(json!("2026-02-29"); "invalid date version fails")] #[test_case(json!(true); "boolean value fails")] #[test_case(json!(1); "integer value fails")] #[test_case(json!(1.2); "float value fails")] @@ -175,18 +205,16 @@ mod serde { #[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"); + serde_json::from_value::(input_value.clone()) + .expect_err(&format!("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")] + #[test_case(json!("1.2.3") => matches Semantic(_); "valid semantic version string value returns semantic version")] + #[test_case(json!("2026-01-15") => matches Date(_) ; "iso8601 date string value returns date version")] + #[test_case(json!("2026-01-15-rc") => matches Date(_) ; "iso8601 date with preview segment string value returns date version")] fn deserializing_valid(input_value: Value) -> ResourceVersion { serde_json::from_value::(input_value) - .expect("deserialization for '{input_value}' should never fail") + .expect("deserializing shouldn't fail") } } @@ -200,7 +228,7 @@ mod traits { fn default() { pretty_assertions::assert_eq!( ResourceVersion::default(), - ResourceVersion::new("0.0.0") + ResourceVersion::parse("0.0.0").unwrap() ); } } @@ -210,26 +238,22 @@ mod traits { 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")] + #[test_case("1.2.3"; "semantic version")] + #[test_case("2026-01-15"; "stable date version")] + #[test_case("2026-01-15-rc"; "preview date version")] fn format(version_string: &str) { pretty_assertions::assert_eq!( - format!("version: {}", ResourceVersion::new(version_string)), + format!("version: {}", ResourceVersion::parse(version_string).unwrap()), 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")] + #[test_case("1.2.3"; "semantic version")] + #[test_case("2026-01-15"; "stable date version")] + #[test_case("2026-01-15-rc"; "preview date version")] fn to_string(version_string: &str) { pretty_assertions::assert_eq!( - ResourceVersion::new(version_string).to_string(), + ResourceVersion::parse(version_string).unwrap().to_string(), version_string.to_string() ) } @@ -237,42 +261,72 @@ mod traits { #[cfg(test)] mod from_str { - use dsc_lib::types::ResourceVersion; + use dsc_lib::types::{ResourceVersion, ResourceVersionError}; 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") + #[test_case("1.2.3" => matches Ok(_); "valid semantic version parses")] + #[test_case("1.2.3a" => matches Err(_); "invalid semantic version fails")] + #[test_case("2026-01-15" => matches Ok(_); "ISO8601 date parses")] + #[test_case("2026-01-15-rc" => matches Ok(_); "ISO8601 date with preview segment parses")] + #[test_case("2026-01" => matches Err(_); "ISO8601 date year and month only fails")] + #[test_case("arbitrary_string" => matches Err(_); "arbitrary string fails")] + fn parse(input: &str) -> Result { + input.parse() } } #[cfg(test)] mod from { - use dsc_lib::types::{ResourceVersion, SemanticVersion}; - use test_case::test_case; + use dsc_lib::types::{DateVersion, ResourceVersion, SemanticVersion}; #[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()) + #[test] + fn date_version() { + let date_version = DateVersion::parse("2026-01-15").unwrap(); + match ResourceVersion::from(date_version.clone()) { + ResourceVersion::Date(v) => pretty_assertions::assert_eq!(v, date_version), + _ => { + panic!("should never fail to convert as date version") + } + } + } + } + + #[cfg(test)] + mod try_from { + use dsc_lib::types::{ResourceVersion, ResourceVersionError}; + 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 Ok(_); "valid ISO8601 date converts")] + #[test_case("2026-01-15-rc" => matches Ok(_); "valid ISO8601 date with preview segment converts")] + #[test_case("2026-01" => matches Err(_); "partial ISO8601 date fails")] + #[test_case("2026-02-29" => matches Err(_); "invalid ISO8601 date fails")] + #[test_case("arbitrary_string" => matches Err(_); "arbitrary string fails")] + fn string(version_string: &str) -> Result { + ResourceVersion::try_from(version_string.to_string()) + } + + #[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 Ok(_); "valid ISO8601 date converts")] + #[test_case("2026-01-15-rc" => matches Ok(_); "valid ISO8601 date with preview segment converts")] + #[test_case("2026-01" => matches Err(_); "partial ISO8601 date fails")] + #[test_case("2026-02-29" => matches Err(_); "invalid ISO8601 date fails")] + #[test_case("arbitrary_string" => matches Err(_); "arbitrary string fails")] + fn str(version_string: &str) -> Result { + ResourceVersion::try_from(version_string) } } @@ -286,9 +340,10 @@ mod traits { use test_case::test_case; #[test_case("1.2.3"; "semantic version")] - #[test_case("arbitrary_version"; "arbitrary string version")] + #[test_case("2026-01-15"; "stable date version")] + #[test_case("2026-01-15-rc"; "preview date version")] fn string(version_string: &str) { - let actual: String = ResourceVersion::new(version_string).into(); + let actual: String = ResourceVersion::parse(version_string).unwrap().into(); let expected = version_string.to_string(); pretty_assertions::assert_eq!(actual, expected) @@ -297,40 +352,53 @@ mod traits { #[cfg(test)] mod try_into { - use dsc_lib::{dscerror::DscError, types::{ResourceVersion, SemanticVersion}}; + use dsc_lib::types::{DateVersion, ResourceVersion, ResourceVersionError, 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)) + #[test_case("1.2.3" => matches Ok(_); "semantic version converts")] + #[test_case("2026-01-15" => matches Err(_); "stable date version fails")] + #[test_case("2026-01-15-rc" => matches Err(_); "preview date version fails")] + fn semantic_version(version_string: &str) -> Result { + TryInto::::try_into(ResourceVersion::parse(version_string).unwrap()) + } + + #[test_case("1.2.3" => matches Err(_); "semantic version fails")] + #[test_case("2026-01-15" => matches Ok(_); "stable date version converts")] + #[test_case("2026-01-15-rc" => matches Ok(_); "preview date version converts")] + fn date_version(version_string: &str) -> Result { + TryInto::::try_into(ResourceVersion::parse(version_string).unwrap()) } } #[cfg(test)] mod partial_eq { - use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use dsc_lib::types::{DateVersion, 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")] + #[test_case("2026-01-15", "2026-01-15", true; "identical stable date versions")] + #[test_case("2026-01-15", "2026-03-15", false; "different stable date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", true; "identical preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-preview", false; "different preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-RC", false; "differently cased preview date 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)) + pretty_assertions::assert_eq!( + ResourceVersion::parse(lhs).unwrap(), + ResourceVersion::parse(rhs).unwrap() + ) } else { - pretty_assertions::assert_ne!(ResourceVersion::new(lhs), ResourceVersion::new(rhs)) + pretty_assertions::assert_ne!( + ResourceVersion::parse(lhs).unwrap(), + ResourceVersion::parse(rhs).unwrap() + ) } } #[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")] + #[test_case("2026-01-15", "3.2.1", false; "date version with semantic version")] fn semantic_version( resource_version_string: &str, semantic_version_string: &str, @@ -352,12 +420,44 @@ mod traits { "expected comparison of {semantic} and {version} to be #{should_be_equal}" ); } + + #[test_case("2026-01-15", "2026-01-15", true; "identical stable date versions")] + #[test_case("2026-01-15", "2026-03-15", false; "different stable date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", true; "identical preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-preview", false; "different preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-RC", false; "differently cased preview date versions")] + #[test_case("1.2.3", "2026-01-15-RC", false; "semantic version with date version")] + fn date_version( + resource_version_string: &str, + date_version_string: &str, + should_be_equal: bool, + ) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let date: DateVersion = date_version_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == date, + should_be_equal, + "expected comparison of {version} and {date} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + date == version, + should_be_equal, + "expected comparison of {date} 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")] + #[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("2026-01-15", "2026-01-15", true; "identical stable date versions")] + #[test_case("2026-01-15", "2026-03-15", false; "different stable date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", true; "identical preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-preview", false; "different preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-RC", false; "differently cased preview date versions")] + #[test_case("1.2.3", "arbitrary", false; "semantic version and arbitrary string")] + #[test_case("2026-01-15", "arbitrary", false; "date version and arbitrary string")] fn str(resource_version_string: &str, string_slice: &str, should_be_equal: bool) { let version: ResourceVersion = resource_version_string.parse().unwrap(); @@ -375,11 +475,15 @@ mod traits { ); } - #[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")] + #[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("2026-01-15", "2026-01-15", true; "identical stable date versions")] + #[test_case("2026-01-15", "2026-03-15", false; "different stable date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", true; "identical preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-preview", false; "different preview date versions")] + #[test_case("2026-01-15-rc", "2026-01-15-RC", false; "differently cased preview date versions")] + #[test_case("1.2.3", "arbitrary", false; "semantic version and arbitrary string")] + #[test_case("2026-01-15", "arbitrary", false; "date version and arbitrary 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(); @@ -402,28 +506,27 @@ mod traits { mod partial_ord { use std::cmp::Ordering; - use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use dsc_lib::types::{DateVersion, 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")] + #[test_case("2026-01-15", "2026-01-15", Ordering::Equal; "identical stable date versions")] + #[test_case("2026-02-15", "2026-01-15", Ordering::Greater; "stable date versions with newer lhs")] + #[test_case("2026-01-15", "2026-02-15", Ordering::Less; "stable date versions with newer rhs")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", Ordering::Equal; "identical preview date versions")] + #[test_case("2026-02-15-rc", "2026-01-15-rc", Ordering::Greater; "preview date versions with newer lhs")] + #[test_case("2026-01-15-rc", "2026-02-15-rc", Ordering::Less; "preview date versions with newer rhs")] + #[test_case("2026-01-15-alpha", "2026-01-15-bravo", Ordering::Less; "preview date versions with lexicographically later rhs preview segment")] + #[test_case("2026-01-15-bravo", "2026-01-15-alpha", Ordering::Greater; "preview date versions with lexicographically earlier rhs preview segment")] + #[test_case("1.2.3", "2026-01-15", Ordering::Greater; "semantic version to date version")] + #[test_case("2026-01-15", "1.2.3", Ordering::Less; "date version to semantic version")] fn resource_version(lhs: &str, rhs: &str, expected_order: Ordering) { pretty_assertions::assert_eq!( - ResourceVersion::new(lhs) - .partial_cmp(&ResourceVersion::new(rhs)) + ResourceVersion::parse(lhs) + .unwrap() + .partial_cmp(&ResourceVersion::parse(rhs).unwrap()) .unwrap(), expected_order, "expected '{lhs}' compared to '{rhs}' to be {expected_order:#?}" @@ -433,7 +536,7 @@ mod traits { #[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")] + #[test_case("2026-01-15", "1.2.3", Ordering::Less; "date version to semantic version")] fn semantic_version( resource_version_string: &str, semantic_version_string: &str, @@ -461,31 +564,29 @@ mod traits { "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) { + + #[test_case("2026-01-15", "2026-01-15", Ordering::Equal; "identical stable date versions")] + #[test_case("2026-02-15", "2026-01-15", Ordering::Greater; "stable date versions with newer lhs")] + #[test_case("2026-01-15", "2026-02-15", Ordering::Less; "stable date versions with newer rhs")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", Ordering::Equal; "identical preview date versions")] + #[test_case("2026-02-15-rc", "2026-01-15-rc", Ordering::Greater; "preview date versions with newer lhs")] + #[test_case("2026-01-15-rc", "2026-02-15-rc", Ordering::Less; "preview date versions with newer rhs")] + #[test_case("2026-01-15-alpha", "2026-01-15-bravo", Ordering::Less; "preview date versions with lexicographically later rhs preview segment")] + #[test_case("2026-01-15-bravo", "2026-01-15-alpha", Ordering::Greater; "preview date versions with lexicographically earlier rhs preview segment")] + #[test_case("1.2.3", "2026-01-15", Ordering::Greater; "semantic version to date version")] + fn date_version( + resource_version_string: &str, + date_version_string: &str, + expected_order: Ordering, + ) { let version: ResourceVersion = resource_version_string.parse().unwrap(); - let string = string_slice.to_string(); + let date: DateVersion = date_version_string.parse().unwrap(); // Test comparison bidirectionally pretty_assertions::assert_eq!( - version.partial_cmp(&string).unwrap(), + version.partial_cmp(&date).unwrap(), expected_order, - "expected comparison of {version} and {string} to be #{expected_order:#?}" + "expected comparison of {version} and {date} to be #{expected_order:#?}" ); let expected_inverted_order = match expected_order { @@ -495,10 +596,93 @@ mod traits { }; pretty_assertions::assert_eq!( - string.partial_cmp(&version).unwrap(), + date.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {date} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Some(Ordering::Equal); "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Some(Ordering::Greater); "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Some(Ordering::Less); "semantic versions with newer rhs")] + #[test_case("2026-01-15", "2026-01-15", Some(Ordering::Equal); "identical stable date versions")] + #[test_case("2026-02-15", "2026-01-15", Some(Ordering::Greater); "stable date versions with newer lhs")] + #[test_case("2026-01-15", "2026-02-15", Some(Ordering::Less); "stable date versions with newer rhs")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", Some(Ordering::Equal); "identical preview date versions")] + #[test_case("2026-02-15-rc", "2026-01-15-rc", Some(Ordering::Greater); "preview date versions with newer lhs")] + #[test_case("2026-01-15-rc", "2026-02-15-rc", Some(Ordering::Less); "preview date versions with newer rhs")] + #[test_case("2026-01-15-alpha", "2026-01-15-bravo", Some(Ordering::Less); "preview date versions with lexicographically later rhs preview segment")] + #[test_case("2026-01-15-bravo", "2026-01-15-alpha", Some(Ordering::Greater); "preview date versions with lexicographically earlier rhs preview segment")] + #[test_case("1.2.3", "2026-01-15", Some(Ordering::Greater); "semantic version to date version")] + #[test_case("1.2.3", "arbitrary", None; "semantic version to arbitrary string")] + #[test_case("2026-01-15", "1.2.3", Some(Ordering::Less); "date version to semantic version")] + #[test_case("2026-01-15", "arbitrary", None; "date version to arbitrary string")] + fn string(resource_version_string: &str, string_slice: &str, expected_order: Option) { + 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), + expected_order, + "expected comparison of {version} and {string} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Some(order) => match order { + Ordering::Equal => Some(Ordering::Equal), + Ordering::Greater => Some(Ordering::Less), + Ordering::Less => Some(Ordering::Greater), + }, + None => None, + }; + + 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 semantic versions")] + #[test_case("3.2.1", "1.2.3", Some(Ordering::Greater); "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Some(Ordering::Less); "semantic versions with newer rhs")] + #[test_case("2026-01-15", "2026-01-15", Some(Ordering::Equal); "identical stable date versions")] + #[test_case("2026-02-15", "2026-01-15", Some(Ordering::Greater); "stable date versions with newer lhs")] + #[test_case("2026-01-15", "2026-02-15", Some(Ordering::Less); "stable date versions with newer rhs")] + #[test_case("2026-01-15-rc", "2026-01-15-rc", Some(Ordering::Equal); "identical preview date versions")] + #[test_case("2026-02-15-rc", "2026-01-15-rc", Some(Ordering::Greater); "preview date versions with newer lhs")] + #[test_case("2026-01-15-rc", "2026-02-15-rc", Some(Ordering::Less); "preview date versions with newer rhs")] + #[test_case("2026-01-15-alpha", "2026-01-15-bravo", Some(Ordering::Less); "preview date versions with lexicographically later rhs preview segment")] + #[test_case("2026-01-15-bravo", "2026-01-15-alpha", Some(Ordering::Greater); "preview date versions with lexicographically earlier rhs preview segment")] + #[test_case("1.2.3", "2026-01-15", Some(Ordering::Greater); "semantic version to date version")] + #[test_case("1.2.3", "arbitrary", None; "semantic version to arbitrary string")] + #[test_case("2026-01-15", "1.2.3", Some(Ordering::Less); "date version to semantic version")] + #[test_case("2026-01-15", "arbitrary", None; "date version to arbitrary string")] + fn str(resource_version_string: &str, string_slice: &str, expected_order: Option) { + let version: ResourceVersion = 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 { + Some(order) => match order { + Ordering::Equal => Some(Ordering::Equal), + Ordering::Greater => Some(Ordering::Less), + Ordering::Less => Some(Ordering::Greater), + }, + None => None, + }; + + pretty_assertions::assert_eq!( + string_slice.partial_cmp(&version), + expected_inverted_order, + "expected comparison of {string_slice} 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 index 2f66ac860..bf62537f5 100644 --- a/lib/dsc-lib/tests/integration/types/resource_version_req.rs +++ b/lib/dsc-lib/tests/integration/types/resource_version_req.rs @@ -7,95 +7,99 @@ mod methods { use test_case::test_case; #[cfg(test)] - mod new { - use dsc_lib::types::ResourceVersionReq; - use dsc_lib::types::ResourceVersionReq::*; + mod parse { + use dsc_lib::types::{ResourceVersionReq, ResourceVersionReqError}; 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 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_req(requirement_string: &str) -> Result { + ResourceVersionReq::parse(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("^*" => matches Err(_); "wildcard is invalid")] + #[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")] + #[test_case("^1.x" => matches Err(_); "major.lowercase_x is invalid")] + #[test_case("^1.X" => matches Err(_); "major.uppercase_x is invalid")] + #[test_case("^1.2.x" => matches Err(_); "major.minor.lowercase_x is invalid")] + #[test_case("^1.2.X" => matches Err(_); "major.minor.uppercase_x is invalid")] + fn wildcard_version_req(requirement_string: &str) -> Result { + ResourceVersionReq::parse(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" => 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(_); "implicit operator is invalid")] + #[test_case("== 1.2.3" => matches Err(_); "unknown operator is invalid")] + fn operators_in_version_req(requirement_string: &str) -> Result { + ResourceVersionReq::parse(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.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_comparator_version_req(requirement_string: &str) -> Result { + ResourceVersionReq::parse(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" => 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_in_version_req(requirement_string: &str) -> Result { + ResourceVersionReq::parse(requirement_string) } } - #[test_case("1.2.3" => true; "single comparator is semver")] + #[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")] + #[test_case("2026-02-01" => false; "stable date version is not semver")] + #[test_case("2026-02-01-rc" => false; "preview date version is not semver")] fn is_semver(requirement_string: &str) -> bool { - ResourceVersionReq::new(requirement_string).is_semver() + ResourceVersionReq::parse(requirement_string).unwrap().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" => false; "single comparator is not date")] + #[test_case("^1.2, >1.5" => false; "multi comparator is not date")] + #[test_case("2026-02-01" => true; "stable date version is date")] + #[test_case("2026-02-01-rc" => true; "preview date version is date")] + fn is_date_version(requirement_string: &str) -> bool { + ResourceVersionReq::parse(requirement_string).unwrap().is_date_version() } - #[test_case("1.2.3" => matches Some(_); "single comparator returns some")] + #[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")] + #[test_case("2026-02-01" => matches None; "stable date version returns none")] + #[test_case("2026-02-01-rc" => matches None; "preview date version returns none")] fn as_semver_req(requirement_string: &str) -> Option { - ResourceVersionReq::new(requirement_string).as_semver_req().cloned() + ResourceVersionReq::parse(requirement_string).unwrap().as_semver_req().cloned() } #[cfg(test)] @@ -104,11 +108,11 @@ mod methods { use test_case::test_case; fn check(requirement: &str, versions: Vec<&str>, should_match: bool) { - let req = ResourceVersionReq::new(requirement); + let req = ResourceVersionReq::parse(requirement).unwrap(); let expected = if should_match { "match" } else { "not match" }; for version in versions { pretty_assertions::assert_eq!( - req.matches(&ResourceVersion::new(version)), + req.matches(&ResourceVersion::parse(version).unwrap()), should_match, "expected version '{version}' to {expected} requirement '{requirement}'" ); @@ -117,23 +121,21 @@ mod methods { // 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")] + #[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"], 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"], 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"], 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"], 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) { + #[test_case("2026-02-01", vec!["2026-02-02", "1.2.3"], false; "not matching version as date")] + fn date(requirement: &str, versions: Vec<&str>, should_match: bool) { check(requirement, versions, should_match); } } @@ -162,7 +164,7 @@ mod schema { .clone() .into() }); - static ARBITRARY_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + static DATE_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { (&*ROOT_SCHEMA) .get_keyword_as_array("anyOf") .unwrap() @@ -185,10 +187,10 @@ mod schema { #[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")] + #[test_case("title", &*DATE_VARIANT_SCHEMA; "dateVersion.title")] + #[test_case("description", &*DATE_VARIANT_SCHEMA; "dateVersion.description")] + #[test_case("deprecationMessage", &*DATE_VARIANT_SCHEMA; "dateVersion.deprecationMessage")] + #[test_case("markdownDescription", &*DATE_VARIANT_SCHEMA; "dateVersion.markdownDescription")] fn has_documentation_keyword(keyword: &str, schema: &Schema) { let value = schema .get_keyword_as_str(keyword) @@ -207,12 +209,20 @@ mod schema { ) } + #[test] + fn date_subschema_is_reference() { + assert!( + (&*DATE_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!("=1.2.3a") => false ; "invalid semantic version req string value is invalid")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 full date string value is valid")] + #[test_case(&json!("2026-01-15-rc") => true ; "iso8601 full date with prerelease segment string value is valid")] + #[test_case(&json!("2026-01") => false ; "iso8601 date year month string value is invalid")] + #[test_case(&json!("arbitrary_string") => false ; "arbitrary 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")] @@ -232,11 +242,11 @@ mod serde { #[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")] + #[test_case("2026-02-01"; "stable date req serializes to string")] + #[test_case("2026-02-01-rc"; "preview date req serializes to string")] fn serializing(requirement: &str) { let actual = serde_json::to_string( - &ResourceVersionReq::new(requirement) + &ResourceVersionReq::parse(requirement).unwrap() ).expect("serialization should never fail"); let expected = format!(r#""{requirement}""#); @@ -244,14 +254,17 @@ mod serde { 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")] + #[test_case(json!("^1.2.3") => matches Ok(_); "valid req string value succeeds")] + #[test_case(json!("2026-02-01") => matches Ok(_); "valid stable date version string value succeeds")] + #[test_case(json!("2026-02-01-rc") => matches Ok(_); "valid preview date version string value succeeds")] + #[test_case(json!("2026-02-29") => matches Err(_); "invalid date version string value fails")] + #[test_case(json!("1.*.3") => matches Err(_); "invalid req string value fails")] + #[test_case(json!(true) => matches Err(_); "boolean value fails")] + #[test_case(json!(1) => matches Err(_); "integer value fails")] + #[test_case(json!(1.2) => matches Err(_); "float value fails")] + #[test_case(json!({"req": "^1.2.3"}) => matches Err(_); "object value fails")] + #[test_case(json!(["^1.2.3"]) => matches Err(_); "array value fails")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value fails")] fn deserializing(value: Value) -> Result { serde_json::from_value::(value) } @@ -277,26 +290,26 @@ mod traits { 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")] + #[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"; "stable date req")] + #[test_case("2020-02-01-rc", "2020-02-01-rc"; "preview date req")] fn format(requirement: &str, expected: &str) { pretty_assertions::assert_eq!( - format!("req: '{}'", ResourceVersionReq::new(requirement)), + format!("req: '{}'", ResourceVersionReq::parse(requirement).unwrap()), 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")] + #[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"; "stable date req")] + #[test_case("2020-02-01-rc", "2020-02-01-rc"; "preview date req")] fn to_string(requirement: &str, expected: &str) { pretty_assertions::assert_eq!( - ResourceVersionReq::new(requirement).to_string(), + ResourceVersionReq::parse(requirement).unwrap().to_string(), expected.to_string() ) } @@ -304,49 +317,67 @@ mod traits { #[cfg(test)] mod from { - use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; - use dsc_lib::types::ResourceVersionReq::*; - use test_case::test_case; + use dsc_lib::types::{ + DateVersion, + ResourceVersionReq, + ResourceVersionReq::*, + SemanticVersionReq + }; #[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") - } + _ => panic!("should never fail to convert as Semantic version requirement"), + } + } + + #[test] + fn date_version() { + let date = DateVersion::parse("2026-02-01").unwrap(); + match ResourceVersionReq::from(date.clone()) { + Date(req) => pretty_assertions::assert_eq!(req, date), + _ => panic!("should never fail to convert as date 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 try_from { + use dsc_lib::types::{ResourceVersionReq, ResourceVersionReqError}; + use test_case::test_case; + + #[test_case("^1.2.3" => matches Ok(_); "single comparator semantic req is valid")] + #[test_case("^1.2, <1.5" => matches Ok(_); "multi comparator semantic req is valid")] + #[test_case("2020-02-01" => matches Ok(_); "stable date req is valid")] + #[test_case("2020-02-01-rc" => matches Ok(_); "preview date req is valid")] + #[test_case("arbitrary" => matches Err(_); "arbitrary string is invalid")] + fn string(requirement_string: &str) -> Result { + ResourceVersionReq::try_from(requirement_string.to_string()) + } + + #[test_case("^1.2.3" => matches Ok(_); "single comparator semantic req is valid")] + #[test_case("^1.2, <1.5" => matches Ok(_); "multi comparator semantic req is valid")] + #[test_case("2020-02-01" => matches Ok(_); "stable date req is valid")] + #[test_case("2020-02-01-rc" => matches Ok(_); "preview date req is valid")] + #[test_case("arbitrary" => matches Err(_); "arbitrary string is invalid")] + fn str(string_slice: &str) -> Result { + ResourceVersionReq::try_from(string_slice) + } } #[cfg(test)] mod from_str { - use dsc_lib::types::ResourceVersionReq; - use dsc_lib::types::ResourceVersionReq::*; + use dsc_lib::types::{ResourceVersionReq, ResourceVersionReqError}; 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") + #[test_case("^1.2.3" => matches Ok(_); "single comparator semantic req is valid")] + #[test_case("^1.2, <1.5" => matches Ok(_); "multi comparator semantic req is valid")] + #[test_case("2020-02-01" => matches Ok(_); "stable date req is valid")] + #[test_case("2020-02-01-rc" => matches Ok(_); "preview date req is valid")] + #[test_case("arbitrary" => matches Err(_); "arbitrary string is invalid")] + fn parse(input: &str) -> Result { + input.parse() } } @@ -357,10 +388,10 @@ mod traits { #[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")] + #[test_case("2020-02-01"; "stable date req")] + #[test_case("2020-02-01-rc"; "preview date req")] fn string(requirement_string: &str) { - let actual: String = ResourceVersionReq::new(requirement_string).into(); + let actual: String = ResourceVersionReq::parse(requirement_string).unwrap().into(); let expected = requirement_string.to_string(); pretty_assertions::assert_eq!(actual, expected) @@ -369,47 +400,66 @@ mod traits { #[cfg(test)] mod try_into { - use dsc_lib::{dscerror::DscError, types::{ResourceVersionReq, SemanticVersionReq}}; + use dsc_lib::types::{ + DateVersion, + ResourceVersionReq, + ResourceVersionReqError, + 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)) + #[test_case("2020-02-01" => matches Err(_); "stable date req fails")] + #[test_case("2020-02-01-rc" => matches Err(_); "preview date req fails")] + fn semantic_version_req(requirement: &str) -> Result { + TryInto::::try_into(ResourceVersionReq::parse(requirement).unwrap()) + } + + #[test_case("^1.2.3" => matches Err(_); "single comparator semantic req fails")] + #[test_case("^1.2, <1.5" => matches Err(_); "multi comparator semantic req fails")] + #[test_case("2020-02-01" => matches Ok(_); "stable date req converts")] + #[test_case("2020-02-01-rc" => matches Ok(_); "preview date req converts")] + fn date_version(requirement: &str) -> Result { + TryInto::::try_into(ResourceVersionReq::parse(requirement).unwrap()) } } #[cfg(test)] mod partial_eq { - use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use dsc_lib::types::{DateVersion, ResourceVersionReq, SemanticVersionReq}; use test_case::test_case; - #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2", "^1.2.*", 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")] + #[test_case("2026-02-01", "2026-02-01", true; "identical stable date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-rc", true; "identical preview date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-RC", false; "differently cased preview date 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)) + pretty_assertions::assert_eq!( + ResourceVersionReq::parse(lhs).unwrap(), + ResourceVersionReq::parse(rhs).unwrap() + ) } else { - pretty_assertions::assert_ne!(ResourceVersionReq::new(lhs), ResourceVersionReq::new(rhs)) + pretty_assertions::assert_ne!( + ResourceVersionReq::parse(lhs).unwrap(), + ResourceVersionReq::parse(rhs).unwrap() + ) } } - #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2", "^1.2.*", 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("2026-02-01", "^1.2.3", false; "date 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 req = ResourceVersionReq::parse(resource_version_req_string).unwrap(); let semantic = SemanticVersionReq::parse(semantic_version_req_string).unwrap(); // Test equivalency bidirectionally @@ -426,13 +476,40 @@ mod traits { ); } - #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("2026-02-01", "2026-02-01", true; "identical stable date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-rc", true; "identical preview date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-RC", false; "differently cased preview date reqs")] + #[test_case("^1.2.3", "2026-02-01", false; "date req and semantic req")] + fn date_version( + resource_version_req_string: &str, + date_version_string: &str, + should_be_equal: bool, + ) { + let req = ResourceVersionReq::parse(resource_version_req_string).unwrap(); + let date = DateVersion::parse(date_version_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == date, + should_be_equal, + "expected comparison of {req} and {date} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + date == req, + should_be_equal, + "expected comparison of {date} and {req} to be #{should_be_equal}" + ); + } + + #[test_case("^1.2", "^1.2.*", 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")] + #[test_case("2026-02-01", "2026-02-01", true; "identical stable date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-rc", true; "identical preview date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-RC", false; "differently cased preview date reqs")] + #[test_case("^1.2.3", "arbitrary", false; "semantic req and arbitrary string")] + #[test_case("2026-02-01", "arbitrary", false; "date req and arbitrary string")] fn str(resource_version_req_string: &str, string_slice: &str, should_be_equal: bool) { let req: ResourceVersionReq = resource_version_req_string.parse().unwrap(); @@ -450,13 +527,14 @@ mod traits { ); } - #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2", "^1.2.*", 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")] + #[test_case("2026-02-01", "2026-02-01", true; "identical stable date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-rc", true; "identical preview date reqs")] + #[test_case("2026-02-01-rc", "2026-02-01-RC", false; "differently cased preview date reqs")] + #[test_case("^1.2.3", "arbitrary", false; "semantic req and arbitrary string")] + #[test_case("2026-02-01", "arbitrary", false; "date req and arbitrary string")] 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(); diff --git a/lib/dsc-lib/tests/integration/types/semantic_version.rs b/lib/dsc-lib/tests/integration/types/semantic_version.rs index d4b42cd80..bdb6e105b 100644 --- a/lib/dsc-lib/tests/integration/types/semantic_version.rs +++ b/lib/dsc-lib/tests/integration/types/semantic_version.rs @@ -3,7 +3,7 @@ #[cfg(test)] mod methods { - use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use dsc_lib::types::{SemanticVersion, SemanticVersionError}; use test_case::test_case; #[test] @@ -20,7 +20,7 @@ mod methods { #[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 { + fn parse(value: &str) -> Result { SemanticVersion::parse(value) } } @@ -51,7 +51,7 @@ mod schema { let schema = &*SCHEMA; let value = schema .get_keyword_as_str(keyword) - .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + .expect(&format!("expected keyword '{keyword}' to be defined")); assert!( !(&*KEYWORD_PATTERN).is_match(value), @@ -174,7 +174,7 @@ mod traits { mod from_str { use std::str::FromStr; - use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use dsc_lib::types::{SemanticVersion, SemanticVersionError}; use test_case::test_case; #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] @@ -184,7 +184,7 @@ mod traits { #[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 { + fn from_str(text: &str) -> Result { SemanticVersion::from_str(text) } @@ -195,14 +195,14 @@ mod traits { #[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 { + fn parse(text: &str) -> Result { text.parse() } } #[cfg(test)] mod try_from { - use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use dsc_lib::types::{SemanticVersion, SemanticVersionError}; use test_case::test_case; #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] @@ -212,7 +212,7 @@ mod traits { #[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 { + fn string(text: &str) -> Result { SemanticVersion::try_from(text.to_string()) } @@ -223,7 +223,7 @@ mod traits { #[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 { + fn str(text: &str) -> Result { SemanticVersion::try_from(text) } } @@ -504,6 +504,7 @@ mod traits { } } + #[cfg(test)] mod ord { use dsc_lib::types::SemanticVersion; diff --git a/lib/dsc-lib/tests/integration/types/semantic_version_req.rs b/lib/dsc-lib/tests/integration/types/semantic_version_req.rs index de4167e27..4a3846f4a 100644 --- a/lib/dsc-lib/tests/integration/types/semantic_version_req.rs +++ b/lib/dsc-lib/tests/integration/types/semantic_version_req.rs @@ -5,37 +5,42 @@ mod methods { #[cfg(test)] mod parse { - use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use dsc_lib::types::{SemanticVersionReq, SemanticVersionReqError}; 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 { + #[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 { + #[test_case("^*" => matches Err(_); "wildcard alone is invalid")] + #[test_case("^*.*" => matches Err(_); "wildcard.wildcard is invalid")] + #[test_case("^*.*.*" => matches Err(_); "wildcard.wildcard.wildcard is invalid")] + #[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")] + #[test_case("^1.x" => matches Err(_); "lowercase x wildcard is invalid")] + #[test_case("^1.X" => matches Err(_); "uppercase X 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 Err(_); "implicit operator is invalid")] #[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")] @@ -43,19 +48,19 @@ mod methods { #[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 { + #[test_case("== 1.2.3" => matches Err(_); "unknown 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.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 { + #[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) } @@ -65,7 +70,7 @@ mod methods { #[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 { + fn spacing(requirement_string: &str) -> Result { SemanticVersionReq::parse(requirement_string) } } @@ -87,18 +92,6 @@ mod methods { } } - #[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")] @@ -199,7 +192,7 @@ mod methods { #[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.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); @@ -235,16 +228,6 @@ mod methods { 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")] @@ -269,12 +252,12 @@ mod patterns { 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"; "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")] @@ -282,8 +265,8 @@ mod patterns { #[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")] + #[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(); @@ -333,30 +316,30 @@ mod schema { ); } - #[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; "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") => false; "implicit operator is invalid")] #[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,^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!("^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")] @@ -388,32 +371,25 @@ mod serde { pretty_assertions::assert_eq!(actual, expected); } - #[test_case(json!("1.2.3") => matches Ok(_); "valid req string value succeeds")] + #[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")] + #[test_case(json!(true) => matches Err(_); "boolean value fails")] + #[test_case(json!(1) => matches Err(_); "integer value fails")] + #[test_case(json!(1.2) => matches Err(_); "float value fails")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value fails")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value fails")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value fails")] 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.*", false; "major.minor.wildcard version with explicit operator does not round trip")] #[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")] @@ -422,7 +398,6 @@ mod serde { #[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(); @@ -462,9 +437,9 @@ mod traits { 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")] + #[test_case("^1.2", "^1.2"; "req with single comparator")] + #[test_case("^1.2, < 1.4", "^1.2, <1.4"; "req with multiple comparators")] + #[test_case("=1.*", "=1"; "req with a wildcard")] fn format(requirement: &str, expected: &str) { pretty_assertions::assert_eq!( format!("req: '{}'", SemanticVersionReq::parse(requirement).unwrap()), @@ -472,9 +447,9 @@ mod traits { ) } - #[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")] + #[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(), @@ -496,33 +471,33 @@ mod traits { #[cfg(test)] mod from_str { - use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use dsc_lib::types::{SemanticVersionReq, SemanticVersionReqError}; 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 Ok(_); "valid requirement returns ok")] #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] - fn parse(input: &str) -> Result { + fn parse(input: &str) -> Result { input.parse() } } #[cfg(test)] mod try_from { - use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use dsc_lib::types::{SemanticVersionReq, SemanticVersionReqError}; 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 Ok(_); "valid requirement returns ok")] #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] - fn string(input: &str) -> Result { + 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 Ok(_); "valid requirement returns ok")] #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] - fn string_slice(input: &str) -> Result { + fn string_slice(input: &str) -> Result { SemanticVersionReq::try_from(input) } } @@ -537,12 +512,12 @@ mod traits { #[test] fn semver_version_req() { - let _: semver::VersionReq = SemanticVersionReq::parse("1.2").unwrap().into(); + let _: semver::VersionReq = SemanticVersionReq::parse("^1.2").unwrap().into(); } #[test] fn string() { - let _: String = SemanticVersionReq::parse("1.2").unwrap().into(); + let _: String = SemanticVersionReq::parse("^1.2").unwrap().into(); } } @@ -551,11 +526,11 @@ mod traits { 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", 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", "^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!( @@ -570,11 +545,11 @@ mod traits { } } - #[test_case("1.2", "1.2", true; "identical requirements")] - #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[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", "^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, @@ -597,12 +572,12 @@ mod traits { ); } - #[test_case("1.2", "1.2", true; "identical requirements")] - #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[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")] + #[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(); @@ -621,12 +596,12 @@ mod traits { ); } - #[test_case("1.2", "1.2", true; "identical requirements")] - #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[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")] + #[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();