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
23#[derive(Debug)]
25pub struct Issue {
26 pub line: usize,
27 pub scope: String,
28 pub message: String,
29}
30
31pub fn resolve_roadmap_path(repo_path: &Path, scope: Option<&str>) -> PathBuf {
37 let c = crate::contract::load(repo_path);
38 match scope {
39 Some(name) if !name.is_empty() => {
40 if let Some(s) = c.scopes.iter().find(|s| s.name == name) {
42 repo_path.join(&s.dir).join("ROADMAP.md")
43 } else {
44 repo_path.join(name).join("ROADMAP.md")
46 }
47 }
48 _ => {
49 let current_dir = std::env::current_dir().unwrap_or_else(|_| repo_path.to_path_buf());
51 if let Some(s) = c.find_scope_by_path(¤t_dir) {
52 repo_path.join(&s.dir).join("ROADMAP.md")
53 } else {
54 repo_path.join("ROADMAP.md")
55 }
56 }
57 }
58}
59
60pub fn parse_roadmap(path: &Path) -> Result<Vec<VersionProgress>, String> {
66 let content = std::fs::read_to_string(path)
67 .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
68
69 let mut versions: Vec<VersionProgress> = Vec::new();
70 let mut current_version: Option<String> = None;
71 let mut done = 0usize;
72 let mut total = 0usize;
73
74 for line in content.lines() {
75 let trimmed = line.trim();
76
77 if trimmed.starts_with("## [") && trimmed.ends_with(']') {
79 if let Some(ver) = current_version.take() {
80 versions.push(VersionProgress {
81 version: ver,
82 done,
83 total,
84 });
85 }
86 done = 0;
87 total = 0;
88 let ver = trimmed
89 .trim_start_matches("## [")
90 .trim_end_matches(']')
91 .trim()
92 .trim_start_matches('v')
93 .to_string();
94 current_version = Some(ver);
95 continue;
96 }
97
98 if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
99 total += 1;
100 done += 1;
101 } else if trimmed.starts_with("- [ ]") {
102 total += 1;
103 }
104 }
105
106 if let Some(ver) = current_version {
107 versions.push(VersionProgress {
108 version: ver,
109 done,
110 total,
111 });
112 }
113 Ok(versions)
114}
115
116pub fn print_status(repo_path: &Path, scope: Option<&str>) -> Result<(), String> {
118 let mut stdout = std::io::stdout();
119 print_status_to(&mut stdout, repo_path, scope)
120}
121
122pub fn print_status_to(
124 writer: &mut impl std::io::Write,
125 repo_path: &Path,
126 scope: Option<&str>,
127) -> Result<(), String> {
128 let roadmap_path = resolve_roadmap_path(repo_path, scope);
129 if !roadmap_path.exists() {
130 writeln!(writer, " 未创建规划文件: {}", roadmap_path.display()).ok();
131 return Ok(());
132 }
133
134 let versions = parse_roadmap(&roadmap_path)?;
135 if versions.is_empty() {
136 writeln!(writer, " 未找到规划条目").ok();
137 return Ok(());
138 }
139
140 let scope_label = scope.unwrap_or("(auto)");
141 writeln!(writer, " [{}] 规划进度", scope_label).ok();
142 writeln!(writer, " {}", "-".repeat(40)).ok();
143
144 let mut total_done = 0usize;
145 let mut total_all = 0usize;
146
147 for v in &versions {
148 let rate = if v.total > 0 {
149 v.done as f64 / v.total as f64 * 100.0
150 } else {
151 0.0
152 };
153 writeln!(
154 writer,
155 " [{:<8}] {:>2}/{:>2} 完成 ({:.0}%)",
156 v.version, v.done, v.total, rate
157 )
158 .ok();
159 total_done += v.done;
160 total_all += v.total;
161 }
162
163 let overall = if total_all > 0 {
164 total_done as f64 / total_all as f64 * 100.0
165 } else {
166 0.0
167 };
168 writeln!(writer, " {}", "-".repeat(40)).ok();
169 writeln!(
170 writer,
171 " 总计: {}/{} 完成 ({:.0}%)",
172 total_done, total_all, overall
173 )
174 .ok();
175 Ok(())
176}
177
178const CATEGORIES: &[&str] = &[
183 "### Added",
184 "### Changed",
185 "### Fixed",
186 "### Removed",
187 "### Deprecated",
188 "### Security",
189];
190
191fn is_done_item(line: &str) -> bool {
192 let t = line.trim();
193 t.starts_with("- [x]") || t.starts_with("- [X]")
194}
195
196fn is_category_header(line: &str) -> bool {
197 let t = line.trim();
198 CATEGORIES
199 .iter()
200 .any(|c| t == *c || t.eq_ignore_ascii_case(c))
201}
202
203fn is_version_header(line: &str) -> bool {
204 let t = line.trim();
205 t.starts_with("## [") && t.ends_with(']')
206}
207
208pub fn clean_roadmap(path: &Path) -> Result<usize, String> {
212 let content = std::fs::read_to_string(path)
213 .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
214 let original_len = content.len();
215
216 let mut lines: Vec<&str> = content.lines().collect();
217
218 lines.retain(|l| !is_done_item(l));
220
221 let mut i = 0;
223 while i + 1 < lines.len() {
224 if is_category_header(lines[i]) {
225 let next = lines[i + 1].trim();
226 if next.is_empty() || is_category_header(next) || is_version_header(next) {
227 lines.remove(i);
228 continue;
229 }
230 }
231 i += 1;
232 }
233 if let Some(last) = lines.last() {
234 if is_category_header(last) {
235 lines.pop();
236 }
237 }
238
239 let mut i = 0;
241 while i + 1 < lines.len() {
242 if is_version_header(lines[i]) {
243 let next = lines[i + 1].trim();
244 if next.is_empty() || is_version_header(next) {
245 lines.remove(i);
246 continue;
247 }
248 }
249 i += 1;
250 }
251 if let Some(last) = lines.last() {
252 if is_version_header(last) {
253 lines.pop();
254 }
255 }
256
257 while let Some(last) = lines.last() {
259 if last.trim().is_empty() {
260 lines.pop();
261 } else {
262 break;
263 }
264 }
265
266 if lines.is_empty() {
267 std::fs::write(path, "").map_err(|e| format!("写入失败: {}", e))?;
268 return Ok(original_len);
269 }
270
271 let mut output = String::new();
272 for line in &lines {
273 output.push_str(line);
274 output.push('\n');
275 }
276 std::fs::write(path, &output).map_err(|e| format!("写入失败: {}", e))?;
277 Ok(original_len.saturating_sub(output.len()))
278}
279
280pub fn validate_roadmap(path: &Path, scope: &str) -> Result<Vec<Issue>, String> {
288 let content = std::fs::read_to_string(path)
289 .map_err(|e| format!("读取 {} 失败: {}", path.display(), e))?;
290
291 let mut issues: Vec<Issue> = Vec::new();
292
293 for (idx, raw_line) in content.lines().enumerate() {
294 let line_num = idx + 1;
295 let trimmed = raw_line.trim();
296
297 if trimmed.starts_with("## [") && trimmed.ends_with(']') {
299 let ver = trimmed
300 .trim_start_matches("## [")
301 .trim_end_matches(']')
302 .trim();
303 if ver.starts_with('v') {
304 issues.push(Issue {
305 line: line_num,
306 scope: scope.to_string(),
307 message: format!("版本号不应有 v 前缀: {}", ver),
308 });
309 }
310 }
311
312 if trimmed.starts_with("### ") {
314 let lowered = trimmed.to_lowercase();
315 if let Some(standard) = CATEGORIES.iter().find(|c| c.to_lowercase() == lowered) {
316 if trimmed != *standard {
317 issues.push(Issue {
318 line: line_num,
319 scope: scope.to_string(),
320 message: format!("分类标题大小写: 应为 '{}',当前 '{}'", standard, trimmed),
321 });
322 }
323 }
324 }
325
326 let has_any_box =
328 trimmed.contains("[x]") || trimmed.contains("[X]") || trimmed.contains("[ ]");
329 let is_standard = trimmed.starts_with("- [x] ")
330 || trimmed.starts_with("- [X] ")
331 || trimmed.starts_with("- [ ] ");
332 if has_any_box && !is_standard {
333 issues.push(Issue {
334 line: line_num,
335 scope: scope.to_string(),
336 message: format!("checkbox 格式异常: {}", trimmed),
337 });
338 }
339 }
340
341 Ok(issues)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use std::io::Write;
348
349 fn write_roadmap(content: &str) -> tempfile::TempDir {
350 let d = tempfile::tempdir().unwrap();
351 let mut f = std::fs::File::create(d.path().join("ROADMAP.md")).unwrap();
352 write!(f, "{}", content).unwrap();
353 d
354 }
355
356 fn read_roadmap(d: &Path) -> String {
357 std::fs::read_to_string(d.join("ROADMAP.md")).unwrap_or_default()
358 }
359
360 #[test]
363 fn test_parse_empty() {
364 let d = write_roadmap("");
365 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
366 assert!(v.is_empty());
367 }
368
369 #[test]
370 fn test_parse_single_version() {
371 let d = write_roadmap(
372 "## [0.1.0]\n\
373 \n\
374 ### Added\n\
375 - [x] feature a\n\
376 - [ ] feature b\n\
377 ### Fixed\n\
378 - [x] bug c\n",
379 );
380 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
381 assert_eq!(v.len(), 1);
382 assert_eq!(v[0].version, "0.1.0");
383 assert_eq!(v[0].done, 2);
384 assert_eq!(v[0].total, 3);
385 }
386
387 #[test]
388 fn test_parse_multi_version() {
389 let d = write_roadmap(
390 "## [0.2.0]\n\
391 - [x] done\n\
392 - [ ] todo\n\
393 \n\
394 ## [0.1.0]\n\
395 - [x] a\n\
396 - [x] b\n",
397 );
398 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
399 assert_eq!(v.len(), 2);
400 assert_eq!(v[0].version, "0.2.0");
401 assert_eq!(v[0].done, 1);
402 assert_eq!(v[0].total, 2);
403 assert_eq!(v[1].version, "0.1.0");
404 assert_eq!(v[1].done, 2);
405 assert_eq!(v[1].total, 2);
406 }
407
408 #[test]
409 fn test_parse_v_prefix() {
410 let d = write_roadmap("## [v0.1.0]\n- [x] item\n");
411 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
412 assert_eq!(v[0].version, "0.1.0");
413 }
414
415 #[test]
416 fn test_parse_no_checkboxes() {
417 let d = write_roadmap("## [0.1.0]\n\njust text\n");
418 let v = parse_roadmap(&d.path().join("ROADMAP.md")).unwrap();
419 assert_eq!(v.len(), 1);
420 assert_eq!(v[0].done, 0);
421 assert_eq!(v[0].total, 0);
422 }
423
424 #[test]
425 fn test_parse_file_not_found() {
426 let d = tempfile::tempdir().unwrap();
427 let result = parse_roadmap(&d.path().join("NONEXISTENT.md"));
428 assert!(result.is_err());
429 }
430
431 #[test]
434 fn test_resolve_path_with_contract_scope() {
435 let d = tempfile::tempdir().unwrap();
436 let contract_dir = d.path().join(".quanttide/devops");
438 std::fs::create_dir_all(&contract_dir).unwrap();
439 std::fs::write(
440 contract_dir.join("contract.yaml"),
441 "scopes:\n cli:\n dir: src/cli\n language: rust\n",
442 )
443 .unwrap();
444 let path = resolve_roadmap_path(d.path(), Some("cli"));
445 assert!(path.to_string_lossy().ends_with("src/cli/ROADMAP.md"));
446 }
447
448 #[test]
449 fn test_resolve_path_fallback_to_name() {
450 let d = tempfile::tempdir().unwrap();
451 let path = resolve_roadmap_path(d.path(), Some("custom"));
452 assert!(path.to_string_lossy().ends_with("custom/ROADMAP.md"));
454 }
455
456 #[test]
457 fn test_resolve_path_no_scope_no_contract() {
458 let d = tempfile::tempdir().unwrap();
459 let path = resolve_roadmap_path(d.path(), None);
460 assert_eq!(path, d.path().join("ROADMAP.md"));
462 }
463
464 #[test]
467 fn test_clean_removes_done_items() {
468 let d = write_roadmap(
469 "## [0.1.0]\n\
470 ### Added\n\
471 - [x] done item\n\
472 - [ ] todo item\n\
473 ### Fixed\n\
474 - [x] fixed bug\n",
475 );
476 let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
477 assert!(removed > 0);
478 let content = read_roadmap(d.path());
479 assert!(!content.contains("done item"));
480 assert!(!content.contains("fixed bug"));
481 assert!(content.contains("todo item"));
482 }
483
484 #[test]
485 fn test_clean_empty_file() {
486 let d = write_roadmap("");
487 let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
488 assert_eq!(removed, 0);
489 }
490
491 #[test]
492 fn test_clean_all_done_empties_file() {
493 let d = write_roadmap("## [0.1.0]\n### Added\n- [x] done\n");
495 clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
496 let content = read_roadmap(d.path());
497 assert!(content.is_empty());
498 }
499
500 #[test]
501 fn test_clean_no_done_items_no_change() {
502 let d = write_roadmap("## [0.1.0]\n- [ ] todo\n");
503 let removed = clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
504 assert_eq!(removed, 0);
505 }
506
507 #[test]
508 fn test_clean_trailing_newlines_removed() {
509 let d = write_roadmap("## [0.1.0]\n- [ ] todo\n\n\n");
511 clean_roadmap(&d.path().join("ROADMAP.md")).unwrap();
512 let content = read_roadmap(d.path());
513 assert_eq!(content.trim_end().lines().count(), 2); }
515
516 #[test]
519 fn test_validate_v_prefix() {
520 let d = write_roadmap("## [v0.1.0]\n- [ ] item\n");
521 let issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
522 assert!(issues.iter().any(|f| f.message.contains("v 前缀")));
523 }
524
525 #[test]
526 fn test_validate_category_case() {
527 let d = write_roadmap("## [0.1.0]\n### added\n- [ ] item\n");
528 let issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
529 assert!(issues.iter().any(|f| f.message.contains("大小写")));
530 }
531
532 #[test]
533 fn test_validate_clean_file_no_issues() {
534 let d = write_roadmap("## [0.1.0]\n### Added\n- [ ] item\n");
535 let issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
536 assert!(issues.is_empty());
537 }
538
539 #[test]
540 fn test_validate_does_not_modify_file() {
541 let original = "## [v0.1.0]\n### added\n- [x] bad format\n";
542 let d = write_roadmap(original);
543 let _issues = validate_roadmap(&d.path().join("ROADMAP.md"), "test").unwrap();
544 assert_eq!(read_roadmap(d.path()), original);
545 }
546
547 #[test]
550 fn test_print_status_file_not_found() {
551 let d = tempfile::tempdir().unwrap();
552 let mut buf = Vec::new();
553 print_status_to(&mut buf, d.path(), None).unwrap();
554 let output = String::from_utf8_lossy(&buf);
555 assert!(output.contains("未创建规划文件"));
556 }
557
558 #[test]
559 fn test_print_status_empty_roadmap() {
560 let d = write_roadmap("");
561 let mut buf = Vec::new();
562 print_status_to(&mut buf, d.path(), None).unwrap();
563 let output = String::from_utf8_lossy(&buf);
564 assert!(output.contains("未找到规划条目"));
565 }
566
567 #[test]
568 fn test_print_status_with_data() {
569 let d =
570 write_roadmap("## [0.2.0]\n- [x] done\n- [ ] todo\n\n## [0.1.0]\n- [x] a\n- [x] b\n");
571 let mut buf = Vec::new();
572 print_status_to(&mut buf, d.path(), None).unwrap();
573 let output = String::from_utf8_lossy(&buf);
574 assert!(output.contains("(auto)"));
575 assert!(output.contains("0.2.0"));
576 assert!(output.contains("0.1.0"));
577 assert!(output.contains("3/4"));
578 assert!(output.contains("总计"));
579 }
580}