Skip to main content

lean_ctx/core/patterns/
mod.rs

1/// Pattern engine version. Bump when output format changes to maintain
2/// shell-output determinism guarantees. Consumers (proofs, benchmarks)
3/// can embed this version to detect pattern-breaking updates.
4///
5/// v2 (#936): `json_schema::compress` now prefers the lossless `json_crush`
6/// form over the schema outline for redundant array-of-object payloads.
7pub const PATTERN_ENGINE_VERSION: u32 = 2;
8
9pub mod alembic;
10pub mod ansible;
11pub mod argocd;
12pub mod artisan;
13pub mod aws;
14pub mod bazel;
15pub mod buf;
16pub mod bun;
17pub mod cargo;
18pub mod cargo_diagnostics;
19pub mod clang;
20pub mod cmake;
21pub mod composer;
22pub mod cosign;
23pub mod curl;
24pub mod dbt;
25pub mod deno;
26pub mod deploy;
27pub mod deps_cmd;
28pub mod docker;
29pub mod dotnet;
30pub mod env_filter;
31pub mod eslint;
32pub mod fd;
33pub mod find;
34pub mod flutter;
35pub mod flyway;
36pub mod gem;
37pub mod gh;
38pub mod git;
39pub mod glab;
40pub mod golang;
41pub mod grep;
42pub mod grype;
43pub mod helm;
44pub mod jj;
45pub mod json_schema;
46pub mod just;
47pub mod kubectl;
48pub mod linkerd;
49pub mod log_dedup;
50pub mod ls;
51pub mod make;
52pub mod maven;
53pub mod mise;
54pub mod mix;
55pub mod mlflow;
56pub mod mypy;
57pub mod mysql;
58pub mod next_build;
59pub mod ninja;
60pub mod npm;
61pub mod ollama;
62pub mod php;
63pub mod pip;
64pub mod playwright;
65pub mod pnpm;
66pub mod poetry;
67pub mod prettier;
68pub mod prisma;
69pub mod psql;
70pub mod pulumi;
71pub mod pytest;
72pub mod ruby;
73pub mod ruff;
74pub mod semgrep;
75pub mod spark;
76pub mod swift;
77pub mod swiftlint;
78pub mod syft;
79pub mod sysinfo;
80pub mod systemd;
81pub mod terraform;
82pub mod test;
83pub mod trivy;
84pub mod typescript;
85pub mod wget;
86pub mod zig;
87
88use crate::core::tokens::count_tokens;
89
90pub fn compress_output(command: &str, output: &str) -> Option<String> {
91    // Policy gate: protected commands (Passthrough + Verbatim) must
92    // never be pattern-compressed. The caller (compress_if_beneficial
93    // or ctx_shell::handle) should have already checked, but we
94    // defend in depth here.
95    let policy = crate::shell::output_policy::classify(command, &[]);
96    if policy.is_protected() {
97        return None;
98    }
99
100    let stripped;
101    let clean_output = {
102        let s = crate::core::compressor::strip_ansi(output);
103        if s.len() < output.len() {
104            stripped = s;
105            &stripped
106        } else {
107            output
108        }
109    };
110
111    if let Some(engine) = crate::core::filters::FilterEngine::load()
112        && let Some(filtered) = engine.apply(command, clean_output)
113    {
114        return shorter_only(filtered, output);
115    }
116
117    // VCS history (git/jj/gh/glab/hg) is owned by its dedicated compressor. Its
118    // lines look log-ish (one per commit) but are NOT application logs, so the
119    // generic json/log/test fallbacks would mis-summarize them — e.g. truncating
120    // an explicit `git log --oneline -40` to "last 15" or reading a commit
121    // subject like "fix: pending_errors" as an error line. Return the dedicated
122    // compressor's result directly — even when it is not shorter, so an already
123    // compact oneline log is preserved verbatim (full history intact) instead of
124    // being reshaped by a generic heuristic.
125    if has_vcs_owner(command) {
126        // The VCS compressor is authoritative and is kept even when it is not
127        // strictly *shorter*, so a compact `git log --oneline` is preserved
128        // verbatim (full history intact). It must still never return *more*
129        // tokens than its input, though — a tiny adversarial `git status` body
130        // could otherwise reshape into a one-token-larger summary and break the
131        // never-inflate invariant. Allow equal (verbatim), reject only growth.
132        return try_specific_pattern(command, clean_output)
133            .filter(|c| !c.trim().is_empty())
134            .filter(|c| !inflates_tokens(c, output));
135    }
136
137    if let Some(compressed) = try_specific_pattern(command, clean_output)
138        && let Some(r) = shorter_only(compressed, output)
139    {
140        return Some(r);
141    }
142
143    if let Some(r) = json_schema::compress(clean_output)
144        && let Some(r) = shorter_only(r, output)
145    {
146        return Some(r);
147    }
148
149    if let Some(r) = log_dedup::compress(clean_output)
150        && let Some(r) = shorter_only(r, output)
151    {
152        return Some(r);
153    }
154
155    if let Some(r) = test::compress(clean_output)
156        && let Some(r) = shorter_only(r, output)
157    {
158        return Some(r);
159    }
160
161    None
162}
163
164/// True for version-control commands whose output is authoritative under their
165/// own compressor and must not be reinterpreted by the generic log/test
166/// fallbacks (commit history is not application log output).
167pub(crate) fn has_vcs_owner(command: &str) -> bool {
168    let c = command.trim_start().to_ascii_lowercase();
169    c.starts_with("git ")
170        || c.starts_with("jj ")
171        || c.starts_with("gh ")
172        || c.starts_with("glab ")
173        || c.starts_with("hg ")
174}
175
176/// Collapse whitespace into single spaces so comparisons align with logical word tokens.
177fn normalize_shell_tokens(text: &str) -> String {
178    text.split_whitespace().collect::<Vec<_>>().join(" ")
179}
180
181/// True when `compressed` carries *more* tokens than the raw `original` — i.e.
182/// the transform inflated rather than compressed. Compared against the raw
183/// (pre-ANSI-strip) output so the guard matches the public `compress_output`
184/// invariant exactly: the returned text never tokenizes larger than its input.
185fn inflates_tokens(compressed: &str, original: &str) -> bool {
186    count_tokens(compressed) > count_tokens(original)
187}
188
189fn shorter_only(compressed: String, original: &str) -> Option<String> {
190    let orig_n = normalize_shell_tokens(original);
191    let comp_n = normalize_shell_tokens(&compressed);
192    let ct_c = count_tokens(&comp_n);
193    let ct_o = count_tokens(&orig_n);
194    if ct_c < ct_o || (ct_c == ct_o && comp_n.len() < orig_n.len()) {
195        Some(compressed)
196    } else {
197        None
198    }
199}
200
201type PatternMatcher = fn(&str) -> bool;
202type PatternHandler = fn(&str, &str) -> Option<String>;
203
204/// Ordered prefix → compressor dispatch table (#660 CC reduction): first
205/// matching entry wins, mirroring the previous `if c.starts_with(..) { return
206/// ...; }` cascade exactly — including that a matched entry's `None` is
207/// final, never falls through to a later entry. Registering a new CLI tool
208/// is now a one-line addition here instead of a new branch in
209/// `try_specific_pattern`.
210const PATTERNS: &[(PatternMatcher, PatternHandler)] = &[
211    (
212        |c| c.starts_with("git "),
213        |c, output| git::compress(c, output),
214    ),
215    (
216        |c| c.starts_with("gh "),
217        |c, output| gh::compress(c, output),
218    ),
219    (
220        |c| c.starts_with("glab "),
221        |c, output| glab::try_glab_pattern(c, output),
222    ),
223    (
224        |c| c == "terraform" || c.starts_with("terraform "),
225        |c, output| terraform::compress(c, output),
226    ),
227    (
228        |c| c == "make" || c.starts_with("make "),
229        |c, output| make::compress(c, output),
230    ),
231    (
232        |c| c == "just" || c.starts_with("just "),
233        |c, output| just::compress(c, output),
234    ),
235    (
236        |c| {
237            c.starts_with("mvn ")
238                || c.starts_with("./mvnw ")
239                || c.starts_with("mvnw ")
240                || c.starts_with("gradle ")
241                || c.starts_with("./gradlew ")
242                || c.starts_with("gradlew ")
243        },
244        |c, output| maven::compress(c, output),
245    ),
246    (
247        |c| c.starts_with("kubectl ") || c.starts_with("k "),
248        |c, output| kubectl::compress(c, output),
249    ),
250    (
251        |c| c.starts_with("helm "),
252        |c, output| helm::compress(c, output),
253    ),
254    (
255        |c| c.starts_with("pnpm "),
256        |c, output| pnpm::compress(c, output),
257    ),
258    (
259        |c| c.starts_with("bun ") || c.starts_with("bunx "),
260        |c, output| bun::compress(c, output),
261    ),
262    (
263        |c| c.starts_with("deno "),
264        |c, output| deno::compress(c, output),
265    ),
266    (
267        |c| c.starts_with("npm ") || c.starts_with("yarn "),
268        |c, output| npm::compress(c, output),
269    ),
270    (
271        |c| c.starts_with("cargo "),
272        |c, output| cargo::compress(c, output),
273    ),
274    (
275        |c| c.starts_with("docker ") || c.starts_with("docker-compose "),
276        |c, output| docker::compress(c, output),
277    ),
278    (
279        |c| c.starts_with("pip ") || c.starts_with("pip3 ") || c.starts_with("python -m pip"),
280        |c, output| pip::compress(c, output),
281    ),
282    (
283        |c| c.starts_with("mypy") || c.starts_with("python -m mypy") || c.starts_with("dmypy "),
284        |c, output| mypy::compress(c, output),
285    ),
286    (
287        |c| c.starts_with("pytest") || c.starts_with("python -m pytest"),
288        |c, output| pytest::compress(c, output).or_else(|| test::compress(output)),
289    ),
290    (
291        |c| c.starts_with("ruff "),
292        |c, output| ruff::compress(c, output),
293    ),
294    (
295        |c| {
296            c.starts_with("eslint")
297                || c.starts_with("npx eslint")
298                || c.starts_with("biome ")
299                || c.starts_with("stylelint")
300        },
301        |c, output| eslint::compress(c, output),
302    ),
303    (
304        |c| c.starts_with("prettier") || c.starts_with("npx prettier"),
305        |_c, output| prettier::compress(output),
306    ),
307    (
308        |c| c.starts_with("go ") || c.starts_with("golangci-lint") || c.starts_with("golint"),
309        |c, output| golang::compress(c, output),
310    ),
311    (
312        |c| {
313            c.starts_with("playwright")
314                || c.starts_with("npx playwright")
315                || c.starts_with("cypress")
316                || c.starts_with("npx cypress")
317        },
318        |c, output| playwright::compress(c, output),
319    ),
320    (
321        |c| c.starts_with("vitest") || c.starts_with("npx vitest") || c.starts_with("pnpm vitest"),
322        |_c, output| test::compress(output),
323    ),
324    (
325        |c| {
326            c.starts_with("next ")
327                || c.starts_with("npx next")
328                || c.starts_with("vite ")
329                || c.starts_with("npx vite")
330                || c.starts_with("vp ")
331                || c.starts_with("vite-plus ")
332        },
333        |c, output| next_build::compress(c, output),
334    ),
335    (
336        |c| c.starts_with("tsc") || c.contains("typescript"),
337        |_c, output| typescript::compress(output),
338    ),
339    (
340        |c| {
341            c.starts_with("rubocop")
342                || c.starts_with("bundle ")
343                || c.starts_with("rake ")
344                || c.starts_with("rails test")
345                || c.starts_with("rspec")
346        },
347        |c, output| ruby::compress(c, output),
348    ),
349    (|c| is_grep_family(c), |_c, output| grep::compress(output)),
350    (
351        |c| c.starts_with("find "),
352        |_c, output| find::compress(output),
353    ),
354    (
355        |c| c.starts_with("fd ") || c.starts_with("fdfind "),
356        |_c, output| fd::compress(output),
357    ),
358    (
359        |c| c.starts_with("ls ") || c == "ls",
360        |_c, output| ls::compress(output),
361    ),
362    (
363        |c| c.starts_with("curl "),
364        |c, output| curl::compress_with_cmd(c, output),
365    ),
366    (
367        |c| c.starts_with("wget "),
368        |_c, output| wget::compress(output),
369    ),
370    (
371        |c| c == "env" || c.starts_with("env ") || c.starts_with("printenv"),
372        |_c, output| env_filter::compress(output),
373    ),
374    (
375        |c| c.starts_with("dotnet "),
376        |c, output| dotnet::compress(c, output),
377    ),
378    (
379        |c| {
380            c.starts_with("flutter ")
381                || (c.starts_with("dart ") && (c.contains(" analyze") || c.ends_with(" analyze")))
382        },
383        |c, output| flutter::compress(c, output),
384    ),
385    (
386        |c| {
387            c.starts_with("poetry ")
388                || c.starts_with("uv ")
389                || c.starts_with("conda ")
390                || c.starts_with("mamba ")
391                || c.starts_with("pipx ")
392        },
393        |c, output| poetry::compress(c, output),
394    ),
395    (
396        |c| c.starts_with("aws "),
397        |c, output| aws::compress(c, output),
398    ),
399    (
400        |c| c.starts_with("psql ") || c.starts_with("pg_"),
401        |c, output| psql::compress(c, output),
402    ),
403    (
404        |c| c.starts_with("mysql ") || c.starts_with("mariadb "),
405        |c, output| mysql::compress(c, output),
406    ),
407    (
408        |c| c.starts_with("prisma ") || c.starts_with("npx prisma"),
409        |c, output| prisma::compress(c, output),
410    ),
411    (
412        |c| c.starts_with("swift "),
413        |c, output| swift::compress(c, output),
414    ),
415    (
416        |c| c.starts_with("zig "),
417        |c, output| zig::compress(c, output),
418    ),
419    (
420        |c| c.starts_with("cmake ") || c.starts_with("ctest"),
421        |c, output| cmake::compress(c, output),
422    ),
423    (
424        |c| c.starts_with("ninja"),
425        |c, output| ninja::compress(c, output),
426    ),
427    (
428        |c| c.starts_with("ansible") || c.starts_with("ansible-playbook"),
429        |c, output| ansible::compress(c, output),
430    ),
431    (
432        |c| c.starts_with("composer "),
433        |c, output| composer::compress(c, output),
434    ),
435    (
436        |c| c.starts_with("php artisan") || c.starts_with("artisan "),
437        |c, output| artisan::compress(c, output),
438    ),
439    (
440        |c| c.starts_with("./vendor/bin/pest") || c.starts_with("pest "),
441        |_c, output| artisan::compress("php artisan test", output),
442    ),
443    (
444        |c| c.starts_with("mix ") || c.starts_with("iex "),
445        |c, output| mix::compress(c, output),
446    ),
447    (
448        |c| c.starts_with("bazel ") || c.starts_with("blaze "),
449        |c, output| bazel::compress(c, output),
450    ),
451    (
452        |c| c.starts_with("systemctl ") || c.starts_with("journalctl"),
453        |c, output| systemd::compress(c, output),
454    ),
455    (
456        |c| c.starts_with("jest") || c.starts_with("npx jest") || c.starts_with("pnpm jest"),
457        |_c, output| test::compress(output),
458    ),
459    (
460        |c| c.starts_with("mocha") || c.starts_with("npx mocha"),
461        |_c, output| test::compress(output),
462    ),
463    (
464        |c| c.starts_with("tofu "),
465        |c, output| terraform::compress(c, output),
466    ),
467    (
468        |c| c.starts_with("ps ") || c == "ps",
469        |_c, output| sysinfo::compress_ps(output),
470    ),
471    (
472        |c| c.starts_with("df ") || c == "df",
473        |_c, output| sysinfo::compress_df(output),
474    ),
475    (
476        |c| c.starts_with("du ") || c == "du",
477        |_c, output| sysinfo::compress_du(output),
478    ),
479    (
480        |c| c.starts_with("ping "),
481        |_c, output| sysinfo::compress_ping(output),
482    ),
483    (
484        |c| c.starts_with("jq ") || c == "jq",
485        |_c, output| json_schema::compress(output),
486    ),
487    (
488        |c| c.starts_with("hadolint"),
489        |c, output| eslint::compress(c, output),
490    ),
491    (
492        |c| c.starts_with("yamllint") || c.starts_with("npx yamllint"),
493        |c, output| eslint::compress(c, output),
494    ),
495    (
496        |c| c.starts_with("markdownlint") || c.starts_with("npx markdownlint"),
497        |c, output| eslint::compress(c, output),
498    ),
499    (
500        |c| c.starts_with("oxlint") || c.starts_with("npx oxlint"),
501        |c, output| eslint::compress(c, output),
502    ),
503    (
504        |c| c.starts_with("pyright") || c.starts_with("basedpyright"),
505        |c, output| mypy::compress(c, output),
506    ),
507    (
508        |c| c.starts_with("turbo ") || c.starts_with("npx turbo"),
509        |c, output| npm::compress(c, output),
510    ),
511    (
512        |c| c.starts_with("nx ") || c.starts_with("npx nx"),
513        |c, output| npm::compress(c, output),
514    ),
515    (
516        |c| c.starts_with("clang++ ") || c.starts_with("clang "),
517        |c, output| clang::compress(c, output),
518    ),
519    (
520        |c| {
521            c.starts_with("gcc ")
522                || c.starts_with("g++ ")
523                || c.starts_with("cc ")
524                || c.starts_with("c++ ")
525        },
526        |c, output| cmake::compress(c, output),
527    ),
528    // --- data domain (#657) ---
529    (
530        |c| c == "dbt" || c.starts_with("dbt "),
531        |c, output| dbt::compress(c, output),
532    ),
533    (
534        |c| c == "alembic" || c.starts_with("alembic "),
535        |c, output| alembic::compress(c, output),
536    ),
537    (
538        |c| c == "flyway" || c.starts_with("flyway "),
539        |c, output| flyway::compress(c, output),
540    ),
541    (
542        |c| c.starts_with("spark-submit") || c.starts_with("spark-sql") || c.starts_with("pyspark"),
543        |c, output| spark::compress(c, output),
544    ),
545    // --- ai domain (#658) ---
546    (
547        |c| c == "ollama" || c.starts_with("ollama "),
548        |c, output| ollama::compress(c, output),
549    ),
550    (
551        |c| c.starts_with("mlflow "),
552        |c, output| mlflow::compress(c, output),
553    ),
554    // --- security / supply-chain (#659) ---
555    (
556        |c| c.starts_with("semgrep "),
557        |c, output| semgrep::compress(c, output),
558    ),
559    (
560        |c| c.starts_with("trivy "),
561        |c, output| trivy::compress(c, output),
562    ),
563    (
564        |c| c.starts_with("grype "),
565        |c, output| grype::compress(c, output),
566    ),
567    (
568        |c| c.starts_with("syft "),
569        |c, output| syft::compress(c, output),
570    ),
571    (
572        |c| c.starts_with("cosign "),
573        |c, output| cosign::compress(c, output),
574    ),
575    (
576        |c| c.starts_with("swiftlint"),
577        |c, output| swiftlint::compress(c, output),
578    ),
579    // --- vcs / toolchain (#660) ---
580    (
581        |c| c == "jj" || c.starts_with("jj "),
582        |c, output| jj::compress(c, output),
583    ),
584    (
585        |c| c == "mise" || c.starts_with("mise "),
586        |c, output| mise::compress(c, output),
587    ),
588    (
589        |c| c == "buf" || c.starts_with("buf "),
590        |c, output| buf::compress(c, output),
591    ),
592    (
593        |c| c.starts_with("gem "),
594        |c, output| gem::compress(c, output),
595    ),
596    // --- edge / infra (#661) ---
597    (
598        |c| c == "pulumi" || c.starts_with("pulumi "),
599        |c, output| pulumi::compress(c, output),
600    ),
601    (
602        |c| c.starts_with("linkerd "),
603        |c, output| linkerd::compress(c, output),
604    ),
605    (
606        |c| c.starts_with("argocd "),
607        |c, output| argocd::compress(c, output),
608    ),
609    (
610        |c| {
611            c == "vercel"
612                || c.starts_with("vercel ")
613                || c == "fly"
614                || c.starts_with("fly ")
615                || c.starts_with("flyctl ")
616                || c.starts_with("wrangler ")
617                || c.starts_with("skaffold ")
618                || c.starts_with("supabase ")
619        },
620        |c, output| deploy::compress(c, output),
621    ),
622];
623
624/// A grep-family search command, bare or with arguments (#980, #985).
625///
626/// The bare form matters: `proxy::compress::infer_command` synthesises `grep`
627/// (no arguments) from tool names like `search_files`. The old `starts_with("grep ")`
628/// required a trailing space and never matched, so those results fell through to the
629/// terse pipeline which corrupted source identifiers.
630fn is_grep_family(cmd: &str) -> bool {
631    let first = cmd.split_whitespace().next().unwrap_or("");
632    let base = first.rsplit('/').next().unwrap_or(first);
633    matches!(
634        base,
635        "grep" | "egrep" | "fgrep" | "rg" | "ag" | "ack" | "ugrep" | "sift"
636    )
637}
638
639pub fn try_specific_pattern(cmd: &str, output: &str) -> Option<String> {
640    let cl = cmd.to_ascii_lowercase();
641    let c = cl.as_str();
642
643    PATTERNS
644        .iter()
645        .find(|(matches, _)| matches(c))
646        .and_then(|(_, handle)| handle(c, output))
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn routes_git_commands() {
655        let output = "On branch main\nnothing to commit";
656        assert!(compress_output("git status", output).is_some());
657    }
658
659    #[test]
660    fn vcs_path_never_inflates_tokens() {
661        // Regression for the `compress_output_never_inflates_tokens` property
662        // (Windows CI): the VCS branch returned the git compressor's reshaped
663        // output without the `shorter_only` guard the other paths use, so this
664        // tiny adversarial `git status` body grew 10 -> 11 tokens. The result
665        // must now never tokenize larger than its input (None == use original).
666        let output = " Abu a\nAa aa_A00A\n\n-";
667        if let Some(compressed) = compress_output("git status", output) {
668            assert!(
669                count_tokens(&compressed) <= count_tokens(output),
670                "VCS compress inflated: {} > {}",
671                count_tokens(&compressed),
672                count_tokens(output),
673            );
674        }
675    }
676
677    #[test]
678    fn routes_cargo_commands() {
679        let output = "   Compiling lean-ctx v2.1.1\n    Finished `release` profile [optimized] target(s) in 30.5s";
680        assert!(compress_output("cargo build --release", output).is_some());
681    }
682
683    #[test]
684    fn routes_npm_commands() {
685        let output = "added 150 packages, and audited 151 packages in 5s\n\n25 packages are looking for funding\n  run `npm fund` for details\n\nfound 0 vulnerabilities";
686        assert!(compress_output("npm install", output).is_some());
687    }
688
689    #[test]
690    fn routes_docker_commands() {
691        let output = "CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES";
692        // docker ps is Verbatim (via is_container_listing), so compress_output
693        // correctly returns None (policy gate). docker build should still compress.
694        assert!(compress_output("docker ps", output).is_none());
695        let build_output =
696            "Step 1/5 : FROM node:18\n ---> abc123\nStep 2/5 : COPY . .\nSuccessfully built def456";
697        assert!(compress_output("docker build .", build_output).is_some());
698    }
699
700    #[test]
701    fn routes_mypy_commands() {
702        let output = "src/main.py:10: error: Missing return  [return]\nFound 1 error in 1 file (checked 3 source files)";
703        assert!(compress_output("mypy .", output).is_some());
704        assert!(compress_output("python -m mypy src/", output).is_some());
705    }
706
707    #[test]
708    fn routes_pytest_commands() {
709        let output = "===== test session starts =====\ncollected 5 items\ntest_main.py ..... [100%]\n===== 5 passed in 0.5s =====";
710        assert!(compress_output("pytest", output).is_some());
711        assert!(compress_output("python -m pytest tests/", output).is_some());
712    }
713
714    #[test]
715    fn routes_data_domain() {
716        let dbt =
717            "20:14:02  Found 12 models\n20:14:20  Done. PASS=11 WARN=0 ERROR=1 SKIP=0 TOTAL=12";
718        assert!(
719            compress_output("dbt run", dbt).is_some(),
720            "dbt routed+compressible"
721        );
722        let alembic = "INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.\nINFO  [alembic.runtime.migration] Running upgrade  -> a1b2c3, create users table\nINFO  [alembic.runtime.migration] Running upgrade a1b2c3 -> d4e5f6, add email index";
723        assert!(compress_output("alembic upgrade head", alembic).is_some());
724        let flyway = "Flyway Community Edition 9.22.0 by Redgate\nDatabase: jdbc:postgresql://localhost/db\nMigrating schema \"public\" to version \"5 - add orders\"\nSuccessfully applied 1 migration to schema \"public\", now at version v5";
725        assert!(compress_output("flyway migrate", flyway).is_some());
726        let spark = "23/01/01 12:00:00 INFO SparkContext: Running Spark version 3.4.0\n23/01/01 12:00:01 INFO ResourceUtils: none configured\n23/01/01 12:00:10 INFO DAGScheduler: Job 0 finished: collect, took 5.1 s\n23/01/01 12:00:15 ERROR Executor: Exception in task 0.0";
727        assert!(compress_output("spark-submit app.py", spark).is_some());
728    }
729
730    #[test]
731    fn routes_ai_domain() {
732        let ollama = "NAME              ID              SIZE      MODIFIED\nllama3.2:latest   a80c4f17acd5    2.0 GB    3 days ago\nqwen2.5-coder:7b  2b0496514337    4.7 GB    2 weeks ago";
733        assert!(compress_output("ollama list", ollama).is_some());
734        let mlflow = "2024/01/01 12:00:01 INFO mlflow.projects.backend.local: === Running command 'python train.py' ===\nCollecting numpy==1.26.0\nDownloading numpy-1.26.0.whl (18.2 MB)\n2024/01/01 12:00:30 INFO mlflow.projects: === Run (ID 'abc123def456') succeeded ===";
735        assert!(compress_output("mlflow run .", mlflow).is_some());
736    }
737
738    #[test]
739    fn routes_security_domain() {
740        let trivy = "2024-01-01T12:00:00.000Z\tINFO\tscanning\nnginx:latest (debian 12.1)\n=====\nTotal: 45 (LOW: 20, HIGH: 8, CRITICAL: 2)";
741        assert!(compress_output("trivy image nginx", trivy).is_some());
742        let grype = "NAME       INSTALLED  FIXED-IN  TYPE  VULNERABILITY   SEVERITY\nlibssl1.1  1.1.1n     1.1.1w    deb   CVE-2023-1234   Critical\nzlib1g     1.2.11     1.2.13    deb   CVE-2022-5678   High";
743        assert!(compress_output("grype nginx", grype).is_some());
744        let syft = "NAME       VERSION    TYPE\nadduser    3.118      deb\napt        2.6.1      deb\nlodash     4.17.21    npm";
745        assert!(compress_output("syft nginx", syft).is_some());
746        let semgrep = "Scanning 120 files.\n  src/app.py\n     python.security.dangerous-subprocess-use\n        Detected subprocess.\n         42┆ subprocess.call(x)\nRan 450 rules on 120 files: 1 findings.";
747        assert!(compress_output("semgrep scan", semgrep).is_some());
748        let swiftlint = "Linting Swift files in current working directory\nLinting 'A.swift' (1/3)\nLinting 'B.swift' (2/3)\nLinting 'C.swift' (3/3)\n/path/A.swift:10:5: warning: Line Length Violation: Line should be 120 chars or less (line_length)\n/path/A.swift:22:1: warning: Trailing Whitespace Violation: no trailing whitespace (trailing_whitespace)\n/path/B.swift:5:1: error: Force Cast Violation: avoid force casts (force_cast)\n/path/C.swift:8:3: warning: Todo Violation: resolve TODOs (todo)\nDone linting! Found 4 violations, 1 serious in 3 files.";
749        assert!(compress_output("swiftlint", swiftlint).is_some());
750    }
751
752    #[test]
753    fn routes_vcs_toolchain_domain() {
754        let jj = "@  qpvuntsm user@host.com 2024-01-01 12:00:00 1234abcd\n│  add feature x\n○  zzzzmmmm user@host.com 2024-01-01 11:00:00 main 5678efab\n│  initial commit\n~";
755        assert!(compress_output("jj log", jj).is_some());
756        let mise = "node    20.10.0  ~/.config/mise/config.toml\npython  3.12.0   ~/.tool-versions\nrust    1.75.0   ~/.config/mise/config.toml";
757        assert!(compress_output("mise ls", mise).is_some());
758        let buf_lines: Vec<String> = (0..30)
759            .map(|i| format!("proto/f{i}.proto:{i}:1:Field name should be lower_snake_case here."))
760            .collect();
761        let buf = buf_lines.join("\n");
762        assert!(compress_output("buf lint", &buf).is_some());
763        let gem = "Fetching rails-7.1.0.gem\nSuccessfully installed activesupport-7.1.0\nSuccessfully installed rails-7.1.0\nParsing documentation for rails-7.1.0\nInstalling ri documentation for rails-7.1.0\nDone installing documentation for rails after 3 seconds\n2 gems installed";
764        assert!(compress_output("gem install rails", gem).is_some());
765        let uv = "Resolved 42 packages in 120ms\nDownloading numpy (18.2MiB)\n 100%|████████| 18.2M/18.2M [00:01<00:00, 15.3MiB/s]\nDownloading pandas (12.1MiB)\n 100%|████████| 12.1M/12.1M [00:00<00:00, 14.1MiB/s]\nPrepared 5 packages in 1.2s\nInstalled 5 packages in 30ms\n + numpy==1.26.0\n + pandas==2.1.0";
766        assert!(compress_output("uv add pandas", uv).is_some());
767    }
768
769    #[test]
770    fn routes_edge_infra_domain() {
771        let pulumi = "Updating (dev):\n     Type   Name   Status\n +   pulumi:pulumi:Stack proj created\n +   aws:s3:Bucket b1 created\n +   aws:s3:Bucket b2 created\n +   aws:s3:Bucket b3 created\n +   aws:lambda:Function fn created\n\nOutputs:\n    url: \"https://x.example.com\"\n\nResources:\n    + 5 created\n    10 unchanged\n\nDuration: 35s";
772        assert!(compress_output("pulumi up", pulumi).is_some());
773        let linkerd = "kubernetes-api\n--------------\n√ can initialize the client\n√ can query the Kubernetes API\n√ is running the minimum kubectl version\n\nlinkerd-existence\n-----------------\n√ 'linkerd-config' config map exists\n× control plane pods are ready\n    some pods are not ready\n\nStatus check results are ×";
774        assert!(compress_output("linkerd check", linkerd).is_some());
775        let argocd = "Name:               argocd/myapp\nProject:            default\nSync Status:        Synced\nHealth Status:      Healthy\n\nGROUP  KIND  NAMESPACE  NAME  STATUS  HEALTH  HOOK  MESSAGE\n  Service ns s1 Synced Healthy\n  Service ns s2 Synced Healthy\n  Service ns s3 Synced Healthy\napps Deployment ns d1 OutOfSync Progressing";
776        assert!(compress_output("argocd app get myapp", argocd).is_some());
777        let vercel = "Vercel CLI 33.0.0\nInstalling dependencies...\nadded 420 packages in 12s\nBuilding...\nCompiling pages\nCollecting page data\nGenerating static pages\nProduction: https://my-app.vercel.app [45s]";
778        assert!(compress_output("vercel deploy --prod", vercel).is_some());
779        let wrangler = "wrangler 3.0.0\n-------------------\nyour worker has access to the following bindings:\n- KV Namespaces:\n  - CACHE: abc123\nTotal Upload: 1.2 MiB / gzip: 0.4 MiB\nUploaded my-worker (3.5 sec)\nPublished my-worker (1.2 sec)\n  https://my-worker.example.workers.dev";
780        assert!(compress_output("wrangler deploy", wrangler).is_some());
781    }
782
783    #[test]
784    fn unknown_command_returns_none() {
785        assert!(compress_output("some-unknown-tool --version", "v1.0").is_none());
786    }
787
788    #[test]
789    fn case_insensitive_routing() {
790        let output = "On branch main\nnothing to commit";
791        assert!(compress_output("Git Status", output).is_some());
792        assert!(compress_output("GIT STATUS", output).is_some());
793    }
794
795    #[test]
796    fn routes_vp_and_vite_plus() {
797        let output = "  VITE v5.0.0  ready in 200 ms\n\n  -> Local:   http://localhost:5173/\n  -> Network: http://192.168.1.2:5173/";
798        assert!(compress_output("vp build", output).is_some());
799        assert!(compress_output("vite-plus build", output).is_some());
800    }
801
802    #[test]
803    fn routes_bunx_commands() {
804        let output = "1 pass tests\n0 fail tests\n3 skip tests\nDone 12ms\nsome extra line\nmore output here";
805        let result = compress_output("bunx test", output);
806        assert!(
807            result.is_some(),
808            "bunx should compress when output is large enough"
809        );
810        assert!(result.unwrap().contains("bun test: 1 passed"));
811    }
812
813    #[test]
814    fn routes_deno_task() {
815        let output = "Task dev deno run --allow-net server.ts\nListening on http://localhost:8000";
816        assert!(try_specific_pattern("deno task dev", output).is_some());
817    }
818
819    #[test]
820    fn routes_jest_commands() {
821        let output = "PASS  tests/main.test.js\nTest Suites: 1 passed, 1 total\nTests:       5 passed, 5 total\nTime:        2.5 s";
822        assert!(try_specific_pattern("jest", output).is_some());
823        assert!(try_specific_pattern("npx jest --coverage", output).is_some());
824    }
825
826    #[test]
827    fn routes_mocha_commands() {
828        let output = "  3 passing (50ms)\n  1 failing\n\n  1) Array #indexOf():\n     Error: expected -1 to equal 0";
829        assert!(try_specific_pattern("mocha", output).is_some());
830        assert!(try_specific_pattern("npx mocha tests/", output).is_some());
831    }
832
833    #[test]
834    fn routes_tofu_commands() {
835        let output = "Initializing the backend...\nInitializing provider plugins...\nTerraform has been successfully initialized!";
836        assert!(try_specific_pattern("tofu init", output).is_some());
837    }
838
839    #[test]
840    fn routes_ps_commands() {
841        let mut lines = vec!["USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND".to_string()];
842        for i in 0..20 {
843            lines.push(format!("user {i} 0.0 0.1 1234 123 ? S 10:00 0:00 proc_{i}"));
844        }
845        let output = lines.join("\n");
846        assert!(try_specific_pattern("ps aux", &output).is_some());
847    }
848
849    #[test]
850    fn routes_ping_commands() {
851        let output = "PING google.com (1.2.3.4): 56 data bytes\n64 bytes from 1.2.3.4: icmp_seq=0 ttl=116 time=12ms\n3 packets transmitted, 3 packets received, 0.0% packet loss\nrtt min/avg/max/stddev = 11/12/13/1 ms";
852        assert!(try_specific_pattern("ping -c 3 google.com", output).is_some());
853    }
854
855    #[test]
856    fn routes_jq_to_json_schema() {
857        let output = "{\"name\": \"test\", \"version\": \"1.0\", \"items\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}, {\"id\": 4}, {\"id\": 5}, {\"id\": 6}, {\"id\": 7}, {\"id\": 8}, {\"id\": 9}, {\"id\": 10}]}";
858        assert!(try_specific_pattern("jq '.items' data.json", output).is_some());
859    }
860
861    #[test]
862    fn routes_linting_tools() {
863        let lint_output = "src/main.py:10: error: Missing return\nsrc/main.py:20: error: Unused var\nFound 2 errors";
864        assert!(try_specific_pattern("hadolint Dockerfile", lint_output).is_some());
865        assert!(try_specific_pattern("oxlint src/", lint_output).is_some());
866        assert!(try_specific_pattern("pyright src/", lint_output).is_some());
867        assert!(try_specific_pattern("basedpyright src/", lint_output).is_some());
868    }
869
870    #[test]
871    fn routes_fd_commands() {
872        let output = "src/main.rs\nsrc/lib.rs\nsrc/util/helpers.rs\nsrc/util/math.rs\ntests/integration.rs\n";
873        assert!(try_specific_pattern("fd --extension rs", output).is_some());
874        assert!(try_specific_pattern("fdfind .rs", output).is_some());
875    }
876
877    #[test]
878    fn routes_just_commands() {
879        let output = "Available recipes:\n    build\n    test\n    lint\n";
880        assert!(try_specific_pattern("just --list", output).is_some());
881        assert!(try_specific_pattern("just build", output).is_some());
882    }
883
884    #[test]
885    fn routes_ninja_commands() {
886        let output = "[1/10] Compiling foo.c\n[10/10] Linking app\n";
887        assert!(try_specific_pattern("ninja", output).is_some());
888        assert!(try_specific_pattern("ninja -j4", output).is_some());
889    }
890
891    #[test]
892    fn routes_clang_commands() {
893        let output =
894            "src/main.c:10:5: error: use of undeclared identifier 'foo'\n1 error generated.\n";
895        assert!(try_specific_pattern("clang src/main.c", output).is_some());
896        assert!(try_specific_pattern("clang++ -std=c++17 main.cpp", output).is_some());
897    }
898
899    #[test]
900    fn routes_cargo_run() {
901        let output = "   Compiling foo v0.1.0\n    Finished `dev` profile\nHello, world!";
902        assert!(try_specific_pattern("cargo run", output).is_some());
903    }
904
905    #[test]
906    fn routes_cargo_bench() {
907        let output = "   Compiling foo v0.1.0\ntest bench_parse ... bench: 1234 ns/iter";
908        assert!(try_specific_pattern("cargo bench", output).is_some());
909    }
910
911    #[test]
912    fn routes_build_tools() {
913        let build_output = "   Compiling foo v0.1.0\n    Finished release [optimized]";
914        assert!(try_specific_pattern("gcc -o main main.c", build_output).is_some());
915        assert!(try_specific_pattern("g++ -o main main.cpp", build_output).is_some());
916    }
917
918    #[test]
919    fn routes_monorepo_tools() {
920        let output = "npm warn deprecated inflight@1.0.6\nnpm warn deprecated rimraf@3.0.2\nadded 150 packages, and audited 151 packages in 5s\n\n25 packages are looking for funding\n  run `npm fund` for details\n\nfound 0 vulnerabilities";
921        assert!(try_specific_pattern("turbo install", output).is_some());
922        assert!(try_specific_pattern("nx install", output).is_some());
923    }
924
925    #[test]
926    fn gh_api_passthrough_never_compresses() {
927        let huge = "line\n".repeat(5000);
928        assert!(
929            compress_output("gh api repos/owner/repo/actions/jobs/123/logs", &huge).is_none(),
930            "gh api must never be compressed, even for large output"
931        );
932        assert!(compress_output("gh api repos/owner/repo/actions/runs/123/logs", &huge).is_none());
933    }
934
935    #[test]
936    fn gh_log_flags_passthrough() {
937        let huge = "line\n".repeat(5000);
938        assert!(compress_output("gh run view 123 --log-failed", &huge).is_none());
939        assert!(compress_output("gh run view 123 --log", &huge).is_none());
940    }
941
942    #[test]
943    fn gh_structured_commands_still_compress() {
944        let output = "On branch main\nnothing to commit";
945        assert!(try_specific_pattern("gh pr list", output).is_some());
946        assert!(try_specific_pattern("gh run list", output).is_some());
947    }
948}