quanttide_devops/source/roadmap/
parse.rs1use std::path::Path;
4
5use crate::source::roadmap::{
6 Roadmap, RoadmapError, RoadmapVersion, RoadmapVersionBuilder,
7};
8
9impl Roadmap {
14 pub fn from_path(path: &Path) -> Result<Self, RoadmapError> {
16 let raw = std::fs::read_to_string(path)?;
17 Self::from_str(&raw)
18 }
19
20 #[allow(clippy::should_implement_trait)]
28 pub fn from_str(s: &str) -> Result<Self, RoadmapError> {
29 let lines: Vec<&str> = s.lines().collect();
30 validate_first_line(lines.first().copied())?;
31 let versions = parse_versions(&lines[1..])?;
32 Ok(Self {
33 raw: s.to_string(),
34 versions,
35 })
36 }
37}
38
39enum LineKind<'a> {
45 VersionHeader(&'a str, &'a str),
47 Category(&'a str),
49 Checklist { completed: bool, desc: &'a str },
51 Skip,
53}
54
55fn classify_line(line: &str) -> LineKind<'_> {
57 let trimmed = line.trim();
58 if trimmed.is_empty() || trimmed.starts_with('>') {
59 return LineKind::Skip;
60 }
61 if trimmed.starts_with("## ") {
62 let inner = trimmed[3..].trim();
63 if let Some(end) = inner.find(']') {
64 let version = &inner[1..end];
65 let rest = inner[end + 1..].trim();
66 let status = rest
67 .strip_prefix('—')
68 .or_else(|| rest.strip_prefix('-'))
69 .map(|s| s.trim())
70 .unwrap_or("");
71 return LineKind::VersionHeader(version, status);
72 }
73 }
74 if trimmed.starts_with("### ") {
75 return LineKind::Category(trimmed.strip_prefix("### ").unwrap_or("").trim());
76 }
77 if trimmed.starts_with("- [") && trimmed.len() > 5 {
78 let completed = trimmed.as_bytes()[3] == b'x';
79 return LineKind::Checklist {
80 completed,
81 desc: trimmed[5..].trim(),
82 };
83 }
84 LineKind::Skip
85}
86
87fn validate_first_line(first: Option<&str>) -> Result<(), RoadmapError> {
93 match first {
94 None => Err(RoadmapError::Parse("ROADMAP 为空".into())),
95 Some(s) if s.trim().starts_with("# ROADMAP") => Ok(()),
96 Some(s) => Err(RoadmapError::Parse(format!(
97 "首行应包含 `# ROADMAP`,发现: {}",
98 s.trim()
99 ))),
100 }
101}
102
103fn parse_versions(lines: &[&str]) -> Result<Vec<RoadmapVersion>, RoadmapError> {
105 let mut versions: Vec<RoadmapVersion> = Vec::new();
106 let mut current_version: Option<RoadmapVersionBuilder> = None;
107
108 for line in lines {
109 match classify_line(line) {
110 LineKind::Skip => {}
111 LineKind::VersionHeader(version, status) => {
112 if let Some(builder) = current_version.take() {
113 versions.push(builder.build());
114 }
115 current_version = Some(RoadmapVersionBuilder::new(
116 normalize_version(version),
117 status.to_string(),
118 ));
119 }
120 LineKind::Category(name) => {
121 if let Some(ref mut builder) = current_version {
122 builder.add_category(name.to_string());
123 }
124 }
125 LineKind::Checklist { completed, desc } => {
126 if let Some(ref mut builder) = current_version {
127 builder.add_issue(completed, desc.to_string());
128 }
129 }
130 }
131 }
132
133 if let Some(builder) = current_version.take() {
134 versions.push(builder.build());
135 }
136
137 if versions.is_empty() {
138 return Err(RoadmapError::Parse(
139 "未找到任何版本区块 (`## [x.y.z]`)".into(),
140 ));
141 }
142
143 Ok(versions)
144}
145
146fn normalize_version(v: &str) -> String {
148 v.strip_prefix('v').unwrap_or(v).to_string()
149}
150
151#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::source::roadmap::Roadmap;
159
160 fn sample_roadmap() -> &'static str {
161 "\
162# ROADMAP
163
164> 格式说明。
165
166## [0.1.5] — 待实施
167
168### Added
169- [ ] item one
170- [ ] item two
171
172### Fixed
173- [ ] bug fix
174
175## [0.1.4] — 已发布
176
177### Added
178- [x] changelog module
179- [x] CI workflow
180"
181 }
182
183 fn mixed_roadmap() -> &'static str {
184 "\
185# ROADMAP
186
187## [0.2.0] — 待实施
188
189### Added
190- [ ] big feature
191
192## [0.1.0] — 已发布
193
194### Added
195- [x] initial
196- [x] second
197- [ ] third
198"
199 }
200
201 #[test]
204 fn test_from_str_empty() {
205 let r = Roadmap::from_str("");
206 assert!(r.is_err());
207 assert!(r.unwrap_err().to_string().contains("为空"));
208 }
209
210 #[test]
211 fn test_from_str_no_header() {
212 let r = Roadmap::from_str("## [0.1.0] — test\n");
213 assert!(r.is_err());
214 assert!(r.unwrap_err().to_string().contains("首行"));
215 }
216
217 #[test]
218 fn test_from_str_header_with_suffix() {
219 let s = "\
220# ROADMAP — cli
221
222## [0.1.0] — test
223
224### Added
225- [ ] something
226";
227 let r = Roadmap::from_str(s).unwrap();
228 assert_eq!(r.versions().len(), 1);
229 assert_eq!(r.versions()[0].version, "0.1.0");
230 }
231
232 #[test]
233 fn test_from_str_valid() {
234 let r = Roadmap::from_str(sample_roadmap()).unwrap();
235 let versions = r.versions();
236 assert_eq!(versions.len(), 2);
237
238 let v1 = &versions[0];
239 assert_eq!(v1.version, "0.1.5");
240 assert_eq!(v1.status, "待实施");
241 assert_eq!(v1.categories.len(), 2);
242 assert_eq!(v1.categories[0].0, "Added");
243 assert_eq!(v1.categories[0].1.len(), 2);
244 assert_eq!(v1.categories[1].0, "Fixed");
245 assert_eq!(v1.categories[1].1.len(), 1);
246
247 assert!(!v1.categories[0].1[0].completed);
248 assert!(!v1.categories[0].1[1].completed);
249 assert!(!v1.categories[1].1[0].completed);
250
251 assert_eq!(v1.total, 3);
252 assert_eq!(v1.done, 0);
253
254 let v2 = &versions[1];
255 assert_eq!(v2.version, "0.1.4");
256 assert_eq!(v2.status, "已发布");
257 assert_eq!(v2.total, 2);
258 assert_eq!(v2.done, 2);
259
260 assert_eq!(r.total_done(), 2);
261 assert_eq!(r.total_all(), 5);
262 }
263
264 #[test]
265 fn test_from_str_mixed() {
266 let r = Roadmap::from_str(mixed_roadmap()).unwrap();
267 let versions = r.versions();
268 assert_eq!(versions.len(), 2);
269
270 let v1 = &versions[0];
271 assert_eq!(v1.version, "0.2.0");
272 assert_eq!(v1.status, "待实施");
273 assert_eq!(v1.total, 1);
274 assert_eq!(v1.done, 0);
275
276 let v2 = &versions[1];
277 assert_eq!(v2.version, "0.1.0");
278 assert_eq!(v2.status, "已发布");
279 assert_eq!(v2.total, 3);
280 assert_eq!(v2.done, 2);
281
282 assert_eq!(r.total_done(), 2);
283 assert_eq!(r.total_all(), 4);
284 }
285
286 #[test]
289 fn test_from_path() {
290 let d = tempfile::tempdir().unwrap();
291 let path = d.path().join("ROADMAP.md");
292 std::fs::write(&path, sample_roadmap()).unwrap();
293 let r = Roadmap::from_path(&path).unwrap();
294 assert_eq!(r.versions().len(), 2);
295 assert_eq!(r.versions()[0].version, "0.1.5");
296 }
297
298 #[test]
299 fn test_from_path_not_found() {
300 let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
301 assert!(r.is_err());
302 }
303
304 #[test]
307 fn test_from_str_parse_version_header_error() {
308 let s = "\
309# ROADMAP
310
311## 无方括号版本
312";
313 let r = Roadmap::from_str(s);
314 assert!(r.is_err());
315 assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
316 }
317
318 #[test]
319 fn test_from_str_no_versions() {
320 let s = "\
321# ROADMAP
322
323> 只有描述,没有版本。
324";
325 let r = Roadmap::from_str(s);
326 assert!(r.is_err());
327 assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
328 }
329
330}