Skip to main content

flake_edit/forge/
version.rs

1//! Ref-string normalisation shared by the forge tag-listing path
2//! and the update-strategy classifier.
3//!
4//! Tag schemes in the wild are inconsistent (`v1.2.3`,
5//! `refs/tags/v1.2.3`, `release-24.05`, bare `1.0`). [`parse_ref`]
6//! reduces each input to a [`semver::Version`]-parseable string plus
7//! the metadata needed to put any stripped prefix back when the
8//! update writes the new ref into `flake.nix`.
9
10/// Outcome of normalising a ref string for semver comparison.
11#[derive(Debug, Clone)]
12pub struct ParsedRef {
13    /// The input passed to [`parse_ref`], verbatim.
14    pub original_ref: String,
15    /// Ref reduced to a shape [`semver::Version::parse`] accepts
16    /// (or an unparseable residue when the input was not a semver
17    /// tag).
18    pub normalized_for_semver: String,
19    /// Form of the input after one round of stripping. Surfaces in
20    /// `Updater`'s "from X to Y" status output, where the user
21    /// expects the displayed previous ref to match what they
22    /// originally pinned rather than the fully-normalised core.
23    pub previous_ref: String,
24    /// `true` when the input carried a `refs/tags/` prefix (or the
25    /// caller forced it via `default_refs_tags_prefix`). Callers
26    /// reattach the prefix to the newly-resolved tag to preserve
27    /// the user's existing ref style.
28    pub has_refs_tags_prefix: bool,
29}
30
31pub(crate) fn normalize_semver(tag: &str) -> String {
32    let (core, suffix) = tag
33        .find(|c| ['-', '+'].contains(&c))
34        .map(|idx| (&tag[..idx], &tag[idx..]))
35        .unwrap_or((tag, ""));
36    if core.is_empty() {
37        return tag.to_string();
38    }
39    let dot_count = core.matches('.').count();
40    let normalized_core = match dot_count {
41        0 => format!("{core}.0.0"),
42        1 => format!("{core}.0"),
43        _ => core.to_string(),
44    };
45    format!("{normalized_core}{suffix}")
46}
47
48/// Returns `true` when `proposed` parses as a strictly lower
49/// version than `current` under semver precedence.
50pub fn is_downgrade(current: &str, proposed: &str) -> bool {
51    let cur = parse_ref(current, false);
52    let prop = parse_ref(proposed, false);
53    match (
54        semver::Version::parse(&cur.normalized_for_semver),
55        semver::Version::parse(&prop.normalized_for_semver),
56    ) {
57        (Ok(c), Ok(p)) => p.cmp_precedence(&c) == std::cmp::Ordering::Less,
58        _ => false,
59    }
60}
61
62/// Normalise `raw` into a [`ParsedRef`] for semver comparison.
63///
64/// Strips `refs/tags/` and then any non-digit scheme prefix that
65/// precedes the first digit, so `v`, `hl`, `release-`, and
66/// `nix-darwin-` are all reduced to their numeric core in one pass.
67/// The remainder is fed through [`normalize_semver`], which pads
68/// short forms like `1.0` out to three segments.
69pub fn parse_ref(raw: &str, default_refs_tags_prefix: bool) -> ParsedRef {
70    let mut maybe_version = raw.to_string();
71    let mut previous_ref = String::new();
72    let mut has_refs_tags_prefix = default_refs_tags_prefix;
73
74    if let Some(stripped) = maybe_version.strip_prefix("refs/tags/") {
75        has_refs_tags_prefix = true;
76        previous_ref = maybe_version.clone();
77        maybe_version = stripped.to_string();
78    }
79
80    if let Some(digit_idx) = maybe_version.find(|c: char| c.is_ascii_digit())
81        && digit_idx > 0
82    {
83        previous_ref = maybe_version.clone();
84        maybe_version = maybe_version[digit_idx..].to_string();
85    }
86
87    if previous_ref.is_empty() {
88        previous_ref = maybe_version.clone();
89    }
90
91    ParsedRef {
92        original_ref: raw.to_string(),
93        normalized_for_semver: normalize_semver(&maybe_version),
94        previous_ref,
95        has_refs_tags_prefix,
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn check(
104        raw: &str,
105        default_refs_tags_prefix: bool,
106        expected_normalized: &str,
107        expected_previous: &str,
108        expected_has_prefix: bool,
109    ) {
110        let parsed = parse_ref(raw, default_refs_tags_prefix);
111        assert_eq!(parsed.original_ref, raw, "original_ref for {raw:?}");
112        assert_eq!(
113            parsed.normalized_for_semver, expected_normalized,
114            "normalized_for_semver for {raw:?}"
115        );
116        assert_eq!(
117            parsed.previous_ref, expected_previous,
118            "previous_ref for {raw:?}"
119        );
120        assert_eq!(
121            parsed.has_refs_tags_prefix, expected_has_prefix,
122            "has_refs_tags_prefix for {raw:?}"
123        );
124    }
125
126    #[test]
127    fn bare_three_segment_passes_through() {
128        check("1.2.3", false, "1.2.3", "1.2.3", false);
129    }
130
131    #[test]
132    fn bare_two_segment_pads_patch() {
133        check("1.0", false, "1.0.0", "1.0", false);
134    }
135
136    #[test]
137    fn v_prefix_normalizes_to_semver() {
138        check("v1.2.3", false, "1.2.3", "v1.2.3", false);
139    }
140
141    #[test]
142    fn v_prefix_with_prerelease_keeps_semver_core() {
143        check("v1.2.3-rc1", false, "1.2.3-rc1", "v1.2.3-rc1", false);
144    }
145
146    #[test]
147    fn bare_prerelease_long_passes_through_as_semver() {
148        check(
149            "1.0.0-alpha.1",
150            false,
151            "1.0.0-alpha.1",
152            "1.0.0-alpha.1",
153            false,
154        );
155    }
156
157    #[test]
158    fn bare_prerelease_short_passes_through_as_semver() {
159        check("2.0.0-beta", false, "2.0.0-beta", "2.0.0-beta", false);
160    }
161
162    #[test]
163    fn hl_prefixed_tag_keeps_version_core_as_prerelease() {
164        check("hl0.47.0-1", false, "0.47.0-1", "hl0.47.0-1", false);
165    }
166
167    #[test]
168    fn plus_metadata_passes_through() {
169        check("1.2.3+gitea", false, "1.2.3+gitea", "1.2.3+gitea", false);
170    }
171
172    #[test]
173    fn plus_metadata_dotted_passes_through() {
174        check("1.2.3+meta.1", false, "1.2.3+meta.1", "1.2.3+meta.1", false);
175    }
176
177    #[test]
178    fn release_channel_strips_first_dash() {
179        check("release-24.05", false, "24.05.0", "release-24.05", false);
180    }
181
182    #[test]
183    fn nix_darwin_channel_strips_full_prefix() {
184        // `05` has a leading zero, so `Version::parse` rejects this
185        // core. That rejection is what stops the `forge::api`
186        // cheap-path predicate from treating channel branches as
187        // semver tags.
188        check(
189            "nix-darwin-24.05",
190            false,
191            "24.05.0",
192            "nix-darwin-24.05",
193            false,
194        );
195    }
196
197    #[test]
198    fn refs_tags_v_prefix_records_prefix_and_strips_v() {
199        check("refs/tags/v1.0.0", false, "1.0.0", "v1.0.0", true);
200    }
201
202    #[test]
203    fn refs_tags_bare_keeps_full_previous_ref() {
204        check("refs/tags/1.2.3", false, "1.2.3", "refs/tags/1.2.3", true);
205    }
206
207    #[test]
208    fn refs_tags_v_prerelease_keeps_semver_core() {
209        check(
210            "refs/tags/v1.2.3-rc1",
211            false,
212            "1.2.3-rc1",
213            "v1.2.3-rc1",
214            true,
215        );
216    }
217
218    #[test]
219    fn iso_date_pads_year_into_semver_with_date_prerelease() {
220        // Inputs that start with a digit skip the prefix strip and
221        // the year becomes the major, so semver ordering still
222        // picks the most recent date out of a date-shaped tag list.
223        check("2024-05-01", false, "2024.0.0-05-01", "2024-05-01", false);
224    }
225
226    #[test]
227    fn empty_input_returns_empty_normalized() {
228        check("", false, "", "", false);
229    }
230
231    #[test]
232    fn lone_v_dash_has_no_digit_to_anchor_strip() {
233        // Pathological inputs are intentionally normalised into a
234        // shape `Version::parse` rejects, so they get dropped
235        // downstream rather than masquerading as a valid version.
236        check("v-", false, "v.0.0-", "v-", false);
237    }
238
239    #[test]
240    fn default_refs_tags_prefix_persists_without_refs_tags_string() {
241        check("1.2.3", true, "1.2.3", "1.2.3", true);
242    }
243
244    #[test]
245    fn is_downgrade_flags_lower_hl_prefixed_proposal() {
246        assert!(is_downgrade("hl0.47.0-1", "hl0.33.0-1"));
247    }
248
249    #[test]
250    fn is_downgrade_allows_strictly_greater_proposal() {
251        assert!(!is_downgrade("hl0.33.0-1", "hl0.47.0-1"));
252        assert!(!is_downgrade("v1.0.0", "v2.0.0"));
253    }
254
255    #[test]
256    fn is_downgrade_allows_equal_versions() {
257        // Equal versions are not a downgrade. The existing "already
258        // on the latest" path handles that case; the guard must not
259        // pre-empt it.
260        assert!(!is_downgrade("v1.2.3", "v1.2.3"));
261        assert!(!is_downgrade("1.0.0", "v1.0.0"));
262    }
263
264    #[test]
265    fn is_downgrade_returns_false_when_either_side_unparseable() {
266        // Non-semver pins (commit hash, branch name) leave the
267        // ordering question undefined; the guard must defer to the
268        // existing flow rather than silently dropping the update.
269        assert!(!is_downgrade("not-a-version", "1.2.3"));
270        assert!(!is_downgrade("1.2.3", "not-a-version"));
271        assert!(!is_downgrade("", "1.2.3"));
272    }
273}