diff --git a/.changepacks/changepack_log_GX545CiPssGap7F5Djjsj.json b/.changepacks/changepack_log_GX545CiPssGap7F5Djjsj.json new file mode 100644 index 0000000..64d7f90 --- /dev/null +++ b/.changepacks/changepack_log_GX545CiPssGap7F5Djjsj.json @@ -0,0 +1 @@ +{"changes":{"bridge/node/package.json":"Patch","crates/core/Cargo.toml":"Patch","crates/rust/Cargo.toml":"Patch","bridge/python/pyproject.toml":"Patch","crates/node/Cargo.toml":"Patch","crates/cli/Cargo.toml":"Patch","crates/python/Cargo.toml":"Patch"},"note":"Impl prerelease issue","date":"2026-04-17T07:29:42.535002100Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a726321..34eae31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ dependencies = [ "miette", "owo-colors", "rustls", + "semver", "serde_json", "tempfile", "tokio", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index bad6069..edef850 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -30,6 +30,7 @@ tracing.workspace = true tracing-subscriber.workspace = true futures.workspace = true owo-colors.workspace = true +semver = "1.0" [dev-dependencies] wiremock.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 097160e..40a445d 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -339,6 +339,28 @@ fn compute_updates( .current_req .trim_start_matches(|c: char| !c.is_ascii_digit()); + // Safety net: never suggest a downgrade. When both current and selected + // can be parsed as semver, skip this dependency if selected <= current. + // This guards against the registry-filtering path returning an older + // stable when the user is already on a higher prerelease (e.g. + // `sea-orm 2.0.0-rc.37` -> `1.1.20` should NOT be reported). + // + // For ranges like `^1` / `0.6` where we cannot fully parse as semver, + // we fall through to the string-based precision comparison below. + if let (Ok(cur_ver), Ok(sel_ver)) = ( + semver::Version::parse(current_bare), + semver::Version::parse(selected), + ) && sel_ver <= cur_ver + { + trace!( + package = %dep.name, + current = %dep.current_req, + selected = %selected, + "skipping: selected version is not newer than current" + ); + continue; + } + // Preserve precision: if the user wrote "0.6" (2 segments), truncate the // resolved version to 2 segments before comparing. This respects the user's // intent to pin only at that granularity. @@ -894,6 +916,109 @@ mod tests { assert_eq!(updates[0].to, "0.25.11"); } + #[test] + fn test_compute_updates_blocks_downgrade_from_prerelease_to_stable() { + // Regression test for the sea-orm 2.0.0-rc.37 -> 1.1.20 bug. + // When the current version is a higher prerelease (2.0.0-rc.37) and + // the registry filtering returns an older stable (1.1.20), the + // safety net MUST skip this update instead of suggesting a downgrade. + let deps = vec![DependencySpec { + name: "sea-orm".to_owned(), + current_req: "2.0.0-rc.37".to_owned(), + section: DependencySection::Dependencies, + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some("1.1.20".to_owned()), + selected: Some("1.1.20".to_owned()), + }), + )]; + let updates = compute_updates(&deps, &resolved); + assert!( + updates.is_empty(), + "must not suggest downgrade from 2.0.0-rc.37 to 1.1.20, got: {updates:?}" + ); + } + + #[test] + fn test_compute_updates_blocks_downgrade_same_major() { + // Current is newer stable; registry returned something older. Skip. + let deps = vec![DependencySpec { + name: "pkg".to_owned(), + current_req: "2.5.0".to_owned(), + section: DependencySection::Dependencies, + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some("2.4.0".to_owned()), + selected: Some("2.4.0".to_owned()), + }), + )]; + let updates = compute_updates(&deps, &resolved); + assert!(updates.is_empty(), "must not downgrade 2.5.0 -> 2.4.0"); + } + + #[test] + fn test_compute_updates_allows_prerelease_to_prerelease_upgrade() { + // Current: 2.0.0-rc.37, Selected: 2.0.0-rc.40 → valid upgrade. + let deps = vec![DependencySpec { + name: "sea-orm".to_owned(), + current_req: "2.0.0-rc.37".to_owned(), + section: DependencySection::Dependencies, + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some("2.0.0-rc.40".to_owned()), + selected: Some("2.0.0-rc.40".to_owned()), + }), + )]; + let updates = compute_updates(&deps, &resolved); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].to, "2.0.0-rc.40"); + } + + #[test] + fn test_compute_updates_allows_prerelease_to_stable_upgrade() { + // Current: 2.0.0-rc.37 (prerelease), Selected: 2.0.0 (stable) → semver: stable > prerelease of same version. + let deps = vec![DependencySpec { + name: "sea-orm".to_owned(), + current_req: "2.0.0-rc.37".to_owned(), + section: DependencySection::Dependencies, + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some("2.0.0".to_owned()), + selected: Some("2.0.0".to_owned()), + }), + )]; + let updates = compute_updates(&deps, &resolved); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].to, "2.0.0"); + } + + #[test] + fn test_compute_updates_equal_semver_skipped() { + // Exact same version: must skip (not a "downgrade", but not an upgrade either). + let deps = vec![DependencySpec { + name: "pkg".to_owned(), + current_req: "1.2.3".to_owned(), + section: DependencySection::Dependencies, + }]; + let resolved = vec![( + 0, + Ok(ResolvedVersion { + latest: Some("1.2.3".to_owned()), + selected: Some("1.2.3".to_owned()), + }), + )]; + let updates = compute_updates(&deps, &resolved); + assert!(updates.is_empty()); + } + #[test] fn test_compute_updates_preserves_section() { let deps = vec![DependencySpec { diff --git a/crates/node/src/patcher.rs b/crates/node/src/patcher.rs index 81ba486..af07ec4 100644 --- a/crates/node/src/patcher.rs +++ b/crates/node/src/patcher.rs @@ -153,7 +153,7 @@ impl JsonPatcher { // Sort descending by start position let mut sorted: Vec<&Patch> = patches.iter().collect(); - sorted.sort_by(|a, b| b.start.cmp(&a.start)); + sorted.sort_by_key(|p| std::cmp::Reverse(p.start)); // Check for overlapping patches for window in sorted.windows(2) { diff --git a/crates/node/src/registry.rs b/crates/node/src/registry.rs index cf44804..c734c96 100644 --- a/crates/node/src/registry.rs +++ b/crates/node/src/registry.rs @@ -136,8 +136,16 @@ impl NpmRegistry { let latest = info.dist_tags.as_ref().and_then(|dt| dt.latest.clone()); - // Fast path: when target is Latest, skip expensive version parsing/sorting - let selected = if target == TargetLevel::Latest { + // Detect if the user's current requirement is a prerelease. When it is, + // we cannot use the dist-tags.latest fast path because the user may be + // ahead of the stable `latest` tag (e.g. `2.0.0-rc.37` while + // dist-tags.latest points at `1.1.20`), and we must consider the full + // sorted version list to preserve the "prerelease tail" policy. + let current_is_prerelease = + parse_base_version(&dep.current_req).is_some_and(|v| !v.pre_release.is_empty()); + + // Fast path: Latest + current is stable → return dist-tags.latest directly. + let selected = if target == TargetLevel::Latest && !current_is_prerelease { trace!( package = %dep.name, latest = ?latest, @@ -150,6 +158,7 @@ impl NpmRegistry { package = %dep.name, version_count = all_versions.len(), latest = ?latest, + current_is_prerelease, "fetched version list" ); select_version(&dep.current_req, latest.as_ref(), &all_versions, target) @@ -245,16 +254,45 @@ fn select_version( let current_version = parse_base_version(current_req_str); + // "Prerelease tail" policy: when the user is already on a prerelease + // (e.g. `4.0.0-beta.1`), `Latest` may pick prereleases of the SAME + // major.minor.patch train so they can move to `4.0.0-beta.2` or + // `4.0.0` stable. Unrelated prereleases are still excluded. + let current_is_prerelease = current_version + .as_ref() + .is_some_and(|v| !v.pre_release.is_empty()); + + let accept_pre_aware = |v: &&node_semver::Version| -> bool { + if v.pre_release.is_empty() { + return true; + } + if !current_is_prerelease { + return false; + } + let cur = current_version.as_ref().expect("checked above"); + v.major == cur.major && v.minor == cur.minor && v.patch == cur.patch + }; + match target { - TargetLevel::Latest => latest.cloned(), + TargetLevel::Latest => { + // When the user is on a prerelease, we can't trust dist-tags.latest + // (may be an older stable). Pick the highest eligible candidate + // from the full sorted list using the prerelease-tail policy. + if current_is_prerelease { + all_versions + .iter() + .rev() + .find(accept_pre_aware) + .map(std::string::ToString::to_string) + } else { + latest.cloned() + } + } + // Greatest: highest version number, INCLUDING prereleases (matches README). + // Newest: MVP alias for Greatest. npm response here does not expose + // publish times, so true publish-date ordering is future work. TargetLevel::Greatest | TargetLevel::Newest => { - // Highest non-prerelease version. - // (Newest would ideally use publish timestamps, but for MVP same as greatest.) - all_versions - .iter() - .rev() - .find(|v| v.pre_release.is_empty()) - .map(std::string::ToString::to_string) + all_versions.last().map(std::string::ToString::to_string) } TargetLevel::Minor => { let Some(current) = ¤t_version else { @@ -263,7 +301,7 @@ fn select_version( all_versions .iter() .rev() - .find(|v| v.major == current.major && v.pre_release.is_empty()) + .find(|v| v.major == current.major && accept_pre_aware(v)) .map(std::string::ToString::to_string) } TargetLevel::Patch => { @@ -274,7 +312,7 @@ fn select_version( .iter() .rev() .find(|v| { - v.major == current.major && v.minor == current.minor && v.pre_release.is_empty() + v.major == current.major && v.minor == current.minor && accept_pre_aware(v) }) .map(std::string::ToString::to_string) } @@ -395,13 +433,109 @@ mod tests { } #[test] - fn test_select_version_skips_prerelease() { + fn test_select_version_greatest_includes_prerelease() { + // Greatest: README says "Highest version number, INCLUDING prereleases". + // Previously this filtered prereleases out (bug). Now it must include them. let latest = "18.2.0".to_owned(); let versions = make_versions(&["18.0.0", "18.2.0", "19.0.0-beta.1"]); let result = select_version("^18.0.0", Some(&latest), &versions, TargetLevel::Greatest); + assert_eq!(result, Some("19.0.0-beta.1".to_owned())); + } + + #[test] + fn test_select_newest_includes_prerelease() { + // Newest is MVP-aliased to Greatest. Must include prereleases. + let latest = "18.2.0".to_owned(); + let versions = make_versions(&["18.0.0", "18.2.0", "19.0.0-beta.1"]); + let result = select_version("^18.0.0", Some(&latest), &versions, TargetLevel::Newest); + assert_eq!(result, Some("19.0.0-beta.1".to_owned())); + } + + #[test] + fn test_latest_stable_current_excludes_prerelease() { + // When current is stable, Latest returns dist-tags.latest (which is + // stable by npm convention). Prereleases in the version list are + // irrelevant here. + let latest = "18.2.0".to_owned(); + let versions = make_versions(&["18.0.0", "18.2.0", "19.0.0-beta.1"]); + let result = select_version("^18.0.0", Some(&latest), &versions, TargetLevel::Latest); assert_eq!(result, Some("18.2.0".to_owned())); } + #[test] + fn test_latest_prerelease_tail_same_train() { + // Current on prerelease: Latest allows higher prereleases of the + // same major.minor.patch train, bypassing dist-tags.latest. + let latest = "3.5.0".to_owned(); // dist-tags.latest is older stable + let versions = make_versions(&["3.5.0", "4.0.0-beta.1", "4.0.0-beta.3"]); + let result = select_version( + "4.0.0-beta.1", + Some(&latest), + &versions, + TargetLevel::Latest, + ); + assert_eq!(result, Some("4.0.0-beta.3".to_owned())); + } + + #[test] + fn test_latest_prerelease_tail_jumps_to_stable() { + // Current prerelease → stable of same train is preferred. + let latest = "3.5.0".to_owned(); + let versions = make_versions(&["3.5.0", "4.0.0-beta.3", "4.0.0"]); + let result = select_version( + "4.0.0-beta.1", + Some(&latest), + &versions, + TargetLevel::Latest, + ); + assert_eq!(result, Some("4.0.0".to_owned())); + } + + #[test] + fn test_latest_prerelease_tail_ignores_unrelated_prereleases() { + // Current: 4.0.0-beta.1. Unrelated 5.0.0-alpha.1 must NOT be selected. + let latest = "3.5.0".to_owned(); + let versions = make_versions(&["3.5.0", "4.0.0-beta.1", "5.0.0-alpha.1"]); + let result = select_version( + "4.0.0-beta.1", + Some(&latest), + &versions, + TargetLevel::Latest, + ); + assert_ne!(result, Some("5.0.0-alpha.1".to_owned())); + } + + #[test] + fn test_latest_prerelease_current_picks_higher_stable_major() { + // Current: 4.0.0-beta.1. Registry has higher-major stable 5.0.0. + // Expected: 5.0.0 — stable is ALWAYS preferred over staying on a + // prerelease, regardless of "train" membership. + let latest = "5.0.0".to_owned(); + let versions = make_versions(&["3.5.0", "4.0.0-beta.1", "5.0.0"]); + let result = select_version( + "4.0.0-beta.1", + Some(&latest), + &versions, + TargetLevel::Latest, + ); + assert_eq!(result, Some("5.0.0".to_owned())); + } + + #[test] + fn test_latest_prerelease_current_stable_wins_over_unrelated_prerelease() { + // 5.0.0-alpha.1 must be skipped (unrelated prerelease), but 5.0.0 + // stable MUST be picked. + let latest = "5.0.0".to_owned(); + let versions = make_versions(&["3.5.0", "4.0.0-beta.1", "5.0.0-alpha.1", "5.0.0"]); + let result = select_version( + "4.0.0-beta.1", + Some(&latest), + &versions, + TargetLevel::Latest, + ); + assert_eq!(result, Some("5.0.0".to_owned())); + } + #[test] fn test_select_version_empty_versions_falls_back_to_latest() { let latest = "1.0.0".to_owned(); @@ -796,6 +930,28 @@ mod tests { assert_eq!(result, Some("2.0.0".to_owned())); } + #[test] + fn test_select_version_minor_skips_prerelease_when_current_stable() { + // Exercises accept_pre_aware: stable current + prerelease candidate in same + // major → prerelease must be rejected (covers the `!current_is_prerelease` + // early-return branch inside accept_pre_aware). + let latest = "18.2.0".to_owned(); + let versions = make_versions(&["18.0.0", "18.1.0", "18.2.0", "18.3.0-beta.1"]); + let result = select_version("^18.0.0", Some(&latest), &versions, TargetLevel::Minor); + // Stable 18.2.0 wins over 18.3.0-beta.1 because current is stable. + assert_eq!(result, Some("18.2.0".to_owned())); + } + + #[test] + fn test_select_version_patch_skips_prerelease_when_current_stable() { + // Same as above, but on the Patch branch, so the iterator walks a + // prerelease candidate on the same major.minor and must reject it. + let latest = "18.0.5".to_owned(); + let versions = make_versions(&["18.0.0", "18.0.1", "18.0.5", "18.0.6-rc.1"]); + let result = select_version("=18.0.0", Some(&latest), &versions, TargetLevel::Patch); + assert_eq!(result, Some("18.0.5".to_owned())); + } + #[tokio::test] async fn test_resolve_version_non_latest_with_tracing() { use wiremock::matchers::{method, path}; diff --git a/crates/python/src/parser.rs b/crates/python/src/parser.rs index e541d60..6477527 100644 --- a/crates/python/src/parser.rs +++ b/crates/python/src/parser.rs @@ -587,6 +587,21 @@ dependencies = [ assert!(dep.is_none()); } + #[test] + fn test_parse_pep508_wildcard_version_rejected() { + // A PEP 508 spec that carries a wildcard version like `pkg==*` must be + // filtered out at parse time — there's nothing meaningful to update. + let dep = parse_pep508_spec("requests==*", DependencySection::ProjectDependencies); + assert!(dep.is_none()); + } + + #[test] + fn test_parse_pep508_bare_star_rejected() { + // `pkg *` should also be rejected via is_wildcard_req. + let dep = parse_pep508_spec("requests *", DependencySection::ProjectDependencies); + assert!(dep.is_none()); + } + #[test] fn test_invalid_toml_returns_error() { let result = PyProjectManifest::parse("not valid [[[toml"); diff --git a/crates/rust/src/registry.rs b/crates/rust/src/registry.rs index bfe6c59..6deb3f8 100644 --- a/crates/rust/src/registry.rs +++ b/crates/rust/src/registry.rs @@ -212,12 +212,43 @@ fn select_version( let current = parse_base_version(current_req_str); + // "Prerelease tail" policy: if the user is already on a prerelease + // (e.g. `2.0.0-rc.37`), `Latest` should consider prereleases of the + // same major.minor.patch train so they can move to `2.0.0-rc.38` or + // `2.0.0` stable. Unrelated prereleases (e.g. `3.0.0-alpha.1`) are + // still excluded — we only include stables outside the current train. + let current_is_prerelease = current.as_ref().is_some_and(|v| !v.pre.is_empty()); + + // Accept stable, or prereleases of the same M.m.p train as `current`. + let accept_pre_aware = |v: &&semver::Version| -> bool { + if v.pre.is_empty() { + return true; + } + if !current_is_prerelease { + return false; + } + // Safe: current_is_prerelease implies current is Some. + let cur = current.as_ref().expect("checked above"); + v.major == cur.major && v.minor == cur.minor && v.patch == cur.patch + }; + match target { - TargetLevel::Latest | TargetLevel::Greatest | TargetLevel::Newest => all_versions + TargetLevel::Latest => all_versions .iter() .rev() - .find(|v| v.pre.is_empty()) + .find(accept_pre_aware) .map(std::string::ToString::to_string), + // Greatest: highest version number, INCLUDING prereleases (matches + // README). `all_versions` is sorted ascending, so the last one wins. + // + // Newest: MVP alias for Greatest. The crates.io response here does + // not expose `created_at`, so true publish-date ordering is future + // work. This is at least consistent with README ("most recently + // published") for repositories where version order matches publish + // order — the common case. + TargetLevel::Greatest | TargetLevel::Newest => { + all_versions.last().map(std::string::ToString::to_string) + } TargetLevel::Minor => { let Some(cur) = ¤t else { return latest.cloned(); @@ -225,7 +256,7 @@ fn select_version( all_versions .iter() .rev() - .find(|v| v.major == cur.major && v.pre.is_empty()) + .find(|v| v.major == cur.major && accept_pre_aware(v)) .map(std::string::ToString::to_string) } TargetLevel::Patch => { @@ -235,7 +266,7 @@ fn select_version( all_versions .iter() .rev() - .find(|v| v.major == cur.major && v.minor == cur.minor && v.pre.is_empty()) + .find(|v| v.major == cur.major && v.minor == cur.minor && accept_pre_aware(v)) .map(std::string::ToString::to_string) } } @@ -307,6 +338,98 @@ mod tests { assert_eq!(result, Some("1.0.0".to_owned())); } + #[test] + fn test_select_greatest_includes_prerelease() { + // Greatest: README says "Highest version number, INCLUDING prereleases". + // Previously this filtered prereleases out (bug). Now it must include them. + let latest = "1.0.0".to_owned(); + let versions = make_versions(&["1.0.0", "2.0.0-rc.1"]); + let result = select_version("^1.0.0", Some(&latest), &versions, TargetLevel::Greatest); + assert_eq!(result, Some("2.0.0-rc.1".to_owned())); + } + + #[test] + fn test_select_newest_includes_prerelease() { + // Newest is MVP-aliased to Greatest. Must include prereleases. + let latest = "1.0.0".to_owned(); + let versions = make_versions(&["1.0.0", "2.0.0-rc.1"]); + let result = select_version("^1.0.0", Some(&latest), &versions, TargetLevel::Newest); + assert_eq!(result, Some("2.0.0-rc.1".to_owned())); + } + + #[test] + fn test_latest_prerelease_tail_ignores_unrelated_prereleases() { + // Current: 2.0.0-rc.37. 3.0.0-alpha.1 (different train) must NOT be + // picked. Only the same-train prereleases or stables qualify. + let latest = "1.1.20".to_owned(); + let versions = make_versions(&["1.1.20", "2.0.0-rc.37", "3.0.0-alpha.1"]); + let result = select_version("2.0.0-rc.37", Some(&latest), &versions, TargetLevel::Latest); + // 3.0.0-alpha.1 is unrelated prerelease → excluded. + // 2.0.0-rc.37 is current → not newer. + // 1.1.20 stable is older → allowed by select but the compute_updates + // safety net will skip the downgrade. select_version itself just + // picks the highest eligible candidate, which is 2.0.0-rc.37 (self). + // Here we confirm that unrelated prereleases are NOT selected. + assert_ne!(result, Some("3.0.0-alpha.1".to_owned())); + } + + #[test] + fn test_latest_prerelease_current_picks_higher_stable_major() { + // Current: 2.0.0-rc.37. Registry: 1.1.20, 2.0.0-rc.37, 3.0.0 (higher stable major). + // Expected: 3.0.0 — stable is ALWAYS preferred over staying on a prerelease, + // regardless of "train". Only prerelease candidates are train-gated. + let latest = "3.0.0".to_owned(); + let versions = make_versions(&["1.1.20", "2.0.0-rc.37", "3.0.0"]); + let result = select_version("2.0.0-rc.37", Some(&latest), &versions, TargetLevel::Latest); + assert_eq!(result, Some("3.0.0".to_owned())); + } + + #[test] + fn test_latest_prerelease_current_stable_wins_over_unrelated_prerelease() { + // Current: 2.0.0-rc.37. Registry: 1.1.20, 2.0.0-rc.37, 3.0.0-alpha.1, 3.0.0. + // The unrelated prerelease 3.0.0-alpha.1 must be skipped, but the + // stable 3.0.0 (even from a different train) must be picked. + let latest = "3.0.0".to_owned(); + let versions = make_versions(&["1.1.20", "2.0.0-rc.37", "3.0.0-alpha.1", "3.0.0"]); + let result = select_version("2.0.0-rc.37", Some(&latest), &versions, TargetLevel::Latest); + assert_eq!(result, Some("3.0.0".to_owned())); + } + + #[test] + fn test_latest_prerelease_tail_jumps_to_stable() { + // Current: 2.0.0-rc.37. When 2.0.0 stable is available, pick it + // (stable > prerelease of same M.m.p in semver ordering). + let latest = "2.0.0".to_owned(); + let versions = make_versions(&["1.1.20", "2.0.0-rc.37", "2.0.0-rc.40", "2.0.0"]); + let result = select_version("2.0.0-rc.37", Some(&latest), &versions, TargetLevel::Latest); + assert_eq!(result, Some("2.0.0".to_owned())); + } + + #[test] + fn test_latest_sea_orm_regression() { + // End-to-end regression for the sea-orm 2.0.0-rc.37 -> 1.1.20 bug. + // select_version will naturally pick 2.0.0-rc.37 (self) as the only + // eligible candidate in the prerelease train, and compute_updates + // (CLI layer) then skips it as "not newer". + let latest = "1.1.20".to_owned(); + let versions = make_versions(&["1.1.20", "2.0.0-rc.37"]); + let result = select_version("2.0.0-rc.37", Some(&latest), &versions, TargetLevel::Latest); + // NOT 1.1.20 (that would be a downgrade). + assert_ne!(result, Some("1.1.20".to_owned())); + // Self is acceptable; the CLI's safety net filters equal-or-lower. + assert_eq!(result, Some("2.0.0-rc.37".to_owned())); + } + + #[test] + fn test_latest_stable_current_excludes_prerelease() { + // When current is stable, latest must NOT pick prereleases + // (preserves existing behavior for stable users). + let latest = "1.0.0".to_owned(); + let versions = make_versions(&["1.0.0", "2.0.0-rc.1"]); + let result = select_version("1.0.0", Some(&latest), &versions, TargetLevel::Latest); + assert_eq!(result, Some("1.0.0".to_owned())); + } + #[tokio::test] async fn test_resolve_version_latest() { use wiremock::matchers::{method, path};