返回 CodeWhale
cargo_failure_summary.rs
根目录 / crates / tui / src / tools / cargo_failure_summary.rs
1 //! Compact summaries for Cargo failures.
2 //!
3 //! Cargo output can be large and noisy. This module extracts stable failure
4 //! signals for tool metadata so context compaction can preserve the actionable
5 //! lines without re-running `cargo test | tail`.
6
7 use serde::{Deserialize, Serialize};
8 use serde_json::{Value, json};
9
10 const MAX_ITEMS: usize = 8;
11 const MAX_SUMMARY_CHARS: usize = 1_200;
12
13 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14 #[serde(rename_all = "snake_case")]
15 pub(crate) enum CargoFailureKind {
16 TestFailure,
17 CompileError,
18 CargoFailure,
19 }
20
21 impl CargoFailureKind {
22 fn label(&self) -> &'static str {
23 match self {
24 Self::TestFailure => "test_failure",
25 Self::CompileError => "compile_error",
26 Self::CargoFailure => "cargo_failure",
27 }
28 }
29 }
30
31 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32 pub(crate) struct CargoFailureSummary {
33 pub(crate) kind: CargoFailureKind,
34 pub(crate) summary: String,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub(crate) failing_tests: Vec<String>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub(crate) error_codes: Vec<String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub(crate) primary_errors: Vec<String>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub(crate) panic_locations: Vec<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub(crate) test_result: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub(crate) final_error: Option<String>,
47 }
48
49 impl CargoFailureSummary {
50 pub(crate) fn to_metadata_value(&self) -> Value {
51 json!(self)
52 }
53 }
54
55 pub(crate) fn summarize_cargo_failure(
56 command: &str,
57 stdout: &str,
58 stderr: &str,
59 exit_code: Option<i32>,
60 ) -> Option<CargoFailureSummary> {
61 if exit_code == Some(0) || !looks_like_cargo_command(command) {
62 return None;
63 }
64
65 let mut failing_tests = Vec::new();
66 let mut error_codes = Vec::new();
67 let mut primary_errors = Vec::new();
68 let mut panic_locations = Vec::new();
69 let mut test_result = None;
70 let mut final_error = None;
71
72 for line in stderr.lines().chain(stdout.lines()) {
73 let trimmed = line.trim();
74 if trimmed.is_empty() {
75 continue;
76 }
77
78 if let Some(test) = parse_failed_test_line(trimmed) {
79 push_unique_limited(&mut failing_tests, test);
80 }
81 if let Some(test) = parse_failure_header(trimmed) {
82 push_unique_limited(&mut failing_tests, test);
83 }
84 if let Some(code) = parse_error_code(trimmed) {
85 push_unique_limited(&mut error_codes, code);
86 }
87 if is_primary_error_line(trimmed) {
88 push_unique_limited(&mut primary_errors, trimmed.to_string());
89 }
90 if trimmed.contains("panicked at ") {
91 push_unique_limited(&mut panic_locations, trimmed.to_string());
92 }
93 if trimmed.starts_with("test result:") {
94 test_result = Some(trimmed.to_string());
95 }
96 if trimmed.starts_with("error: could not compile")
97 || trimmed.starts_with("error: aborting due to")
98 || trimmed.starts_with("error: test failed")
99 {
100 final_error = Some(trimmed.to_string());
101 }
102 }
103
104 let kind = classify_failure(&failing_tests, &primary_errors, test_result.as_deref());
105 if !has_actionable_signal(
106 &failing_tests,
107 &error_codes,
108 &primary_errors,
109 &panic_locations,
110 test_result.as_deref(),
111 final_error.as_deref(),
112 ) {
113 return None;
114 }
115 let summary = build_summary(
116 &kind,
117 &failing_tests,
118 &error_codes,
119 &primary_errors,
120 &panic_locations,
121 test_result.as_deref(),
122 final_error.as_deref(),
123 );
124
125 Some(CargoFailureSummary {
126 kind,
127 summary,
128 failing_tests,
129 error_codes,
130 primary_errors,
131 panic_locations,
132 test_result,
133 final_error,
134 })
135 }
136
137 fn looks_like_cargo_command(command: &str) -> bool {
138 let Some(tokens) = shlex::split(command) else {
139 return false;
140 };
141
142 let mut expect_command = true;
143 for (idx, raw_token) in tokens.iter().enumerate() {
144 let token = normalize_shell_token(raw_token);
145 if token.is_empty() {
146 continue;
147 }
148 if is_shell_separator(token) {
149 expect_command = true;
150 continue;
151 }
152 if !expect_command {
153 continue;
154 }
155 if looks_like_env_assignment(token) {
156 continue;
157 }
158 if is_cargo_binary(token) {
159 return cargo_subcommand(&tokens[idx + 1..]).is_some();
160 }
161 expect_command = false;
162 }
163
164 false
165 }
166
167 fn parse_failed_test_line(line: &str) -> Option<String> {
168 let rest = line.strip_prefix("test ")?;
169 let (name, status) = rest.rsplit_once(" ... ")?;
170 (status == "FAILED").then(|| name.trim().to_string())
171 }
172
173 fn parse_failure_header(line: &str) -> Option<String> {
174 let rest = line.strip_prefix("---- ")?;
175 let name = rest.strip_suffix(" stdout ----")?;
176 Some(name.trim().to_string())
177 }
178
179 fn parse_error_code(line: &str) -> Option<String> {
180 let rest = line.strip_prefix("error[")?;
181 let (code, _) = rest.split_once("]")?;
182 Some(code.to_string())
183 }
184
185 fn is_primary_error_line(line: &str) -> bool {
186 line.starts_with("error[")
187 || (line.starts_with("error:") && !line.starts_with("error: test failed"))
188 }
189
190 fn classify_failure(
191 failing_tests: &[String],
192 primary_errors: &[String],
193 test_result: Option<&str>,
194 ) -> CargoFailureKind {
195 if !failing_tests.is_empty()
196 || test_result.is_some_and(|line| line.to_ascii_lowercase().contains("failed"))
197 {
198 CargoFailureKind::TestFailure
199 } else if !primary_errors.is_empty() {
200 CargoFailureKind::CompileError
201 } else {
202 CargoFailureKind::CargoFailure
203 }
204 }
205
206 fn has_actionable_signal(
207 failing_tests: &[String],
208 error_codes: &[String],
209 primary_errors: &[String],
210 panic_locations: &[String],
211 test_result: Option<&str>,
212 final_error: Option<&str>,
213 ) -> bool {
214 !failing_tests.is_empty()
215 || !error_codes.is_empty()
216 || !primary_errors.is_empty()
217 || !panic_locations.is_empty()
218 || test_result.is_some()
219 || final_error.is_some()
220 }
221
222 fn build_summary(
223 kind: &CargoFailureKind,
224 failing_tests: &[String],
225 error_codes: &[String],
226 primary_errors: &[String],
227 panic_locations: &[String],
228 test_result: Option<&str>,
229 final_error: Option<&str>,
230 ) -> String {
231 let mut lines = Vec::new();
232 lines.push(format!("Cargo failure kind: {}.", kind.label()));
233 if !failing_tests.is_empty() {
234 lines.push(format!("Failing tests: {}.", failing_tests.join(", ")));
235 }
236 if !error_codes.is_empty() {
237 lines.push(format!("Rust error codes: {}.", error_codes.join(", ")));
238 }
239 if let Some(line) = primary_errors.first() {
240 lines.push(format!("Primary error: {line}"));
241 }
242 if let Some(line) = panic_locations.first() {
243 lines.push(format!("Panic: {line}"));
244 }
245 if let Some(line) = test_result {
246 lines.push(line.to_string());
247 }
248 if let Some(line) = final_error {
249 lines.push(line.to_string());
250 }
251 truncate_chars(&lines.join("\n"), MAX_SUMMARY_CHARS)
252 }
253
254 fn normalize_shell_token(token: &str) -> &str {
255 token.trim_matches(|ch| matches!(ch, '(' | ')' | '{' | '}'))
256 }
257
258 fn is_shell_separator(token: &str) -> bool {
259 matches!(token, "&&" | "||" | ";" | "|")
260 }
261
262 fn looks_like_env_assignment(token: &str) -> bool {
263 let Some((name, _)) = token.split_once('=') else {
264 return false;
265 };
266 !name.is_empty()
267 && name
268 .bytes()
269 .all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
270 && !name.as_bytes()[0].is_ascii_digit()
271 }
272
273 fn is_cargo_binary(token: &str) -> bool {
274 let name = token.rsplit(['/', '\\']).next().unwrap_or(token);
275 name.eq_ignore_ascii_case("cargo") || name.eq_ignore_ascii_case("cargo.exe")
276 }
277
278 fn cargo_subcommand(tokens: &[String]) -> Option<&str> {
279 let mut idx = 0;
280 while let Some(raw_token) = tokens.get(idx) {
281 let token = normalize_shell_token(raw_token);
282 if token.is_empty() {
283 idx += 1;
284 continue;
285 }
286 if is_shell_separator(token) {
287 return None;
288 }
289 if token.starts_with('+') {
290 idx += 1;
291 continue;
292 }
293 if token.starts_with('-') {
294 if cargo_global_flag_takes_value(token) {
295 idx += 2;
296 } else {
297 idx += 1;
298 }
299 continue;
300 }
301 return is_supported_cargo_subcommand(token).then_some(token);
302 }
303 None
304 }
305
306 fn cargo_global_flag_takes_value(token: &str) -> bool {
307 if token.contains('=') {
308 return false;
309 }
310 matches!(
311 token,
312 "--color"
313 | "--config"
314 | "-C"
315 | "--jobs"
316 | "-j"
317 | "--lockfile-path"
318 | "--manifest-path"
319 | "--message-format"
320 | "--package"
321 | "-p"
322 | "--target"
323 | "--target-dir"
324 | "-Z"
325 )
326 }
327
328 fn is_supported_cargo_subcommand(token: &str) -> bool {
329 matches!(
330 token,
331 "test" | "check" | "build" | "clippy" | "run" | "t" | "c" | "b" | "r"
332 )
333 }
334
335 fn push_unique_limited(target: &mut Vec<String>, value: String) {
336 if target.len() >= MAX_ITEMS || target.iter().any(|existing| existing == &value) {
337 return;
338 }
339 target.push(value);
340 }
341
342 fn truncate_chars(text: &str, max_chars: usize) -> String {
343 if let Some((idx, _)) = text.char_indices().nth(max_chars) {
344 if max_chars < 3 {
345 return text[..idx].to_string();
346 }
347 let truncate_at = text
348 .char_indices()
349 .nth(max_chars - 3)
350 .map(|(idx, _)| idx)
351 .unwrap_or(0);
352 format!("{}...", &text[..truncate_at])
353 } else {
354 text.to_string()
355 }
356 }
357
358 #[cfg(test)]
359 mod tests {
360 use super::*;
361
362 #[test]
363 fn summarizes_failed_libtest_output() {
364 let stdout = r"
365 running 1 test
366 test tests::fails ... FAILED
367
368 failures:
369
370 ---- tests::fails stdout ----
371 thread 'tests::fails' panicked at src/lib.rs:7:9:
372 assertion `left == right` failed
373
374 test result: FAILED. 0 passed; 1 failed; 0 ignored; finished in 0.00s
375 ";
376 let summary =
377 summarize_cargo_failure("cargo test", stdout, "", Some(101)).expect("summary");
378
379 assert_eq!(summary.kind, CargoFailureKind::TestFailure);
380 assert_eq!(summary.failing_tests, vec!["tests::fails"]);
381 assert!(summary.summary.contains("Failing tests: tests::fails"));
382 assert!(summary.test_result.unwrap().contains("1 failed"));
383 }
384
385 #[test]
386 fn summarizes_rustc_compile_error() {
387 let stderr = r#"
388 error[E0308]: mismatched types
389 --> src/lib.rs:2:5
390 |
391 2 | ""
392 | ^^ expected `i32`, found `&str`
393 error: could not compile `demo` (lib) due to 1 previous error
394 "#;
395 let summary =
396 summarize_cargo_failure("cargo check", "", stderr, Some(101)).expect("summary");
397
398 assert_eq!(summary.kind, CargoFailureKind::CompileError);
399 assert_eq!(summary.error_codes, vec!["E0308"]);
400 assert!(summary.primary_errors[0].contains("mismatched types"));
401 assert!(summary.final_error.unwrap().contains("could not compile"));
402 }
403
404 #[test]
405 fn recognizes_cargo_aliases_and_uncoded_errors() {
406 let stderr = "error: cannot find value `missing` in this scope\n";
407 let summary = summarize_cargo_failure("cargo c", "", stderr, Some(101)).expect("summary");
408
409 assert_eq!(summary.kind, CargoFailureKind::CompileError);
410 assert_eq!(
411 summary.primary_errors,
412 vec!["error: cannot find value `missing` in this scope"]
413 );
414 }
415
416 #[test]
417 fn recognizes_tokenized_cargo_invocations() {
418 assert!(
419 summarize_cargo_failure(
420 "cargo +nightly --manifest-path demo/Cargo.toml test",
421 "test tests::fails ... FAILED\n",
422 "",
423 Some(101),
424 )
425 .is_some()
426 );
427 assert!(
428 summarize_cargo_failure(
429 "DEMO=1 cargo --locked run",
430 "",
431 "error: process didn't exit successfully\n",
432 Some(101),
433 )
434 .is_some()
435 );
436 assert!(
437 summarize_cargo_failure(
438 "echo cargo test && false",
439 "test tests::fails ... FAILED\n",
440 "",
441 Some(1),
442 )
443 .is_none()
444 );
445 }
446
447 #[test]
448 fn skips_generic_cargo_failure_without_actionable_signal() {
449 assert!(
450 summarize_cargo_failure("cargo test", "build failed", "command failed", Some(1))
451 .is_none()
452 );
453 }
454
455 #[test]
456 fn truncate_chars_respects_tiny_limits() {
457 assert_eq!(truncate_chars("abcdef", 0), "");
458 assert_eq!(truncate_chars("abcdef", 1), "a");
459 assert_eq!(truncate_chars("abcdef", 2), "ab");
460 assert_eq!(truncate_chars("abcdef", 3), "...");
461 assert_eq!(truncate_chars("abcdef", 4), "a...");
462 }
463
464 #[test]
465 fn ignores_successful_or_non_cargo_commands() {
466 assert!(summarize_cargo_failure("cargo test", "", "", Some(0)).is_none());
467 assert!(summarize_cargo_failure("npm test", "failed", "", Some(1)).is_none());
468 }
469 }
470
470 lines RUST