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 validate_roadmap(path: &Path, scope: &str) -> Result<Vec<Issue>, String> {
297 let content = std::fs::read_to_string(path)
298 .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
299
300 let mut issues: Vec<Issue> = Vec::new();
301
302 for (idx, raw_line) in content.lines().enumerate() {
303 let line_num = idx + 1;
304 let trimmed = raw_line.trim();
305
306 if is_version_line(trimmed).is_some() {
308 let raw_ver = trimmed
310 .trim_start_matches("## [")
311 .split(']')
312 .next()
313 .unwrap_or("")
314 .trim();
315 if raw_ver.starts_with('v') {
316 issues.push(Issue {
317 line: line_num,
318 scope: scope.to_string(),
319 message: format!("版本号不应有 v 前缀: {}", raw_ver),
320 });
321 }
322 }
323
324 if trimmed.starts_with("### ") {
326 let lowered = trimmed.to_lowercase();
327 if let Some(standard) = CATEGORIES.iter().find(|c| c.to_lowercase() == lowered) {
328 if trimmed != *standard {
329 issues.push(Issue {
330 line: line_num,
331 scope: scope.to_string(),
332 message: format!("分类标题大小写: 应为 '{}',当前 '{}'", standard, trimmed),
333 });
334 }
335 }
336 }
337
338 let has_any_box =
340 trimmed.contains("[x]") || trimmed.contains("[X]") || trimmed.contains("[ ]");
341 let is_standard = trimmed.starts_with("- [x] ")
342 || trimmed.starts_with("- [X] ")
343 || trimmed.starts_with("- [ ] ");
344 if has_any_box && !is_standard {
345 issues.push(Issue {
346 line: line_num,
347 scope: scope.to_string(),
348 message: format!("checkbox 格式异常: {}", trimmed),
349 });
350 }
351 }
352
353 Ok(issues)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use std::io::Write;
360
361 fn write_roadmap(content: &str) -> tempfile::TempDir {
362 let d = tempfile::tempdir().unwrap();
363 let mut f = std::fs::File::create(d.path().join("ROADMAP.md")).unwrap();
364 write!(f, "{}", content).unwrap();
365 d
366 }
367
368 fn read_roadmap(d: &Path) -> String {
369 std::fs::read_to_string(d.join("ROADMAP.md")).unwrap_or_default()
370 }
371
372 #[test]
375 fn test_parse_empty() {
376 let d = write_roadmap("");
377 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
378 assert!(v.is_empty());
379 }
380
381 #[test]
382 fn test_parse_single_version() {
383 let d = write_roadmap(
384 "## [0.1.0]\n\
385 \n\
386 ### Added\n\
387 - [x] feature a\n\
388 - [ ] feature b\n\
389 ### Fixed\n\
390 - [x] bug c\n",
391 );
392 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
393 assert_eq!(v.len(), 1);
394 assert_eq!(v[0].version, "0.1.0");
395 assert_eq!(v[0].done, 2);
396 assert_eq!(v[0].total, 3);
397 }
398
399 #[test]
400 fn test_parse_multi_version() {
401 let d = write_roadmap(
402 "## [0.2.0]\n\
403 - [x] done\n\
404 - [ ] todo\n\
405 \n\
406 ## [0.1.0]\n\
407 - [x] a\n\
408 - [x] b\n",
409 );
410 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
411 assert_eq!(v.len(), 2);
412 assert_eq!(v[0].version, "0.2.0");
413 assert_eq!(v[0].done, 1);
414 assert_eq!(v[0].total, 2);
415 assert_eq!(v[1].version, "0.1.0");
416 assert_eq!(v[1].done, 2);
417 assert_eq!(v[1].total, 2);
418 }
419
420 #[test]
421 fn test_parse_v_prefix() {
422 let d = write_roadmap("## [v0.1.0]\n- [x] item\n");
423 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
424 assert_eq!(v[0].version, "0.1.0");
425 }
426
427 #[test]
428 fn test_parse_no_checkboxes() {
429 let d = write_roadmap("## [0.1.0]\n\njust text\n");
430 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
431 assert_eq!(v.len(), 1);
432 assert_eq!(v[0].done, 0);
433 assert_eq!(v[0].total, 0);
434 }
435
436 #[test]
437 fn test_parse_version_with_suffix() {
438 let d = write_roadmap("## [0.1.0] — 已发布\n- [x] done\n- [ ] todo\n");
440 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
441 assert_eq!(v.len(), 1);
442 assert_eq!(v[0].version, "0.1.0");
443 assert_eq!(v[0].done, 1);
444 assert_eq!(v[0].total, 2);
445 }
446
447 #[test]
448 fn test_clean_version_with_suffix() {
449 let d = write_roadmap("## [0.1.0] — 已发布\n- [x] done\n");
451 clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
452 let content = read_roadmap(d.path());
453 assert!(!content.contains("0.1.0"), "空版本应被清理");
454 }
455
456 #[test]
457 fn test_parse_file_not_found() {
458 let d = tempfile::tempdir().unwrap();
459 let result = parse_roadmap(&d.path().join("NONEXISTENT.md"));
460 assert!(result.is_err());
461 }
462
463 #[test]
466 fn test_resolve_path_with_contract_scope() {
467 let d = tempfile::tempdir().unwrap();
468 let contract_dir = d.path().join(".quanttide/devops");
470 std::fs::create_dir_all(&contract_dir).unwrap();
471 std::fs::write(
472 contract_dir.join("contract.yaml"),
473 "scopes:\n cli:\n dir: src/cli\n language: rust\n",
474 )
475 .unwrap();
476 let path = resolve_roadmap_path(d.path(), Some("cli"));
477 assert!(path.to_string_lossy().ends_with("src/cli/ROADMAP.md"));
478 }
479
480 #[test]
481 fn test_resolve_path_fallback_to_name() {
482 let d = tempfile::tempdir().unwrap();
483 let path = resolve_roadmap_path(d.path(), Some("custom"));
484 assert!(path.to_string_lossy().ends_with("custom/ROADMAP.md"));
486 }
487
488 #[test]
489 fn test_resolve_path_no_scope_no_contract() {
490 let d = tempfile::tempdir().unwrap();
491 let path = resolve_roadmap_path(d.path(), None);
492 assert_eq!(path, d.path().join("ROADMAP.md"));
494 }
495
496 #[test]
499 fn test_clean_removes_done_items() {
500 let d = write_roadmap(
501 "## [0.1.0]\n\
502 ### Added\n\
503 - [x] done item\n\
504 - [ ] todo item\n\
505 ### Fixed\n\
506 - [x] fixed bug\n",
507 );
508 let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
509 assert!(removed > 0);
510 let content = read_roadmap(d.path());
511 assert!(!content.contains("done item"));
512 assert!(!content.contains("fixed bug"));
513 assert!(content.contains("todo item"));
514 }
515
516 #[test]
517 fn test_clean_empty_file() {
518 let d = write_roadmap("");
519 let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
520 assert_eq!(removed, 0);
521 }
522
523 #[test]
524 fn test_clean_all_done_empties_file() {
525 let d = write_roadmap("## [0.1.0]\n### Added\n- [x] done\n");
527 clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
528 let content = read_roadmap(d.path());
529 assert!(content.is_empty());
530 }
531
532 #[test]
533 fn test_clean_no_done_items_no_change() {
534 let d = write_roadmap("## [0.1.0]\n- [ ] todo\n");
535 let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
536 assert_eq!(removed, 0);
537 }
538
539 #[test]
540 fn test_clean_trailing_newlines_removed() {
541 let d = write_roadmap("## [0.1.0]\n- [ ] todo\n\n\n");
543 clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
544 let content = read_roadmap(d.path());
545 assert_eq!(content.trim_end().lines().count(), 2); }
547
548 #[test]
551 fn test_validate_v_prefix() {
552 let d = write_roadmap("## [v0.1.0]\n- [ ] item\n");
553 let issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
554 assert!(issues.iter().any(|f| f.message.contains("v 前缀")));
555 }
556
557 #[test]
558 fn test_validate_category_case() {
559 let d = write_roadmap("## [0.1.0]\n### added\n- [ ] item\n");
560 let issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
561 assert!(issues.iter().any(|f| f.message.contains("大小写")));
562 }
563
564 #[test]
565 fn test_validate_clean_file_no_issues() {
566 let d = write_roadmap("## [0.1.0]\n### Added\n- [ ] item\n");
567 let issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
568 assert!(issues.is_empty());
569 }
570
571 #[test]
572 fn test_validate_does_not_modify_file() {
573 let original = "## [v0.1.0]\n### added\n- [x] bad format\n";
574 let d = write_roadmap(original);
575 let _issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
576 assert_eq!(read_roadmap(d.path()), original);
577 }
578
579 #[test]
582 fn test_print_status_file_not_found() {
583 let d = tempfile::tempdir().unwrap();
584 let mut buf = Vec::new();
585 print_status_to(&mut buf, d.path(), None).unwrap();
586 let output = String::from_utf8_lossy(&buf);
587 assert!(output.contains("未创建规划文件"));
588 }
589
590 #[test]
591 fn test_print_status_empty_roadmap() {
592 let d = write_roadmap("");
593 let mut buf = Vec::new();
594 print_status_to(&mut buf, d.path(), None).unwrap();
595 let output = String::from_utf8_lossy(&buf);
596 assert!(output.contains("未找到规划条目"));
597 }
598
599 #[test]
600 fn test_print_status_with_data() {
601 let d =
602 write_roadmap("## [0.2.0]\n- [x] done\n- [ ] todo\n\n## [0.1.0]\n- [x] a\n- [x] b\n");
603 let mut buf = Vec::new();
604 print_status_to(&mut buf, d.path(), None).unwrap();
605 let output = String::from_utf8_lossy(&buf);
606 assert!(output.contains("(auto)"));
607 assert!(output.contains("0.2.0"));
608 assert!(output.contains("0.1.0"));
609 assert!(output.contains("3/4"));
610 assert!(output.contains("总计"));
611 }
612}