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 scopes = contract::load_scopes(repo_path);
30
31 println!("测试状态");
32 println!("{}", "-".repeat(50));
33
34 if scopes.is_empty() {
35 let lang = contract::detect_by_files(repo_path);
36 let summary = collect_test_summary(repo_path, &lang);
37 let coverage = collect_coverage(repo_path, &lang, c.stages.test.threshold);
38 print_scope("(root)", &summary, &coverage);
39 } else {
40 for scope in &scopes {
41 let scope_dir = repo_path.join(&scope.dir);
42 if !scope_dir.exists() {
43 println!(" [{}] ⚠ 目录不存在", scope.name);
44 continue;
45 }
46 let lang = c.resolve_language(scope, &scope_dir);
47 let summary = collect_test_summary(&scope_dir, &lang);
48 let threshold = c.scope_test_threshold(scope);
49 let coverage = collect_coverage(&scope_dir, &lang, threshold);
50 print_scope(&scope.name, &summary, &coverage);
51 }
52 }
53}
54
55fn print_scope(name: &str, summary: &TestSummary, coverage: &Coverage) {
56 let status_icon = if summary.failed > 0 {
57 "❌"
58 } else if summary.skipped > 0 {
59 "⚠"
60 } else if summary.total > 0 {
61 "✅"
62 } else {
63 "—"
64 };
65
66 let detail = if summary.total > 0 {
67 if summary.failed > 0 {
68 format!("{} / {} 失败", summary.failed, summary.total)
69 } else if summary.skipped > 0 {
70 format!(
71 "{} 通过 / {} 跳过 / {} 总计",
72 summary.passed, summary.skipped, summary.total
73 )
74 } else {
75 format!("{} ✅ 全部通过", summary.total)
76 }
77 } else {
78 "暂无测试".into()
79 };
80
81 println!(" [{:<12}] {}", name, status_icon);
82 println!(" 测试数: {}", detail);
83
84 let cov_icon = if coverage.met() {
85 "✅"
86 } else if coverage.percentage > 0.0 {
87 "⚠"
88 } else {
89 "—"
90 };
91 if coverage.percentage > 0.0 {
92 println!(
93 " 覆盖率: {:.1}%{}(阈值 {}%)",
94 coverage.percentage, cov_icon, coverage.threshold,
95 );
96 } else {
97 println!(" 覆盖率: 未检测到覆盖率报告");
98 }
99}
100
101fn test_command(lang: &contract::Language) -> Option<(&'static str, &'static [&'static str])> {
103 match lang {
104 contract::Language::Rust => Some(("cargo", &["test"])),
105 contract::Language::Python => Some(("python", &["-m", "pytest"])),
106 contract::Language::Go => Some(("go", &["test", "./..."])),
107 contract::Language::Dart => Some(("flutter", &["test"])),
108 contract::Language::TypeScript => Some(("npm", &["test"])),
109 contract::Language::Unknown(_) => None,
110 }
111}
112
113fn test_manifest_file(lang: &contract::Language) -> Option<&'static str> {
115 match lang {
116 contract::Language::Rust => Some("Cargo.toml"),
117 contract::Language::Python => Some("pyproject.toml"),
118 contract::Language::Go => Some("go.mod"),
119 contract::Language::Dart => Some("pubspec.yaml"),
120 contract::Language::TypeScript => Some("package.json"),
121 contract::Language::Unknown(_) => None,
122 }
123}
124
125fn collect_test_summary(dir: &Path, lang: &contract::Language) -> TestSummary {
129 let (cmd, args) = match test_command(lang) {
130 Some(x) => x,
131 None => return TestSummary::default(),
132 };
133 if let Some(mf) = test_manifest_file(lang) {
134 if !dir.join(mf).exists() {
135 return TestSummary::default();
136 }
137 }
138 let result = std::process::Command::new(cmd)
139 .args(args)
140 .current_dir(dir)
141 .output();
142 match result {
143 Ok(o) => {
144 let output = String::from_utf8_lossy(&o.stdout);
145 let errors = String::from_utf8_lossy(&o.stderr);
146 let combined = format!("{}{}", output, errors);
148 parse_test_summary(&combined)
149 }
150 Err(_) => TestSummary::default(),
151 }
152}
153
154fn parse_test_summary(content: &str) -> TestSummary {
155 let mut passed = 0u32;
156 let mut failed = 0u32;
157 let mut skipped = 0u32;
158
159 for line in content.lines() {
160 if line.contains("test result:") {
161 for part in line.split(';') {
162 let p = part.trim();
163 let words: Vec<&str> = p.split_whitespace().collect();
164 if words.len() < 2 {
165 continue;
166 }
167 let kind = words[words.len() - 1];
168 if let Ok(n) = words[words.len() - 2].parse::<u32>() {
169 match kind {
170 "passed" => passed += n,
171 "failed" => failed += n,
172 "ignored" => skipped += n,
173 _ => {}
174 }
175 }
176 }
177 }
178 }
179 let total = passed + failed + skipped;
180 TestSummary {
181 total,
182 passed,
183 failed,
184 skipped,
185 }
186}
187
188fn collect_coverage(dir: &Path, lang: &contract::Language, threshold: f64) -> Coverage {
192 let paths: &[std::path::PathBuf] = match lang {
193 contract::Language::Rust => &[
194 dir.join("target/coverage/lcov.info"),
195 dir.join("coverage/lcov.info"),
196 ],
197 contract::Language::Python => &[dir.join("coverage.xml"), dir.join("htmlcov/coverage.xml")],
198 _ => {
199 return Coverage {
200 percentage: 0.0,
201 threshold,
202 }
203 }
204 };
205 for path in paths {
206 if path.exists() {
207 let content = std::fs::read_to_string(path).unwrap_or_default();
208 if let Some(pct) = parse_lcov_coverage(&content) {
209 return Coverage {
210 percentage: pct,
211 threshold,
212 };
213 }
214 if let Some(pct) = parse_cobertura_coverage(&content) {
215 return Coverage {
216 percentage: pct,
217 threshold,
218 };
219 }
220 }
221 }
222 Coverage {
223 percentage: 0.0,
224 threshold,
225 }
226}
227
228fn parse_lcov_coverage(content: &str) -> Option<f64> {
239 let mut total_lines = 0u32;
240 let mut hit_lines = 0u32;
241
242 for line in content.lines() {
243 if let Some(rest) = line.strip_prefix("DA:") {
244 if let Some(count_str) = rest.split(',').nth(1) {
245 total_lines += 1;
246 if let Ok(count) = count_str.trim().parse::<u32>() {
247 if count > 0 {
248 hit_lines += 1;
249 }
250 }
251 }
252 }
253 }
254
255 if total_lines == 0 {
256 None
257 } else {
258 Some((hit_lines as f64 / total_lines as f64) * 100.0)
259 }
260}
261
262fn parse_cobertura_coverage(content: &str) -> Option<f64> {
266 for line in content.lines() {
267 if let Some(rest) = line.trim().strip_prefix("<coverage") {
268 if let Some(attr) = rest.split("line-rate=\"").nth(1) {
269 let val_str = attr.split('"').next()?;
270 let rate: f64 = val_str.parse().ok()?;
271 return Some(rate * 100.0);
272 }
273 }
274 }
275 None
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_parse_test_summary_ok() {
284 let s = parse_test_summary(
285 "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 12 filtered out",
286 );
287 assert_eq!(s.passed, 10);
288 assert_eq!(s.failed, 0);
289 assert_eq!(s.skipped, 2);
290 assert_eq!(s.total, 12);
291 }
292
293 #[test]
294 fn test_parse_test_summary_failed() {
295 let s =
296 parse_test_summary("test result: FAILED. 8 passed; 3 failed; 1 ignored; 0 measured");
297 assert_eq!(s.passed, 8);
298 assert_eq!(s.failed, 3);
299 assert_eq!(s.skipped, 1);
300 }
301
302 #[test]
303 fn test_parse_lcov_empty() {
304 assert!(parse_lcov_coverage("").is_none());
305 }
306
307 #[test]
308 fn test_parse_lcov_simple() {
309 let content = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n";
310 let pct = parse_lcov_coverage(content).unwrap();
311 assert!((pct - 66.666).abs() < 0.01);
312 }
313
314 #[test]
315 fn test_coverage_met() {
316 let c = Coverage {
317 percentage: 80.0,
318 threshold: 70.0,
319 };
320 assert!(c.met());
321 }
322
323 #[test]
324 fn test_parse_cobertura_simple() {
325 let content = r#"<coverage line-rate="0.85"></coverage>"#;
326 let pct = parse_cobertura_coverage(content).unwrap();
327 assert!((pct - 85.0).abs() < 0.01);
328 }
329
330 #[test]
331 fn test_coverage_not_met() {
332 let c = Coverage {
333 percentage: 60.0,
334 threshold: 70.0,
335 };
336 assert!(!c.met());
337 }
338
339 #[test]
342 fn test_command_all_languages() {
343 assert_eq!(
344 test_command(&contract::Language::Rust),
345 Some(("cargo", &["test"][..]))
346 );
347 assert_eq!(
348 test_command(&contract::Language::Python),
349 Some(("python", &["-m", "pytest"][..]))
350 );
351 assert_eq!(
352 test_command(&contract::Language::Go),
353 Some(("go", &["test", "./..."][..]))
354 );
355 assert_eq!(
356 test_command(&contract::Language::Dart),
357 Some(("flutter", &["test"][..]))
358 );
359 assert_eq!(
360 test_command(&contract::Language::TypeScript),
361 Some(("npm", &["test"][..]))
362 );
363 assert_eq!(test_command(&contract::Language::Unknown("?".into())), None);
364 }
365
366 #[test]
369 fn test_manifest_file_all_languages() {
370 assert_eq!(
371 test_manifest_file(&contract::Language::Rust),
372 Some("Cargo.toml")
373 );
374 assert_eq!(
375 test_manifest_file(&contract::Language::Python),
376 Some("pyproject.toml")
377 );
378 assert_eq!(test_manifest_file(&contract::Language::Go), Some("go.mod"));
379 assert_eq!(
380 test_manifest_file(&contract::Language::Dart),
381 Some("pubspec.yaml")
382 );
383 assert_eq!(
384 test_manifest_file(&contract::Language::TypeScript),
385 Some("package.json")
386 );
387 assert_eq!(
388 test_manifest_file(&contract::Language::Unknown("?".into())),
389 None
390 );
391 }
392}