1#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum TagPin {
17 Exact,
20 Prefix(Vec<u64>),
23}
24
25pub fn strip_v_prefix(s: &str) -> &str {
27 s.strip_prefix('v')
28 .or_else(|| s.strip_prefix('V'))
29 .unwrap_or(s)
30}
31
32pub fn classify_tag_pin(tag: &str) -> Option<TagPin> {
37 let stripped = strip_v_prefix(tag);
38 if semver::Version::parse(stripped).is_ok() {
39 return Some(TagPin::Exact);
40 }
41 let parts: Option<Vec<u64>> = stripped.split('.').map(|s| s.parse::<u64>().ok()).collect();
42 let parts = parts?;
43 if parts.is_empty() || parts.len() >= 3 {
44 return None;
45 }
46 Some(TagPin::Prefix(parts))
47}
48
49pub fn pick_latest_for_pin(tags: &[String], pin: &[u64]) -> Option<String> {
54 let mut best: Option<(semver::Version, &String)> = None;
55 for tag in tags {
56 let stripped = strip_v_prefix(tag);
57 let Ok(ver) = semver::Version::parse(stripped) else {
58 continue;
59 };
60 if !ver.pre.is_empty() {
61 continue;
62 }
63 let comps = [ver.major, ver.minor, ver.patch];
64 if pin.len() > comps.len() {
65 continue;
66 }
67 if pin.iter().zip(comps.iter()).any(|(p, c)| p != c) {
68 continue;
69 }
70 match &best {
71 None => best = Some((ver, tag)),
72 Some((b, _)) if &ver > b => best = Some((ver, tag)),
73 _ => {}
74 }
75 }
76 best.map(|(_, t)| t.clone())
77}
78
79pub fn pick_latest_overall(tags: &[String]) -> Option<String> {
81 let mut best: Option<(semver::Version, &String)> = None;
82 for tag in tags {
83 let stripped = strip_v_prefix(tag);
84 let Ok(ver) = semver::Version::parse(stripped) else {
85 continue;
86 };
87 if !ver.pre.is_empty() {
88 continue;
89 }
90 match &best {
91 None => best = Some((ver, tag)),
92 Some((b, _)) if &ver > b => best = Some((ver, tag)),
93 _ => {}
94 }
95 }
96 best.map(|(_, t)| t.clone())
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn classify_full_semver_is_exact() {
105 assert_eq!(classify_tag_pin("v1.2.3"), Some(TagPin::Exact));
106 assert_eq!(classify_tag_pin("1.2.3"), Some(TagPin::Exact));
107 assert_eq!(classify_tag_pin("v0.1.0"), Some(TagPin::Exact));
108 assert_eq!(classify_tag_pin("1.0.0-rc1"), Some(TagPin::Exact));
109 }
110
111 #[test]
112 fn classify_partial_is_prefix() {
113 assert_eq!(classify_tag_pin("v1.0"), Some(TagPin::Prefix(vec![1, 0])));
114 assert_eq!(classify_tag_pin("v1"), Some(TagPin::Prefix(vec![1])));
115 assert_eq!(classify_tag_pin("2.5"), Some(TagPin::Prefix(vec![2, 5])));
116 }
117
118 #[test]
119 fn classify_non_semver_is_none() {
120 assert_eq!(classify_tag_pin("latest"), None);
121 assert_eq!(classify_tag_pin("release-candidate"), None);
122 assert_eq!(classify_tag_pin(""), None);
123 }
124
125 #[test]
126 fn pick_for_pin_takes_prefix_max() {
127 let tags = vec![
128 "v1.0.0".to_string(),
129 "v1.0.1".to_string(),
130 "v1.0.5".to_string(),
131 "v1.1.0".to_string(),
132 "v2.0.0".to_string(),
133 ];
134 assert_eq!(
135 pick_latest_for_pin(&tags, &[1, 0]),
136 Some("v1.0.5".to_string())
137 );
138 assert_eq!(pick_latest_for_pin(&tags, &[1]), Some("v1.1.0".to_string()));
139 assert_eq!(pick_latest_for_pin(&tags, &[3]), None);
140 }
141
142 #[test]
143 fn pick_for_pin_excludes_prerelease() {
144 let tags = vec![
145 "v1.0.0".to_string(),
146 "v1.0.1-rc1".to_string(),
147 "v1.0.1".to_string(),
148 ];
149 assert_eq!(
150 pick_latest_for_pin(&tags, &[1, 0]),
151 Some("v1.0.1".to_string())
152 );
153 }
154
155 #[test]
156 fn pick_for_pin_ignores_unrelated_tags() {
157 let tags = vec![
158 "v1.0.0".to_string(),
159 "release-2024".to_string(),
160 "v1.0.1".to_string(),
161 ];
162 assert_eq!(
163 pick_latest_for_pin(&tags, &[1, 0]),
164 Some("v1.0.1".to_string())
165 );
166 }
167
168 #[test]
169 fn pick_overall_takes_global_max() {
170 let tags = vec![
171 "v0.9.9".to_string(),
172 "v1.0.5".to_string(),
173 "v2.0.0".to_string(),
174 "v1.9.9".to_string(),
175 ];
176 assert_eq!(pick_latest_overall(&tags), Some("v2.0.0".to_string()));
177 }
178}