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 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
188const 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
217pub 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 lines.retain(|l| !is_done_item(l));
229
230 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 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 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
289pub 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 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
319fn 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 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 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 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
407fn 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 #[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 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 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 #[test]
567 fn test_resolve_path_with_contract_scope() {
568 let d = tempfile::tempdir().unwrap();
569 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 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 assert_eq!(path, d.path().join("ROADMAP.md"));
595 }
596
597 #[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 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 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); }
648
649 #[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 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 #[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}