Skip to main content

sqz_engine/
cmd_formatters.rs

1/// Per-command output formatters — hand-tuned compression for the top 10
2/// most common CLI commands in AI coding sessions.
3///
4/// Each formatter takes raw command output and returns a compact version
5/// that preserves all actionable information while stripping noise.
6/// Formatters are stateless and infallible (return original on any issue).
7
8/// Route command output to the appropriate formatter.
9/// Returns `None` if no specialized formatter matches (use generic pipeline).
10pub fn format_command(cmd: &str, output: &str) -> Option<String> {
11    let parts: Vec<&str> = cmd.split_whitespace().collect();
12    let base = parts.first().map(|s| s.rsplit('/').next().unwrap_or(s)).unwrap_or("");
13
14    match base {
15        "git" => format_git(parts.get(1).copied(), output),
16        "cargo" => format_cargo(parts.get(1).copied(), output),
17        "npm" | "npx" => format_npm(parts.get(1).copied(), output),
18        "pytest" | "python" if cmd.contains("pytest") => Some(format_test_failures(output)),
19        "go" if parts.get(1).copied() == Some("test") => Some(format_test_failures(output)),
20        "docker" => format_docker(parts.get(1).copied(), output),
21        "kubectl" => format_kubectl(parts.get(1).copied(), output),
22        "ls" => Some(format_ls(output)),
23        "find" | "fd" => Some(format_find(output)),
24        "tsc" => Some(format_tsc(output)),
25        "eslint" | "biome" => Some(format_lint(output)),
26        _ => None,
27    }
28}
29
30// ── git ───────────────────────────────────────────────────────────────────
31
32fn format_git(subcmd: Option<&str>, output: &str) -> Option<String> {
33    match subcmd? {
34        "status" => Some(format_git_status(output)),
35        "log" => Some(format_git_log(output)),
36        "diff" => Some(format_git_diff(output)),
37        "add" | "commit" | "push" | "pull" | "checkout" | "switch" | "branch" => {
38            Some(format_git_short(subcmd.unwrap(), output))
39        }
40        _ => None,
41    }
42}
43
44fn format_git_status(output: &str) -> String {
45    let mut staged = Vec::new();
46    let mut modified = Vec::new();
47    let mut untracked = Vec::new();
48
49    for line in output.lines() {
50        let trimmed = line.trim();
51        if trimmed.starts_with("new file:") || trimmed.starts_with("modified:") && line.starts_with('\t') {
52            // In the staged section (after "Changes to be committed:")
53            staged.push(trimmed.to_string());
54        } else if trimmed.starts_with("modified:") || trimmed.starts_with("deleted:") {
55            modified.push(trimmed.to_string());
56        } else if line.starts_with("\t") && !trimmed.starts_with("(use") {
57            // Could be untracked or staged depending on section
58            if output[..output.find(line).unwrap_or(0)].contains("Untracked files:") {
59                untracked.push(trimmed.to_string());
60            }
61        }
62    }
63
64    // Also handle short-format status (git status -s)
65    if staged.is_empty() && modified.is_empty() && untracked.is_empty() {
66        let mut short_staged = Vec::new();
67        let mut short_modified = Vec::new();
68        let mut short_untracked = Vec::new();
69        for line in output.lines() {
70            if line.len() < 3 { continue; }
71            let (idx, rest) = (line.get(..2), line.get(3..));
72            if let (Some(idx), Some(rest)) = (idx, rest) {
73                match idx.trim() {
74                    "M" | "A" | "D" | "R" => short_staged.push(format!("{} {}", idx.trim(), rest)),
75                    "??" => short_untracked.push(rest.to_string()),
76                    _ if idx.contains('M') => short_modified.push(format!("M {}", rest)),
77                    _ => {}
78                }
79            }
80        }
81        if !short_staged.is_empty() || !short_modified.is_empty() || !short_untracked.is_empty() {
82            staged = short_staged;
83            modified = short_modified;
84            untracked = short_untracked;
85        }
86    }
87
88    if staged.is_empty() && modified.is_empty() && untracked.is_empty() {
89        if output.contains("nothing to commit") {
90            return "clean".to_string();
91        }
92        return output.to_string();
93    }
94
95    let mut result = Vec::new();
96    if !staged.is_empty() {
97        result.push(format!("staged({}): {}", staged.len(), staged.join(", ")));
98    }
99    if !modified.is_empty() {
100        result.push(format!("modified({}): {}", modified.len(), modified.join(", ")));
101    }
102    if !untracked.is_empty() {
103        if untracked.len() > 5 {
104            result.push(format!("untracked({}): {}, ...+{}", untracked.len(),
105                untracked[..3].join(", "), untracked.len() - 3));
106        } else {
107            result.push(format!("untracked({}): {}", untracked.len(), untracked.join(", ")));
108        }
109    }
110    result.join("\n")
111}
112
113fn format_git_log(output: &str) -> String {
114    // Compact: one line per commit — hash + subject
115    let mut commits = Vec::new();
116    let mut current_hash = String::new();
117    let mut current_subject = String::new();
118
119    for line in output.lines() {
120        if line.starts_with("commit ") {
121            if !current_hash.is_empty() {
122                commits.push(format!("{} {}", &current_hash[..current_hash.len().min(7)], current_subject.trim()));
123            }
124            current_hash = line.strip_prefix("commit ").unwrap_or("").trim().to_string();
125            current_subject.clear();
126        } else if line.starts_with("Author:") || line.starts_with("Date:") || line.starts_with("Merge:") {
127            // Skip
128        } else {
129            let trimmed = line.trim();
130            if !trimmed.is_empty() && current_subject.is_empty() {
131                current_subject = trimmed.to_string();
132            }
133        }
134    }
135    if !current_hash.is_empty() {
136        commits.push(format!("{} {}", &current_hash[..current_hash.len().min(7)], current_subject.trim()));
137    }
138
139    if commits.is_empty() {
140        // Might already be --oneline format
141        return output.to_string();
142    }
143    commits.join("\n")
144}
145
146fn format_git_diff(output: &str) -> String {
147    // Keep hunk headers and changed lines, strip context to 1 line
148    let mut result = Vec::new();
149    let mut context_count = 0;
150
151    for line in output.lines() {
152        if line.starts_with("diff --git") || line.starts_with("---") || line.starts_with("+++") {
153            result.push(line.to_string());
154            context_count = 0;
155        } else if line.starts_with("@@") {
156            result.push(line.to_string());
157            context_count = 0;
158        } else if line.starts_with('+') || line.starts_with('-') {
159            result.push(line.to_string());
160            context_count = 0;
161        } else {
162            // Context line — keep max 1
163            context_count += 1;
164            if context_count <= 1 {
165                result.push(line.to_string());
166            }
167        }
168    }
169    result.join("\n")
170}
171
172fn format_git_short(subcmd: &str, output: &str) -> String {
173    match subcmd {
174        "add" => {
175            if output.trim().is_empty() { return "ok".to_string(); }
176            output.to_string()
177        }
178        "commit" => {
179            // Extract short hash and subject
180            for line in output.lines() {
181                if line.contains(']') && line.contains('[') {
182                    return format!("ok {}", line.trim());
183                }
184            }
185            if output.trim().is_empty() { return "ok".to_string(); }
186            // First non-empty line
187            output.lines().find(|l| !l.trim().is_empty()).unwrap_or("ok").to_string()
188        }
189        "push" => {
190            for line in output.lines() {
191                if line.contains("->") {
192                    return format!("ok {}", line.trim());
193                }
194            }
195            "ok".to_string()
196        }
197        "pull" => {
198            let mut files_changed = 0;
199            let mut insertions = 0;
200            let mut deletions = 0;
201            for line in output.lines() {
202                if line.contains("files changed") || line.contains("file changed") {
203                    let parts: Vec<&str> = line.split_whitespace().collect();
204                    for (i, p) in parts.iter().enumerate() {
205                        if *p == "file" || p.starts_with("file") { files_changed = parts.get(i-1).and_then(|n| n.parse().ok()).unwrap_or(0); }
206                        if p.starts_with("insertion") { insertions = parts.get(i-1).and_then(|n| n.parse().ok()).unwrap_or(0); }
207                        if p.starts_with("deletion") { deletions = parts.get(i-1).and_then(|n| n.parse().ok()).unwrap_or(0); }
208                    }
209                }
210            }
211            if files_changed > 0 {
212                format!("ok {} files +{} -{}", files_changed, insertions, deletions)
213            } else if output.contains("Already up to date") {
214                "ok up-to-date".to_string()
215            } else {
216                "ok".to_string()
217            }
218        }
219        _ => output.lines().take(3).collect::<Vec<_>>().join("\n"),
220    }
221}
222
223// ── cargo ─────────────────────────────────────────────────────────────────
224
225fn format_cargo(subcmd: Option<&str>, output: &str) -> Option<String> {
226    match subcmd? {
227        "test" => Some(format_test_failures(output)),
228        "build" | "check" => Some(format_cargo_build(output)),
229        "clippy" => Some(format_lint(output)),
230        _ => None,
231    }
232}
233
234fn format_cargo_build(output: &str) -> String {
235    let errors: Vec<&str> = output.lines()
236        .filter(|l| l.starts_with("error") || l.contains("error[E") || l.starts_with("warning"))
237        .collect();
238
239    if errors.is_empty() {
240        // Success — find the Finished/Compiling summary
241        for line in output.lines().rev() {
242            if line.contains("Finished") || line.contains("Compiling") {
243                return line.trim().to_string();
244            }
245        }
246        return "ok".to_string();
247    }
248
249    // Group errors by file
250    let mut grouped: Vec<String> = Vec::new();
251    let current_file = String::new();
252    let file_errors: Vec<String> = Vec::new();
253
254    for line in output.lines() {
255        if line.starts_with("error") || line.contains("error[E") {
256            // Extract file path from " --> file:line:col"
257            grouped.push(line.to_string());
258        } else if line.trim().starts_with("-->") {
259            grouped.push(format!("  {}", line.trim()));
260        }
261    }
262
263    if grouped.is_empty() {
264        return output.to_string();
265    }
266
267    let _ = (current_file, file_errors); // suppress unused warnings
268    format!("ERRORS: {}\n{}", errors.len(), grouped.join("\n"))
269}
270
271// ── test runners (generic) ────────────────────────────────────────────────
272
273fn format_test_failures(output: &str) -> String {
274    let mut failures = Vec::new();
275    let mut summary_line = String::new();
276    let mut in_failure = false;
277    let mut failure_buf = Vec::new();
278
279    for line in output.lines() {
280        // Rust test result line
281        if line.starts_with("test result:") || line.starts_with("Tests:") {
282            summary_line = line.to_string();
283        }
284        // Rust: "---- test_name stdout ----" marks failure start
285        if line.starts_with("---- ") && line.ends_with(" ----") {
286            if !failure_buf.is_empty() {
287                failures.push(failure_buf.join("\n"));
288                failure_buf.clear();
289            }
290            in_failure = true;
291            failure_buf.push(line.to_string());
292            continue;
293        }
294        // Rust: "failures:" section
295        if line == "failures:" {
296            in_failure = true;
297            continue;
298        }
299        // pytest: "FAILED" lines
300        if line.contains("FAILED") || line.contains("FAIL:") {
301            failures.push(line.to_string());
302        }
303        // go test: "--- FAIL:"
304        if line.starts_with("--- FAIL:") {
305            failures.push(line.to_string());
306        }
307        // Collect failure details
308        if in_failure {
309            if line.trim().is_empty() && !failure_buf.is_empty() {
310                failures.push(failure_buf.join("\n"));
311                failure_buf.clear();
312                in_failure = false;
313            } else {
314                failure_buf.push(line.to_string());
315            }
316        }
317        // "test ... FAILED" individual lines
318        if line.contains("... FAILED") || line.contains("FAILED") && line.starts_with("test ") {
319            if !failures.iter().any(|f| f.contains(line)) {
320                failures.push(line.to_string());
321            }
322        }
323    }
324    if !failure_buf.is_empty() {
325        failures.push(failure_buf.join("\n"));
326    }
327
328    // If all tests passed, return compact summary
329    if failures.is_empty() {
330        if !summary_line.is_empty() {
331            return summary_line;
332        }
333        // Count test lines
334        let total = output.lines().filter(|l| l.contains("... ok") || l.contains("PASSED") || l.contains("passed")).count();
335        if total > 0 {
336            return format!("ok: {} tests passed", total);
337        }
338        return output.to_string();
339    }
340
341    let mut result = Vec::new();
342    if !summary_line.is_empty() {
343        result.push(summary_line);
344    }
345    result.push(format!("FAILURES ({}):", failures.len()));
346    for f in &failures {
347        result.push(f.clone());
348    }
349    result.join("\n")
350}
351
352// ── npm ───────────────────────────────────────────────────────────────────
353
354fn format_npm(subcmd: Option<&str>, output: &str) -> Option<String> {
355    match subcmd? {
356        "test" => Some(format_test_failures(output)),
357        "run" if output.contains("error") || output.contains("FAIL") => Some(format_test_failures(output)),
358        "install" | "i" | "add" => Some(format_npm_install(output)),
359        _ => None,
360    }
361}
362
363fn format_npm_install(output: &str) -> String {
364    let mut vulns = String::new();
365    for line in output.lines() {
366        if line.contains("added") && line.contains("packages") {
367            return line.trim().to_string();
368        }
369        if line.contains("vulnerabilities") {
370            vulns = line.trim().to_string();
371        }
372    }
373    if !vulns.is_empty() {
374        return format!("ok ({})", vulns);
375    }
376    "ok".to_string()
377}
378
379// ── docker ────────────────────────────────────────────────────────────────
380
381fn format_docker(subcmd: Option<&str>, output: &str) -> Option<String> {
382    match subcmd? {
383        "ps" => Some(format_docker_ps(output)),
384        "images" => Some(format_docker_images(output)),
385        "logs" => None, // Use generic condense for log dedup
386        _ => None,
387    }
388}
389
390fn format_docker_ps(output: &str) -> String {
391    let lines: Vec<&str> = output.lines().collect();
392    if lines.is_empty() { return output.to_string(); }
393
394    let mut result = Vec::new();
395    for (i, line) in lines.iter().enumerate() {
396        if i == 0 {
397            // Header — keep only NAME, IMAGE, STATUS
398            result.push("NAME | IMAGE | STATUS".to_string());
399            continue;
400        }
401        let parts: Vec<&str> = line.split_whitespace().collect();
402        if parts.len() >= 5 {
403            // CONTAINER_ID IMAGE COMMAND CREATED STATUS ... NAMES
404            let name = parts.last().unwrap_or(&"");
405            let image = parts.get(1).unwrap_or(&"");
406            let status_parts: Vec<&&str> = parts.iter().skip(4).take_while(|p| !p.starts_with("0.0.0.0")).collect();
407            let status = status_parts.iter().map(|s| **s).collect::<Vec<_>>().join(" ");
408            result.push(format!("{} | {} | {}", name, image, status));
409        } else {
410            result.push(line.to_string());
411        }
412    }
413    result.join("\n")
414}
415
416fn format_docker_images(output: &str) -> String {
417    let lines: Vec<&str> = output.lines().collect();
418    if lines.is_empty() { return output.to_string(); }
419
420    let mut result = Vec::new();
421    for (i, line) in lines.iter().enumerate() {
422        if i == 0 {
423            result.push("REPO:TAG | SIZE".to_string());
424            continue;
425        }
426        let parts: Vec<&str> = line.split_whitespace().collect();
427        if parts.len() >= 5 {
428            let repo = parts[0];
429            let tag = parts[1];
430            let size = parts.last().unwrap_or(&"");
431            result.push(format!("{}:{} | {}", repo, tag, size));
432        }
433    }
434    result.join("\n")
435}
436
437// ── kubectl ───────────────────────────────────────────────────────────────
438
439fn format_kubectl(subcmd: Option<&str>, output: &str) -> Option<String> {
440    match subcmd? {
441        "get" => Some(format_kubectl_get(output)),
442        _ => None,
443    }
444}
445
446fn format_kubectl_get(output: &str) -> String {
447    // Keep header + data but strip AGE column and collapse whitespace
448    let lines: Vec<&str> = output.lines().collect();
449    if lines.is_empty() { return output.to_string(); }
450
451    let mut result = Vec::new();
452    for line in &lines {
453        // Collapse multiple spaces to single
454        let collapsed: String = line.split_whitespace().collect::<Vec<_>>().join(" ");
455        result.push(collapsed);
456    }
457    result.join("\n")
458}
459
460// ── ls ────────────────────────────────────────────────────────────────────
461
462fn format_ls(output: &str) -> String {
463    let lines: Vec<&str> = output.lines().collect();
464    if lines.len() <= 20 { return output.to_string(); }
465
466    // Group by directory structure
467    let mut dirs = Vec::new();
468    let mut files = Vec::new();
469
470    for line in &lines {
471        let trimmed = line.trim();
472        if trimmed.is_empty() || trimmed.starts_with("total") { continue; }
473        if trimmed.starts_with('d') || trimmed.ends_with('/') {
474            dirs.push(trimmed.split_whitespace().last().unwrap_or(trimmed).to_string());
475        } else {
476            files.push(trimmed.split_whitespace().last().unwrap_or(trimmed).to_string());
477        }
478    }
479
480    let mut result = Vec::new();
481    if !dirs.is_empty() {
482        result.push(format!("dirs({}): {}", dirs.len(), dirs.join(", ")));
483    }
484    if files.len() > 10 {
485        result.push(format!("files({}): {}, ...+{}", files.len(),
486            files[..5].join(", "), files.len() - 5));
487    } else if !files.is_empty() {
488        result.push(format!("files({}): {}", files.len(), files.join(", ")));
489    }
490    result.join("\n")
491}
492
493// ── find ──────────────────────────────────────────────────────────────────
494
495fn format_find(output: &str) -> String {
496    let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
497    if lines.len() <= 20 { return output.to_string(); }
498
499    // Group by parent directory
500    let mut by_dir: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
501    for line in &lines {
502        let path = std::path::Path::new(line.trim());
503        let parent = path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
504        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
505        by_dir.entry(parent).or_default().push(name);
506    }
507
508    let mut result = Vec::new();
509    result.push(format!("{} files found:", lines.len()));
510    for (dir, files) in &by_dir {
511        if files.len() > 5 {
512            result.push(format!("  {}/ ({} files)", dir, files.len()));
513        } else {
514            result.push(format!("  {}/ {}", dir, files.join(", ")));
515        }
516    }
517    result.join("\n")
518}
519
520// ── tsc ───────────────────────────────────────────────────────────────────
521
522fn format_tsc(output: &str) -> String {
523    // Group TypeScript errors by file
524    let mut by_file: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
525    let mut error_count = 0;
526
527    for line in output.lines() {
528        // TS errors: "src/file.ts(10,5): error TS2345: ..."
529        if line.contains("): error TS") || line.contains("): warning TS") {
530            error_count += 1;
531            if let Some(paren_pos) = line.find('(') {
532                let file = &line[..paren_pos];
533                by_file.entry(file.to_string()).or_default().push(line.to_string());
534            } else {
535                by_file.entry("unknown".to_string()).or_default().push(line.to_string());
536            }
537        }
538    }
539
540    if error_count == 0 {
541        if output.contains("Found 0 errors") || output.trim().is_empty() {
542            return "ok: 0 errors".to_string();
543        }
544        return output.to_string();
545    }
546
547    let mut result = Vec::new();
548    result.push(format!("ERRORS: {} in {} files", error_count, by_file.len()));
549    for (file, errors) in &by_file {
550        result.push(format!("  {} ({}):", file, errors.len()));
551        for e in errors.iter().take(5) {
552            result.push(format!("    {}", e));
553        }
554        if errors.len() > 5 {
555            result.push(format!("    ...+{} more", errors.len() - 5));
556        }
557    }
558    result.join("\n")
559}
560
561// ── lint (eslint/biome) ───────────────────────────────────────────────────
562
563fn format_lint(output: &str) -> String {
564    let mut by_rule: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
565    let mut total = 0;
566
567    for line in output.lines() {
568        // ESLint: "  10:5  error  Description  rule-name"
569        // Clippy: "warning: description"
570        let trimmed = line.trim();
571        if trimmed.contains("error") || trimmed.contains("warning") {
572            total += 1;
573            // Try to extract rule name (last word in ESLint format)
574            let parts: Vec<&str> = trimmed.split_whitespace().collect();
575            if let Some(rule) = parts.last() {
576                *by_rule.entry(rule.to_string()).or_insert(0) += 1;
577            }
578        }
579    }
580
581    if total == 0 {
582        return "ok: 0 issues".to_string();
583    }
584
585    let mut result = Vec::new();
586    result.push(format!("{} issues:", total));
587    let mut sorted: Vec<_> = by_rule.iter().collect();
588    sorted.sort_by(|a, b| b.1.cmp(a.1));
589    for (rule, count) in sorted.iter().take(10) {
590        result.push(format!("  {} ({}x)", rule, count));
591    }
592    if sorted.len() > 10 {
593        result.push(format!("  ...+{} more rules", sorted.len() - 10));
594    }
595    result.join("\n")
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn test_git_status_clean() {
604        let output = "On branch main\nnothing to commit, working tree clean\n";
605        assert_eq!(format_git_status(output), "clean");
606    }
607
608    #[test]
609    fn test_git_log_compact() {
610        let output = "commit abc1234567890\nAuthor: Test <test@test.com>\nDate:   Mon Apr 13\n\n    feat: Add feature\n\ncommit def5678901234\nAuthor: Test <test@test.com>\nDate:   Sun Apr 12\n\n    fix: Bug fix\n";
611        let result = format_git_log(output);
612        assert!(result.contains("abc1234"));
613        assert!(result.contains("feat: Add feature"));
614        assert!(!result.contains("Author:"));
615    }
616
617    #[test]
618    fn test_git_push_compact() {
619        let output = "Enumerating objects: 5, done.\nCounting objects: 100% (5/5), done.\nDelta compression using up to 8 threads\n   abc1234..def5678  main -> main\n";
620        let result = format_git_short("push", output);
621        assert!(result.starts_with("ok"));
622    }
623
624    #[test]
625    fn test_cargo_test_all_pass() {
626        let output = "running 15 tests\ntest a ... ok\ntest b ... ok\ntest result: ok. 15 passed; 0 failed; 0 ignored\n";
627        let result = format_test_failures(output);
628        assert!(result.contains("ok") || result.contains("passed"));
629        assert!(!result.contains("FAILURES"));
630    }
631
632    #[test]
633    fn test_cargo_test_with_failure() {
634        let output = "running 3 tests\ntest a ... ok\ntest b ... FAILED\ntest c ... ok\n\nfailures:\n\n---- b stdout ----\nassertion failed\n\ntest result: FAILED. 2 passed; 1 failed\n";
635        let result = format_test_failures(output);
636        assert!(result.contains("FAIL"));
637    }
638
639    #[test]
640    fn test_docker_ps_compact() {
641        let output = "CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES\nabc123def456   nginx     \"nginx\"   2h ago    Up 2h     80/tcp    web\n";
642        let result = format_docker_ps(output);
643        assert!(result.contains("NAME | IMAGE | STATUS"));
644        assert!(result.contains("web"));
645    }
646
647    #[test]
648    fn test_tsc_no_errors() {
649        let result = format_tsc("Found 0 errors.\n");
650        assert_eq!(result, "ok: 0 errors");
651    }
652
653    #[test]
654    fn test_format_command_routing() {
655        assert!(format_command("git status", "nothing to commit").is_some());
656        assert!(format_command("cargo test", "test result: ok").is_some());
657        assert!(format_command("unknown_tool", "output").is_none());
658    }
659
660    #[test]
661    fn test_ls_short_passthrough() {
662        let output = "file1.rs\nfile2.rs\n";
663        assert_eq!(format_ls(output), output);
664    }
665
666    #[test]
667    fn test_npm_install_compact() {
668        let output = "added 42 packages in 3s\n2 vulnerabilities\n";
669        let result = format_npm_install(output);
670        assert!(result.contains("added 42 packages"));
671    }
672}