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 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
114pub 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
203fn 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
215fn 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
227fn 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 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
290fn 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
330fn 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
364fn 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]
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 #[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]
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 #[test]
575 fn test_status_to_passing() {
576 let d = tempfile::tempdir().unwrap();
577 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}