1use std::path::{Path, PathBuf};
10
11#[derive(Debug)]
17pub struct VersionProgress {
18 pub version: String,
19 pub done: usize,
20 pub total: usize,
21}
22
23pub 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#[derive(Debug)]
41pub struct Issue {
42 pub line: usize,
43 pub scope: String,
44 pub message: String,
45}
46
47pub 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 if let Some(s) = c.scopes.iter().find(|s| s.name == name) {
58 repo_path.join(&s.dir).join("ROADMAP.md")
59 } else {
60 repo_path.join(name).join("ROADMAP.md")
62 }
63 }
64 _ => {
65 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(¤t_dir) {
68 repo_path.join(&s.dir).join("ROADMAP.md")
69 } else {
70 repo_path.join("ROADMAP.md")
71 }
72 }
73 }
74}
75
76pub 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 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
126pub 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
132pub 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 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 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
193fn 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
236const 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
265pub 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 lines.retain(|l| !is_done_item(l));
277
278 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 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 lines.remove(i);
305 continue;
306 }
307 }
309 i += 1;
310 }
311 if let Some(last) = lines.last() {
312 if is_version_header(last) {
313 lines.pop();
314 }
315 }
316
317 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
340pub 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 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 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
371fn 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 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 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 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 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 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
487fn 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 #[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 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 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 #[test]
647 fn test_resolve_path_with_contract_scope() {
648 let d = tempfile::tempdir().unwrap();
649 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 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 assert_eq!(path, d.path().join("ROADMAP.md"));
675 }
676
677 #[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 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 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 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); }
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 assert!(!content.contains("0.1.0"), "0.1.0 版本应被删除");
760 assert!(content.contains("0.2.0"), "0.2.0 版本应保留");
762 assert!(content.contains("待办"), "待办内容应保留");
764 }
765
766 #[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 #[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 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 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}