Skip to main content

quanttide_devops/source/roadmap/
parse.rs

1//! ROADMAP 解析实现:行分类、版本区块提取、格式校验。
2
3use std::path::Path;
4
5use crate::source::roadmap::{
6    Roadmap, RoadmapError, RoadmapVersion, RoadmapVersionBuilder,
7};
8
9// ═══════════════════════════════════════════════════════════════════════
10// 入口
11// ═══════════════════════════════════════════════════════════════════════
12
13impl Roadmap {
14    /// 从文件路径解析 ROADMAP.md。
15    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    /// 从字符串解析 ROADMAP.md。
21    ///
22    /// 格式约定(Keep a Changelog 变体):
23    /// - `# ROADMAP` 作为文档标题(首行可以带后缀,如 `# ROADMAP — cli`)
24    /// - `## [版本号] — 状态` 作为版本边界
25    /// - `### 分类` 作为类别分组(Added / Fixed / Changed 等)
26    /// - `- [ ] 描述` 为待办条目,`- [x] 描述` 为已完成
27    #[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
39// ═══════════════════════════════════════════════════════════════════════
40// 行分类器
41// ═══════════════════════════════════════════════════════════════════════
42
43/// 行类型,用于降低解析循环的嵌套深度。
44enum LineKind<'a> {
45    /// `## [version] — status`
46    VersionHeader(&'a str, &'a str),
47    /// `### CategoryName`
48    Category(&'a str),
49    /// `- [ ] desc` 或 `- [x] desc`
50    Checklist { completed: bool, desc: &'a str },
51    /// 应跳过的行(空行、blockquote)
52    Skip,
53}
54
55/// 对一行按 ROADMAP 格式分类。
56fn 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
87// ═══════════════════════════════════════════════════════════════════════
88// 解析循环
89// ═══════════════════════════════════════════════════════════════════════
90
91/// 校验第一行是否以 `# ROADMAP` 开头。
92fn 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
103/// 解析版本区块,返回所有版本(自上而下 = 最新优先)。
104fn 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
146/// 标准化版本号:去掉 `v` 前缀。
147fn normalize_version(v: &str) -> String {
148    v.strip_prefix('v').unwrap_or(v).to_string()
149}
150
151// ═══════════════════════════════════════════════════════════════════════
152// 测试
153// ═══════════════════════════════════════════════════════════════════════
154
155#[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    // ── from_str ─────────────────────────────────────────────────
202
203    #[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    // ── from_path ────────────────────────────────────────────────
287
288    #[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    // ── 边界 ─────────────────────────────────────────────────────
305
306    #[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}