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