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        return Ok(());
148    }
149
150    let scope_label = scope.unwrap_or("(auto)");
151    writeln!(writer, "  [{}] 规划进度", scope_label).ok();
152    writeln!(writer, "  {}", "-".repeat(40)).ok();
153
154    let mut total_done = 0usize;
155    let mut total_all = 0usize;
156
157    for v in &versions {
158        let rate = if v.total > 0 {
159            v.done as f64 / v.total as f64 * 100.0
160        } else {
161            0.0
162        };
163        writeln!(
164            writer,
165            "  [{:<8}] {:>2}/{:>2} 完成 ({:.0}%)",
166            v.version, v.done, v.total, rate
167        )
168        .ok();
169        total_done += v.done;
170        total_all += v.total;
171    }
172
173    let overall = if total_all > 0 {
174        total_done as f64 / total_all as f64 * 100.0
175    } else {
176        0.0
177    };
178    writeln!(writer, "  {}", "-".repeat(40)).ok();
179    writeln!(
180        writer,
181        "  总计:  {}/{} 完成 ({:.0}%)",
182        total_done, total_all, overall
183    )
184    .ok();
185    Ok(())
186}
187
188// ═══════════════════════════════════════════════════════════════════════
189// plan clean
190// ═══════════════════════════════════════════════════════════════════════
191
192const CATEGORIES: &[&str] = &[
193    "### Added",
194    "### Changed",
195    "### Fixed",
196    "### Removed",
197    "### Deprecated",
198    "### Security",
199];
200
201fn is_done_item(line: &str) -> bool {
202    let t = line.trim();
203    t.starts_with("- [x]") || t.starts_with("- [X]")
204}
205
206fn is_category_header(line: &str) -> bool {
207    let t = line.trim();
208    CATEGORIES
209        .iter()
210        .any(|c| t == *c || t.eq_ignore_ascii_case(c))
211}
212
213fn is_version_header(line: &str) -> bool {
214    is_version_line(line).is_some()
215}
216
217/// 删除 ROADMAP.md 中所有已完成条目。
218///
219/// 只删 `- [x]` 行,级联清理空分类和空版本标题。
220pub fn clean_roadmap(path: &Path) -> Result<usize, String> {
221    let content = std::fs::read_to_string(path)
222        .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
223    let original_len = content.len();
224
225    let mut lines: Vec<&str> = content.lines().collect();
226
227    // 第一遍:删除 done item 行
228    lines.retain(|l| !is_done_item(l));
229
230    // 第二遍:删除空的分类标题
231    let mut i = 0;
232    while i + 1 < lines.len() {
233        if is_category_header(lines[i]) {
234            let next = lines[i + 1].trim();
235            if next.is_empty() || is_category_header(next) || is_version_header(next) {
236                lines.remove(i);
237                continue;
238            }
239        }
240        i += 1;
241    }
242    if let Some(last) = lines.last() {
243        if is_category_header(last) {
244            lines.pop();
245        }
246    }
247
248    // 第三遍:删除空的版本标题
249    let mut i = 0;
250    while i + 1 < lines.len() {
251        if is_version_header(lines[i]) {
252            let next = lines[i + 1].trim();
253            if next.is_empty() || is_version_header(next) {
254                lines.remove(i);
255                continue;
256            }
257        }
258        i += 1;
259    }
260    if let Some(last) = lines.last() {
261        if is_version_header(last) {
262            lines.pop();
263        }
264    }
265
266    // 清理尾部空行
267    while let Some(last) = lines.last() {
268        if last.trim().is_empty() {
269            lines.pop();
270        } else {
271            break;
272        }
273    }
274
275    if lines.is_empty() {
276        std::fs::write(path, "").map_err(|e| format!("写入失败: {}", e))?;
277        return Ok(original_len);
278    }
279
280    let mut output = String::new();
281    for line in &lines {
282        output.push_str(line);
283        output.push('\n');
284    }
285    std::fs::write(path, &output).map_err(|e| format!("写入失败: {}", e))?;
286    Ok(original_len.saturating_sub(output.len()))
287}
288
289// ═══════════════════════════════════════════════════════════════════════
290// plan doctor
291// ═══════════════════════════════════════════════════════════════════════
292
293/// 诊断并修复 ROADMAP.md 的格式问题。
294///
295/// 1. 规则修复:v 前缀、分类大小写、checkbox 格式
296/// 2. LLM 修复:复杂格式问题(LLM 已配置时)
297pub fn doctor_roadmap(path: &Path, scope: &str) -> Result<Vec<Issue>, String> {
298    let content = std::fs::read_to_string(path)
299        .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
300
301    let mut issues = apply_rule_fixes(path, &content, scope)?;
302
303    // LLM 修复:规则修不了或仍有问题时才调 LLM
304    if issues.is_empty() {
305        return Ok(issues);
306    }
307    let settings = quanttide_agent::Settings::from_env();
308    if !settings.llm_api_key.is_empty() {
309        let content_after_rules =
310            std::fs::read_to_string(path).map_err(|e| format!("读取失败: {}", e))?;
311        if let Some(llm_issues) = doctor_llm(&content_after_rules, scope, &settings, path)? {
312            issues.extend(llm_issues);
313        }
314    }
315
316    Ok(issues)
317}
318
319/// 规则修复:v 前缀、分类大小写、checkbox 格式。
320fn apply_rule_fixes(path: &Path, content: &str, scope: &str) -> Result<Vec<Issue>, String> {
321    let mut issues: Vec<Issue> = Vec::new();
322    let mut new_lines: Vec<String> = Vec::new();
323
324    for (idx, raw_line) in content.lines().enumerate() {
325        let line_num = idx + 1;
326        let trimmed = raw_line.trim();
327
328        // 1. 版本标题:去掉 v 前缀
329        if let Some(ver) = is_version_line(trimmed) {
330            let raw_ver = trimmed
331                .trim_start_matches("## [")
332                .split(']')
333                .next()
334                .unwrap_or("")
335                .trim();
336            if raw_ver.starts_with('v') {
337                issues.push(Issue {
338                    line: line_num,
339                    scope: scope.to_string(),
340                    message: format!("修复 v 前缀: {} → {}", raw_ver, ver),
341                });
342                let suffix = trimmed.split(']').nth(1).unwrap_or("");
343                new_lines.push(format!("## [{}]{}", ver, suffix));
344                continue;
345            }
346            new_lines.push(raw_line.to_string());
347            continue;
348        }
349
350        // 2. 分类标题:标准化大小写
351        if trimmed.starts_with("### ") {
352            let lowered = trimmed.to_lowercase();
353            if let Some(standard) = CATEGORIES.iter().find(|c| c.to_lowercase() == lowered) {
354                if trimmed != *standard {
355                    issues.push(Issue {
356                        line: line_num,
357                        scope: scope.to_string(),
358                        message: format!("修复大小写: {} → {}", trimmed, standard),
359                    });
360                    let indent = &raw_line[..raw_line.len() - raw_line.trim_start().len()];
361                    new_lines.push(format!("{}{}", indent, standard));
362                    continue;
363                }
364            }
365            new_lines.push(raw_line.to_string());
366            continue;
367        }
368
369        // 3. checkbox:修复异常格式
370        let has_any_box =
371            trimmed.contains("[x]") || trimmed.contains("[X]") || trimmed.contains("[ ]");
372        let is_standard = trimmed.starts_with("- [x] ")
373            || trimmed.starts_with("- [X] ")
374            || trimmed.starts_with("- [ ] ");
375        if has_any_box && !is_standard {
376            let content_start = trimmed.find(']').map(|p| p + 1).unwrap_or(trimmed.len());
377            let item_content = trimmed[content_start..].trim();
378            let is_done = trimmed.contains("[x]") || trimmed.contains("[X]");
379            let prefix = if is_done { "- [x]" } else { "- [ ]" };
380            issues.push(Issue {
381                line: line_num,
382                scope: scope.to_string(),
383                message: format!(
384                    "修复 checkbox 格式: {} → {} {}",
385                    trimmed, prefix, item_content
386                ),
387            });
388            new_lines.push(format!("{} {}", prefix, item_content));
389            continue;
390        }
391
392        new_lines.push(raw_line.to_string());
393    }
394
395    if !issues.is_empty() {
396        let mut output = String::new();
397        for line in &new_lines {
398            output.push_str(line);
399            output.push('\n');
400        }
401        std::fs::write(path, &output).map_err(|e| format!("写入失败: {}", e))?;
402    }
403
404    Ok(issues)
405}
406
407/// LLM 修复:处理规则无法覆盖的复杂格式问题。
408fn doctor_llm(
409    content: &str,
410    _scope: &str,
411    settings: &quanttide_agent::Settings,
412    path: &Path,
413) -> Result<Option<Vec<Issue>>, String> {
414    use quanttide_agent::{llm::CompleteOptions, Message, LLM};
415
416    let format_spec = "ROADMAP.md 格式规范:
417a) 版本标题:## [X.Y.Z],可选后缀如 — 已发布
418b) 分类标题:### Added / Changed / Fixed / Removed / Deprecated / Security
419c) 条目格式:- [x] 内容 或 - [ ] 内容
420";
421    let prompt = format!(
422        "{}\n\n以下 ROADMAP.md 可能存在格式问题,请按规范修复格式(只修格式,不增删条目):\n\n{}",
423        format_spec, content
424    );
425
426    let llm = LLM::new(
427        &settings.llm_model,
428        &settings.llm_base_url,
429        &settings.llm_api_key,
430    );
431    let messages = vec![
432        Message::new(
433            "system",
434            "你是 ROADMAP.md 格式修复助手。只修格式,不增删条目内容。",
435        ),
436        Message::new("user", &prompt),
437    ];
438    let response = llm
439        .complete(&messages, CompleteOptions::default())
440        .map_err(|e| format!("LLM 调用失败: {}", e))?;
441
442    let fixed = response.content.trim().to_string();
443    if fixed.is_empty() || fixed == content {
444        return Ok(None);
445    }
446
447    std::fs::write(path, &fixed).map_err(|e| format!("写入失败: {}", e))?;
448    println!("  📋 LLM 格式修复已应用");
449
450    Ok(Some(vec![Issue {
451        line: 0,
452        scope: String::new(),
453        message: "LLM 格式修复完成".to_string(),
454    }]))
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use std::io::Write;
461
462    fn write_roadmap(content: &str) -> tempfile::TempDir {
463        let d = tempfile::tempdir().unwrap();
464        let mut f = std::fs::File::create(d.path().join("ROADMAP.md")).unwrap();
465        write!(f, "{}", content).unwrap();
466        d
467    }
468
469    fn read_roadmap(d: &Path) -> String {
470        std::fs::read_to_string(d.join("ROADMAP.md")).unwrap_or_default()
471    }
472
473    // ── parse_roadmap ────────────────────────────────────────────
474
475    #[test]
476    fn test_parse_empty() {
477        let d = write_roadmap("");
478        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
479        assert!(v.is_empty());
480    }
481
482    #[test]
483    fn test_parse_single_version() {
484        let d = write_roadmap(
485            "## [0.1.0]\n\
486             \n\
487             ### Added\n\
488             - [x] feature a\n\
489             - [ ] feature b\n\
490             ### Fixed\n\
491             - [x] bug c\n",
492        );
493        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
494        assert_eq!(v.len(), 1);
495        assert_eq!(v[0].version, "0.1.0");
496        assert_eq!(v[0].done, 2);
497        assert_eq!(v[0].total, 3);
498    }
499
500    #[test]
501    fn test_parse_multi_version() {
502        let d = write_roadmap(
503            "## [0.2.0]\n\
504             - [x] done\n\
505             - [ ] todo\n\
506             \n\
507             ## [0.1.0]\n\
508             - [x] a\n\
509             - [x] b\n",
510        );
511        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
512        assert_eq!(v.len(), 2);
513        assert_eq!(v[0].version, "0.2.0");
514        assert_eq!(v[0].done, 1);
515        assert_eq!(v[0].total, 2);
516        assert_eq!(v[1].version, "0.1.0");
517        assert_eq!(v[1].done, 2);
518        assert_eq!(v[1].total, 2);
519    }
520
521    #[test]
522    fn test_parse_v_prefix() {
523        let d = write_roadmap("## [v0.1.0]\n- [x] item\n");
524        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
525        assert_eq!(v[0].version, "0.1.0");
526    }
527
528    #[test]
529    fn test_parse_no_checkboxes() {
530        let d = write_roadmap("## [0.1.0]\n\njust text\n");
531        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
532        assert_eq!(v.len(), 1);
533        assert_eq!(v[0].done, 0);
534        assert_eq!(v[0].total, 0);
535    }
536
537    #[test]
538    fn test_parse_version_with_suffix() {
539        // `## [0.1.0] — 已发布` 应被正确识别
540        let d = write_roadmap("## [0.1.0] — 已发布\n- [x] done\n- [ ] todo\n");
541        let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
542        assert_eq!(v.len(), 1);
543        assert_eq!(v[0].version, "0.1.0");
544        assert_eq!(v[0].done, 1);
545        assert_eq!(v[0].total, 2);
546    }
547
548    #[test]
549    fn test_clean_version_with_suffix() {
550        // 后缀版本头应被识别并可级联清理
551        let d = write_roadmap("## [0.1.0] — 已发布\n- [x] done\n");
552        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
553        let content = read_roadmap(d.path());
554        assert!(!content.contains("0.1.0"), "空版本应被清理");
555    }
556
557    #[test]
558    fn test_parse_file_not_found() {
559        let d = tempfile::tempdir().unwrap();
560        let result = parse_roadmap(&d.path().join("NONEXISTENT.md"));
561        assert!(result.is_err());
562    }
563
564    // ── resolve_roadmap_path ────────────────────────────────────
565
566    #[test]
567    fn test_resolve_path_with_contract_scope() {
568        let d = tempfile::tempdir().unwrap();
569        // 创建 scope 契约
570        let contract_dir = d.path().join(".quanttide/devops");
571        std::fs::create_dir_all(&contract_dir).unwrap();
572        std::fs::write(
573            contract_dir.join("contract.yaml"),
574            "scopes:\n  cli:\n    dir: src/cli\n    language: rust\n",
575        )
576        .unwrap();
577        let path = resolve_roadmap_path(d.path(), Some("cli"));
578        assert!(path.to_string_lossy().ends_with("src/cli/ROADMAP.md"));
579    }
580
581    #[test]
582    fn test_resolve_path_fallback_to_name() {
583        let d = tempfile::tempdir().unwrap();
584        let path = resolve_roadmap_path(d.path(), Some("custom"));
585        // scope 不在契约中 → 回退为子目录名
586        assert!(path.to_string_lossy().ends_with("custom/ROADMAP.md"));
587    }
588
589    #[test]
590    fn test_resolve_path_no_scope_no_contract() {
591        let d = tempfile::tempdir().unwrap();
592        let path = resolve_roadmap_path(d.path(), None);
593        // 无 scope + 无契约 → repo 根目录
594        assert_eq!(path, d.path().join("ROADMAP.md"));
595    }
596
597    // ── clean_roadmap ───────────────────────────────────────────
598
599    #[test]
600    fn test_clean_removes_done_items() {
601        let d = write_roadmap(
602            "## [0.1.0]\n\
603             ### Added\n\
604             - [x] done item\n\
605             - [ ] todo item\n\
606             ### Fixed\n\
607             - [x] fixed bug\n",
608        );
609        let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
610        assert!(removed > 0);
611        let content = read_roadmap(d.path());
612        assert!(!content.contains("done item"));
613        assert!(!content.contains("fixed bug"));
614        assert!(content.contains("todo item"));
615    }
616
617    #[test]
618    fn test_clean_empty_file() {
619        let d = write_roadmap("");
620        let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
621        assert_eq!(removed, 0);
622    }
623
624    #[test]
625    fn test_clean_all_done_empties_file() {
626        // 所有条目都是 done → 清理后只剩空文件
627        let d = write_roadmap("## [0.1.0]\n### Added\n- [x] done\n");
628        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
629        let content = read_roadmap(d.path());
630        assert!(content.is_empty());
631    }
632
633    #[test]
634    fn test_clean_no_done_items_no_change() {
635        let d = write_roadmap("## [0.1.0]\n- [ ] todo\n");
636        let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
637        assert_eq!(removed, 0);
638    }
639
640    #[test]
641    fn test_clean_trailing_newlines_removed() {
642        // 末尾多余空行应被清理
643        let d = write_roadmap("## [0.1.0]\n- [ ] todo\n\n\n");
644        clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
645        let content = read_roadmap(d.path());
646        assert_eq!(content.trim_end().lines().count(), 2); // 版本标题 + 条目
647    }
648
649    // ── doctor_roadmap ────────────────────────────────────────
650
651    #[test]
652    fn test_doctor_fixes_v_prefix() {
653        let d = write_roadmap("## [v0.1.0]\n- [ ] item\n");
654        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
655        assert!(issues.iter().any(|f| f.message.contains("v 前缀")));
656        let content = read_roadmap(d.path());
657        assert!(!content.contains("## [v"));
658    }
659
660    #[test]
661    fn test_doctor_fixes_category_case() {
662        let d = write_roadmap("## [0.1.0]\n### added\n- [ ] item\n");
663        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
664        assert!(issues.iter().any(|f| f.message.contains("大小写")));
665        let content = read_roadmap(d.path());
666        assert!(content.contains("### Added"));
667    }
668
669    #[test]
670    fn test_doctor_clean_file_no_issues() {
671        let d = write_roadmap("## [0.1.0]\n### Added\n- [ ] item\n");
672        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
673        assert!(issues.is_empty());
674    }
675
676    #[test]
677    fn test_doctor_modifies_file() {
678        // doctor 会实际修改文件,不再是只读
679        let d = write_roadmap("## [v0.1.0]\n### ADDED\n-  [x] bad\n");
680        let issues = doctor_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
681        assert!(!issues.is_empty());
682        let content = read_roadmap(d.path());
683        assert!(content.contains("## [0.1.0]"));
684        assert!(content.contains("### Added"));
685        assert!(content.contains("- [x] bad"));
686    }
687
688    // ── print_status_to ─────────────────────────────────────────
689
690    #[test]
691    fn test_print_status_file_not_found() {
692        let d = tempfile::tempdir().unwrap();
693        let mut buf = Vec::new();
694        print_status_to(&mut buf, d.path(), None).unwrap();
695        let output = String::from_utf8_lossy(&buf);
696        assert!(output.contains("未创建规划文件"));
697    }
698
699    #[test]
700    fn test_print_status_empty_roadmap() {
701        let d = write_roadmap("");
702        let mut buf = Vec::new();
703        print_status_to(&mut buf, d.path(), None).unwrap();
704        let output = String::from_utf8_lossy(&buf);
705        assert!(output.contains("未找到规划条目"));
706    }
707
708    #[test]
709    fn test_print_status_with_data() {
710        let d =
711            write_roadmap("## [0.2.0]\n- [x] done\n- [ ] todo\n\n## [0.1.0]\n- [x] a\n- [x] b\n");
712        let mut buf = Vec::new();
713        print_status_to(&mut buf, d.path(), None).unwrap();
714        let output = String::from_utf8_lossy(&buf);
715        assert!(output.contains("(auto)"));
716        assert!(output.contains("0.2.0"));
717        assert!(output.contains("0.1.0"));
718        assert!(output.contains("3/4"));
719        assert!(output.contains("总计"));
720    }
721}