Skip to main content

quanttide_devops/source/roadmap/
validate.rs

1//! ROADMAP 格式验证:版本号格式、分类大小写、checkbox 格式。
2
3use crate::source::roadmap::{Roadmap, RoadmapIssue};
4
5// ═══════════════════════════════════════════════════════════════════════
6// 格式验证
7// ═══════════════════════════════════════════════════════════════════════
8
9impl Roadmap {
10    /// 验证 ROADMAP.md 格式规范。
11    ///
12    /// 规则:
13    /// - 版本号必须为纯数字 X.Y.Z
14    /// - 分类标题必须使用标准大小写(`### Added` 而非 `### added`)
15    /// - checkbox 必须使用标准格式(`- [ ]` 或 `- [x]`)
16    ///
17    /// `scope` 参数用于标记问题所属范围(通常传入 scope name)。
18    pub fn validate(&self, scope: &str) -> Vec<RoadmapIssue> {
19        let lines: Vec<&str> = self.raw.lines().collect();
20        let mut issues = Vec::new();
21        issues.extend(check_version_headers(&lines, scope));
22        issues.extend(check_category_case(&lines, scope));
23        issues.extend(check_checkbox_format(&lines, scope));
24        issues
25    }
26}
27
28// ═══════════════════════════════════════════════════════════════════════
29// 检查函数
30// ═══════════════════════════════════════════════════════════════════════
31
32/// 返回分类的标准大小写形式。未知分类保持原样返回。
33fn category_expected_case(s: &str) -> String {
34    match s {
35        "added" | "Added" => "Added".into(),
36        "changed" | "Changed" => "Changed".into(),
37        "deprecated" | "Deprecated" => "Deprecated".into(),
38        "removed" | "Removed" => "Removed".into(),
39        "fixed" | "Fixed" => "Fixed".into(),
40        "security" | "Security" => "Security".into(),
41        _ => s.to_string(),
42    }
43}
44
45/// 检查版本号格式(`## [X.Y.Z]`)。
46fn check_version_headers(lines: &[&str], scope: &str) -> Vec<RoadmapIssue> {
47    lines
48        .iter()
49        .enumerate()
50        .filter_map(|(i, line)| {
51            let trimmed = line.trim();
52            if !trimmed.starts_with("## [") {
53                return None;
54            }
55            let end = trimmed.find(']')?;
56            let raw_version = &trimmed[4..end];
57            let clean = raw_version.strip_prefix('v').unwrap_or(raw_version);
58            let parts: Vec<&str> = clean.split('.').collect();
59            if parts.len() != 3
60                || parts
61                    .iter()
62                    .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
63            {
64                Some(RoadmapIssue {
65                    line: i + 1,
66                    scope: scope.to_string(),
67                    message: format!("版本号格式异常(期待 `X.Y.Z`): `{}`", raw_version),
68                })
69            } else {
70                None
71            }
72        })
73        .collect()
74}
75
76/// 检查分类标题的标准大小写。
77fn check_category_case(lines: &[&str], scope: &str) -> Vec<RoadmapIssue> {
78    lines
79        .iter()
80        .enumerate()
81        .filter_map(|(i, line)| {
82            let trimmed = line.trim();
83            if !trimmed.starts_with("### ") {
84                return None;
85            }
86            let category = trimmed.strip_prefix("### ").unwrap_or("").trim();
87            let expected = category_expected_case(category);
88            if category != expected {
89                Some(RoadmapIssue {
90                    line: i + 1,
91                    scope: scope.to_string(),
92                    message: format!(
93                        "分类标题大小写不标准: `### {}`,标准写法为 `### {}`",
94                        category, expected
95                    ),
96                })
97            } else {
98                None
99            }
100        })
101        .collect()
102}
103
104/// 检查 checkbox 格式。
105fn check_checkbox_format(lines: &[&str], scope: &str) -> Vec<RoadmapIssue> {
106    lines
107        .iter()
108        .enumerate()
109        .filter_map(|(i, line)| {
110            let trimmed = line.trim();
111            if !trimmed.starts_with("- [") || trimmed.len() <= 5 {
112                return None;
113            }
114            let third = trimmed.as_bytes().get(3);
115            if third != Some(&b' ') && third != Some(&b'x') {
116                Some(RoadmapIssue {
117                    line: i + 1,
118                    scope: scope.to_string(),
119                    message: format!(
120                        "checkbox 格式异常: `{}`,标准为 `- [ ]` 或 `- [x]`",
121                        trimmed
122                    ),
123                })
124            } else {
125                None
126            }
127        })
128        .collect()
129}
130
131// ═══════════════════════════════════════════════════════════════════════
132// 测试
133// ═══════════════════════════════════════════════════════════════════════
134
135#[cfg(test)]
136mod tests {
137    use crate::source::roadmap::Roadmap;
138
139    fn sample_roadmap() -> &'static str {
140        "\
141# ROADMAP
142
143> 格式说明。
144
145## [0.1.5] — 待实施
146
147### Added
148- [ ] item one
149- [ ] item two
150
151### Fixed
152- [ ] bug fix
153
154## [0.1.4] — 已发布
155
156### Added
157- [x] changelog module
158- [x] CI workflow
159"
160    }
161
162    #[test]
163    fn test_validate_valid() {
164        let r = Roadmap::from_str(sample_roadmap()).unwrap();
165        let issues = r.validate("test-scope");
166        let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
167        assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
168    }
169
170    #[test]
171    fn test_validate_v_prefix_allowed() {
172        let s = "\
173# ROADMAP
174
175## [v0.1.0] — test
176
177### Added
178- [ ] something
179";
180        let r = Roadmap::from_str(s).unwrap();
181        assert_eq!(r.versions()[0].version, "0.1.0");
182        let issues = r.validate("scope");
183        let v_issues: Vec<_> = issues
184            .iter()
185            .filter(|i| i.message.contains("v 前缀"))
186            .collect();
187        assert!(
188            v_issues.is_empty(),
189            "不应有 v 前缀相关验证问题: {:?}",
190            v_issues
191        );
192    }
193
194    #[test]
195    fn test_validate_invalid_version() {
196        let s = "\
197# ROADMAP
198
199## [abc] — 待实施
200
201### Added
202- [ ] something
203";
204        let r = Roadmap::from_str(s).unwrap();
205        let issues = r.validate("scope");
206        assert!(!issues.is_empty());
207        assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
208    }
209
210    #[test]
211    fn test_validate_category_case() {
212        let s = "\
213# ROADMAP
214
215## [0.1.0] — test
216
217### added
218- [ ] something
219";
220        let r = Roadmap::from_str(s).unwrap();
221        let issues = r.validate("scope");
222        assert!(!issues.is_empty());
223        assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
224    }
225
226    #[test]
227    fn test_validate_line_numbers() {
228        let s = "\
229# ROADMAP
230
231## [abc] — test
232
233### Added
234- [ ] ok
235";
236        let r = Roadmap::from_str(s).unwrap();
237        let issues = r.validate("scope");
238        assert!(!issues.is_empty());
239        assert_eq!(issues[0].line, 3);
240        assert_eq!(issues[0].scope, "scope");
241    }
242
243    #[test]
244    fn test_validate_invalid_checkbox() {
245        let s = "\
246# ROADMAP
247
248## [0.1.0] — test
249
250### Added
251- [X] uppercase
252";
253        let r = Roadmap::from_str(s).unwrap();
254        let issues = r.validate("scope");
255        let checkbox_issues: Vec<_> = issues
256            .iter()
257            .filter(|i| i.message.contains("checkbox 格式异常"))
258            .collect();
259        assert_eq!(checkbox_issues.len(), 1);
260        assert!(checkbox_issues[0].message.contains("- [X]"));
261    }
262
263    #[test]
264    fn test_validate_unknown_category() {
265        let s = "\
266# ROADMAP
267
268## [0.1.0] — test
269
270### CustomSection
271- [ ] something
272";
273        let r = Roadmap::from_str(s).unwrap();
274        let issues = r.validate("scope");
275        assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
276    }
277}