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")?;
17//! ```
18
19use crate::contract::version::normalize_version;
20use std::path::{Path, PathBuf};
21
22// ═══════════════════════════════════════════════════════════════════════
23// 错误类型
24// ═══════════════════════════════════════════════════════════════════════
25
26/// Git tag 读取操作错误。
27#[derive(Debug)]
28pub enum TagError {
29    /// 仓库打开失败。
30    RepoOpen(String),
31    /// gix 内部错误。
32    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
46// ═══════════════════════════════════════════════════════════════════════
47// TagSource trait
48// ═══════════════════════════════════════════════════════════════════════
49
50/// Tag 列表的抽象来源。
51pub trait TagSource {
52    fn all_tags(&self) -> Result<Vec<String>, TagError>;
53}
54
55/// gix 实现的 [`TagSource`]。
56pub 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
89// ═══════════════════════════════════════════════════════════════════════
90// 纯逻辑
91// ═══════════════════════════════════════════════════════════════════════
92
93/// 从 tag 列表中选出指定 scope 的最新版本,标准化后返回。
94///
95/// scope 匹配规则:
96/// - `cli/v0.1.0` → scope `cli` 匹配,返回 `0.1.0`
97/// - `v0.1.0`(无前缀)→ 任何 scope 都不匹配,仅在 scope 无专属 tag 时作为兜底
98/// - 使用 semver 排序
99///
100/// ```
101/// use quanttide_devops::source::git_tag::filter_latest_tag;
102///
103/// let tags = vec!["cli/v0.2.0".into(), "cli/v0.1.0".into(), "v1.0.0".into()];
104/// assert_eq!(filter_latest_tag(&tags, "cli"), Some("0.2.0".into()));
105/// ```
106pub 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
127/// 从 tag 列表中过滤出指定 scope 的 tag。
128///
129/// ```
130/// use quanttide_devops::source::git_tag::filter_tags_by_scope;
131///
132/// let tags = vec!["cli/v0.1.0".into(), "studio/v0.2.0".into()];
133/// assert_eq!(filter_tags_by_scope(&tags, "cli"), vec!["cli/v0.1.0"]);
134/// ```
135pub 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
143// ═══════════════════════════════════════════════════════════════════════
144// 公开 API
145// ═══════════════════════════════════════════════════════════════════════
146
147/// 获取指定 scope 的最新 tag,标准化后返回。
148pub 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
152/// 获取指定 scope 的所有 tag(原始格式)。
153pub 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
157/// 带注入 [`TagSource`] 的 `latest_tag`。
158pub 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
166/// 带注入 [`TagSource`] 的 `tags_for_scope`。
167pub 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
175// ═══════════════════════════════════════════════════════════════════════
176// semver 比较
177// ═══════════════════════════════════════════════════════════════════════
178
179fn 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// ═══════════════════════════════════════════════════════════════════════
200// 测试
201// ═══════════════════════════════════════════════════════════════════════
202
203#[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}