Skip to main content

quanttide_devops/contract/
version.rs

1use std::path::Path;
2
3/// 校验版本号格式。
4///
5/// 接受以下格式:
6/// - `vX.Y.Z` — 标准语义化版本
7/// - `vX.Y.Z-prerelease` — 带预发布后缀
8/// - `scope/vX.Y.Z` — 带作用域前缀
9///
10/// ```
11/// use quanttide_devops::contract::validate_version;
12/// assert!(validate_version("v1.2.3"));
13/// assert!(validate_version("cli/v0.5.0-rc.1"));
14/// assert!(!validate_version("1.2.3"));        // 缺 v 前缀
15/// assert!(!validate_version("v1.2"));          // 缺 patch
16/// assert!(!validate_version(""));              // 空
17/// ```
18pub fn validate_version(version: &str) -> bool {
19    if version.is_empty() {
20        return false;
21    }
22
23    // 处理 scope/vX.Y.Z 格式
24    let ver = if let Some(pos) = version.find('/') {
25        let scope = &version[..pos];
26        // scope 允许字母、数字、下划线、点、连字符
27        if scope.is_empty()
28            || !scope
29                .chars()
30                .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-')
31        {
32            return false;
33        }
34        &version[pos + 1..]
35    } else {
36        version
37    };
38
39    // 必须 v 开头
40    let without_v = match ver.strip_prefix('v') {
41        Some(v) => v,
42        None => return false,
43    };
44
45    // 拆 X.Y.Z-prerelease
46    let (semver, _prerelease) = if let Some(dash) = without_v.find('-') {
47        let sv = &without_v[..dash];
48        let pr = &without_v[dash + 1..];
49        // prerelease 不能为空或点开头
50        if pr.is_empty() || pr.starts_with('.') {
51            return false;
52        }
53        (sv, Some(pr))
54    } else {
55        (without_v, None)
56    };
57
58    // 验证 X.Y.Z
59    let parts: Vec<&str> = semver.split('.').collect();
60    if parts.len() != 3 {
61        return false;
62    }
63    parts
64        .iter()
65        .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
66}
67
68type VersionExtract = fn(&str) -> Option<String>;
69
70/// 读取目录下所有已知配置文件的版本号。
71///
72/// ```
73/// use std::path::Path;
74/// use quanttide_devops::contract::read_all_config_versions;
75/// let versions = read_all_config_versions(Path::new("/tmp/nonexistent"));
76/// assert!(versions.is_empty());
77/// ```
78pub fn read_all_config_versions(dir: &Path) -> Vec<(String, Option<String>)> {
79    let checks: &[(&str, VersionExtract)] = &[
80        ("Cargo.toml", |c| extract_kv_version(c, "version")),
81        ("pyproject.toml", |c| extract_kv_version(c, "version")),
82        ("package.json", extract_json_version),
83        ("pubspec.yaml", |c| extract_kv_yaml_version(c)),
84    ];
85    checks
86        .iter()
87        .filter_map(|(name, extract)| {
88            let path = dir.join(name);
89            if path.exists() {
90                let content = std::fs::read_to_string(&path).ok()?;
91                Some((name.to_string(), extract(&content)))
92            } else {
93                None
94            }
95        })
96        .collect()
97}
98
99fn extract_kv_version(content: &str, key: &str) -> Option<String> {
100    let p = format!("{} = \"", key);
101    for line in content.lines() {
102        let t = line.trim();
103        if let Some(r) = t.strip_prefix(&p)
104            && let Some(end) = r.find('"')
105        {
106            let v = r[..end].to_string();
107            if !v.is_empty() {
108                return Some(v);
109            }
110        }
111    }
112    None
113}
114
115fn extract_json_version(content: &str) -> Option<String> {
116    for line in content.lines() {
117        if let Some(pos) = line.find(r#""version":"#) {
118            let after_key = line[pos + 10..].trim();
119            if let Some(start) = after_key.find('"') {
120                let after_open = &after_key[start + 1..];
121                if let Some(end) = after_open.find('"') {
122                    let v = &after_open[..end];
123                    if !v.is_empty() {
124                        return Some(v.to_string());
125                    }
126                }
127            }
128        }
129    }
130    None
131}
132
133fn extract_kv_yaml_version(content: &str) -> Option<String> {
134    for line in content.lines() {
135        let t = line.trim();
136        if let Some(r) = t.strip_prefix("version:") {
137            let v = r.trim();
138            if !v.is_empty() && !v.starts_with('#') {
139                return Some(v.to_string());
140            }
141        }
142    }
143    None
144}
145
146/// 标准化版本号:去掉 `v` 前缀和 scope 前缀。
147///
148/// ```
149/// use quanttide_devops::contract::normalize_version;
150/// assert_eq!(normalize_version("v1.2.3"), "1.2.3");
151/// assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
152/// ```
153pub fn normalize_version(version: &str) -> String {
154    let after_scope = version.split('/').next_back().unwrap_or(version);
155    after_scope
156        .strip_prefix('v')
157        .unwrap_or(after_scope)
158        .to_string()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    // ── validate_version ──────────────────────────────────────────
166
167    #[test]
168    fn test_validate_version_standard() {
169        assert!(validate_version("v1.2.3"));
170    }
171
172    #[test]
173    fn test_validate_version_prerelease() {
174        assert!(validate_version("v1.2.3-rc.1"));
175        assert!(validate_version("v1.2.3-alpha"));
176    }
177
178    #[test]
179    fn test_validate_version_scoped() {
180        assert!(validate_version("cli/v1.2.3"));
181        assert!(validate_version("pkg.name/v0.1.0"));
182    }
183
184    #[test]
185    fn test_validate_version_no_v() {
186        assert!(!validate_version("1.2.3"));
187    }
188
189    #[test]
190    fn test_validate_version_incomplete() {
191        assert!(!validate_version("v1.2"));
192        assert!(!validate_version("v1"));
193    }
194
195    #[test]
196    fn test_validate_version_empty() {
197        assert!(!validate_version(""));
198    }
199
200    #[test]
201    fn test_validate_version_scope_only() {
202        assert!(!validate_version("cli/"));
203    }
204
205    // ── 版本提取 ──────────────────────────────────────────────────
206
207    #[test]
208    fn test_extract_kv_version() {
209        let c = r#"[package]
210name = "test"
211version = "1.2.3"
212"#;
213        assert_eq!(extract_kv_version(c, "version"), Some("1.2.3".into()));
214    }
215
216    #[test]
217    fn test_extract_kv_version_not_found() {
218        assert_eq!(extract_kv_version("", "version"), None);
219    }
220
221    #[test]
222    fn test_extract_json_version() {
223        assert_eq!(
224            extract_json_version(r#"{"version": "1.0.0"}"#),
225            Some("1.0.0".into())
226        );
227    }
228
229    #[test]
230    fn test_extract_json_version_not_found() {
231        assert_eq!(extract_json_version("{}"), None);
232    }
233
234    #[test]
235    fn test_extract_kv_yaml_version() {
236        assert_eq!(
237            extract_kv_yaml_version("version: 0.2.0"),
238            Some("0.2.0".into())
239        );
240    }
241
242    #[test]
243    fn test_extract_kv_yaml_version_commented() {
244        assert_eq!(extract_kv_yaml_version("# version: 0.2.0"), None);
245    }
246
247    // ── read_all_config_versions ──────────────────────────────────
248
249    #[test]
250    fn test_read_all_config_versions_empty_dir() {
251        let d = tempfile::tempdir().unwrap();
252        assert!(read_all_config_versions(d.path()).is_empty());
253    }
254
255    #[test]
256    fn test_read_all_config_versions_cargo() {
257        let d = tempfile::tempdir().unwrap();
258        std::fs::write(
259            d.path().join("Cargo.toml"),
260            r#"[package]
261name = "test"
262version = "0.1.0"
263"#,
264        )
265        .unwrap();
266        let versions = read_all_config_versions(d.path());
267        assert_eq!(versions.len(), 1);
268        assert_eq!(versions[0].1.as_deref(), Some("0.1.0"));
269    }
270
271    // ── normalize_version ─────────────────────────────────────────
272
273    #[test]
274    fn test_normalize_version_v_prefix() {
275        assert_eq!(normalize_version("v1.2.3"), "1.2.3");
276    }
277
278    #[test]
279    fn test_normalize_version_scoped() {
280        assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
281    }
282
283    #[test]
284    fn test_normalize_version_no_prefix() {
285        assert_eq!(normalize_version("1.2.3"), "1.2.3");
286    }
287}