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` 作为文档标题(必须)
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 != "# 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_valid() {
556        let r = Roadmap::from_str(sample_roadmap()).unwrap();
557        let versions = r.versions();
558        assert_eq!(versions.len(), 2);
559
560        // 第一个版本 [0.1.5]
561        let v1 = &versions[0];
562        assert_eq!(v1.version, "0.1.5");
563        assert_eq!(v1.status, "待实施");
564        assert_eq!(v1.categories.len(), 2);
565        assert_eq!(v1.categories[0].0, "Added");
566        assert_eq!(v1.categories[0].1.len(), 2);
567        assert_eq!(v1.categories[1].0, "Fixed");
568        assert_eq!(v1.categories[1].1.len(), 1);
569
570        // 条目状态
571        assert!(!v1.categories[0].1[0].completed);
572        assert!(!v1.categories[0].1[1].completed);
573        assert!(!v1.categories[1].1[0].completed);
574
575        // done/total
576        assert_eq!(v1.total, 3);
577        assert_eq!(v1.done, 0);
578
579        // 第二个版本 [0.1.4]
580        let v2 = &versions[1];
581        assert_eq!(v2.version, "0.1.4");
582        assert_eq!(v2.status, "已发布");
583        assert_eq!(v2.total, 2);
584        assert_eq!(v2.done, 2);
585
586        // 全局统计
587        assert_eq!(r.total_done(), 2);
588        assert_eq!(r.total_all(), 5);
589    }
590
591    #[test]
592    fn test_from_str_mixed() {
593        let r = Roadmap::from_str(mixed_roadmap()).unwrap();
594        let versions = r.versions();
595        assert_eq!(versions.len(), 2);
596
597        let v1 = &versions[0];
598        assert_eq!(v1.version, "0.2.0");
599        assert_eq!(v1.status, "待实施");
600        assert_eq!(v1.total, 1);
601        assert_eq!(v1.done, 0);
602
603        let v2 = &versions[1];
604        assert_eq!(v2.version, "0.1.0");
605        assert_eq!(v2.status, "已发布");
606        assert_eq!(v2.total, 3);
607        assert_eq!(v2.done, 2);
608
609        assert_eq!(r.total_done(), 2);
610        assert_eq!(r.total_all(), 4);
611    }
612
613    #[test]
614    fn test_from_path() {
615        let d = tempfile::tempdir().unwrap();
616        let path = d.path().join("ROADMAP.md");
617        std::fs::write(&path, sample_roadmap()).unwrap();
618        let r = Roadmap::from_path(&path).unwrap();
619        assert_eq!(r.versions().len(), 2);
620        assert_eq!(r.versions()[0].version, "0.1.5");
621    }
622
623    #[test]
624    fn test_from_path_not_found() {
625        let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
626        assert!(r.is_err());
627    }
628
629    // ── 格式验证 ────────────────────────────────────────────────────
630
631    #[test]
632    fn test_validate_valid() {
633        let r = Roadmap::from_str(sample_roadmap()).unwrap();
634        let issues = r.validate("test-scope");
635        let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
636        assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
637    }
638
639    #[test]
640    fn test_validate_v_prefix_allowed() {
641        // v 前缀应被允许且标准化(与 CHANGELOG 统一)
642        let s = "\
643# ROADMAP
644
645## [v0.1.0] — test
646
647### Added
648- [ ] something
649";
650        let r = Roadmap::from_str(s).unwrap();
651        // 解析时应标准化去掉 v 前缀
652        assert_eq!(r.versions()[0].version, "0.1.0");
653        // validate 不应报 v 前缀相关的错误
654        let issues = r.validate("scope");
655        let v_issues: Vec<_> = issues
656            .iter()
657            .filter(|i| i.message.contains("v 前缀"))
658            .collect();
659        assert!(
660            v_issues.is_empty(),
661            "不应有 v 前缀相关验证问题: {:?}",
662            v_issues
663        );
664    }
665
666    #[test]
667    fn test_validate_invalid_version() {
668        let s = "\
669# ROADMAP
670
671## [abc] — 待实施
672
673### Added
674- [ ] something
675";
676        let r = Roadmap::from_str(s).unwrap();
677        let issues = r.validate("scope");
678        assert!(!issues.is_empty());
679        assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
680    }
681
682    #[test]
683    fn test_validate_category_case() {
684        let s = "\
685# ROADMAP
686
687## [0.1.0] — test
688
689### added
690- [ ] something
691";
692        let r = Roadmap::from_str(s).unwrap();
693        let issues = r.validate("scope");
694        assert!(!issues.is_empty());
695        assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
696    }
697
698    #[test]
699    fn test_validate_line_numbers() {
700        // 用不合法版本号测试行号,v 前缀现在被允许
701        let s = "\
702# ROADMAP
703
704## [abc] — test
705
706### Added
707- [ ] ok
708";
709        let r = Roadmap::from_str(s).unwrap();
710        let issues = r.validate("scope");
711        assert!(!issues.is_empty());
712        // 格式异常在第 3 行(1-based)
713        assert_eq!(issues[0].line, 3);
714        assert_eq!(issues[0].scope, "scope");
715    }
716
717    // ── parse_version_header ───────────────────────────────────────
718
719    #[test]
720    fn test_parse_version_header_normal() {
721        let (v, s) = parse_version_header("## [0.1.5] — 待实施").unwrap();
722        assert_eq!(v, "0.1.5");
723        assert_eq!(s, "待实施");
724    }
725
726    #[test]
727    fn test_parse_version_header_no_bracket() {
728        let r = parse_version_header("## 0.1.5 — test");
729        assert!(r.is_err());
730    }
731
732    #[test]
733    fn test_parse_version_header_empty_version() {
734        let r = parse_version_header("## [] — test");
735        assert!(r.is_err());
736    }
737
738    #[test]
739    fn test_parse_version_header_hyphen() {
740        let (v, s) = parse_version_header("## [0.1.0] - released").unwrap();
741        assert_eq!(v, "0.1.0");
742        assert_eq!(s, "released");
743    }
744
745    #[test]
746    fn test_parse_version_header_no_status() {
747        let (v, s) = parse_version_header("## [0.1.0]").unwrap();
748        assert_eq!(v, "0.1.0");
749        assert_eq!(s, "");
750    }
751
752    // ── 覆盖率补充 ────────────────────────────────────────────────────
753
754    #[test]
755    fn test_from_str_parse_version_header_error() {
756        // 通过 from_str 触发 parse_version_header 报错分支
757        let s = "\
758# ROADMAP
759
760## 无方括号版本
761";
762        let r = Roadmap::from_str(s);
763        assert!(r.is_err());
764        assert!(r.unwrap_err().to_string().contains("版本标题格式无效"));
765    }
766
767    #[test]
768    fn test_from_str_no_versions() {
769        // 有 # ROADMAP 但无 ## [x.y.z] ,触发 versions.is_empty() 分支
770        let s = "\
771# ROADMAP
772
773> 只有描述,没有版本。
774";
775        let r = Roadmap::from_str(s);
776        assert!(r.is_err());
777        assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
778    }
779
780    #[test]
781    fn test_validate_invalid_checkbox() {
782        // 非标准 checkbox 格式触发 validate 的 checklist 分支
783        let s = "\
784# ROADMAP
785
786## [0.1.0] — test
787
788### Added
789- [X] uppercase
790";
791        let r = Roadmap::from_str(s).unwrap();
792        let issues = r.validate("scope");
793        let checkbox_issues: Vec<_> = issues
794            .iter()
795            .filter(|i| i.message.contains("checkbox 格式异常"))
796            .collect();
797        assert_eq!(checkbox_issues.len(), 1);
798        assert!(checkbox_issues[0].message.contains("- [X]"));
799    }
800
801    #[test]
802    fn test_validate_unknown_category() {
803        // 自定义分类触发 category_expected_case 的 catch-all
804        let s = "\
805# ROADMAP
806
807## [0.1.0] — test
808
809### CustomSection
810- [ ] something
811";
812        let r = Roadmap::from_str(s).unwrap();
813        // 自定义分类不应报大小写问题(保持原样)
814        let issues = r.validate("scope");
815        // 应无任何大小写相关验证问题
816        assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
817    }
818}