1use crate::contract::version::normalize_version;
21use std::path::{Path, PathBuf};
22
23#[derive(Debug)]
29pub enum TagError {
30 RepoOpen(String),
32 Gix(String),
34}
35
36impl std::fmt::Display for TagError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::RepoOpen(p) => write!(f, "无法打开仓库: {}", p),
40 Self::Gix(e) => write!(f, "gix 错误: {}", e),
41 }
42 }
43}
44
45impl std::error::Error for TagError {}
46
47pub trait TagSource {
53 fn all_tags(&self) -> Result<Vec<String>, TagError>;
54}
55
56pub struct GixTagSource {
58 repo_path: PathBuf,
59}
60
61impl GixTagSource {
62 pub fn new(path: &Path) -> Self {
63 Self {
64 repo_path: path.to_path_buf(),
65 }
66 }
67}
68
69impl TagSource for GixTagSource {
70 fn all_tags(&self) -> Result<Vec<String>, TagError> {
71 let repo = gix::open(&self.repo_path)
72 .map_err(|e| TagError::RepoOpen(format!("{}: {}", self.repo_path.display(), e)))?;
73 let refs = repo
74 .references()
75 .map_err(|e| TagError::Gix(e.to_string()))?;
76 let iter = refs
77 .prefixed("refs/tags")
78 .map_err(|e| TagError::Gix(e.to_string()))?;
79 Ok(iter
80 .filter_map(|r| r.ok())
81 .filter_map(|r| {
82 let full = r.name().as_bstr().to_string();
83 let short = full.strip_prefix("refs/tags/")?;
84 Some(short.to_string())
85 })
86 .collect())
87 }
88}
89
90pub fn filter_latest_tag(tags: &[String], scope_name: &str) -> Option<String> {
108 let prefix = format!("{}/", scope_name);
109 let mut scoped: Vec<&str> = Vec::new();
110 let mut unscoped: Vec<&str> = Vec::new();
111 for tag in tags {
112 if let Some(rest) = tag.strip_prefix(&prefix) {
113 if !rest.is_empty() {
114 scoped.push(tag);
115 }
116 } else if !tag.contains('/') {
117 unscoped.push(tag);
118 }
119 }
120 scoped.sort_by(|a, b| semver_desc(a, b));
121 unscoped.sort_by(|a, b| semver_desc(a, b));
122 match scoped.first() {
123 Some(t) => Some(t.to_string()),
124 None => unscoped.first().map(|t| t.to_string()),
125 }
126}
127
128pub fn filter_latest_version(tags: &[String], scope_name: &str) -> Option<String> {
139 filter_latest_tag(tags, scope_name).map(|t| normalize_version(&t))
140}
141
142pub fn filter_tags_by_scope(tags: &[String], scope_name: &str) -> Vec<String> {
151 let prefix = format!("{}/", scope_name);
152 tags.iter()
153 .filter(|t| t.starts_with(&prefix))
154 .cloned()
155 .collect()
156}
157
158pub fn latest_tag(repo_path: &Path, scope_name: &str) -> Result<Option<String>, TagError> {
166 latest_tag_with(&GixTagSource::new(repo_path), scope_name)
167}
168
169pub fn latest_version(repo_path: &Path, scope_name: &str) -> Result<Option<String>, TagError> {
171 latest_version_with(&GixTagSource::new(repo_path), scope_name)
172}
173
174pub fn tags_for_scope(repo_path: &Path, scope_name: &str) -> Result<Vec<String>, TagError> {
176 tags_for_scope_with(&GixTagSource::new(repo_path), scope_name)
177}
178
179pub fn latest_tag_with(
181 source: &impl TagSource,
182 scope_name: &str,
183) -> Result<Option<String>, TagError> {
184 let tags = source.all_tags()?;
185 Ok(filter_latest_tag(&tags, scope_name))
186}
187
188pub fn latest_version_with(
190 source: &impl TagSource,
191 scope_name: &str,
192) -> Result<Option<String>, TagError> {
193 let tags = source.all_tags()?;
194 Ok(filter_latest_version(&tags, scope_name))
195}
196
197pub fn tags_for_scope_with(
199 source: &impl TagSource,
200 scope_name: &str,
201) -> Result<Vec<String>, TagError> {
202 let tags = source.all_tags()?;
203 Ok(filter_tags_by_scope(&tags, scope_name))
204}
205
206pub fn parse_semver_tag(tag: &str) -> Option<semver::Version> {
211 let after_scope = tag.split('/').next_back().unwrap_or(tag);
212 let ver = after_scope
213 .strip_prefix('v')
214 .or_else(|| after_scope.strip_prefix('V'))
215 .unwrap_or(after_scope);
216 semver::Version::parse(ver).ok()
217}
218
219fn semver_desc(a: &str, b: &str) -> std::cmp::Ordering {
220 let va = parse_semver_tag(a);
221 let vb = parse_semver_tag(b);
222 match (va, vb) {
223 (Some(a), Some(b)) => b.cmp(&a),
224 (Some(_), None) => std::cmp::Ordering::Less,
225 (None, Some(_)) => std::cmp::Ordering::Greater,
226 (None, None) => std::cmp::Ordering::Equal,
227 }
228}
229
230#[cfg(test)]
235mod tests {
236 use super::*;
237
238 struct MockTagSource {
239 tags: Vec<String>,
240 }
241
242 impl TagSource for MockTagSource {
243 fn all_tags(&self) -> Result<Vec<String>, TagError> {
244 Ok(self.tags.clone())
245 }
246 }
247
248 fn mock(tags: &[&str]) -> MockTagSource {
249 MockTagSource {
250 tags: tags.iter().map(|s| s.to_string()).collect(),
251 }
252 }
253
254 #[test]
257 fn test_filter_latest_tag_raw_scoped() {
258 let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
259 assert_eq!(
260 filter_latest_tag(&tags, "cli"),
261 Some("cli/v0.2.0".into())
262 );
263 }
264
265 #[test]
266 fn test_filter_latest_tag_raw_unscoped_fallback() {
267 let tags = vec!["v1.0.0".into()];
268 assert_eq!(filter_latest_tag(&tags, "cli"), Some("v1.0.0".into()));
269 }
270
271 #[test]
272 fn test_filter_latest_tag_raw_empty() {
273 let tags: Vec<String> = vec![];
274 assert_eq!(filter_latest_tag(&tags, "cli"), None);
275 }
276
277 #[test]
280 fn test_filter_latest_version_scoped_wins() {
281 let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
282 assert_eq!(filter_latest_version(&tags, "cli"), Some("0.2.0".into()));
283 }
284
285 #[test]
286 fn test_filter_latest_version_unscoped_fallback() {
287 let tags = vec!["v1.0.0".into()];
288 assert_eq!(filter_latest_version(&tags, "cli"), Some("1.0.0".into()));
289 }
290
291 #[test]
292 fn test_filter_latest_version_empty() {
293 let tags: Vec<String> = vec![];
294 assert_eq!(filter_latest_version(&tags, "cli"), None);
295 }
296
297 #[test]
298 fn test_filter_latest_version_semver_sort() {
299 let tags = vec!["cli/v9.0.0".into(), "cli/v10.0.0".into()];
300 assert_eq!(
301 filter_latest_version(&tags, "cli"),
302 Some("10.0.0".into())
303 );
304 }
305
306 #[test]
307 fn test_filter_latest_version_scope_no_match() {
308 let tags = vec!["other/v0.1.0".into()];
309 assert_eq!(filter_latest_version(&tags, "cli"), None);
310 }
311
312 #[test]
313 fn test_filter_latest_version_excludes_empty_scope() {
314 let tags = vec!["cli/".into(), "v1.0.0".into()];
315 assert_eq!(filter_latest_version(&tags, "cli"), Some("1.0.0".into()));
316 }
317
318 #[test]
319 fn test_filter_latest_version_multi_scope() {
320 let tags = vec![
321 "cli/v0.2.0".into(),
322 "studio/v0.3.0".into(),
323 "cli/v0.1.0".into(),
324 ];
325 assert_eq!(
326 filter_latest_version(&tags, "cli"),
327 Some("0.2.0".into())
328 );
329 assert_eq!(
330 filter_latest_version(&tags, "studio"),
331 Some("0.3.0".into())
332 );
333 }
334
335 #[test]
338 fn test_latest_tag_with_mock() {
339 let source = mock(&["cli/v0.2.0", "v1.0.0"]);
340 assert_eq!(
341 latest_tag_with(&source, "cli").unwrap(),
342 Some("cli/v0.2.0".into())
343 );
344 }
345
346 #[test]
347 fn test_latest_version_with_mock() {
348 let source = mock(&["cli/v0.2.0", "v1.0.0"]);
349 assert_eq!(
350 latest_version_with(&source, "cli").unwrap(),
351 Some("0.2.0".into())
352 );
353 }
354
355 #[test]
356 fn test_latest_tag_with_empty() {
357 let source = mock(&[]);
358 assert_eq!(latest_tag_with(&source, "cli").unwrap(), None);
359 }
360
361 #[test]
362 fn test_latest_version_with_empty() {
363 let source = mock(&[]);
364 assert_eq!(latest_version_with(&source, "cli").unwrap(), None);
365 }
366
367 #[test]
368 fn test_filter_tags_by_scope_matches() {
369 let tags = vec!["cli/v0.1.0".into(), "studio/v0.2.0".into()];
370 assert_eq!(filter_tags_by_scope(&tags, "cli"), vec!["cli/v0.1.0"]);
371 }
372
373 #[test]
374 fn test_filter_tags_by_scope_no_match() {
375 let tags = vec!["v1.0.0".into()];
376 assert!(filter_tags_by_scope(&tags, "cli").is_empty());
377 }
378
379 #[test]
380 fn test_tags_for_scope_with_mock() {
381 let source = mock(&["cli/v0.1.0", "studio/v0.2.0"]);
382 assert_eq!(
383 tags_for_scope_with(&source, "cli").unwrap(),
384 vec!["cli/v0.1.0"]
385 );
386 }
387
388 #[test]
389 fn test_parse_semver_standard() {
390 assert_eq!(
391 parse_semver_tag("v1.2.3"),
392 Some(semver::Version::new(1, 2, 3))
393 );
394 }
395 #[test]
396 fn test_parse_semver_scoped() {
397 assert_eq!(
398 parse_semver_tag("cli/v0.5.0"),
399 Some(semver::Version::new(0, 5, 0))
400 );
401 }
402 #[test]
403 fn test_parse_semver_prerelease() {
404 let v = parse_semver_tag("v1.0.0-rc.1").unwrap();
405 assert!(!v.pre.is_empty());
406 }
407 #[test]
408 fn test_parse_semver_no_v() {
409 assert_eq!(
410 parse_semver_tag("1.2.3"),
411 Some(semver::Version::new(1, 2, 3))
412 );
413 }
414 #[test]
415 fn test_parse_semver_invalid() {
416 assert_eq!(parse_semver_tag("not-a-version"), None);
417 }
418 #[test]
419 fn test_parse_semver_build_metadata() {
420 assert!(parse_semver_tag("v1.0.0+build.1").is_some());
421 }
422 #[test]
423 fn test_parse_semver_complex_prerelease() {
424 let v = parse_semver_tag("v2.0.0-alpha.1.2").unwrap();
425 assert_eq!(v.pre.to_string(), "alpha.1.2");
426 }
427 #[test]
428 fn test_parse_semver_empty_patch() {
429 assert_eq!(parse_semver_tag("v1.0"), None);
430 }
431 #[test]
432 fn test_parse_semver_multiple_scopes() {
433 assert_eq!(
434 parse_semver_tag("parent/cli/v1.2.3"),
435 Some(semver::Version::new(1, 2, 3))
436 );
437 }
438 #[test]
439 fn test_parse_semver_uppercase() {
440 assert_eq!(
441 parse_semver_tag("V1.2.3"),
442 Some(semver::Version::new(1, 2, 3))
443 );
444 }
445 #[test]
446 fn test_parse_semver_scope_with_dot() {
447 assert_eq!(
448 parse_semver_tag("pkg.name/v1.2.3"),
449 Some(semver::Version::new(1, 2, 3))
450 );
451 }
452 #[test]
453 fn test_semver_desc_valid_vs_invalid() {
454 assert_eq!(
455 semver_desc("v1.0.0", "not-a-version"),
456 std::cmp::Ordering::Less
457 );
458 assert_eq!(
459 semver_desc("not-a-version", "v1.0.0"),
460 std::cmp::Ordering::Greater
461 );
462 }
463 #[test]
464 fn test_semver_desc_both_invalid() {
465 assert_eq!(
466 semver_desc("not-a-version", "also-invalid"),
467 std::cmp::Ordering::Equal
468 );
469 }
470 #[test]
471 fn test_semver_desc_prerelease_vs_release() {
472 assert_eq!(
473 semver_desc("v1.0.0-alpha", "v1.0.0"),
474 std::cmp::Ordering::Greater
475 );
476 }
477}