Skip to main content

qtcloud_devops_cli/
test.rs

1use std::path::Path;
2
3use crate::contract;
4
5/// 测试结果汇总。
6#[derive(Debug, Default)]
7pub struct TestSummary {
8    pub total: u32,
9    pub passed: u32,
10    pub failed: u32,
11    pub skipped: u32,
12}
13
14/// 覆盖率数据。
15#[derive(Debug, Default)]
16pub struct Coverage {
17    pub percentage: f64,
18    pub threshold: f64,
19}
20
21impl Coverage {
22    pub fn met(&self) -> bool {
23        self.percentage >= self.threshold
24    }
25}
26
27/// 按 scope 输出测试状态(写 stdout 的便捷封装)。
28pub fn status(repo_path: &Path, c: &contract::Contract) {
29    let _ = status_to(&mut std::io::stdout(), repo_path, c);
30}
31
32/// 运行测试和覆盖率。
33pub fn run(repo_path: &Path) -> Result<(), String> {
34    let c = crate::contract::load(repo_path);
35    let scopes = &c.scopes;
36
37    if scopes.is_empty() {
38        let lang = crate::contract::detect_by_files(repo_path);
39        run_tests_for_lang(repo_path, &lang)?;
40        run_coverage_for_lang(repo_path, &lang);
41    } else {
42        for scope in scopes {
43            let scope_dir = repo_path.join(&scope.dir);
44            if !scope_dir.exists() {
45                println!("  [{}]     ⚠ 目录不存在,跳过", scope.name);
46                continue;
47            }
48            let lang = c.resolve_language(scope, &scope_dir);
49            println!("  [{}] 运行测试...", scope.name);
50            run_tests_for_lang(&scope_dir, &lang)?;
51            run_coverage_for_lang(&scope_dir, &lang);
52        }
53    }
54    Ok(())
55}
56
57fn run_tests_for_lang(dir: &Path, lang: &contract::Language) -> Result<(), String> {
58    let Some((cmd, args)) = test_command(lang) else {
59        println!("  ⚠ 不支持的语言: {:?},跳过", lang);
60        return Ok(());
61    };
62    let status = std::process::Command::new(cmd)
63        .args(args)
64        .current_dir(dir)
65        .status()
66        .map_err(|e| format!("启动 {} 失败: {}", cmd, e))?;
67    if status.success() {
68        println!("  ✅ {} 测试通过", cmd);
69        Ok(())
70    } else {
71        Err(format!("{} 测试失败", cmd))
72    }
73}
74
75fn coverage_command(lang: &contract::Language) -> Option<(&'static str, &'static [&'static str])> {
76    match lang {
77        contract::Language::Rust => Some((
78            "cargo",
79            &[
80                "llvm-cov",
81                "--lcov",
82                "--output-path",
83                "target/coverage/lcov.info",
84            ],
85        )),
86        contract::Language::Python => Some(("coverage", &["xml"])),
87        contract::Language::Go => Some((
88            "go",
89            &["tool", "cover", "-html=coverage.out", "-o", "coverage.html"],
90        )),
91        contract::Language::Dart => Some(("flutter", &["test", "--coverage"])),
92        contract::Language::TypeScript => Some(("npx", &["nyc", "--reporter=lcov", "npm", "test"])),
93        contract::Language::Unknown(_) => None,
94    }
95}
96
97fn run_coverage_for_lang(dir: &Path, lang: &contract::Language) {
98    let Some((cmd, args)) = coverage_command(lang) else {
99        println!("  ⚠ {:?} 覆盖率不可用,跳过", lang);
100        return;
101    };
102    println!("  生成覆盖率 ({})...", cmd);
103    match std::process::Command::new(cmd)
104        .args(args)
105        .current_dir(dir)
106        .status()
107    {
108        Ok(s) if s.success() => println!("  ✅ 覆盖率已更新"),
109        Ok(_) => println!("  ⚠ 覆盖率生成失败(可忽略)"),
110        Err(e) => println!("  ⚠ 覆盖率工具不可用: {}(可忽略)", e),
111    }
112}
113
114/// 按 scope 输出测试状态,写入任意 writer。
115pub fn status_to(
116    writer: &mut impl std::io::Write,
117    repo_path: &Path,
118    c: &contract::Contract,
119) -> std::io::Result<()> {
120    let scopes = &c.scopes;
121
122    writeln!(writer, "测试状态")?;
123    writeln!(writer, "{}", "-".repeat(50))?;
124
125    if scopes.is_empty() {
126        let lang = contract::detect_by_files(repo_path);
127        let summary = collect_test_summary(repo_path, &lang);
128        let coverage = collect_coverage(repo_path, &lang, c.stages.test.threshold);
129        print_scope(writer, "(root)", &summary, &coverage)?;
130    } else {
131        for scope in scopes {
132            let scope_dir = repo_path.join(&scope.dir);
133            if !scope_dir.exists() {
134                writeln!(writer, "  [{}]     ⚠ 目录不存在", scope.name)?;
135                continue;
136            }
137            let lang = c.resolve_language(scope, &scope_dir);
138            let summary = collect_test_summary(&scope_dir, &lang);
139            let threshold = c.scope_test_threshold(scope);
140            let coverage = collect_coverage(&scope_dir, &lang, threshold);
141            print_scope(writer, &scope.name, &summary, &coverage)?;
142        }
143    }
144
145    Ok(())
146}
147
148fn print_scope(
149    writer: &mut impl std::io::Write,
150    name: &str,
151    summary: &TestSummary,
152    coverage: &Coverage,
153) -> std::io::Result<()> {
154    let status_icon = if summary.failed > 0 {
155        "❌"
156    } else if summary.skipped > 0 {
157        "⚠"
158    } else if summary.total > 0 {
159        "✅"
160    } else {
161        "—"
162    };
163
164    let detail = if summary.total > 0 {
165        if summary.failed > 0 {
166            format!("{} / {} 失败", summary.failed, summary.total)
167        } else if summary.skipped > 0 {
168            format!(
169                "{} 通过 / {} 跳过 / {} 总计",
170                summary.passed, summary.skipped, summary.total
171            )
172        } else {
173            format!("{} ✅ 全部通过", summary.total)
174        }
175    } else {
176        "暂无测试".into()
177    };
178
179    writeln!(writer, "  [{:<12}] {}", name, status_icon)?;
180    writeln!(writer, "    测试数:       {}", detail)?;
181
182    let cov_icon = if coverage.met() {
183        "✅"
184    } else if coverage.percentage > 0.0 {
185        "⚠"
186    } else {
187        "—"
188    };
189    if coverage.percentage > 0.0 {
190        writeln!(
191            writer,
192            "    覆盖率:       {:.1}%{}(阈值 {}%)",
193            coverage.percentage, cov_icon, coverage.threshold,
194        )?;
195    } else {
196        writeln!(writer, "    覆盖率:       未检测到覆盖率报告")?;
197        writeln!(writer, "                  运行 `cargo llvm-cov --lcov --output-path target/coverage/lcov.info` 生成")?;
198    }
199
200    Ok(())
201}
202
203/// 返回语言对应的测试命令和标签,None 表示不支持。
204fn test_command(lang: &contract::Language) -> Option<(&'static str, &'static [&'static str])> {
205    match lang {
206        contract::Language::Rust => Some(("cargo", &["test"])),
207        contract::Language::Python => Some(("python", &["-m", "pytest"])),
208        contract::Language::Go => Some(("go", &["test", "./..."])),
209        contract::Language::Dart => Some(("flutter", &["test"])),
210        contract::Language::TypeScript => Some(("npm", &["test"])),
211        contract::Language::Unknown(_) => None,
212    }
213}
214
215/// 返回语言对应的清单文件名(存在验证用),None 表示不需要验证。
216fn test_manifest_file(lang: &contract::Language) -> Option<&'static str> {
217    match lang {
218        contract::Language::Rust => Some("Cargo.toml"),
219        contract::Language::Python => Some("pyproject.toml"),
220        contract::Language::Go => Some("go.mod"),
221        contract::Language::Dart => Some("pubspec.yaml"),
222        contract::Language::TypeScript => Some("package.json"),
223        contract::Language::Unknown(_) => None,
224    }
225}
226
227/// 收集测试结果。
228///
229/// 按语言运行对应的测试命令,解析输出。
230fn collect_test_summary(dir: &Path, lang: &contract::Language) -> TestSummary {
231    let (cmd, args) = match test_command(lang) {
232        Some(x) => x,
233        None => return TestSummary::default(),
234    };
235    if let Some(mf) = test_manifest_file(lang) {
236        if !dir.join(mf).exists() {
237            return TestSummary::default();
238        }
239    }
240    let result = std::process::Command::new(cmd)
241        .args(args)
242        .current_dir(dir)
243        .output();
244    match result {
245        Ok(o) => {
246            let output = String::from_utf8_lossy(&o.stdout);
247            let errors = String::from_utf8_lossy(&o.stderr);
248            // Rust 的输出在 stdout,pytest 的输出在 stderr
249            let combined = format!("{}{}", output, errors);
250            parse_test_summary(&combined)
251        }
252        Err(_) => TestSummary::default(),
253    }
254}
255
256fn parse_test_summary(content: &str) -> TestSummary {
257    let mut passed = 0u32;
258    let mut failed = 0u32;
259    let mut skipped = 0u32;
260
261    for line in content.lines() {
262        if line.contains("test result:") {
263            for part in line.split(';') {
264                let p = part.trim();
265                let words: Vec<&str> = p.split_whitespace().collect();
266                if words.len() < 2 {
267                    continue;
268                }
269                let kind = words[words.len() - 1];
270                if let Ok(n) = words[words.len() - 2].parse::<u32>() {
271                    match kind {
272                        "passed" => passed += n,
273                        "failed" => failed += n,
274                        "ignored" => skipped += n,
275                        _ => {}
276                    }
277                }
278            }
279        }
280    }
281    let total = passed + failed + skipped;
282    TestSummary {
283        total,
284        passed,
285        failed,
286        skipped,
287    }
288}
289
290/// 收集覆盖率数据。
291///
292/// 按语言读取对应的覆盖率报告。
293fn collect_coverage(dir: &Path, lang: &contract::Language, threshold: f64) -> Coverage {
294    let paths: &[std::path::PathBuf] = match lang {
295        contract::Language::Rust => &[
296            dir.join("target/coverage/lcov.info"),
297            dir.join("coverage/lcov.info"),
298        ],
299        contract::Language::Python => &[dir.join("coverage.xml"), dir.join("htmlcov/coverage.xml")],
300        _ => {
301            return Coverage {
302                percentage: 0.0,
303                threshold,
304            }
305        }
306    };
307    for path in paths {
308        if path.exists() {
309            let content = std::fs::read_to_string(path).unwrap_or_default();
310            if let Some(pct) = parse_lcov_coverage(&content) {
311                return Coverage {
312                    percentage: pct,
313                    threshold,
314                };
315            }
316            if let Some(pct) = parse_cobertura_coverage(&content) {
317                return Coverage {
318                    percentage: pct,
319                    threshold,
320                };
321            }
322        }
323    }
324    Coverage {
325        percentage: 0.0,
326        threshold,
327    }
328}
329
330/// 从 lcov.info 解析覆盖率百分比。
331///
332/// lcov 格式:
333/// ```text
334/// SF:src/lib.rs
335/// DA:1,1
336/// DA:2,0
337/// end_of_record
338/// ```
339/// 覆盖率 = 命中行数 / 总行数
340fn parse_lcov_coverage(content: &str) -> Option<f64> {
341    let mut total_lines = 0u32;
342    let mut hit_lines = 0u32;
343
344    for line in content.lines() {
345        if let Some(rest) = line.strip_prefix("DA:") {
346            if let Some(count_str) = rest.split(',').nth(1) {
347                total_lines += 1;
348                if let Ok(count) = count_str.trim().parse::<u32>() {
349                    if count > 0 {
350                        hit_lines += 1;
351                    }
352                }
353            }
354        }
355    }
356
357    if total_lines == 0 {
358        None
359    } else {
360        Some((hit_lines as f64 / total_lines as f64) * 100.0)
361    }
362}
363
364/// 从 Cobertura XML 解析覆盖率百分比。
365///
366/// 格式:<coverage line-rate="0.85" ...>
367fn parse_cobertura_coverage(content: &str) -> Option<f64> {
368    for line in content.lines() {
369        if let Some(rest) = line.trim().strip_prefix("<coverage") {
370            if let Some(attr) = rest.split("line-rate=\"").nth(1) {
371                let val_str = attr.split('"').next()?;
372                let rate: f64 = val_str.parse().ok()?;
373                return Some(rate * 100.0);
374            }
375        }
376    }
377    None
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_parse_test_summary_ok() {
386        let s = parse_test_summary(
387            "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 12 filtered out",
388        );
389        assert_eq!(s.passed, 10);
390        assert_eq!(s.failed, 0);
391        assert_eq!(s.skipped, 2);
392        assert_eq!(s.total, 12);
393    }
394
395    #[test]
396    fn test_parse_test_summary_failed() {
397        let s =
398            parse_test_summary("test result: FAILED. 8 passed; 3 failed; 1 ignored; 0 measured");
399        assert_eq!(s.passed, 8);
400        assert_eq!(s.failed, 3);
401        assert_eq!(s.skipped, 1);
402    }
403
404    #[test]
405    fn test_parse_lcov_empty() {
406        assert!(parse_lcov_coverage("").is_none());
407    }
408
409    #[test]
410    fn test_parse_lcov_simple() {
411        let content = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n";
412        let pct = parse_lcov_coverage(content).unwrap();
413        assert!((pct - 66.666).abs() < 0.01);
414    }
415
416    #[test]
417    fn test_print_scope_skipped() {
418        let mut buf = Vec::new();
419        let s = TestSummary {
420            total: 10,
421            passed: 8,
422            failed: 0,
423            skipped: 2,
424        };
425        let c = Coverage {
426            percentage: 0.0,
427            threshold: 70.0,
428        };
429        print_scope(&mut buf, "test", &s, &c).unwrap();
430        let out = String::from_utf8_lossy(&buf);
431        assert!(out.contains("⚠"), "跳过应有 ⚠");
432    }
433
434    #[test]
435    fn test_print_scope_no_tests() {
436        let mut buf = Vec::new();
437        let s = TestSummary::default();
438        let c = Coverage {
439            percentage: 0.0,
440            threshold: 70.0,
441        };
442        print_scope(&mut buf, "test", &s, &c).unwrap();
443        let out = String::from_utf8_lossy(&buf);
444        assert!(out.contains("—"), "无测试应有 —");
445        assert!(out.contains("暂无测试"));
446    }
447
448    #[test]
449    fn test_print_scope_coverage_warn() {
450        let mut buf = Vec::new();
451        let s = TestSummary {
452            total: 10,
453            passed: 10,
454            failed: 0,
455            skipped: 0,
456        };
457        let c = Coverage {
458            percentage: 50.0,
459            threshold: 70.0,
460        };
461        print_scope(&mut buf, "test", &s, &c).unwrap();
462        let out = String::from_utf8_lossy(&buf);
463        assert!(out.contains("⚠"), "低于阈值应有 ⚠");
464    }
465
466    #[test]
467    fn test_coverage_met() {
468        let c = Coverage {
469            percentage: 80.0,
470            threshold: 70.0,
471        };
472        assert!(c.met());
473    }
474
475    #[test]
476    fn test_parse_cobertura_simple() {
477        let content = r#"<coverage line-rate="0.85"></coverage>"#;
478        let pct = parse_cobertura_coverage(content).unwrap();
479        assert!((pct - 85.0).abs() < 0.01);
480    }
481
482    #[test]
483    fn test_coverage_not_met() {
484        let c = Coverage {
485            percentage: 60.0,
486            threshold: 70.0,
487        };
488        assert!(!c.met());
489    }
490
491    // ── test_command ──────────────────────────────────────────
492
493    #[test]
494    fn test_command_all_languages() {
495        assert_eq!(
496            test_command(&contract::Language::Rust),
497            Some(("cargo", &["test"][..]))
498        );
499        assert_eq!(
500            test_command(&contract::Language::Python),
501            Some(("python", &["-m", "pytest"][..]))
502        );
503        assert_eq!(
504            test_command(&contract::Language::Go),
505            Some(("go", &["test", "./..."][..]))
506        );
507        assert_eq!(
508            test_command(&contract::Language::Dart),
509            Some(("flutter", &["test"][..]))
510        );
511        assert_eq!(
512            test_command(&contract::Language::TypeScript),
513            Some(("npm", &["test"][..]))
514        );
515        assert_eq!(test_command(&contract::Language::Unknown("?".into())), None);
516    }
517
518    // ── coverage_command ──────────────────────────────────
519
520    #[test]
521    fn test_coverage_command_all_languages() {
522        assert_eq!(
523            coverage_command(&contract::Language::Rust).map(|(c, _)| c),
524            Some("cargo")
525        );
526        assert_eq!(
527            coverage_command(&contract::Language::Python).map(|(c, _)| c),
528            Some("coverage")
529        );
530        assert_eq!(
531            coverage_command(&contract::Language::Go).map(|(c, _)| c),
532            Some("go")
533        );
534        assert_eq!(
535            coverage_command(&contract::Language::Dart).map(|(c, _)| c),
536            Some("flutter")
537        );
538        assert_eq!(
539            coverage_command(&contract::Language::TypeScript).map(|(c, _)| c),
540            Some("npx")
541        );
542        assert!(coverage_command(&contract::Language::Unknown("auto".into())).is_none());
543    }
544
545    // ── test_manifest_file ────────────────────────────────────
546
547    #[test]
548    fn test_manifest_file_all_languages() {
549        assert_eq!(
550            test_manifest_file(&contract::Language::Rust),
551            Some("Cargo.toml")
552        );
553        assert_eq!(
554            test_manifest_file(&contract::Language::Python),
555            Some("pyproject.toml")
556        );
557        assert_eq!(test_manifest_file(&contract::Language::Go), Some("go.mod"));
558        assert_eq!(
559            test_manifest_file(&contract::Language::Dart),
560            Some("pubspec.yaml")
561        );
562        assert_eq!(
563            test_manifest_file(&contract::Language::TypeScript),
564            Some("package.json")
565        );
566        assert_eq!(
567            test_manifest_file(&contract::Language::Unknown("?".into())),
568            None
569        );
570    }
571
572    // ── status_to ──────────────────────────────────────────────
573
574    #[test]
575    fn test_status_to_passing() {
576        let d = tempfile::tempdir().unwrap();
577        // 创建一个真实的 Rust 项目,使得 cargo test 能运行并通过
578        std::fs::write(
579            d.path().join("Cargo.toml"),
580            "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
581        )
582        .unwrap();
583        std::fs::create_dir_all(d.path().join("src")).unwrap();
584        std::fs::write(d.path().join("src/lib.rs"), "#[test]\nfn it_works() {}\n").unwrap();
585
586        let c = contract::Contract::default();
587        let mut buf = Vec::new();
588        status_to(&mut buf, d.path(), &c).unwrap();
589        let out = String::from_utf8_lossy(&buf);
590
591        assert!(out.contains("测试状态"));
592        assert!(out.contains("全部通过") || out.contains("暂无测试"));
593    }
594
595    #[test]
596    fn test_status_to_empty() {
597        let d = tempfile::tempdir().unwrap();
598        let c = contract::Contract::default();
599        let mut buf = Vec::new();
600        status_to(&mut buf, d.path(), &c).unwrap();
601        let out = String::from_utf8_lossy(&buf);
602
603        assert!(out.contains("测试状态"));
604    }
605}