1use crate::contract::version::normalize_version;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug)]
28pub enum TagError {
29 RepoOpen(String),
31 Gix(String),
33}
34
35impl std::fmt::Display for TagError {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::RepoOpen(p) => write!(f, "无法打开仓库: {}", p),
39 Self::Gix(e) => write!(f, "gix 错误: {}", e),
40 }
41 }
42}
43
44impl std::error::Error for TagError {}
45
46pub trait TagSource {
52 fn all_tags(&self) -> Result<Vec<String>, TagError>;
53}
54
55pub struct GixTagSource {
57 repo_path: PathBuf,
58}
59
60impl GixTagSource {
61 pub fn new(path: &Path) -> Self {
62 Self {
63 repo_path: path.to_path_buf(),
64 }
65 }
66}
67
68impl TagSource for GixTagSource {
69 fn all_tags(&self) -> Result<Vec<String>, TagError> {
70 let repo = gix::open(&self.repo_path)
71 .map_err(|e| TagError::RepoOpen(format!("{}: {}", self.repo_path.display(), e)))?;
72 let refs = repo
73 .references()
74 .map_err(|e| TagError::Gix(e.to_string()))?;
75 let iter = refs
76 .prefixed("refs/tags")
77 .map_err(|e| TagError::Gix(e.to_string()))?;
78 Ok(iter
79 .filter_map(|r| r.ok())
80 .filter_map(|r| {
81 let full = r.name().as_bstr().to_string();
82 let short = full.strip_prefix("refs/tags/")?;
83 Some(short.to_string())
84 })
85 .collect())
86 }
87}
88
89pub fn filter_latest_tag(tags: &[String], scope_name: &str) -> Option<String> {
107 let prefix = format!("{}/", scope_name);
108 let mut scoped: Vec<&str> = Vec::new();
109 let mut unscoped: Vec<&str> = Vec::new();
110 for tag in tags {
111 if let Some(rest) = tag.strip_prefix(&prefix) {
112 if !rest.is_empty() {
113 scoped.push(tag);
114 }
115 } else if !tag.contains('/') {
116 unscoped.push(tag);
117 }
118 }
119 scoped.sort_by(|a, b| semver_desc(a, b));
120 unscoped.sort_by(|a, b| semver_desc(a, b));
121 match scoped.first() {
122 Some(t) => Some(normalize_version(t)),
123 None => unscoped.first().map(|t| normalize_version(t)),
124 }
125}
126
127pub fn filter_tags_by_scope(tags: &[String], scope_name: &str) -> Vec<String> {
136 let prefix = format!("{}/", scope_name);
137 tags.iter()
138 .filter(|t| t.starts_with(&prefix))
139 .cloned()
140 .collect()
141}
142
143pub fn latest_tag(repo_path: &Path, scope_name: &str) -> Result<Option<String>, TagError> {
149 latest_tag_with(&GixTagSource::new(repo_path), scope_name)
150}
151
152pub fn tags_for_scope(repo_path: &Path, scope_name: &str) -> Result<Vec<String>, TagError> {
154 tags_for_scope_with(&GixTagSource::new(repo_path), scope_name)
155}
156
157pub fn latest_tag_with(
159 source: &impl TagSource,
160 scope_name: &str,
161) -> Result<Option<String>, TagError> {
162 let tags = source.all_tags()?;
163 Ok(filter_latest_tag(&tags, scope_name))
164}
165
166pub fn tags_for_scope_with(
168 source: &impl TagSource,
169 scope_name: &str,
170) -> Result<Vec<String>, TagError> {
171 let tags = source.all_tags()?;
172 Ok(filter_tags_by_scope(&tags, scope_name))
173}
174
175fn parse_semver_tag(tag: &str) -> Option<semver::Version> {
180 let after_scope = tag.split('/').next_back().unwrap_or(tag);
181 let ver = after_scope
182 .strip_prefix('v')
183 .or_else(|| after_scope.strip_prefix('V'))
184 .unwrap_or(after_scope);
185 semver::Version::parse(ver).ok()
186}
187
188fn semver_desc(a: &str, b: &str) -> std::cmp::Ordering {
189 let va = parse_semver_tag(a);
190 let vb = parse_semver_tag(b);
191 match (va, vb) {
192 (Some(a), Some(b)) => b.cmp(&a),
193 (Some(_), None) => std::cmp::Ordering::Less,
194 (None, Some(_)) => std::cmp::Ordering::Greater,
195 (None, None) => std::cmp::Ordering::Equal,
196 }
197}
198
199#[cfg(test)]
204mod tests {
205 use super::*;
206
207 struct MockTagSource {
208 tags: Vec<String>,
209 }
210
211 impl TagSource for MockTagSource {
212 fn all_tags(&self) -> Result<Vec<String>, TagError> {
213 Ok(self.tags.clone())
214 }
215 }
216
217 fn mock(tags: &[&str]) -> MockTagSource {
218 MockTagSource {
219 tags: tags.iter().map(|s| s.to_string()).collect(),
220 }
221 }
222
223 #[test]
224 fn test_latest_tag_scoped_wins() {
225 let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
226 assert_eq!(filter_latest_tag(&tags, "cli"), Some("0.2.0".into()));
227 }
228
229 #[test]
230 fn test_latest_tag_unscoped_fallback() {
231 let tags = vec!["v1.0.0".into()];
232 assert_eq!(filter_latest_tag(&tags, "cli"), Some("1.0.0".into()));
233 }
234
235 #[test]
236 fn test_latest_tag_empty() {
237 let tags: Vec<String> = vec![];
238 assert_eq!(filter_latest_tag(&tags, "cli"), None);
239 }
240
241 #[test]
242 fn test_latest_tag_semver_sort() {
243 let tags = vec!["cli/v9.0.0".into(), "cli/v10.0.0".into()];
244 assert_eq!(filter_latest_tag(&tags, "cli"), Some("10.0.0".into()));
245 }
246
247 #[test]
248 fn test_latest_tag_scope_no_match() {
249 let tags = vec!["other/v0.1.0".into()];
250 assert_eq!(filter_latest_tag(&tags, "cli"), None);
251 }
252
253 #[test]
254 fn test_latest_tag_excludes_empty_scope() {
255 let tags = vec!["cli/".into(), "v1.0.0".into()];
256 assert_eq!(filter_latest_tag(&tags, "cli"), Some("1.0.0".into()));
257 }
258
259 #[test]
260 fn test_latest_tag_multi_scope() {
261 let tags = vec![
262 "cli/v0.2.0".into(),
263 "studio/v0.3.0".into(),
264 "cli/v0.1.0".into(),
265 ];
266 assert_eq!(filter_latest_tag(&tags, "cli"), Some("0.2.0".into()));
267 assert_eq!(filter_latest_tag(&tags, "studio"), Some("0.3.0".into()));
268 }
269
270 #[test]
271 fn test_latest_tag_with_mock() {
272 let source = mock(&["cli/v0.2.0", "v1.0.0"]);
273 assert_eq!(
274 latest_tag_with(&source, "cli").unwrap(),
275 Some("0.2.0".into())
276 );
277 }
278
279 #[test]
280 fn test_latest_tag_with_empty() {
281 let source = mock(&[]);
282 assert_eq!(latest_tag_with(&source, "cli").unwrap(), None);
283 }
284
285 #[test]
286 fn test_filter_tags_by_scope_matches() {
287 let tags = vec!["cli/v0.1.0".into(), "studio/v0.2.0".into()];
288 assert_eq!(filter_tags_by_scope(&tags, "cli"), vec!["cli/v0.1.0"]);
289 }
290
291 #[test]
292 fn test_filter_tags_by_scope_no_match() {
293 let tags = vec!["v1.0.0".into()];
294 assert!(filter_tags_by_scope(&tags, "cli").is_empty());
295 }
296
297 #[test]
298 fn test_tags_for_scope_with_mock() {
299 let source = mock(&["cli/v0.1.0", "studio/v0.2.0"]);
300 assert_eq!(
301 tags_for_scope_with(&source, "cli").unwrap(),
302 vec!["cli/v0.1.0"]
303 );
304 }
305
306 #[test]
307 fn test_parse_semver_standard() {
308 assert_eq!(
309 parse_semver_tag("v1.2.3"),
310 Some(semver::Version::new(1, 2, 3))
311 );
312 }
313 #[test]
314 fn test_parse_semver_scoped() {
315 assert_eq!(
316 parse_semver_tag("cli/v0.5.0"),
317 Some(semver::Version::new(0, 5, 0))
318 );
319 }
320 #[test]
321 fn test_parse_semver_prerelease() {
322 let v = parse_semver_tag("v1.0.0-rc.1").unwrap();
323 assert!(!v.pre.is_empty());
324 }
325 #[test]
326 fn test_parse_semver_no_v() {
327 assert_eq!(
328 parse_semver_tag("1.2.3"),
329 Some(semver::Version::new(1, 2, 3))
330 );
331 }
332 #[test]
333 fn test_parse_semver_invalid() {
334 assert_eq!(parse_semver_tag("not-a-version"), None);
335 }
336 #[test]
337 fn test_parse_semver_build_metadata() {
338 assert!(parse_semver_tag("v1.0.0+build.1").is_some());
339 }
340 #[test]
341 fn test_parse_semver_complex_prerelease() {
342 let v = parse_semver_tag("v2.0.0-alpha.1.2").unwrap();
343 assert_eq!(v.pre.to_string(), "alpha.1.2");
344 }
345 #[test]
346 fn test_parse_semver_empty_patch() {
347 assert_eq!(parse_semver_tag("v1.0"), None);
348 }
349 #[test]
350 fn test_parse_semver_multiple_scopes() {
351 assert_eq!(
352 parse_semver_tag("parent/cli/v1.2.3"),
353 Some(semver::Version::new(1, 2, 3))
354 );
355 }
356 #[test]
357 fn test_parse_semver_uppercase() {
358 assert_eq!(
359 parse_semver_tag("V1.2.3"),
360 Some(semver::Version::new(1, 2, 3))
361 );
362 }
363 #[test]
364 fn test_parse_semver_scope_with_dot() {
365 assert_eq!(
366 parse_semver_tag("pkg.name/v1.2.3"),
367 Some(semver::Version::new(1, 2, 3))
368 );
369 }
370 #[test]
371 fn test_semver_desc_valid_vs_invalid() {
372 assert_eq!(
373 semver_desc("v1.0.0", "not-a-version"),
374 std::cmp::Ordering::Less
375 );
376 assert_eq!(
377 semver_desc("not-a-version", "v1.0.0"),
378 std::cmp::Ordering::Greater
379 );
380 }
381 #[test]
382 fn test_semver_desc_both_invalid() {
383 assert_eq!(
384 semver_desc("not-a-version", "also-invalid"),
385 std::cmp::Ordering::Equal
386 );
387 }
388 #[test]
389 fn test_semver_desc_prerelease_vs_release() {
390 assert_eq!(
391 semver_desc("v1.0.0-alpha", "v1.0.0"),
392 std::cmp::Ordering::Greater
393 );
394 }
395}