Skip to main content

sqz_engine/cmd_formatters/
mod.rs

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
58/// A formatter may summarize success, but every line reporting a failure
59/// has to come through, unless the formatter says it capped them. Lines
60/// that only restate the failure (`Build FAILED.`, `could not compile`) may
61/// go as long as the output still says it failed.
62fn 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
90/// Narrower than a substring match on "error", so `Compiling thiserror`
91/// and `7 passed; 0 failed` don't count.
92pub(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    // Whole words only: `Errors  Files` and `ExceptionHandler` don't count.
105    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    // `bundle exec rspec ...` / `bin/rails ...` — unwrap the bundler prefix so
117    // the real Ruby tool is dispatched. `bundle install/update` is left intact.
118    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        // Version control
128        "git" => git::format_git(parts.get(1).copied(), output),
129
130        // Rust
131        "cargo" => cargo::format_cargo(parts.get(1).copied(), output, raw),
132
133        // JavaScript/TypeScript
134        "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        // Python
141        "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
150        "go" => go::format_go(parts.get(1).copied(), output),
151        "golangci-lint" => Some(lint::format_lint(output)),
152
153        // JVM
154        "gradle" | "gradlew" | "./gradlew" => jvm::format_gradle(parts.get(1).copied(), output),
155        "mvn" | "maven" => jvm::format_maven(parts.get(1).copied(), output),
156
157        // Apple / Android / .NET
158        "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        // Ruby
163        "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        // Containers / orchestration
169        "docker" | "podman" => docker::format_docker(parts.get(1).copied(), output),
170        "kubectl" => kubectl::format_kubectl(parts.get(1).copied(), output),
171
172        // Infrastructure
173        "terraform" | "tf" => cloud::format_terraform(parts.get(1).copied(), output),
174
175        // System utilities
176        "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        // A summary formatter that answers "ok" would hide the failure.
214        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        // Restating lines may go when the output still says it failed.
218        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        // Their output is the data the agent asked for: JSON, YAML, tables,
296        // diffs, file lists. The generic pipeline keeps all of it.
297        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        // `bundle exec` prefix unwraps to the real tool.
350        assert!(format_command("bundle exec rspec", "1 example, 0 failures").is_some());
351        // Non-test rake tasks fall through to generic compression.
352        assert!(format_command("rake db:migrate", "== migrating ==").is_none());
353    }
354}