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            // 跳过开头的引号
120            if let Some(start) = after_key.find('"') {
121                let after_open = &after_key[start + 1..];
122                if let Some(end) = after_open.find('"') {
123                    let v = &after_open[..end];
124                    if !v.is_empty() {
125                        return Some(v.to_string());
126                    }
127                }
128            }
129        }
130    }
131    None
132}
133
134fn extract_kv_yaml_version(content: &str) -> Option<String> {
135    for line in content.lines() {
136        let t = line.trim();
137        if let Some(r) = t.strip_prefix("version:") {
138            let v = r.trim();
139            if !v.is_empty() && !v.starts_with('#') {
140                return Some(v.to_string());
141            }
142        }
143    }
144    None
145}
146
147/// 标准化版本号:去掉 `v` 前缀和 scope 前缀。
148///
149/// ```
150/// use quanttide_devops::contract::normalize_version;
151/// assert_eq!(normalize_version("v1.2.3"), "1.2.3");
152/// assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
153/// ```
154pub fn normalize_version(version: &str) -> String {
155    let after_scope = version.split('/').next_back().unwrap_or(version);
156    after_scope
157        .strip_prefix('v')
158        .unwrap_or(after_scope)
159        .to_string()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    // ── validate_version ──────────────────────────────────────────
167
168    #[test]
169    fn test_validate_version_standard() {
170        assert!(validate_version("v1.2.3"));
171    }
172
173    #[test]
174    fn test_validate_version_prerelease() {
175        assert!(validate_version("v1.2.3-rc.1"));
176        assert!(validate_version("v1.2.3-alpha"));
177    }
178
179    #[test]
180    fn test_validate_version_scoped() {
181        assert!(validate_version("cli/v1.2.3"));
182        assert!(validate_version("pkg.name/v0.1.0"));
183    }
184
185    #[test]
186    fn test_validate_version_no_v() {
187        assert!(!validate_version("1.2.3"));
188    }
189
190    #[test]
191    fn test_validate_version_incomplete() {
192        assert!(!validate_version("v1.2"));
193        assert!(!validate_version("v1"));
194    }
195
196    #[test]
197    fn test_validate_version_empty() {
198        assert!(!validate_version(""));
199    }
200
201    #[test]
202    fn test_validate_version_scope_only() {
203        assert!(!validate_version("cli/"));
204    }
205
206    // ── 版本提取 ──────────────────────────────────────────────────
207
208    #[test]
209    fn test_extract_kv_version() {
210        let c = r#"[package]
211name = "test"
212version = "1.2.3"
213"#;
214        assert_eq!(extract_kv_version(c, "version"), Some("1.2.3".into()));
215    }
216
217    #[test]
218    fn test_extract_kv_version_not_found() {
219        assert_eq!(extract_kv_version("", "version"), None);
220    }
221
222    #[test]
223    fn test_extract_json_version() {
224        assert_eq!(
225            extract_json_version(r#"{"version": "1.0.0"}"#),
226            Some("1.0.0".into())
227        );
228    }
229
230    #[test]
231    fn test_extract_json_version_not_found() {
232        assert_eq!(extract_json_version("{}"), None);
233    }
234
235    #[test]
236    fn test_extract_kv_yaml_version() {
237        assert_eq!(
238            extract_kv_yaml_version("version: 0.2.0"),
239            Some("0.2.0".into())
240        );
241    }
242
243    #[test]
244    fn test_extract_kv_yaml_version_commented() {
245        assert_eq!(extract_kv_yaml_version("# version: 0.2.0"), None);
246    }
247
248    // ── read_all_config_versions ──────────────────────────────────
249
250    #[test]
251    fn test_read_all_config_versions_empty_dir() {
252        let d = tempfile::tempdir().unwrap();
253        assert!(read_all_config_versions(d.path()).is_empty());
254    }
255
256    #[test]
257    fn test_read_all_config_versions_cargo() {
258        let d = tempfile::tempdir().unwrap();
259        std::fs::write(
260            d.path().join("Cargo.toml"),
261            r#"[package]
262name = "test"
263version = "0.1.0"
264"#,
265        )
266        .unwrap();
267        let versions = read_all_config_versions(d.path());
268        assert_eq!(versions.len(), 1);
269        assert_eq!(versions[0].1.as_deref(), Some("0.1.0"));
270    }
271
272    // ── normalize_version ─────────────────────────────────────────
273
274    #[test]
275    fn test_normalize_version_v_prefix() {
276        assert_eq!(normalize_version("v1.2.3"), "1.2.3");
277    }
278
279    #[test]
280    fn test_normalize_version_scoped() {
281        assert_eq!(normalize_version("cli/v0.5.0"), "0.5.0");
282    }
283
284    #[test]
285    fn test_normalize_version_no_prefix() {
286        assert_eq!(normalize_version("1.2.3"), "1.2.3");
287    }
288}