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(thiserror::Error, Debug)]
29pub enum TagError {
30    /// 仓库打开失败。
31    #[error("无法打开仓库: {0}")]
32    RepoOpen(String),
33    /// gix 内部错误。
34    #[error("gix 错误: {0}")]
35    Gix(String),
36}
37
38// ═══════════════════════════════════════════════════════════════════════
39// TagSource trait
40// ═══════════════════════════════════════════════════════════════════════
41
42/// Tag 列表的抽象来源。
43pub trait TagSource {
44    fn all_tags(&self) -> Result<Vec<String>, TagError>;
45}
46
47/// gix 实现的 [`TagSource`]。
48pub 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
81// ═══════════════════════════════════════════════════════════════════════
82// 纯逻辑
83// ═══════════════════════════════════════════════════════════════════════
84
85/// 从 tag 列表中选出指定 scope 的最新 tag(原始格式,如 `cli/v0.2.0`)。
86///
87/// scope 匹配规则:
88/// - `cli/v0.1.0` → scope `cli` 匹配,返回 `cli/v0.1.0`
89/// - `v0.1.0`(无前缀)→ 任何 scope 都不匹配,仅在 scope 无专属 tag 时作为兜底
90/// - 使用 semver 排序
91///
92/// ```
93/// use quanttide_devops::source::git::tag::filter_latest_tag;
94///
95/// let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
96/// assert_eq!(filter_latest_tag(&tags, "cli"), Some("cli/v0.2.0".into()));
97/// ```
98pub 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
119/// 从 tag 列表中选出指定 scope 的最新版本号(标准化,如 `0.2.0`)。
120///
121/// 与 [`filter_latest_tag`] 的区别:后者返回原始 tag 名,本函数返回去 scope 去 v 前缀的版本号。
122///
123/// ```
124/// use quanttide_devops::source::git::tag::filter_latest_version;
125///
126/// let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
127/// assert_eq!(filter_latest_version(&tags, "cli"), Some("0.2.0".into()));
128/// ```
129pub 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
133/// 从 tag 列表中过滤出指定 scope 的 tag。
134///
135/// ```
136/// use quanttide_devops::source::git::tag::filter_tags_by_scope;
137///
138/// let tags = vec!["cli/v0.1.0".into(), "studio/v0.2.0".into()];
139/// assert_eq!(filter_tags_by_scope(&tags, "cli"), vec!["cli/v0.1.0"]);
140/// ```
141pub 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
149// ═══════════════════════════════════════════════════════════════════════
150// 公开 API
151// ═══════════════════════════════════════════════════════════════════════
152
153/// 获取指定 scope 的最新 tag(原始格式,如 `cli/v0.2.0`)。
154///
155/// 需要标准化版本号时使用 [`latest_version`]。
156pub 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
160/// 获取指定 scope 的最新版本号(标准化,如 `0.2.0`)。
161pub 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
165/// 获取指定 scope 的所有 tag(原始格式)。
166pub 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
170/// 带注入 [`TagSource`] 的 `latest_tag`。
171pub 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
179/// 带注入 [`TagSource`] 的 `latest_version`。
180pub 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
188/// 带注入 [`TagSource`] 的 `tags_for_scope`。
189pub 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
197// ═══════════════════════════════════════════════════════════════════════
198// semver 比较
199// ═══════════════════════════════════════════════════════════════════════
200
201pub 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// ═══════════════════════════════════════════════════════════════════════
222// 测试
223// ═══════════════════════════════════════════════════════════════════════
224
225#[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    // ── filter_latest_tag (raw) ─────────────────────────────────────
246
247    #[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    // ── filter_latest_version (normalized) ──────────────────────────
269
270    #[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    // ── latest_tag_with / latest_version_with ───────────────────────
327
328    #[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}