1mod preprocess;
2mod truncate;
3mod test_output;
4mod git;
5mod cargo;
6mod npm;
7mod docker;
8mod kubectl;
9mod system;
10mod js;
11mod lint;
12mod python;
13mod go;
14mod cloud;
15mod jvm;
16mod ruby;
17mod xcode;
18mod android;
19mod dotnet;
20#[cfg(test)]
21mod props;
22
23pub fn format_command(cmd: &str, output: &str) -> Option<String> {
24 let cleaned = preprocess::clean(output);
25 let input = if cleaned.len() < output.len() { &cleaned } else { output };
26
27 let formatted = dispatch(cmd, input, output)?;
28 if drops_error_lines(input, &formatted) {
29 return None;
30 }
31 if drops_protected_lines(output, &formatted) {
32 return None;
33 }
34 Some(formatted)
35}
36
37fn drops_protected_lines(output: &str, formatted: &str) -> bool {
38 let protected = crate::confidence_router::protected_lines(output);
39 if protected.is_empty() {
40 return false;
41 }
42 let mut kept: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
43 for line in formatted.lines() {
44 *kept.entry(line).or_default() += 1;
45 }
46 protected.iter().any(|line| {
47 let line = crate::ansi_strip::strip_ansi(line);
48 match kept.get_mut(line.as_str()) {
49 Some(n) if *n > 0 => {
50 *n -= 1;
51 false
52 }
53 _ => true,
54 }
55 })
56}
57
58fn drops_error_lines(input: &str, formatted: &str) -> bool {
63 if formatted.contains("...+") && !input.contains("...+") {
64 return false;
65 }
66 let lower = formatted.to_ascii_lowercase();
67 let says_failed = lower.contains("fail") || lower.contains("error");
68 let mut must_keep = Vec::new();
69 for l in input.lines().filter(|l| is_error_line(l)) {
70 let t = l.trim();
71 if !is_failure_summary(t) {
72 must_keep.push(t);
73 } else if !says_failed {
74 return true;
75 }
76 }
77 crate::text_search::present(formatted, &must_keep).contains(&false)
78}
79
80fn is_failure_summary(line: &str) -> bool {
81 const SUMMARIES: &[&str] = &[
82 "could not compile", "aborting due to", "previous error", "build failed",
83 "build failure", "a complete log of this run", "test result: failed",
84 ];
85 let lower = line.to_ascii_lowercase();
86 (line.len() <= 40 && (lower.contains("failed") || lower.contains("failure")))
87 || SUMMARIES.iter().any(|s| lower.contains(s))
88}
89
90pub(crate) fn is_error_line(line: &str) -> bool {
93 let t = line.trim_start();
94 const STARTS: &[&str] = &[
95 "error", "Error", "ERROR", "fatal", "Fatal", "FATAL", "FAILURE", "FAILED", "panic",
96 "Traceback", "Exception", "npm ERR!", "npm error", "ERR_", "CONFLICT", "! [rejected]",
97 "! [remote rejected]", "denied:", "[ERROR]",
98 ];
99 const CONTAINS: &[&str] = &[
100 ": error", ": fatal", "error[E", " FAILED", "Execution failed", "failed to ",
101 "Failed to ", "panicked at", "did not complete successfully", "An error occurred",
102 "Exception in thread", "Automatic merge failed",
103 ];
104 let starts = STARTS.iter().any(|p| {
106 t.starts_with(p)
107 && !(p.ends_with(|c: char| c.is_ascii_alphabetic())
108 && t[p.len()..].starts_with(|c: char| c.is_ascii_alphanumeric()))
109 });
110 starts || CONTAINS.iter().any(|p| t.contains(p))
111}
112
113fn dispatch(cmd: &str, output: &str, raw: &str) -> Option<String> {
114 let mut parts: Vec<&str> = cmd.split_whitespace().collect();
115
116 if parts.first().map(|s| s.rsplit('/').next().unwrap_or(s)) == Some("bundle")
119 && parts.get(1) == Some(&"exec")
120 {
121 parts.drain(0..2);
122 }
123
124 let base = parts.first().map(|s| s.rsplit('/').next().unwrap_or(s)).unwrap_or("");
125
126 match base {
127 "git" => git::format_git(parts.get(1).copied(), output),
129
130 "cargo" => cargo::format_cargo(parts.get(1).copied(), output, raw),
132
133 "npm" | "npx" => npm::format_npm(parts.get(1).copied(), output),
135 "pnpm" => npm::format_pnpm(parts.get(1).copied(), output),
136 "yarn" | "bun" => npm::format_npm(parts.get(1).copied(), output),
137 "tsc" => Some(js::format_tsc(output)),
138 "eslint" | "biome" => Some(lint::format_lint(output)),
139
140 "pytest" => Some(python::format_pytest(output)),
142 "python" | "python3" if cmd.contains("pytest") || cmd.contains("-m pytest") => {
143 Some(python::format_pytest(output))
144 }
145 "ruff" => python::format_python(cmd, parts.get(1).copied(), output),
146 "mypy" => python::format_python(cmd, None, output),
147 "pip" | "pip3" => python::format_python(cmd, parts.get(1).copied(), output),
148
149 "go" => go::format_go(parts.get(1).copied(), output),
151 "golangci-lint" => Some(lint::format_lint(output)),
152
153 "gradle" | "gradlew" | "./gradlew" => jvm::format_gradle(parts.get(1).copied(), output),
155 "mvn" | "maven" => jvm::format_maven(parts.get(1).copied(), output),
156
157 "xcodebuild" => Some(xcode::format_xcodebuild(output)),
159 "adb" if parts.get(1) == Some(&"logcat") => Some(android::format_logcat(output)),
160 "logcat" => Some(android::format_logcat(output)),
161 "dotnet" => dotnet::format_dotnet(parts.get(1).copied(), output),
162 "rspec" => Some(ruby::format_rspec(output)),
164 "rubocop" => Some(ruby::format_rubocop(output)),
165 "rake" | "rails" => ruby::format_rake(parts.get(1).copied(), output),
166 "bundle" | "bundler" => ruby::format_bundle(parts.get(1).copied(), output),
167
168 "docker" | "podman" => docker::format_docker(parts.get(1).copied(), output),
170 "kubectl" => kubectl::format_kubectl(parts.get(1).copied(), output),
171
172 "terraform" | "tf" => cloud::format_terraform(parts.get(1).copied(), output),
174
175 "grep" | "rg" | "ag" => system::format_grep(output),
177 "tree" => Some(system::format_tree(output)),
178 "curl" | "wget" => Some(system::format_curl(output)),
179
180 _ => None,
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn test_error_lines_are_whole_words() {
190 for line in [
191 "error[E0425]: cannot find value `x` in this scope",
192 "Error from server (NotFound): namespaces \"prod\" not found",
193 "npm error code ERESOLVE",
194 "fatal: not a git repository",
195 "src/a.ts(3,7): error TS2322: Type 'string' is not assignable",
196 "thread 'main' panicked at src/main.rs:3:5:",
197 " ! [rejected] main -> main (fetch first)",
198 ] {
199 assert!(is_error_line(line), "{line}");
200 }
201 for line in [
202 " Compiling thiserror v1.0.63",
203 "Errors Files",
204 "test result: ok. 7 passed; 0 failed",
205 "ExceptionHandler.java compiled",
206 ] {
207 assert!(!is_error_line(line), "{line}");
208 }
209 }
210
211 #[test]
212 fn test_formatter_that_drops_an_error_is_not_used() {
213 let failed = "Collecting torch==9.0\nERROR: No matching distribution found for torch==9.0\n";
215 assert!(drops_error_lines(failed, "ok"));
216 assert!(!drops_error_lines(failed, "ERROR: No matching distribution found for torch==9.0"));
217 let build = "x.cs(1,1): error CS0103: nope\nBuild FAILED.\n";
219 assert!(!drops_error_lines(build, "dotnet build: FAILED\n x.cs(1,1): error CS0103: nope"));
220 assert!(drops_error_lines("Build FAILED.\n", "done"));
221 }
222
223 #[test]
224 fn test_formatter_that_drops_a_protected_line_is_not_used() {
225 let trace = "thread 'main' panicked at src/main.rs:3:5:\nstack backtrace:\n 0: rust_begin_unwind\n 1: app::main\n at ./src/main.rs:3:5";
226 let output = format!("{}\n{trace}\n", " Compiling app v0.1.0\n".repeat(4));
227 assert!(!drops_protected_lines(&output, &format!("ok (4 crates)\n{trace}")));
228 assert!(drops_protected_lines(&output, &format!("ok (4 crates)\n{}", trace.replace(" 1: app::main\n", ""))));
229 assert!(drops_protected_lines(&output, &format!("ok (4 crates)\n{}", trace.replace('\n', "\n "))));
230 assert!(drops_protected_lines(&format!("{output}\n{trace}\n"), &format!("ok (4 crates)\n{trace}")));
231 let colored = output.replace("thread 'main'", "\x1b[31mthread 'main'").replace(":3:5:\n", ":3:5:\x1b[0m\n");
232 assert!(!drops_protected_lines(&colored, &format!("ok (4 crates)\n{trace}")));
233 assert!(!drops_protected_lines(&output.replace("stack backtrace:", "note: run with `RUST_BACKTRACE=1`"), "ok (4 crates)"));
234 }
235
236 #[test]
237 fn test_error_line_guard_is_linear() {
238 let input = |bytes: usize| {
239 let mut s = String::new();
240 let mut i = 0;
241 while s.len() < bytes {
242 s.push_str(&format!(
243 "src/m{i}.rs:{i}: error: mismatched types in item {i}\n"
244 ));
245 i += 1;
246 }
247 s
248 };
249 let quote = |s: &str| s.lines().map(|l| format!("> {l}\n")).collect::<String>();
250 let (small, large) = (input(100 << 10), input(1 << 20));
251 let (small_quoted, large_quoted) = (quote(&small), quote(&large));
252 assert!(!drops_error_lines(&large, &large));
253 assert!(!drops_error_lines(&large, &large_quoted));
254 assert!(drops_error_lines(
255 &large,
256 &large_quoted.replace("item 9999\n", "item\n")
257 ));
258 let check = |input: &str, quoted: &str| {
259 std::hint::black_box(drops_error_lines(input, input));
260 std::hint::black_box(drops_error_lines(input, quoted));
261 };
262 let ratio = crate::text_search::time_ratio(
263 || check(&small, &small_quoted),
264 || check(&large, &large_quoted),
265 );
266 assert!(ratio < 25.0, "1 MB took {ratio:.1}x as long as 100 KB");
267 }
268
269 #[test]
270 fn test_format_command_routing() {
271 assert!(format_command("git status", "nothing to commit").is_some());
272 assert!(format_command("cargo test", "test result: ok").is_some());
273 assert!(format_command("unknown_tool", "output").is_none());
274 }
275
276 #[test]
277 fn test_ansi_stripped_before_dispatch() {
278 let output = "\x1b[32mOn branch main\x1b[0m\nnothing to commit, working tree clean\n";
279 let result = format_command("git status", output);
280 assert_eq!(result, Some("clean".to_string()));
281 }
282
283 #[test]
284 fn test_new_commands_dispatch() {
285 assert!(format_command("ruff check", "All checks passed!").is_some());
286 assert!(format_command("go test ./...", "ok").is_some());
287 let grep: String = (0..10).map(|i| format!("src/main.rs:{i}:let x = compute();\n")).collect();
288 assert!(format_command("grep -rn compute src", &grep).is_some());
289 assert!(format_command("tree", ".\n├── src\n└── Cargo.toml").is_some());
290 assert!(format_command("curl http://example.com", "response").is_some());
291 }
292
293 #[test]
294 fn test_data_commands_are_left_to_the_pipeline() {
295 for (cmd, out) in [
298 ("gh pr view 12 --json body", r#"{"body":"long text","number":12}"#),
299 ("gh pr diff 12", "diff --git a/x b/x\n"),
300 ("aws ec2 describe-instances", r#"{"Reservations":[]}"#),
301 ("gcloud compute instances list", "NAME ZONE\nweb us-east1-b\n"),
302 ("kubectl get pods -o json", r#"{"kind":"PodList","items":[]}"#),
303 ("ls -la", "total 0\n-rw-r--r-- 1 dev staff 0 Sep 1 12:00 a\n"),
304 ("find . -name '*.rs'", "./a.rs\n./b.rs\n"),
305 ] {
306 assert!(format_command(cmd, out).is_none(), "{cmd}");
307 }
308 }
309
310 #[test]
311 fn test_mobile_dotnet_dispatch() {
312 assert!(format_command("xcodebuild -scheme App", "** BUILD SUCCEEDED **").is_some());
313 assert!(format_command("adb logcat -d", "08-29 10:00:01.000 1 1 E Tag: boom").is_some());
314 assert!(format_command("logcat", "08-29 10:00:01.000 1 1 E Tag: boom").is_some());
315 assert!(format_command("dotnet build", "Build succeeded.").is_some());
316 assert!(format_command("dotnet test", "Passed! - Failed: 0, Passed: 1, Total: 1").is_some());
317 assert!(format_command("adb devices", "List of devices attached").is_none());
318 assert!(format_command("dotnet run", "hello").is_none());
319 }
320
321 #[test]
322 fn test_phase4_commands_dispatch() {
323 assert!(format_command(
324 "terraform plan",
325 "aws_instance.web: Refreshing state... [id=i-1]\nNo changes. Infrastructure is up-to-date."
326 )
327 .is_some());
328 assert!(format_command("gradle build", "BUILD SUCCESSFUL in 3s").is_some());
329 assert!(format_command("mvn compile", "[INFO] BUILD SUCCESS").is_some());
330 assert!(format_command("kubectl describe pod nginx", "Name: nginx").is_some());
331 }
332
333 #[test]
334 fn test_ruby_commands_dispatch() {
335 assert!(format_command("rspec", "1 example, 0 failures").is_some());
336 assert!(
337 format_command("rubocop", "10 files inspected, no offenses detected").is_some()
338 );
339 assert!(format_command(
340 "rake test",
341 "8 runs, 9 assertions, 0 failures, 0 errors, 0 skips"
342 )
343 .is_some());
344 assert!(format_command(
345 "bundle install",
346 "Bundle complete! 85 dependencies"
347 )
348 .is_some());
349 assert!(format_command("bundle exec rspec", "1 example, 0 failures").is_some());
351 assert!(format_command("rake db:migrate", "== migrating ==").is_none());
353 }
354}