Skip to main content

quanttide_devops/source/
roadmap.rs

1use std::path::Path;
2
3// ═══════════════════════════════════════════════════════════════════════
4// 错误类型
5// ═══════════════════════════════════════════════════════════════════════
6
7/// ROADMAP 操作错误。
8#[derive(Debug)]
9pub enum RoadmapError {
10    /// 文件读取失败。
11    Io(std::io::Error),
12    /// 解析失败(格式不符合预期)。
13    Parse(String),
14}
15
16impl std::fmt::Display for RoadmapError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Self::Io(e) => write!(f, "读取 ROADMAP 失败: {}", e),
20            Self::Parse(e) => write!(f, "解析 ROADMAP 失败: {}", e),
21        }
22    }
23}
24
25impl std::error::Error for RoadmapError {}
26
27impl From<std::io::Error> for RoadmapError {
28    fn from(e: std::io::Error) -> Self {
29        Self::Io(e)
30    }
31}
32
33// ═══════════════════════════════════════════════════════════════════════
34// 类型定义
35// ═══════════════════════════════════════════════════════════════════════
36
37/// 单个 checklist 条目。
38#[derive(Debug, Clone, PartialEq)]
39pub struct RoadmapChecklistItem {
40    /// 描述文本。
41    pub description: String,
42    /// 是否已勾选(`[x]`)。
43    pub completed: bool,
44}
45
46/// 进度统计(用于 RoadmapProgress 和 RoadmapVersion 中的进度字段)。
47#[derive(Debug, Clone, PartialEq)]
48pub struct RoadmapProgress {
49    /// 总条目数。
50    pub total: usize,
51    /// 已完成条目数。
52    pub completed: usize,
53}
54
55impl RoadmapProgress {
56    /// 完成百分比(0.0 ~ 100.0)。无条目时返回 100.0。
57    pub fn percent(&self) -> f64 {
58        if self.total == 0 {
59            return 100.0;
60        }
61        (self.completed as f64 / self.total as f64) * 100.0
62    }
63}
64
65/// 单版本的规划进度。
66#[derive(Debug, Clone, PartialEq)]
67pub struct RoadmapVersion {
68    /// 版本号(如 `"0.1.5"`)。
69    pub version: String,
70    /// 状态标签(如 `"待实施"`、`"已发布"`)。
71    pub status: String,
72    /// 已完成条目数。
73    pub done: usize,
74    /// 总条目数。
75    pub total: usize,
76    /// 分类分组:`(分类名, 条目列表)`。
77    /// 分类名即 `### Added` / `### Fixed` 等去掉 `### ` 前缀。
78    pub categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
79}
80
81impl RoadmapVersion {
82    /// 完成百分比(0.0 ~ 100.0)。无条目时返回 100.0。
83    pub fn percent(&self) -> f64 {
84        if self.total == 0 {
85            return 100.0;
86        }
87        (self.done as f64 / self.total as f64) * 100.0
88    }
89}
90
91/// 格式验证发现的单个问题。
92#[derive(Debug, Clone, PartialEq)]
93pub struct RoadmapIssue {
94    /// 问题所在行号(1-based)。
95    pub line: usize,
96    /// 问题所属 scope(验证时传入)。
97    pub scope: String,
98    /// 问题描述。
99    pub message: String,
100}
101
102/// 解析后的 ROADMAP.md 文档。
103#[derive(Debug, Clone, PartialEq)]
104pub struct Roadmap {
105    /// 原始文本(保障引用有效性,但当前未使用;保留以便未来扩展)。
106    #[allow(dead_code)]
107    raw: String,
108    /// 所有版本区块(自上而下 = 最新优先)。
109    versions: Vec<RoadmapVersion>,
110}
111
112// ═══════════════════════════════════════════════════════════════════════
113// 解析
114// ═══════════════════════════════════════════════════════════════════════
115
116impl Roadmap {
117    /// 从文件路径解析 ROADMAP.md。
118    pub fn from_path(path: &Path) -> Result<Self, RoadmapError> {
119        let raw = std::fs::read_to_string(path)?;
120        Self::from_str(&raw)
121    }
122
123    /// 从字符串解析 ROADMAP.md。
124    ///
125    /// 格式约定(Keep a Changelog 变体):
126    /// - `# ROADMAP` 作为文档标题(首行可以带后缀,如 `# ROADMAP — cli`)
127    /// - `## [版本号] — 状态` 作为版本边界
128    /// - `### 分类` 作为类别分组(Added / Fixed / Changed 等)
129    /// - `- [ ] 描述` 为待办条目,`- [x] 描述` 为已完成
130    #[allow(clippy::should_implement_trait)]
131    pub fn from_str(s: &str) -> Result<Self, RoadmapError> {
132        let lines: Vec<&str> = s.lines().collect();
133        if lines.is_empty() {
134            return Err(RoadmapError::Parse("ROADMAP 为空".into()));
135        }
136
137        // 校验第一行是否以 `# ROADMAP` 开头
138        let first = lines[0].trim();
139        if !first.starts_with("# ROADMAP") {
140            return Err(RoadmapError::Parse(format!(
141                "首行应包含 `# ROADMAP`,发现: {}",
142                first
143            )));
144        }
145
146        let mut versions: Vec<RoadmapVersion> = Vec::new();
147        let mut current_version: Option<RoadmapVersionBuilder> = None;
148
149        for line in &lines[1..] {
150            let trimmed = line.trim();
151
152            // 跳过空行和 blockquote
153            if trimmed.is_empty() || trimmed.starts_with('>') {
154                continue;
155            }
156
157            if trimmed.starts_with("## ") {
158                // 新版本区块开始
159                if let Some(builder) = current_version.take() {
160                    versions.push(builder.build());
161                }
162                match parse_version_header(trimmed) {
163                    Ok((version, status)) => {
164                        current_version = Some(RoadmapVersionBuilder::new(version, status));
165                    }
166                    Err(e) => {
167                        return Err(RoadmapError::Parse(format!(
168                            "版本标题格式无效: {} — {}",
169                            trimmed, e
170                        )));
171                    }
172                }
173            } else if let Some(ref mut builder) = current_version {
174                if trimmed.starts_with("### ") {
175                    // 新分类
176                    let category = trimmed
177                        .strip_prefix("### ")
178                        .unwrap_or("")
179                        .trim()
180                        .to_string();
181                    builder.add_category(category);
182                } else if trimmed.starts_with("- [") && trimmed.len() > 5 {
183                    // checklist 条目:`- [ ]` 或 `- [x]`
184                    let completed = trimmed.as_bytes()[3] == b'x';
185                    let description = trimmed[5..].trim().to_string();
186                    builder.add_issue(completed, description);
187                }
188                // 其他行(描述文本)忽略
189            }
190        }
191
192        // 收尾最后一个版本
193        if let Some(builder) = current_version.take() {
194            versions.push(builder.build());
195        }
196
197        if versions.is_empty() {
198            return Err(RoadmapError::Parse(
199                "未找到任何版本区块 (`## [x.y.z]`)".into(),
200            ));
201        }
202
203        Ok(Self {
204            raw: s.to_string(),
205            versions,
206        })
207    }
208}
209
210// ═══════════════════════════════════════════════════════════════════════
211// 公共访问器
212// ═══════════════════════════════════════════════════════════════════════
213
214impl Roadmap {
215    /// 获取所有版本的规划进度。
216    pub fn versions(&self) -> &[RoadmapVersion] {
217        &self.versions
218    }
219
220    /// 总已完成条目数。
221    pub fn total_done(&self) -> usize {
222        self.versions.iter().map(|v| v.done).sum()
223    }
224
225    /// 总条目数。
226    pub fn total_all(&self) -> usize {
227        self.versions.iter().map(|v| v.total).sum()
228    }
229}
230
231// ═══════════════════════════════════════════════════════════════════════
232// 格式验证
233// ═══════════════════════════════════════════════════════════════════════
234
235impl Roadmap {
236    /// 验证 ROADMAP.md 格式规范。
237    ///
238    /// 规则:
239    /// - 版本号必须为纯数字 X.Y.Z
240    /// - 分类标题必须使用标准大小写(`### Added` 而非 `### added`)
241    /// - checkbox 必须使用标准格式(`- [ ]` 或 `- [x]`)
242    ///
243    /// `scope` 参数用于标记问题所属范围(通常传入 scope name)。
244    pub fn validate(&self, scope: &str) -> Vec<RoadmapIssue> {
245        let mut issues = Vec::new();
246        let lines: Vec<&str> = self.raw.lines().collect();
247
248        for (line_number, line) in lines.iter().enumerate() {
249            let trimmed = line.trim();
250            let line_number = line_number + 1;
251
252            // 检查版本号格式(`## [0.1.0]` 或 `## [v0.1.0]`)
253            if trimmed.starts_with("## [")
254                && let Some(end) = trimmed.find(']')
255            {
256                let raw_version = &trimmed[4..end];
257                // 去 v 前缀后验证 X.Y.Z 格式
258                let clean = raw_version.strip_prefix('v').unwrap_or(raw_version);
259                let parts: Vec<&str> = clean.split('.').collect();
260                if parts.len() != 3
261                    || parts
262                        .iter()
263                        .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
264                {
265                    issues.push(RoadmapIssue {
266                        line: line_number,
267                        scope: scope.to_string(),
268                        message: format!("版本号格式异常(期待 `X.Y.Z`): `{}`", raw_version),
269                    });
270                }
271            }
272
273            // 检查分类标题的标准大小写
274            if trimmed.starts_with("### ") {
275                let category = trimmed.strip_prefix("### ").unwrap_or("").trim();
276                let expected = category_expected_case(category);
277                if category != expected {
278                    issues.push(RoadmapIssue {
279                        line: line_number,
280                        scope: scope.to_string(),
281                        message: format!(
282                            "分类标题大小写不标准: `### {}`,标准写法为 `### {}`",
283                            category, expected
284                        ),
285                    });
286                }
287            }
288
289            // 检查 checklist 格式
290            if trimmed.starts_with("- [") && trimmed.len() > 5 {
291                let third = trimmed.as_bytes().get(3);
292                if third != Some(&b' ') && third != Some(&b'x') {
293                    issues.push(RoadmapIssue {
294                        line: line_number,
295                        scope: scope.to_string(),
296                        message: format!(
297                            "checkbox 格式异常: `{}`,标准为 `- [ ]` 或 `- [x]`",
298                            trimmed
299                        ),
300                    });
301                }
302            }
303        }
304
305        issues
306    }
307}
308
309/// 返回分类的标准大小写形式。未知分类保持原样返回。
310fn category_expected_case(s: &str) -> String {
311    match s {
312        "added" | "Added" => "Added".into(),
313        "changed" | "Changed" => "Changed".into(),
314        "deprecated" | "Deprecated" => "Deprecated".into(),
315        "removed" | "Removed" => "Removed".into(),
316        "fixed" | "Fixed" => "Fixed".into(),
317        "security" | "Security" => "Security".into(),
318        _ => s.to_string(),
319    }
320}
321
322// ═══════════════════════════════════════════════════════════════════════
323// 内部构建器
324// ═══════════════════════════════════════════════════════════════════════
325
326struct RoadmapVersionBuilder {
327    version: String,
328    status: String,
329    categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
330}
331
332impl RoadmapVersionBuilder {
333    fn new(version: String, status: String) -> Self {
334        Self {
335            version,
336            status,
337            categories: Vec::new(),
338        }
339    }
340
341    fn add_category(&mut self, name: String) {
342        self.categories.push((name, Vec::new()));
343    }
344
345    fn add_issue(&mut self, completed: bool, description: String) {
346        if let Some(last) = self.categories.last_mut() {
347            last.1.push(RoadmapChecklistItem {
348                description,
349                completed,
350            });
351        }
352        // 没有打开的分类时不处理(格式异常但容错)
353    }
354
355    fn build(self) -> RoadmapVersion {
356        let total: usize = self.categories.iter().map(|(_, items)| items.len()).sum();
357        let done: usize = self
358            .categories
359            .iter()
360            .flat_map(|(_, items)| items)
361            .filter(|i| i.completed)
362            .count();
363
364        RoadmapVersion {
365            version: self.version,
366            status: self.status,
367            done,
368            total,
369            categories: self.categories,
370        }
371    }
372}
373
374// ═══════════════════════════════════════════════════════════════════════
375// 辅助函数
376// ═══════════════════════════════════════════════════════════════════════
377
378/// 解析 `## [0.1.5] — 待实施` 格式的版本标题。
379fn parse_version_header(s: &str) -> Result<(String, String), String> {
380    // s 形如 `## [0.1.5] — 待实施`,已 trim
381    let inner = s[3..].trim(); // 去掉 `## `
382    if !inner.starts_with('[') {
383        return Err("版本号应以 `[` 开头".into());
384    }
385    let close_bracket = inner.find(']').ok_or("缺少 `]`".to_string())?;
386    let version = inner[1..close_bracket].to_string();
387    if version.is_empty() {
388        return Err("版本号为空".into());
389    }
390    // 标准化:去掉 v 前缀(与 CHANGELOG 的 parse-changelog 行为一致)
391    let version = version.strip_prefix('v').unwrap_or(&version).to_string();
392    let rest = inner[close_bracket + 1..].trim();
393    // 分隔符可以是 ` — `、` - `、` —`、` -` 等
394    let status = if let Some(pos) = rest.find('—') {
395        rest[pos + 3..].trim().to_string()
396    } else if let Some(pos) = rest.find('-') {
397        rest[pos + 1..].trim().to_string()
398    } else {
399        rest.to_string()
400    };
401
402    Ok((version, status))
403}
404
405// ═══════════════════════════════════════════════════════════════════════
406// 测试
407// ═══════════════════════════════════════════════════════════════════════
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    // ── Error display ────────────────────────────────────────────────
414
415    #[test]
416    fn test_roadmap_error_display() {
417        let err = RoadmapError::Io(std::io::Error::new(
418            std::io::ErrorKind::NotFound,
419            "not found",
420        ));
421        assert!(err.to_string().contains("读取 ROADMAP 失败"));
422
423        let err = RoadmapError::Parse("bad format".into());
424        assert!(err.to_string().contains("解析 ROADMAP 失败"));
425    }
426
427    fn sample_roadmap() -> &'static str {
428        "\
429# ROADMAP
430
431> 格式说明。
432
433## [0.1.5] — 待实施
434
435### Added
436- [ ] item one
437- [ ] item two
438
439### Fixed
440- [ ] bug fix
441
442## [0.1.4] — 已发布
443
444### Added
445- [x] changelog module
446- [x] CI workflow
447"
448    }
449
450    fn mixed_roadmap() -> &'static str {
451        "\
452# ROADMAP
453
454## [0.2.0] — 待实施
455
456### Added
457- [ ] big feature
458
459## [0.1.0] — 已发布
460
461### Added
462- [x] initial
463- [x] second
464- [ ] third
465"
466    }
467
468    // ── RoadmapVersion ─────────────────────────────────────────────
469
470    #[test]
471    fn test_version_percent() {
472        let v = RoadmapVersion {
473            version: "0.1.0".into(),
474            status: "test".into(),
475            done: 2,
476            total: 4,
477            categories: Vec::new(),
478        };
479        assert!((v.percent() - 50.0).abs() < f64::EPSILON);
480    }
481
482    #[test]
483    fn test_version_percent_empty() {
484        let v = RoadmapVersion {
485            version: "0.1.0".into(),
486            status: "test".into(),
487            done: 0,
488            total: 0,
489            categories: Vec::new(),
490        };
491        assert!((v.percent() - 100.0).abs() < f64::EPSILON);
492    }
493
494    #[test]
495    fn test_version_percent_all_done() {
496        let v = RoadmapVersion {
497            version: "0.1.0".into(),
498            status: "test".into(),
499            done: 3,
500            total: 3,
501            categories: Vec::new(),
502        };
503        assert!((v.percent() - 100.0).abs() < f64::EPSILON);
504    }
505
506    #[test]
507    fn test_version_percent_none_done() {
508        let v = RoadmapVersion {
509            version: "0.1.0".into(),
510            status: "test".into(),
511            done: 0,
512            total: 5,
513            categories: Vec::new(),
514        };
515        assert!((v.percent() - 0.0).abs() < f64::EPSILON);
516    }
517
518    // ── RoadmapProgress ────────────────────────────────────────────
519
520    #[test]
521    fn test_progress_percent() {
522        let p = RoadmapProgress {
523            total: 4,
524            completed: 2,
525        };
526        assert!((p.percent() - 50.0).abs() < f64::EPSILON);
527    }
528
529    #[test]
530    fn test_progress_empty() {
531        let p = RoadmapProgress {
532            total: 0,
533            completed: 0,
534        };
535        assert!((p.percent() - 100.0).abs() < f64::EPSILON);
536    }
537
538    // ── 解析 ────────────────────────────────────────────────────────
539
540    #[test]
541    fn test_from_str_empty() {
542        let r = Roadmap::from_str("");
543        assert!(r.is_err());
544        assert!(r.unwrap_err().to_string().contains("为空"));
545    }
546
547    #[test]
548    fn test_from_str_no_header() {
549        let r = Roadmap::from_str("## [0.1.0] — test\n");
550        assert!(r.is_err());
551        assert!(r.unwrap_err().to_string().contains("首行"));
552    }
553
554    #[test]
555    fn test_from_str_header_with_suffix() {
556        let s = "\
557# ROADMAP — cli
558
559## [0.1.0] — test
560
561### Added
562- [ ] something
563";
564        let r = Roadmap::from_str(s).unwrap();
565        assert_eq!(r.versions().len(), 1);
566        assert_eq!(r.versions()[0].version, "0.1.0");
567    }
568
569    #[test]
570    fn test_from_str_valid() {
571        let r = Roadmap::from_str(sample_roadmap()).unwrap();
572        let versions = r.versions();
573        assert_eq!(versions.len(), 2);
574
575        // 第一个版本 [0.1.5]
576        let v1 = &versions[0];
577        assert_eq!(v1.version, "0.1.5");
578        assert_eq!(v1.status, "待实施");
579        assert_eq!(v1.categories.len(), 2);
580        assert_eq!(v1.categories[0].0, "Added");
581        assert_eq!(v1.categories[0].1.len(), 2);
582        assert_eq!(v1.categories[1].0, "Fixed");
583        assert_eq!(v1.categories[1].1.len(), 1);
584
585        // 条目状态
586        assert!(!v1.categories[0].1[0].completed);
587        assert!(!v1.categories[0].1[1].completed);
588        assert!(!v1.categories[1].1[0].completed);
589
590        // done/total
591        assert_eq!(v1.total, 3);
592        assert_eq!(v1.done, 0);
593
594        // 第二个版本 [0.1.4]
595        let v2 = &versions[1];
596        assert_eq!(v2.version, "0.1.4");
597        assert_eq!(v2.status, "已发布");
598        assert_eq!(v2.total, 2);
599        assert_eq!(v2.done, 2);
600
601        // 全局统计
602        assert_eq!(r.total_done(), 2);
603        assert_eq!(r.total_all(), 5);
604    }
605
606    #[test]
607    fn test_from_str_mixed() {
608        let r = Roadmap::from_str(mixed_roadmap()).unwrap();
609        let versions = r.versions();
610        assert_eq!(versions.len(), 2);
611
612        let v1 = &versions[0];
613        assert_eq!(v1.version, "0.2.0");
614        assert_eq!(v1.status, "待实施");
615        assert_eq!(v1.total, 1);
616        assert_eq!(v1.done, 0);
617
618        let v2 = &versions[1];
619        assert_eq!(v2.version, "0.1.0");
620        assert_eq!(v2.status, "已发布");
621        assert_eq!(v2.total, 3);
622        assert_eq!(v2.done, 2);
623
624        assert_eq!(r.total_done(), 2);
625        assert_eq!(r.total_all(), 4);
626    }
627
628    #[test]
629    fn test_from_path() {
630        let d = tempfile::tempdir().unwrap();
631        let path = d.path().join("ROADMAP.md");
632        std::fs::write(&path, sample_roadmap()).unwrap();
633        let r = Roadmap::from_path(&path).unwrap();
634        assert_eq!(r.versions().len(), 2);
635        assert_eq!(r.versions()[0].version, "0.1.5");
636    }
637
638    #[test]
639    fn test_from_path_not_found() {
640        let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
641        assert!(r.is_err());
642    }
643
644    // ── 格式验证 ────────────────────────────────────────────────────
645
646    #[test]
647    fn test_validate_valid() {
648        let r = Roadmap::from_str(sample_roadmap()).unwrap();
649        let issues = r.validate("test-scope");
650        let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
651        assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
652    }
653
654    #[test]
655    fn test_validate_v_prefix_allowed() {
656        // v 前缀应被允许且标准化(与 CHANGELOG 统一)
657        let s = "\
658# ROADMAP
659
660## [v0.1.0] — test
661
662### Added
663- [ ] something
664";
665        let r = Roadmap::from_str(s).unwrap();
666        // 解析时应标准化去掉 v 前缀
667        assert_eq!(r.versions()[0].version, "0.1.0");
668        // validate 不应报 v 前缀相关的错误
669        let issues = r.validate("scope");
670        let v_issues: Vec<_> = issues
671            .iter()
672            .filter(|i| i.message.contains("v 前缀"))
673            .collect();
674        assert!(
675            v_issues.is_empty(),
676            "不应有 v 前缀相关验证问题: {:?}",
677            v_issues
678        );
679    }
680
681    #[test]
682    fn test_validate_invalid_version() {
683        let s = "\
684# ROADMAP
685
686## [abc] — 待实施
687
688### Added
689- [ ] something
690";
691        let r = Roadmap::from_str(s).unwrap();
692        let issues = r.validate("scope");
693        assert!(!issues.is_empty());
694        assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
695    }
696
697    #[test]
698    fn test_validate_category_case() {
699        let s = "\
700# ROADMAP
701
702## [0.1.0] — test
703
704### added
705- [ ] something
706";
707        let r = Roadmap::from_str(s).unwrap();
708        let issues = r.validate("scope");
709        assert!(!issues.is_empty());
710        assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
711    }
712
713    #[test]
714    fn test_validate_line_numbers() {
715        // 用不合法版本号测试行号,v 前缀现在被允许
716        let s = "\
717# ROADMAP
718
719## [abc] — test
720
721### Added
722- [ ] ok
723";
724        let r = Roadmap::from_str(s).unwrap();
725        let issues = r.validate("scope");
726        assert!(!issues.is_empty());
727        // 格式异常在第 3 行(1-based)
728        assert_eq!(issues[0].line, 3);
729        assert_eq!(issues[0].scope, "scope");
730    }
731
732    // ── parse_version_header ───────────────────────────────────────
733
734    #[test]
735    fn test_parse_version_header_normal() {
736        let (v, s) = parse_version_header("## [0.1.5] — 待实施").unwrap();
737        assert_eq!(v, "0.1.5");
738        assert_eq!(s, "待实施");
739    }
740
741    #[test]
742    fn test_parse_version_header_no_bracket() {
743        let r = parse_version_header("## 0.1.5 — test");
744        assert!(r.is_err());
745    }
746
747    #[test]
748    fn test_parse_version_header_empty_version() {
749        let r = parse_version_header("## [] — test");
750        assert!(r.is_err());
751    }
752
753    #[test]
754    fn test_parse_version_header_hyphen() {
755        let (v, s) = parse_version_header("## [0.1.0] - released").unwrap();
756        assert_eq!(v, "0.1.0");
757        assert_eq!(s, "released");
758    }
759
760    #[test]
761    fn test_parse_version_header_no_status() {
762        let (v, s) = parse_version_header("## [0.1.0]").unwrap();
763        assert_eq!(v, "0.1.0");
764        assert_eq!(s, "");
765    }
766
767    // ── 覆盖率补充 ────────────────────────────────────────────────────
768
769    #[test]
770    fn test_from_str_parse_version_header_error() {
771        // 通过 from_str 触发 parse_version_header 报错分支
772        let s = "\
773# ROADMAP
774
775## 无方括号版本
776";
777        let r = Roadmap::from_str(s);
778        assert!(r.is_err());
779        assert!(r.unwrap_err().to_string().contains("版本标题格式无效"));
780    }
781
782    #[test]
783    fn test_from_str_no_versions() {
784        // 有 # ROADMAP 但无 ## [x.y.z] ,触发 versions.is_empty() 分支
785        let s = "\
786# ROADMAP
787
788> 只有描述,没有版本。
789";
790        let r = Roadmap::from_str(s);
791        assert!(r.is_err());
792        assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
793    }
794
795    #[test]
796    fn test_validate_invalid_checkbox() {
797        // 非标准 checkbox 格式触发 validate 的 checklist 分支
798        let s = "\
799# ROADMAP
800
801## [0.1.0] — test
802
803### Added
804- [X] uppercase
805";
806        let r = Roadmap::from_str(s).unwrap();
807        let issues = r.validate("scope");
808        let checkbox_issues: Vec<_> = issues
809            .iter()
810            .filter(|i| i.message.contains("checkbox 格式异常"))
811            .collect();
812        assert_eq!(checkbox_issues.len(), 1);
813        assert!(checkbox_issues[0].message.contains("- [X]"));
814    }
815
816    #[test]
817    fn test_validate_unknown_category() {
818        // 自定义分类触发 category_expected_case 的 catch-all
819        let s = "\
820# ROADMAP
821
822## [0.1.0] — test
823
824### CustomSection
825- [ ] something
826";
827        let r = Roadmap::from_str(s).unwrap();
828        // 自定义分类不应报大小写问题(保持原样)
829        let issues = r.validate("scope");
830        // 应无任何大小写相关验证问题
831        assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
832    }
833}