Skip to main content

quanttide_devops/source/
git_tag.rs

1//! 从 Git tag 读取版本号,作为版本号的事实源之一。
2//!
3//! # 事实源定位
4//!
5//! 本模块提供的是**事实**(scope 名和版本号当前是什么),不涉及规则判断。
6//! 规则的判定在 `contract::version` 中。
7//!
8//! # 架构
9//!
10//! 通过 [`TagSource`] trait 将 I/O(读取 tag)与业务逻辑(过滤、排序)分离。
11//!
12//! # 示例
13//!
14//! ```ignore
15//! use quanttide_devops::source::git_tag::latest_tag;
16//! let tag = latest_tag(repo_path, "cli")?;  // "cli/v0.2.0"
17//! let ver = quanttide_devops::source::git_tag::latest_version(repo_path, "cli")?;  // "0.2.0"
18//! ```
19
20use crate::contract::version::normalize_version;
21use std::path::{Path, PathBuf};
22
23// ═══════════════════════════════════════════════════════════════════════
24// 错误类型
25// ═══════════════════════════════════════════════════════════════════════
26
27/// Git tag 读取操作错误。
28#[derive(Debug)]
29pub enum TagError {
30    /// 仓库打开失败。
31    RepoOpen(String),
32    /// gix 内部错误。
33    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
47// ═══════════════════════════════════════════════════════════════════════
48// TagSource trait
49// ═══════════════════════════════════════════════════════════════════════
50
51/// Tag 列表的抽象来源。
52pub trait TagSource {
53    fn all_tags(&self) -> Result<Vec<String>, TagError>;
54}
55
56/// gix 实现的 [`TagSource`]。
57pub 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
90// ═══════════════════════════════════════════════════════════════════════
91// 纯逻辑
92// ═══════════════════════════════════════════════════════════════════════
93
94/// 从 tag 列表中选出指定 scope 的最新 tag(原始格式,如 `cli/v0.2.0`)。
95///
96/// scope 匹配规则:
97/// - `cli/v0.1.0` → scope `cli` 匹配,返回 `cli/v0.1.0`
98/// - `v0.1.0`(无前缀)→ 任何 scope 都不匹配,仅在 scope 无专属 tag 时作为兜底
99/// - 使用 semver 排序
100///
101/// ```
102/// use quanttide_devops::source::git_tag::filter_latest_tag;
103///
104/// let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
105/// assert_eq!(filter_latest_tag(&tags, "cli"), Some("cli/v0.2.0".into()));
106/// ```
107pub 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
128/// 从 tag 列表中选出指定 scope 的最新版本号(标准化,如 `0.2.0`)。
129///
130/// 与 [`filter_latest_tag`] 的区别:后者返回原始 tag 名,本函数返回去 scope 去 v 前缀的版本号。
131///
132/// ```
133/// use quanttide_devops::source::git_tag::filter_latest_version;
134///
135/// let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
136/// assert_eq!(filter_latest_version(&tags, "cli"), Some("0.2.0".into()));
137/// ```
138pub 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
142/// 从 tag 列表中过滤出指定 scope 的 tag。
143///
144/// ```
145/// use quanttide_devops::source::git_tag::filter_tags_by_scope;
146///
147/// let tags = vec!["cli/v0.1.0".into(), "studio/v0.2.0".into()];
148/// assert_eq!(filter_tags_by_scope(&tags, "cli"), vec!["cli/v0.1.0"]);
149/// ```
150pub 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
158// ═══════════════════════════════════════════════════════════════════════
159// 公开 API
160// ═══════════════════════════════════════════════════════════════════════
161
162/// 获取指定 scope 的最新 tag(原始格式,如 `cli/v0.2.0`)。
163///
164/// 需要标准化版本号时使用 [`latest_version`]。
165pub 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
169/// 获取指定 scope 的最新版本号(标准化,如 `0.2.0`)。
170pub 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
174/// 获取指定 scope 的所有 tag(原始格式)。
175pub 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
179/// 带注入 [`TagSource`] 的 `latest_tag`。
180pub 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
188/// 带注入 [`TagSource`] 的 `latest_version`。
189pub 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
197/// 带注入 [`TagSource`] 的 `tags_for_scope`。
198pub 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
206// ═══════════════════════════════════════════════════════════════════════
207// semver 比较
208// ═══════════════════════════════════════════════════════════════════════
209
210pub 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// ═══════════════════════════════════════════════════════════════════════
231// 测试
232// ═══════════════════════════════════════════════════════════════════════
233
234#[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    // ── filter_latest_tag (raw) ─────────────────────────────────────
255
256    #[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    // ── filter_latest_version (normalized) ──────────────────────────
278
279    #[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    // ── latest_tag_with / latest_version_with ───────────────────────
336
337    #[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}