Skip to main content

quanttide_devops/contract/
version.rs

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