qtcloud_devops_cli/
test.rs1use 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 collect_test_summary(dir: &Path, lang: &contract::Language) -> TestSummary {
105 let result = match lang {
106 contract::Language::Rust => {
107 if !dir.join("Cargo.toml").exists() {
108 return TestSummary::default();
109 }
110 std::process::Command::new("cargo")
111 .args(["test"])
112 .current_dir(dir)
113 .output()
114 }
115 contract::Language::Python => {
116 if !dir.join("pyproject.toml").exists() {
117 return TestSummary::default();
118 }
119 std::process::Command::new("python")
120 .args(["-m", "pytest"])
121 .current_dir(dir)
122 .output()
123 }
124 contract::Language::Go => {
125 if !dir.join("go.mod").exists() {
126 return TestSummary::default();
127 }
128 std::process::Command::new("go")
129 .args(["test", "./..."])
130 .current_dir(dir)
131 .output()
132 }
133 contract::Language::Dart => {
134 if !dir.join("pubspec.yaml").exists() {
135 return TestSummary::default();
136 }
137 std::process::Command::new("flutter")
138 .args(["test"])
139 .current_dir(dir)
140 .output()
141 }
142 contract::Language::TypeScript => {
143 if !dir.join("package.json").exists() {
144 return TestSummary::default();
145 }
146 std::process::Command::new("npm")
147 .args(["test"])
148 .current_dir(dir)
149 .output()
150 }
151 contract::Language::Unknown(_) => return TestSummary::default(),
152 };
153 match result {
154 Ok(o) => {
155 let output = String::from_utf8_lossy(&o.stdout);
156 let errors = String::from_utf8_lossy(&o.stderr);
157 let combined = format!("{}{}", output, errors);
159 parse_test_summary(&combined)
160 }
161 Err(_) => TestSummary::default(),
162 }
163}
164
165fn parse_test_summary(content: &str) -> TestSummary {
166 let mut passed = 0u32;
167 let mut failed = 0u32;
168 let mut skipped = 0u32;
169
170 for line in content.lines() {
171 if line.contains("test result:") {
172 for part in line.split(';') {
173 let p = part.trim();
174 let words: Vec<&str> = p.split_whitespace().collect();
175 if words.len() < 2 {
176 continue;
177 }
178 let kind = words[words.len() - 1];
179 if let Ok(n) = words[words.len() - 2].parse::<u32>() {
180 match kind {
181 "passed" => passed += n,
182 "failed" => failed += n,
183 "ignored" => skipped += n,
184 _ => {}
185 }
186 }
187 }
188 }
189 }
190 let total = passed + failed + skipped;
191 TestSummary {
192 total,
193 passed,
194 failed,
195 skipped,
196 }
197}
198
199fn collect_coverage(dir: &Path, lang: &contract::Language, threshold: f64) -> Coverage {
203 let paths: &[std::path::PathBuf] = match lang {
204 contract::Language::Rust => &[
205 dir.join("target/coverage/lcov.info"),
206 dir.join("coverage/lcov.info"),
207 ],
208 contract::Language::Python => &[dir.join("coverage.xml"), dir.join("htmlcov/coverage.xml")],
209 _ => {
210 return Coverage {
211 percentage: 0.0,
212 threshold,
213 }
214 }
215 };
216 for path in paths {
217 if path.exists() {
218 let content = std::fs::read_to_string(path).unwrap_or_default();
219 if let Some(pct) = parse_lcov_coverage(&content) {
220 return Coverage {
221 percentage: pct,
222 threshold,
223 };
224 }
225 if let Some(pct) = parse_cobertura_coverage(&content) {
226 return Coverage {
227 percentage: pct,
228 threshold,
229 };
230 }
231 }
232 }
233 Coverage {
234 percentage: 0.0,
235 threshold,
236 }
237}
238
239fn parse_lcov_coverage(content: &str) -> Option<f64> {
250 let mut total_lines = 0u32;
251 let mut hit_lines = 0u32;
252
253 for line in content.lines() {
254 if let Some(rest) = line.strip_prefix("DA:") {
255 if let Some(count_str) = rest.split(',').nth(1) {
256 total_lines += 1;
257 if let Ok(count) = count_str.trim().parse::<u32>() {
258 if count > 0 {
259 hit_lines += 1;
260 }
261 }
262 }
263 }
264 }
265
266 if total_lines == 0 {
267 None
268 } else {
269 Some((hit_lines as f64 / total_lines as f64) * 100.0)
270 }
271}
272
273fn parse_cobertura_coverage(content: &str) -> Option<f64> {
277 for line in content.lines() {
278 if let Some(rest) = line.trim().strip_prefix("<coverage") {
279 if let Some(attr) = rest.split("line-rate=\"").nth(1) {
280 let val_str = attr.split('"').next()?;
281 let rate: f64 = val_str.parse().ok()?;
282 return Some(rate * 100.0);
283 }
284 }
285 }
286 None
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_parse_test_summary_ok() {
295 let s = parse_test_summary(
296 "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 12 filtered out",
297 );
298 assert_eq!(s.passed, 10);
299 assert_eq!(s.failed, 0);
300 assert_eq!(s.skipped, 2);
301 assert_eq!(s.total, 12);
302 }
303
304 #[test]
305 fn test_parse_test_summary_failed() {
306 let s =
307 parse_test_summary("test result: FAILED. 8 passed; 3 failed; 1 ignored; 0 measured");
308 assert_eq!(s.passed, 8);
309 assert_eq!(s.failed, 3);
310 assert_eq!(s.skipped, 1);
311 }
312
313 #[test]
314 fn test_parse_lcov_empty() {
315 assert!(parse_lcov_coverage("").is_none());
316 }
317
318 #[test]
319 fn test_parse_lcov_simple() {
320 let content = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n";
321 let pct = parse_lcov_coverage(content).unwrap();
322 assert!((pct - 66.666).abs() < 0.01);
323 }
324
325 #[test]
326 fn test_coverage_met() {
327 let c = Coverage {
328 percentage: 80.0,
329 threshold: 70.0,
330 };
331 assert!(c.met());
332 }
333
334 #[test]
335 fn test_parse_cobertura_simple() {
336 let content = r#"<coverage line-rate="0.85"></coverage>"#;
337 let pct = parse_cobertura_coverage(content).unwrap();
338 assert!((pct - 85.0).abs() < 0.01);
339 }
340
341 #[test]
342 fn test_coverage_not_met() {
343 let c = Coverage {
344 percentage: 60.0,
345 threshold: 70.0,
346 };
347 assert!(!c.met());
348 }
349}