1use std::path::Path;
2
3use crate::contract;
4
5#[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#[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
27pub fn status(repo_path: &Path, c: &contract::Contract) {
29 let _ = status_to(&mut std::io::stdout(), repo_path, c);
30}
31
32pub 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
121fn 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
133fn 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
145fn 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 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
208fn 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
248fn 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
282fn 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]
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]
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 #[test]
466 fn test_status_to_passing() {
467 let d = tempfile::tempdir().unwrap();
468 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}