Skip to main content

quanttide_devops/contract/
version.rs

1//! 版本一致性检查:对比 Git tag、配置文件、契约声明的版本号。
2
3use std::path::Path;
4
5use crate::contract::Scope;
6
7/// 校验版本号格式。
8///
9/// 接受以下格式:
10/// - `vX.Y.Z` — 标准语义化版本
11/// - `vX.Y.Z-prerelease` — 带预发布后缀
12/// - `scope/vX.Y.Z` — 带作用域前缀
13///
14/// ```
15/// use quanttide_devops::contract::validate_version;
16/// assert!(validate_version("v1.2.3"));
17/// assert!(validate_version("cli/v0.5.0-rc.1"));
18/// assert!(!validate_version("1.2.3"));        // 缺 v 前缀
19/// assert!(!validate_version("v1.2"));          // 缺 patch
20/// assert!(!validate_version(""));              // 空
21/// ```
22pub fn validate_version(version: &str) -> bool {
23    if version.is_empty() {
24        return false;
25    }
26
27    // 处理 scope/vX.Y.Z 格式
28    let ver = if let Some(pos) = version.find('/') {
29        let scope = &version[..pos];
30        // scope 允许字母、数字、下划线、点、连字符
31        if scope.is_empty()
32            || !scope
33                .chars()
34                .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-')
35        {
36            return false;
37        }
38        &version[pos + 1..]
39    } else {
40        version
41    };
42
43    // 必须 v 开头
44    let without_v = match ver.strip_prefix('v') {
45        Some(v) => v,
46        None => return false,
47    };
48
49    // 拆 X.Y.Z-prerelease
50    let (semver, _prerelease) = if let Some(dash) = without_v.find('-') {
51        let sv = &without_v[..dash];
52        let pr = &without_v[dash + 1..];
53        // prerelease 不能为空或点开头
54        if pr.is_empty() || pr.starts_with('.') {
55            return false;
56        }
57        (sv, Some(pr))
58    } else {
59        (without_v, None)
60    };
61
62    // 验证 X.Y.Z
63    let parts: Vec<&str> = semver.split('.').collect();
64    if parts.len() != 3 {
65        return false;
66    }
67    parts
68        .iter()
69        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
70}
71
72/// 标准化版本号:去掉 `v` 前缀和 scope 前缀。
73///
74/// ```
75/// use quanttide_devops::contract::normalize_version;
76/// assert_eq!(normalize_version("v1.2.3"), "1.2.3");
77/// assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
78/// ```
79pub fn normalize_version(version: &str) -> String {
80    let after_scope = version.split('/').next_back().unwrap_or(version);
81    after_scope
82        .strip_prefix('v')
83        .unwrap_or(after_scope)
84        .to_string()
85}
86
87// ═══════════════════════════════════════════════════════════════════════
88// 版本一致性规则
89// ═══════════════════════════════════════════════════════════════════════
90
91/// 版本信息快照(State):记录两个事实源的版本信息及一致性结论。
92///
93/// 调用 `verify_version` 后得到一个快照,`consistent` 字段是核心判断结果。
94/// 之所以不叫 `VersionStatus`,是因为结构体包含多个字段(版本信息),
95/// 而不只是一个单一布尔值。`consistent` 字段才是真正的状态值。
96#[derive(Debug)]
97pub struct VersionState {
98    /// 最新 git tag 的版本号(已标准化,去 `v` 前缀和 scope 前缀)。
99    pub tag_version: Option<String>,
100    /// 配置文件中找到的第一个非空版本号。
101    pub config_version: Option<String>,
102    /// tag 与所有配置文件版本是否一致。
103    pub consistent: bool,
104    /// 所有配置文件的版本号明细。
105    pub config_files: Vec<(String, Option<String>)>,
106}
107
108/// 检查 tag 版本与配置文件版本是否一致。
109///
110/// `config_files` 来自 `source::version::read_config_versions`、
111/// `tag_version` 来自 `source::version::latest_tag`。
112/// 本函数只做规则判定,不关心数据来源。
113///
114/// # 一致性规则
115///
116/// - 有 tag:所有有版本号的配置文件必须与 tag 版本一致,无版本号的忽略
117/// - 无 tag:所有配置文件必须无版本号
118///
119/// # 示例
120///
121/// ```
122/// use quanttide_devops::contract::check_version_consistency;
123///
124/// assert!(check_version_consistency(
125///     Some("0.1.0"),
126///     &[("Cargo.toml".into(), Some("0.1.0".into()))],
127/// ));
128/// assert!(!check_version_consistency(
129///     Some("0.1.0"),
130///     &[("Cargo.toml".into(), Some("0.2.0".into()))],
131/// ));
132/// assert!(check_version_consistency(
133///     Some("0.1.0"),
134///     &[("Cargo.toml".into(), None)],
135/// ));
136/// assert!(check_version_consistency(None, &[("Cargo.toml".into(), None)]));
137/// ```
138pub fn check_version_consistency(
139    tag_version: Option<&str>,
140    config_files: &[(String, Option<String>)],
141) -> bool {
142    match tag_version {
143        Some(t) => config_files.iter().all(|(_, v)| match v {
144            Some(cv) => cv == t,
145            None => true,
146        }),
147        None => config_files.iter().all(|(_, v)| v.is_none()),
148    }
149}
150
151/// 从 git tag 和配置文件中读取版本号,验证一致性。
152///
153/// 组合两个事实源([`source::git::tag::latest_version`] 和
154/// [`source::config_file::read_config_versions`]),应用一致性规则,
155/// 返回 [`VersionState`]。
156pub fn verify_version(
157    repo_path: &Path,
158    scope: &Scope,
159) -> Result<VersionState, Box<dyn std::error::Error>> {
160    let tag_version = crate::source::git::tag::latest_version(repo_path, &scope.name)?;
161    let scope_dir = repo_path.join(&scope.dir);
162    let config_files = crate::source::config_file::read_config_versions(&scope_dir);
163    let config_version = config_files
164        .iter()
165        .find(|(_, v)| v.is_some())
166        .and_then(|(_, v)| v.clone());
167    let consistent = check_version_consistency(tag_version.as_deref(), &config_files);
168    Ok(VersionState {
169        tag_version,
170        config_version,
171        consistent,
172        config_files,
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // ── validate_version ──────────────────────────────────────────
181
182    #[test]
183    fn test_validate_version_standard() {
184        assert!(validate_version("v1.2.3"));
185    }
186
187    #[test]
188    fn test_validate_version_prerelease() {
189        assert!(validate_version("v1.2.3-rc.1"));
190        assert!(validate_version("v1.2.3-alpha"));
191    }
192
193    #[test]
194    fn test_validate_version_scoped() {
195        assert!(validate_version("cli/v1.2.3"));
196        assert!(validate_version("pkg.name/v0.1.0"));
197    }
198
199    #[test]
200    fn test_validate_version_no_v() {
201        assert!(!validate_version("1.2.3"));
202    }
203
204    #[test]
205    fn test_validate_version_incomplete() {
206        assert!(!validate_version("v1.2"));
207        assert!(!validate_version("v1"));
208    }
209
210    #[test]
211    fn test_validate_version_empty() {
212        assert!(!validate_version(""));
213    }
214
215    #[test]
216    fn test_validate_version_scope_only() {
217        assert!(!validate_version("cli/"));
218    }
219
220    #[test]
221    fn test_validate_version_invalid_scope_chars() {
222        assert!(!validate_version("bad space/v1.2.3"));
223        assert!(!validate_version("/v1.2.3"));
224    }
225
226    #[test]
227    fn test_validate_version_empty_prerelease() {
228        assert!(!validate_version("v1.2.3-"));
229        assert!(!validate_version("v1.2.3-."));
230    }
231
232    // ── normalize_version ─────────────────────────────────────────
233
234    #[test]
235    fn test_normalize_version_v_prefix() {
236        assert_eq!(normalize_version("v1.2.3"), "1.2.3");
237    }
238
239    #[test]
240    fn test_normalize_version_scoped() {
241        assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
242    }
243
244    #[test]
245    fn test_normalize_version_no_prefix() {
246        assert_eq!(normalize_version("1.2.3"), "1.2.3");
247    }
248
249    // ── check_version_consistency ──────────────────────────────────
250
251    #[test]
252    fn test_consistency_matches() {
253        assert!(check_version_consistency(
254            Some("0.1.0"),
255            &[("Cargo.toml".into(), Some("0.1.0".into()))]
256        ));
257    }
258
259    #[test]
260    fn test_consistency_mismatch() {
261        assert!(!check_version_consistency(
262            Some("0.1.0"),
263            &[("Cargo.toml".into(), Some("0.2.0".into()))]
264        ));
265    }
266
267    #[test]
268    fn test_consistency_config_no_version() {
269        assert!(check_version_consistency(
270            Some("0.1.0"),
271            &[("Cargo.toml".into(), None)]
272        ));
273    }
274
275    #[test]
276    fn test_consistency_no_tag_no_config() {
277        assert!(check_version_consistency(
278            None,
279            &[("Cargo.toml".into(), None)]
280        ));
281    }
282
283    #[test]
284    fn test_consistency_no_tag_but_config_has_version() {
285        assert!(!check_version_consistency(
286            None,
287            &[("Cargo.toml".into(), Some("0.1.0".into()))]
288        ));
289    }
290
291    #[test]
292    fn test_consistency_multi_file_all_match() {
293        let files = vec![
294            ("Cargo.toml".into(), Some("0.1.0".into())),
295            ("pyproject.toml".into(), Some("0.1.0".into())),
296        ];
297        assert!(check_version_consistency(Some("0.1.0"), &files));
298    }
299
300    #[test]
301    fn test_consistency_multi_file_one_mismatch() {
302        let files = vec![
303            ("Cargo.toml".into(), Some("0.1.0".into())),
304            ("pyproject.toml".into(), Some("0.2.0".into())),
305        ];
306        assert!(!check_version_consistency(Some("0.1.0"), &files));
307    }
308}