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