1#[derive(Debug, Clone)]
12pub struct ParsedRef {
13 pub original_ref: String,
15 pub normalized_for_semver: String,
19 pub previous_ref: String,
24 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
48pub 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
62pub 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 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 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 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 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 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}