Skip to main content

qtcloud_devops_cli/
plan.rs

1/// plan 命令:ROADMAP.md 规划管理。
2///
3/// 对应 `data/roadmap/platform/plan-command.md`。
4///
5/// 三个子命令:
6/// - `status` — 查看 scope 规划进度
7/// - `clean`  — 删除已完成条目
8/// - `doctor` — 修复格式问题(规则修复 + LLM 修复)
9use std::path::{Path, PathBuf};
10
11// ═══════════════════════════════════════════════════════════════════════
12// 模型
13// ═══════════════════════════════════════════════════════════════════════
14
15/// 单个版本的规划进度。
16#[derive(Debug)]
17pub struct VersionProgress {
18    pub version: String,
19    pub done: usize,
20    pub total: usize,
21}
22
23/// 检测一行是否为版本标题,成功时返回版本号(去除 v 前缀)。
24/// 支持 `## [X.Y.Z]`、`## [X.Y.Z] — 已发布`、`## [vX.Y.Z]` 等格式。
25pub fn is_version_line(line: &str) -> Option<String> {
26    let t = line.trim();
27    if t.starts_with("## [") {
28        if let Some(end) = t.find(']') {
29            let ver = t["## [".len()..end].trim().trim_start_matches('v');
30            if !ver.is_empty() {
31                return Some(ver.to_string());
32            }
33        }
34    }
35    None
36}
37
38/// 格式问题。
39/// 验证发现的格式问题。
40#[derive(Debug)]
41pub struct Issue {
42    pub line: usize,
43    pub scope: String,
44    pub message: String,
45}
46
47// ═══════════════════════════════════════════════════════════════════════
48// 路径解析
49// ═══════════════════════════════════════════════════════════════════════
50
51/// 解析 scope 参数,返回实际 ROADMAP.md 路径。
52pub fn resolve_roadmap_path(repo_path: &Path, scope: Option<&str>) -> PathBuf {
53    let c = crate::contract::load(repo_path);
54    match scope {
55        Some(name) if !name.is_empty() => {
56            // 按 scope 名称查找
57            if let Some(s) = c.scopes.iter().find(|s| s.name == name) {
58                repo_path.join(&s.dir).join("ROADMAP.md")
59            } else {
60                // 回退:scope 名作为子目录
61                repo_path.join(name).join("ROADMAP.md")
62            }
63        }
64        _ => {
65            // 省略 scope → 找当前目录所属 scope
66            let current_dir = std::env::current_dir().unwrap_or_else(|_| repo_path.to_path_buf());
67            if let Some(s) = c.find_scope_by_path(&current_dir) {
68                repo_path.join(&s.dir).join("ROADMAP.md")
69            } else {
70                repo_path.join("ROADMAP.md")
71            }
72        }
73    }
74}
75
76// ═══════════════════════════════════════════════════════════════════════
77// plan status
78// ═══════════════════════════════════════════════════════════════════════
79
80/// 解析 ROADMAP.md,返回各版本进度列表。
81pub fn parse_roadmap(path: &Path) -> Result<Vec<VersionProgress>, String> {
82    let content = std::fs::read_to_string(path)
83        .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
84
85    let mut versions: Vec<VersionProgress> = Vec::new();
86    let mut current_version: Option<String> = None;
87    let mut done = 0usize;
88    let mut total = 0usize;
89
90    for line in content.lines() {
91        let trimmed = line.trim();
92
93        // `## [X.Y.Z]` — 版本标题(支持 `— 已发布` 等后缀)
94        if let Some(ver) = is_version_line(trimmed) {
95            if let Some(v) = current_version.take() {
96                versions.push(VersionProgress {
97                    version: v,
98                    done,
99                    total,
100                });
101            }
102            done = 0;
103            total = 0;
104            current_version = Some(ver);
105            continue;
106        }
107
108        if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
109            total += 1;
110            done += 1;
111        } else if trimmed.starts_with("- [ ]") {
112            total += 1;
113        }
114    }
115
116    if let Some(ver) = current_version {
117        versions.push(VersionProgress {
118            version: ver,
119            done,
120            total,
121        });
122    }
123    Ok(versions)
124}
125
126/// 格式化输出 scope 规划进度。
127pub fn print_status(repo_path: &Path, scope: Option<&str>) -> Result<(), String> {
128    let mut stdout = std::io::stdout();
129    print_status_to(&mut stdout, repo_path, scope)
130}
131
132/// 写入指定 writer 的版本(可测试)。
133pub fn print_status_to(
134    writer: &mut impl std::io::Write,
135    repo_path: &Path,
136    scope: Option<&str>,
137) -> Result<(), String> {
138    let roadmap_path = resolve_roadmap_path(repo_path, scope);
139    if !roadmap_path.exists() {
140        writeln!(writer, "  未创建规划文件: {}", roadmap_path.display()).ok();
141        return Ok(());
142    }
143
144    let versions = parse_roadmap(&roadmap_path)?;
145    if versions.is_empty() {
146        writeln!(writer, "  未找到标准规划条目").ok();
147        // 诊断:检查文件是否有非标准格式
148        let content = std::fs::read_to_string(&roadmap_path).unwrap_or_default();
149        let has_unknown_headers = content.lines().any(|l| {
150            let t = l.trim();
151            (t.starts_with("## ") && !t.starts_with("## ["))
152                || (t.starts_with("### ")
153                    && !CATEGORIES
154                        .iter()
155                        .any(|c| c.to_lowercase() == t.to_lowercase()))
156        });
157        if has_unknown_headers {
158            // LLM 已配置时(非测试环境):尝试转换非标准格式
159            let settings = quanttide_agent::Settings::from_env();
160            if !settings.llm_api_key.is_empty() && cfg!(not(test)) {
161                writeln!(writer, "  🔄 检测到非标准格式,调用 LLM 转换...").ok();
162                if let Ok(llm_result) = doctor_llm(
163                    &content,
164                    scope.unwrap_or("(auto)"),
165                    &settings,
166                    &roadmap_path,
167                ) {
168                    if llm_result.is_some() {
169                        if let Ok(new_versions) = parse_roadmap(&roadmap_path) {
170                            if !new_versions.is_empty() {
171                                return print_progress(
172                                    writer,
173                                    scope.unwrap_or("(auto)"),
174                                    &new_versions,
175                                );
176                            }
177                        }
178                    }
179                }
180            }
181            writeln!(
182                writer,
183                "  ⚠ 文件含有非标准格式的标题,运行 `plan doctor` 查看详情"
184            )
185            .ok();
186        }
187        return Ok(());
188    }
189
190    print_progress(writer, scope.unwrap_or("(auto)"), &versions)
191}
192
193/// 输出进度表(抽离以供 LLM 转换后重用)。
194fn print_progress(
195    writer: &mut impl std::io::Write,
196    scope_label: &str,
197    versions: &[VersionProgress],
198) -> Result<(), String> {
199    writeln!(writer, "  [{}] 规划进度", scope_label).ok();
200    writeln!(writer, "  {}", "-".repeat(40)).ok();
201
202    let mut total_done = 0usize;
203    let mut total_all = 0usize;
204
205    for v in versions {
206        let rate = if v.total > 0 {
207            v.done as f64 / v.total as f64 * 100.0
208        } else {
209            0.0
210        };
211        writeln!(
212            writer,
213            "  [{:<8}] {:>2}/{:>2} 完成 ({:.0}%)",
214            v.version, v.done, v.total, rate
215        )
216        .ok();
217        total_done += v.done;
218        total_all += v.total;
219    }
220
221    let overall = if total_all > 0 {
222        total_done as f64 / total_all as f64 * 100.0
223    } else {
224        0.0
225    };
226    writeln!(writer, "  {}", "-".repeat(40)).ok();
227    writeln!(
228        writer,
229        "  总计:  {}/{} 完成 ({:.0}%)",
230        total_done, total_all, overall
231    )
232    .ok();
233    Ok(())
234}
235
236// ═══════════════════════════════════════════════════════════════════════
237// plan clean
238// ═══════════════════════════════════════════════════════════════════════
239
240const CATEGORIES: &[&str] = &[
241    "### Added",
242    "### Changed",
243    "### Fixed",
244    "### Removed",
245    "### Deprecated",
246    "### Security",
247];
248
249fn is_done_item(line: &str) -> bool {
250    let t = line.trim();
251    t.starts_with("- [x]") || t.starts_with("- [X]")
252}
253
254fn is_category_header(line: &str) -> bool {
255    let t = line.trim();
256    CATEGORIES
257        .iter()
258        .any(|c| t == *c || t.eq_ignore_ascii_case(c))
259}
260
261fn is_version_header(line: &str) -> bool {
262    is_version_line(line).is_some()
263}
264
265/// 删除 ROADMAP.md 中所有已完成条目。
266///
267/// 只删 `- [x]` 行,级联清理空分类和空版本标题。
268pub fn clean_roadmap(path: &Path) -> Result<usize, String> {
269    let content = std::fs::read_to_string(path)
270        .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
271    let original_len = content.len();
272
273    let mut lines: Vec<&str> = content.lines().collect();
274
275    // 第一遍:删除 done item 行
276    lines.retain(|l| !is_done_item(l));
277
278    // 第二遍:删除空的分类标题(跳过空行看后面是否真有内容)
279    let mut i = 0;
280    while i < lines.len() {
281        if is_category_header(lines[i]) {
282            let mut j = i + 1;
283            while j < lines.len() && lines[j].trim().is_empty() {
284                j += 1;
285            }
286            if j >= lines.len() || is_category_header(lines[j]) || is_version_header(lines[j]) {
287                lines.remove(i);
288                continue;
289            }
290        }
291        i += 1;
292    }
293
294    // 第三遍:删除空的版本标题(跳过空行看后面是否真有内容)
295    let mut i = 0;
296    while i < lines.len() {
297        if is_version_header(lines[i]) {
298            let mut j = i + 1;
299            while j < lines.len() && lines[j].trim().is_empty() {
300                j += 1;
301            }
302            if j >= lines.len() || is_version_header(lines[j]) {
303                // 后面是文件尾或另一个版本头 → 此版本为空
304                lines.remove(i);
305                continue;
306            }
307            // 后面有内容(checkbox、分类等)→ 保留
308        }
309        i += 1;
310    }
311    if let Some(last) = lines.last() {
312        if is_version_header(last) {
313            lines.pop();
314        }
315    }
316
317    // 清理尾部空行
318    while let Some(last) = lines.last() {
319        if last.trim().is_empty() {
320            lines.pop();
321        } else {
322            break;
323        }
324    }
325
326    if lines.is_empty() {
327        std::fs::write(path, "").map_err(|e| format!("写入失败: {}", e))?;
328        return Ok(original_len);
329    }
330
331    let mut output = String::new();
332    for line in &lines {
333        output.push_str(line);
334        output.push('\n');
335    }
336    std::fs::write(path, &output).map_err(|e| format!("写入失败: {}", e))?;
337    Ok(original_len.saturating_sub(output.len()))
338}
339
340// ═══════════════════════════════════════════════════════════════════════
341// plan doctor
342// ═══════════════════════════════════════════════════════════════════════
343
344/// 诊断并修复 ROADMAP.md 的格式问题。
345///
346/// 1. LLM 修复:理解非标准格式并转换为标准格式(LLM 已配置时)
347/// 2. 规则校验:v 前缀、分类大小写、checkbox 格式(双重保障)
348pub fn doctor_roadmap(path: &Path, scope: &str) -> Result<Vec<Issue>, String> {
349    let content = std::fs::read_to_string(path)
350        .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
351
352    let mut issues: Vec<Issue> = Vec::new();
353
354    // Phase 1: LLM 先判断并修复(LLM 已配置且非测试环境时)
355    let settings = quanttide_agent::Settings::from_env();
356    if !settings.llm_api_key.is_empty() && cfg!(not(test)) {
357        if let Some(llm_issues) = doctor_llm(&content, scope, &settings, path)? {
358            issues.extend(llm_issues);
359        }
360    }
361
362    // Phase 2: 规则校验(对 LLM 修复后的内容或原内容做细节修复)
363    let content_after_llm =
364        std::fs::read_to_string(path).map_err(|e| format!("读取失败: {}", e))?;
365    let rule_issues = apply_rule_fixes(path, &content_after_llm, scope)?;
366    issues.extend(rule_issues);
367
368    Ok(issues)
369}
370
371/// 规则修复:v 前缀、分类大小写、checkbox 格式。
372fn apply_rule_fixes(path: &Path, content: &str, scope: &str) -> Result<Vec<Issue>, String> {
373    let mut issues: Vec<Issue> = Vec::new();
374    let mut new_lines: Vec<String> = Vec::new();
375
376    for (idx, raw_line) in content.lines().enumerate() {
377        let line_num = idx + 1;
378        let trimmed = raw_line.trim();
379
380        // 0a. 检测非标准 ## 头(如 ## P0 — 阻塞)
381        if trimmed.starts_with("## ") && !is_version_line(trimmed).is_some() {
382            issues.push(Issue {
383                line: line_num,
384                scope: scope.to_string(),
385                message: format!("非标准版本头(应为 ## [X.Y.Z]): {}", trimmed),
386            });
387            new_lines.push(raw_line.to_string());
388            continue;
389        }
390
391        // 0b. 检测非标准 ### 分类(如 ### 0.1 xxx)
392
393        // 0b. 检测非标准 ### 分类(如 ### 0.1 xxx)
394        if trimmed.starts_with("### ")
395            && !CATEGORIES
396                .iter()
397                .any(|c| c.to_lowercase() == trimmed.to_lowercase())
398        {
399            issues.push(Issue {
400                line: line_num,
401                scope: scope.to_string(),
402                message: format!("非标准分类标题: {}", trimmed),
403            });
404            new_lines.push(raw_line.to_string());
405            continue;
406        }
407
408        // 1. 版本标题:去掉 v 前缀
409        if let Some(ver) = is_version_line(trimmed) {
410            let raw_ver = trimmed
411                .trim_start_matches("## [")
412                .split(']')
413                .next()
414                .unwrap_or("")
415                .trim();
416            if raw_ver.starts_with('v') {
417                issues.push(Issue {
418                    line: line_num,
419                    scope: scope.to_string(),
420                    message: format!("修复 v 前缀: {} → {}", raw_ver, ver),
421                });
422                let suffix = trimmed.split(']').nth(1).unwrap_or("");
423                new_lines.push(format!("## [{}]{}", ver, suffix));
424                continue;
425            }
426            new_lines.push(raw_line.to_string());
427            continue;
428        }
429
430        // 2. 分类标题:标准化大小写
431        if trimmed.starts_with("### ") {
432            let lowered = trimmed.to_lowercase();
433            if let Some(standard) = CATEGORIES.iter().find(|c| c.to_lowercase() == lowered) {
434                if trimmed != *standard {
435                    issues.push(Issue {
436                        line: line_num,
437                        scope: scope.to_string(),
438                        message: format!("修复大小写: {} → {}", trimmed, standard),
439                    });
440                    let indent = &raw_line[..raw_line.len() - raw_line.trim_start().len()];
441                    new_lines.push(format!("{}{}", indent, standard));
442                    continue;
443                }
444            }
445            new_lines.push(raw_line.to_string());
446            continue;
447        }
448
449        // 3. checkbox:修复异常格式
450        let has_any_box =
451            trimmed.contains("[x]") || trimmed.contains("[X]") || trimmed.contains("[ ]");
452        let is_standard = trimmed.starts_with("- [x] ")
453            || trimmed.starts_with("- [X] ")
454            || trimmed.starts_with("- [ ] ");
455        if has_any_box && !is_standard {
456            let content_start = trimmed.find(']').map(|p| p + 1).unwrap_or(trimmed.len());
457            let item_content = trimmed[content_start..].trim();
458            let is_done = trimmed.contains("[x]") || trimmed.contains("[X]");
459            let prefix = if is_done { "- [x]" } else { "- [ ]" };
460            issues.push(Issue {
461                line: line_num,
462                scope: scope.to_string(),
463                message: format!(
464                    "修复 checkbox 格式: {} → {} {}",
465                    trimmed, prefix, item_content
466                ),
467            });
468            new_lines.push(format!("{} {}", prefix, item_content));
469            continue;
470        }
471
472        new_lines.push(raw_line.to_string());
473    }
474
475    if !issues.is_empty() {
476        let mut output = String::new();
477        for line in &new_lines {
478            output.push_str(line);
479            output.push('\n');
480        }
481        std::fs::write(path, &output).map_err(|e| format!("写入失败: {}", e))?;
482    }
483
484    Ok(issues)
485}
486
487/// LLM 修复:处理规则无法覆盖的复杂格式问题。
488fn doctor_llm(
489    content: &str,
490    _scope: &str,
491    settings: &quanttide_agent::Settings,
492    path: &Path,
493) -> Result<Option<Vec<Issue>>, String> {
494    use quanttide_agent::{llm::CompleteOptions, Message, LLM};
495
496    let format_spec = "ROADMAP.md 格式规范:
497a) 版本标题:## [X.Y.Z],可选后缀如 — 已发布
498b) 分类标题:### Added / Changed / Fixed / Removed / Deprecated / Security
499c) 条目格式:- [x] 内容 或 - [ ] 内容
500";
501    let prompt = format!(
502        "{}\n\n以下 ROADMAP.md 可能存在格式问题,请按规范修复格式(只修格式,不增删条目):\n\n{}",
503        format_spec, content
504    );
505
506    let llm = LLM::new(
507        &settings.llm_model,
508        &settings.llm_base_url,
509        &settings.llm_api_key,
510    );
511    let messages = vec![
512        Message::new(
513            "system",
514            "你是 ROADMAP.md 格式修复助手。只修格式,不增删条目内容。",
515        ),
516        Message::new("user", &prompt),
517    ];
518    let response = llm
519        .complete(&messages, CompleteOptions::default())
520        .map_err(|e| format!("LLM 调用失败: {}", e))?;
521
522    let fixed = response.content.trim().to_string();
523    if fixed.is_empty() || fixed == content {
524        return Ok(None);
525    }
526
527    std::fs::write(path, &fixed).map_err(|e| format!("写入失败: {}", e))?;
528    println!("  📋 LLM 格式修复已应用");
529
530    Ok(Some(vec![Issue {
531        line: 0,
532        scope: String::new(),
533        message: "LLM 格式修复完成".to_string(),
534    }]))
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use std::io::Write;
541
542    fn write_roadmap(content: &str) -> tempfile::TempDir {
543        let d = tempfile::tempdir().unwrap();
544        let mut f = std::fs::File::create(d.path().join("ROADMAP.md")).unwrap();
545        write!(f, "{}", content).unwrap();
546        d
547    }
548
549    fn read_roadmap(d: &Path) -> String {
550        std::fs::read_to_string(d.join("ROADMAP.md")).unwrap_or_default()
551    }
552
553    // ── parse_roadmap ────────────────────────────────────────────
554
555    #[test]
556    fn test_parse_empty() {
557        let d = write_roadmap("");
558        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
559        assert!(v.is_empty());
560    }
561
562    #[test]
563    fn test_parse_single_version() {
564        let d = write_roadmap(
565            "## [0.1.0]\n\
566             \n\
567             ### Added\n\
568             - [x] feature a\n\
569             - [ ] feature b\n\
570             ### Fixed\n\
571             - [x] bug c\n",
572        );
573        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
574        assert_eq!(v.len(), 1);
575        assert_eq!(v[0].version, "0.1.0");
576        assert_eq!(v[0].done, 2);
577        assert_eq!(v[0].total, 3);
578    }
579
580    #[test]
581    fn test_parse_multi_version() {
582        let d = write_roadmap(
583            "## [0.2.0]\n\
584             - [x] done\n\
585             - [ ] todo\n\
586             \n\
587             ## [0.1.0]\n\
588             - [x] a\n\
589             - [x] b\n",
590        );
591        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
592        assert_eq!(v.len(), 2);
593        assert_eq!(v[0].version, "0.2.0");
594        assert_eq!(v[0].done, 1);
595        assert_eq!(v[0].total, 2);
596        assert_eq!(v[1].version, "0.1.0");
597        assert_eq!(v[1].done, 2);
598        assert_eq!(v[1].total, 2);
599    }
600
601    #[test]
602    fn test_parse_v_prefix() {
603        let d = write_roadmap("## [v0.1.0]\n- [x] item\n");
604        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
605        assert_eq!(v[0].version, "0.1.0");
606    }
607
608    #[test]
609    fn test_parse_no_checkboxes() {
610        let d = write_roadmap("## [0.1.0]\n\njust text\n");
611        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
612        assert_eq!(v.len(), 1);
613        assert_eq!(v[0].done, 0);
614        assert_eq!(v[0].total, 0);
615    }
616
617    #[test]
618    fn test_parse_version_with_suffix() {
619        // `## [0.1.0] — 已发布` 应被正确识别
620        let d = write_roadmap("## [0.1.0] — 已发布\n- [x] done\n- [ ] todo\n");
621        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
622        assert_eq!(v.len(), 1);
623        assert_eq!(v[0].version, "0.1.0");
624        assert_eq!(v[0].done, 1);
625        assert_eq!(v[0].total, 2);
626    }
627
628    #[test]
629    fn test_clean_version_with_suffix() {
630        // 后缀版本头应被识别并可级联清理
631        let d = write_roadmap("## [0.1.0] — 已发布\n- [x] done\n");
632        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
633        let content = read_roadmap(d.path());
634        assert!(!content.contains("0.1.0"), "空版本应被清理");
635    }
636
637    #[test]
638    fn test_parse_file_not_found() {
639        let d = tempfile::tempdir().unwrap();
640        let result = parse_roadmap(&d.path().join("NONEXISTENT.md"));
641        assert!(result.is_err());
642    }
643
644    // ── resolve_roadmap_path ────────────────────────────────────
645
646    #[test]
647    fn test_resolve_path_with_contract_scope() {
648        let d = tempfile::tempdir().unwrap();
649        // 创建 scope 契约
650        let contract_dir = d.path().join(".quanttide/devops");
651        std::fs::create_dir_all(&contract_dir).unwrap();
652        std::fs::write(
653            contract_dir.join("contract.yaml"),
654            "scopes:\n  cli:\n    dir: src/cli\n    language: rust\n",
655        )
656        .unwrap();
657        let path = resolve_roadmap_path(d.path(), Some("cli"));
658        assert!(path.to_string_lossy().ends_with("src/cli/ROADMAP.md"));
659    }
660
661    #[test]
662    fn test_resolve_path_fallback_to_name() {
663        let d = tempfile::tempdir().unwrap();
664        let path = resolve_roadmap_path(d.path(), Some("custom"));
665        // scope 不在契约中 → 回退为子目录名
666        assert!(path.to_string_lossy().ends_with("custom/ROADMAP.md"));
667    }
668
669    #[test]
670    fn test_resolve_path_no_scope_no_contract() {
671        let d = tempfile::tempdir().unwrap();
672        let path = resolve_roadmap_path(d.path(), None);
673        // 无 scope + 无契约 → repo 根目录
674        assert_eq!(path, d.path().join("ROADMAP.md"));
675    }
676
677    // ── clean_roadmap ───────────────────────────────────────────
678
679    #[test]
680    fn test_clean_removes_done_items() {
681        let d = write_roadmap(
682            "## [0.1.0]\n\
683             ### Added\n\
684             - [x] done item\n\
685             - [ ] todo item\n\
686             ### Fixed\n\
687             - [x] fixed bug\n",
688        );
689        let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
690        assert!(removed > 0);
691        let content = read_roadmap(d.path());
692        assert!(!content.contains("done item"));
693        assert!(!content.contains("fixed bug"));
694        assert!(content.contains("todo item"));
695    }
696
697    #[test]
698    fn test_clean_empty_file() {
699        let d = write_roadmap("");
700        let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
701        assert_eq!(removed, 0);
702    }
703
704    #[test]
705    fn test_clean_all_done_empties_file() {
706        // 所有条目都是 done → 清理后只剩空文件
707        let d = write_roadmap("## [0.1.0]\n### Added\n- [x] done\n");
708        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
709        let content = read_roadmap(d.path());
710        assert!(content.is_empty());
711    }
712
713    #[test]
714    fn test_clean_no_done_items_no_change() {
715        let d = write_roadmap("## [0.1.0]\n- [ ] todo\n");
716        let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
717        assert_eq!(removed, 0);
718    }
719
720    #[test]
721    fn test_clean_cascade_does_not_delete_adjacent_version() {
722        // Issue #5-4: [0.5.0] 全 done 被清后,[0.6.0] 不应被连带删除
723        let content = "## [0.6.0]\n\
724- [ ] 修复 bug\n\
725\n\
726## [0.5.0]\n\
727- [x] 已删除 legacy\n";
728        let d = write_roadmap(content);
729        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
730        let result = read_roadmap(d.path());
731        assert!(result.contains("0.6.0"), "[0.6.0] 不应被删除: {}", result);
732        assert!(!result.contains("0.5.0"), "[0.5.0] 应被删除: {}", result);
733        assert!(result.contains("修复 bug"), "内容应保留: {}", result);
734    }
735
736    #[test]
737    fn test_clean_trailing_newlines_removed() {
738        // 末尾多余空行应被清理
739        let d = write_roadmap("## [0.1.0]\n- [ ] todo\n\n\n");
740        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
741        let content = read_roadmap(d.path());
742        assert_eq!(content.trim_end().lines().count(), 2); // 版本标题 + 条目
743    }
744
745    #[test]
746    fn test_clean_file_not_found() {
747        let d = tempfile::tempdir().unwrap();
748        let nonexistent = d.path().join("NONEXISTENT.md");
749        let result = clean_roadmap(&nonexistent);
750        assert!(result.is_err());
751    }
752
753    #[test]
754    fn test_clean_suffix_version_all_done_cascade() {
755        let d = write_roadmap("## [0.2.0]\n\n- [ ] 待办\n\n## [0.1.0] — 已发布\n\n- [x] 旧功能\n");
756        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
757        let content = read_roadmap(d.path());
758        // 0.1.0 版本应被删除(全部 done 且带后缀)
759        assert!(!content.contains("0.1.0"), "0.1.0 版本应被删除");
760        // 0.2.0 版本应保留
761        assert!(content.contains("0.2.0"), "0.2.0 版本应保留");
762        // 待办内容应保留
763        assert!(content.contains("待办"), "待办内容应保留");
764    }
765
766    // ── doctor_roadmap ────────────────────────────────────────
767
768    #[test]
769    fn test_doctor_fixes_v_prefix() {
770        let d = write_roadmap("## [v0.1.0]\n- [ ] item\n");
771        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
772        assert!(issues.iter().any(|f| f.message.contains("v 前缀")));
773        let content = read_roadmap(d.path());
774        assert!(!content.contains("## [v"));
775    }
776
777    #[test]
778    fn test_doctor_fixes_category_case() {
779        let d = write_roadmap("## [0.1.0]\n### added\n- [ ] item\n");
780        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
781        assert!(issues.iter().any(|f| f.message.contains("大小写")));
782        let content = read_roadmap(d.path());
783        assert!(content.contains("### Added"));
784    }
785
786    #[test]
787    fn test_doctor_clean_file_no_issues() {
788        let d = write_roadmap("## [0.1.0]\n### Added\n- [ ] item\n");
789        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
790        assert!(issues.is_empty());
791    }
792
793    #[test]
794    fn test_doctor_modifies_file() {
795        let d = write_roadmap("## [v0.1.0]\n### ADDED\n-  [x] bad\n");
796        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
797        assert!(!issues.is_empty());
798        let content = read_roadmap(d.path());
799        assert!(content.contains("## [0.1.0]"));
800        assert!(content.contains("### Added"));
801        assert!(content.contains("- [x] bad"));
802    }
803
804    #[test]
805    fn test_doctor_detects_nonstandard_header() {
806        let d = write_roadmap("## 现状 (Current)\n- [ ] item\n");
807        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
808        assert!(
809            issues.iter().any(|i| i.message.contains("非标准版本头")),
810            "应检测到非标准版本头: {:?}",
811            issues
812        );
813    }
814
815    #[test]
816    fn test_doctor_detects_nonstandard_category() {
817        let d = write_roadmap("## [0.1.0]\n### 0.1 fix bug\n- [ ] item\n");
818        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
819        assert!(
820            issues.iter().any(|i| i.message.contains("非标准分类")),
821            "应检测到非标准分类: {:?}",
822            issues
823        );
824    }
825
826    #[test]
827    fn test_doctor_file_not_found() {
828        let d = tempfile::tempdir().unwrap();
829        let nonexistent = d.path().join("NONEXISTENT.md");
830        let result = doctor_roadmap(&nonexistent, "test");
831        assert!(result.is_err());
832    }
833
834    #[test]
835    fn test_doctor_mixed_format() {
836        let d = write_roadmap("## [0.1.0]\n\n- [ ] 标准条目\n\n## 杂项 (Misc)\n\n- [ ] 非标准\n");
837        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
838        assert!(
839            issues.iter().any(|i| i.message.contains("非标准版本头")),
840            "应检测到非标准版本头: {:?}",
841            issues
842        );
843    }
844
845    // ── print_status_to ─────────────────────────────────────────
846
847    #[test]
848    fn test_print_status_file_not_found() {
849        let d = tempfile::tempdir().unwrap();
850        let mut buf = Vec::new();
851        print_status_to(&mut buf, d.path(), None).unwrap();
852        let output = String::from_utf8_lossy(&buf);
853        assert!(output.contains("未创建规划文件"));
854    }
855
856    #[test]
857    fn test_print_status_empty_roadmap() {
858        let d = write_roadmap("");
859        let mut buf = Vec::new();
860        print_status_to(&mut buf, d.path(), None).unwrap();
861        let output = String::from_utf8_lossy(&buf);
862        assert!(output.contains("未找到标准规划条目"));
863    }
864
865    #[test]
866    fn test_print_status_unknown_headers_warns() {
867        // 非标准 ## 头应触发 warning
868        let d = write_roadmap("## 现状 (Current)\n- [ ] item\n");
869        let mut buf = Vec::new();
870        print_status_to(&mut buf, d.path(), None).unwrap();
871        let output = String::from_utf8_lossy(&buf);
872        assert!(
873            output.contains("plan doctor"),
874            "应提示运行 plan doctor: {}",
875            output
876        );
877    }
878
879    #[test]
880    fn test_print_status_to_with_scope() {
881        // scope "test" 不在契约中 → 回退到 test/ROADMAP.md
882        let d = tempfile::tempdir().unwrap();
883        let scope_dir = d.path().join("test");
884        std::fs::create_dir_all(&scope_dir).unwrap();
885        std::fs::write(
886            scope_dir.join("ROADMAP.md"),
887            "## [0.1.0]\n- [x] done\n- [ ] todo\n",
888        )
889        .unwrap();
890        let mut buf = Vec::new();
891        print_status_to(&mut buf, d.path(), Some("test")).unwrap();
892        let out = String::from_utf8_lossy(&buf);
893        assert!(out.contains("test"), "应显示 scope 名称");
894        assert!(out.contains("0.1.0"), "应显示版本号");
895    }
896
897    #[test]
898    fn test_print_status_with_data() {
899        let d =
900            write_roadmap("## [0.2.0]\n- [x] done\n- [ ] todo\n\n## [0.1.0]\n- [x] a\n- [x] b\n");
901        let mut buf = Vec::new();
902        print_status_to(&mut buf, d.path(), None).unwrap();
903        let output = String::from_utf8_lossy(&buf);
904        assert!(output.contains("(auto)"));
905        assert!(output.contains("0.2.0"));
906        assert!(output.contains("0.1.0"));
907        assert!(output.contains("3/4"));
908        assert!(output.contains("总计"));
909    }
910}