quanttide_devops/source/roadmap/
validate.rs1use crate::source::roadmap::{Roadmap, RoadmapIssue};
4
5impl Roadmap {
10 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
28fn 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
45fn 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
76fn 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
104fn 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#[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}