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/// 验证发现的格式问题。
24#[derive(Debug)]
25pub struct Issue {
26    pub line: usize,
27    pub scope: String,
28    pub message: String,
29}
30
31// ═══════════════════════════════════════════════════════════════════════
32// 路径解析
33// ═══════════════════════════════════════════════════════════════════════
34
35/// 解析 scope 参数,返回实际 ROADMAP.md 路径。
36pub 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            // 按 scope 名称查找
41            if let Some(s) = c.scopes.iter().find(|s| s.name == name) {
42                repo_path.join(&s.dir).join("ROADMAP.md")
43            } else {
44                // 回退:scope 名作为子目录
45                repo_path.join(name).join("ROADMAP.md")
46            }
47        }
48        _ => {
49            // 省略 scope → 找当前目录所属 scope
50            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(&current_dir) {
52                repo_path.join(&s.dir).join("ROADMAP.md")
53            } else {
54                repo_path.join("ROADMAP.md")
55            }
56        }
57    }
58}
59
60// ═══════════════════════════════════════════════════════════════════════
61// plan status
62// ═══════════════════════════════════════════════════════════════════════
63
64/// 解析 ROADMAP.md,返回各版本进度列表。
65pub 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        // `## [X.Y.Z]` — 版本标题(无日期后缀)
78        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
116/// 格式化输出 scope 规划进度。
117pub 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
122/// 写入指定 writer 的版本(可测试)。
123pub 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
178// ═══════════════════════════════════════════════════════════════════════
179// plan clean
180// ═══════════════════════════════════════════════════════════════════════
181
182const 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
208/// 删除 ROADMAP.md 中所有已完成条目。
209///
210/// 只删 `- [x]` 行,级联清理空分类和空版本标题。
211pub 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    // 第一遍:删除 done item 行
219    lines.retain(|l| !is_done_item(l));
220
221    // 第二遍:删除空的分类标题
222    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    // 第三遍:删除空的版本标题
240    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    // 清理尾部空行
258    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
280// ═══════════════════════════════════════════════════════════════════════
281// plan doctor
282// ═══════════════════════════════════════════════════════════════════════
283
284/// 验证 ROADMAP.md 的格式问题。
285///
286/// 规则只做验证,不做修复。修复由 LLM 完成(当前未接入)。
287pub 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        // 1. 版本标题禁止 v 前缀
298        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        // 2. 分类标题必须使用标准大小写
313        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        // 3. checkbox 必须使用标准格式
327        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    // ── parse_roadmap ────────────────────────────────────────────
361
362    #[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    // ── resolve_roadmap_path ────────────────────────────────────
432
433    #[test]
434    fn test_resolve_path_with_contract_scope() {
435        let d = tempfile::tempdir().unwrap();
436        // 创建 scope 契约
437        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        // scope 不在契约中 → 回退为子目录名
453        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        // 无 scope + 无契约 → repo 根目录
461        assert_eq!(path, d.path().join("ROADMAP.md"));
462    }
463
464    // ── clean_roadmap ───────────────────────────────────────────
465
466    #[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        // 所有条目都是 done → 清理后只剩空文件
494        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        // 末尾多余空行应被清理
510        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); // 版本标题 + 条目
514    }
515
516    // ── validate_roadmap ────────────────────────────────────────
517
518    #[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    // ── print_status_to ─────────────────────────────────────────
548
549    #[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}