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