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.
4pub const PATTERN_ENGINE_VERSION: u32 = 1;
5
6pub mod pattern_trait;
7pub use pattern_trait::{CompressionPattern, CompressionResult};
8
9pub mod ansible;
10pub mod artisan;
11pub mod aws;
12pub mod bazel;
13pub mod bun;
14pub mod cargo;
15pub mod clang;
16pub mod cmake;
17pub mod composer;
18pub mod curl;
19pub mod deno;
20pub mod deps_cmd;
21pub mod docker;
22pub mod dotnet;
23pub mod env_filter;
24pub mod eslint;
25pub mod fd;
26pub mod find;
27pub mod flutter;
28pub mod gh;
29pub mod git;
30pub mod glab;
31pub mod golang;
32pub mod grep;
33pub mod helm;
34pub mod json_schema;
35pub mod just;
36pub mod kubectl;
37pub mod log_dedup;
38pub mod ls;
39pub mod make;
40pub mod maven;
41pub mod mix;
42pub mod mypy;
43pub mod mysql;
44pub mod next_build;
45pub mod ninja;
46pub mod npm;
47pub mod php;
48pub mod pip;
49pub mod playwright;
50pub mod pnpm;
51pub mod poetry;
52pub mod prettier;
53pub mod prisma;
54pub mod psql;
55pub mod pytest;
56pub mod ruby;
57pub mod ruff;
58pub mod swift;
59pub mod sysinfo;
60pub mod systemd;
61pub mod terraform;
62pub mod test;
63pub mod typescript;
64pub mod wget;
65pub mod zig;
66
67use crate::core::tokens::count_tokens;
68
69pub fn compress_output(command: &str, output: &str) -> Option<String> {
70    // Policy gate: protected commands (Passthrough + Verbatim) must
71    // never be pattern-compressed. The caller (compress_if_beneficial
72    // or ctx_shell::handle) should have already checked, but we
73    // defend in depth here.
74    let policy = crate::shell::output_policy::classify(command, &[]);
75    if policy.is_protected() {
76        return None;
77    }
78
79    let stripped;
80    let clean_output = {
81        let s = crate::core::compressor::strip_ansi(output);
82        if s.len() < output.len() {
83            stripped = s;
84            &stripped
85        } else {
86            output
87        }
88    };
89
90    if let Some(engine) = crate::core::filters::FilterEngine::load() {
91        if let Some(filtered) = engine.apply(command, clean_output) {
92            return shorter_only(filtered, output);
93        }
94    }
95
96    if let Some(compressed) = try_specific_pattern(command, clean_output) {
97        if let Some(r) = shorter_only(compressed, output) {
98            return Some(r);
99        }
100    }
101
102    if let Some(r) = json_schema::compress(clean_output) {
103        if let Some(r) = shorter_only(r, output) {
104            return Some(r);
105        }
106    }
107
108    if let Some(r) = log_dedup::compress(clean_output) {
109        if let Some(r) = shorter_only(r, output) {
110            return Some(r);
111        }
112    }
113
114    if let Some(r) = test::compress(clean_output) {
115        if let Some(r) = shorter_only(r, output) {
116            return Some(r);
117        }
118    }
119
120    None
121}
122
123/// Collapse whitespace into single spaces so comparisons align with logical word tokens.
124fn normalize_shell_tokens(text: &str) -> String {
125    text.split_whitespace().collect::<Vec<_>>().join(" ")
126}
127
128fn shorter_only(compressed: String, original: &str) -> Option<String> {
129    let orig_n = normalize_shell_tokens(original);
130    let comp_n = normalize_shell_tokens(&compressed);
131    let ct_c = count_tokens(&comp_n);
132    let ct_o = count_tokens(&orig_n);
133    if ct_c < ct_o || (ct_c == ct_o && comp_n.len() < orig_n.len()) {
134        Some(compressed)
135    } else {
136        None
137    }
138}
139
140pub fn try_specific_pattern(cmd: &str, output: &str) -> Option<String> {
141    let cl = cmd.to_ascii_lowercase();
142    let c = cl.as_str();
143
144    if c.starts_with("git ") {
145        return git::compress(c, output);
146    }
147    if c.starts_with("gh ") {
148        return gh::compress(c, output);
149    }
150    if c.starts_with("glab ") {
151        return glab::try_glab_pattern(c, output);
152    }
153    if c == "terraform" || c.starts_with("terraform ") {
154        return terraform::compress(c, output);
155    }
156    if c == "make" || c.starts_with("make ") {
157        return make::compress(c, output);
158    }
159    if c == "just" || c.starts_with("just ") {
160        return just::compress(c, output);
161    }
162    if c.starts_with("mvn ")
163        || c.starts_with("./mvnw ")
164        || c.starts_with("mvnw ")
165        || c.starts_with("gradle ")
166        || c.starts_with("./gradlew ")
167        || c.starts_with("gradlew ")
168    {
169        return maven::compress(c, output);
170    }
171    if c.starts_with("kubectl ") || c.starts_with("k ") {
172        return kubectl::compress(c, output);
173    }
174    if c.starts_with("helm ") {
175        return helm::compress(c, output);
176    }
177    if c.starts_with("pnpm ") {
178        return pnpm::compress(c, output);
179    }
180    if c.starts_with("bun ") || c.starts_with("bunx ") {
181        return bun::compress(c, output);
182    }
183    if c.starts_with("deno ") {
184        return deno::compress(c, output);
185    }
186    if c.starts_with("npm ") || c.starts_with("yarn ") {
187        return npm::compress(c, output);
188    }
189    if c.starts_with("cargo ") {
190        return cargo::compress(c, output);
191    }
192    if c.starts_with("docker ") || c.starts_with("docker-compose ") {
193        return docker::compress(c, output);
194    }
195    if c.starts_with("pip ") || c.starts_with("pip3 ") || c.starts_with("python -m pip") {
196        return pip::compress(c, output);
197    }
198    if c.starts_with("mypy") || c.starts_with("python -m mypy") || c.starts_with("dmypy ") {
199        return mypy::compress(c, output);
200    }
201    if c.starts_with("pytest") || c.starts_with("python -m pytest") {
202        return pytest::compress(c, output).or_else(|| test::compress(output));
203    }
204    if c.starts_with("ruff ") {
205        return ruff::compress(c, output);
206    }
207    if c.starts_with("eslint")
208        || c.starts_with("npx eslint")
209        || c.starts_with("biome ")
210        || c.starts_with("stylelint")
211    {
212        return eslint::compress(c, output);
213    }
214    if c.starts_with("prettier") || c.starts_with("npx prettier") {
215        return prettier::compress(output);
216    }
217    if c.starts_with("go ") || c.starts_with("golangci-lint") || c.starts_with("golint") {
218        return golang::compress(c, output);
219    }
220    if c.starts_with("playwright")
221        || c.starts_with("npx playwright")
222        || c.starts_with("cypress")
223        || c.starts_with("npx cypress")
224    {
225        return playwright::compress(c, output);
226    }
227    if c.starts_with("vitest") || c.starts_with("npx vitest") || c.starts_with("pnpm vitest") {
228        return test::compress(output);
229    }
230    if c.starts_with("next ")
231        || c.starts_with("npx next")
232        || c.starts_with("vite ")
233        || c.starts_with("npx vite")
234        || c.starts_with("vp ")
235        || c.starts_with("vite-plus ")
236    {
237        return next_build::compress(c, output);
238    }
239    if c.starts_with("tsc") || c.contains("typescript") {
240        return typescript::compress(output);
241    }
242    if c.starts_with("rubocop")
243        || c.starts_with("bundle ")
244        || c.starts_with("rake ")
245        || c.starts_with("rails test")
246        || c.starts_with("rspec")
247    {
248        return ruby::compress(c, output);
249    }
250    if c.starts_with("grep ") || c.starts_with("rg ") {
251        return grep::compress(output);
252    }
253    if c.starts_with("find ") {
254        return find::compress(output);
255    }
256    if c.starts_with("fd ") || c.starts_with("fdfind ") {
257        return fd::compress(output);
258    }
259    if c.starts_with("ls ") || c == "ls" {
260        return ls::compress(output);
261    }
262    if c.starts_with("curl ") {
263        return curl::compress_with_cmd(c, output);
264    }
265    if c.starts_with("wget ") {
266        return wget::compress(output);
267    }
268    if c == "env" || c.starts_with("env ") || c.starts_with("printenv") {
269        return env_filter::compress(output);
270    }
271    if c.starts_with("dotnet ") {
272        return dotnet::compress(c, output);
273    }
274    if c.starts_with("flutter ")
275        || (c.starts_with("dart ") && (c.contains(" analyze") || c.ends_with(" analyze")))
276    {
277        return flutter::compress(c, output);
278    }
279    if c.starts_with("poetry ")
280        || c.starts_with("uv sync")
281        || (c.starts_with("uv ") && c.contains("pip install"))
282        || c.starts_with("conda ")
283        || c.starts_with("mamba ")
284        || c.starts_with("pipx ")
285    {
286        return poetry::compress(c, output);
287    }
288    if c.starts_with("aws ") {
289        return aws::compress(c, output);
290    }
291    if c.starts_with("psql ") || c.starts_with("pg_") {
292        return psql::compress(c, output);
293    }
294    if c.starts_with("mysql ") || c.starts_with("mariadb ") {
295        return mysql::compress(c, output);
296    }
297    if c.starts_with("prisma ") || c.starts_with("npx prisma") {
298        return prisma::compress(c, output);
299    }
300    if c.starts_with("swift ") {
301        return swift::compress(c, output);
302    }
303    if c.starts_with("zig ") {
304        return zig::compress(c, output);
305    }
306    if c.starts_with("cmake ") || c.starts_with("ctest") {
307        return cmake::compress(c, output);
308    }
309    if c.starts_with("ninja") {
310        return ninja::compress(c, output);
311    }
312    if c.starts_with("ansible") || c.starts_with("ansible-playbook") {
313        return ansible::compress(c, output);
314    }
315    if c.starts_with("composer ") {
316        return composer::compress(c, output);
317    }
318    if c.starts_with("php artisan") || c.starts_with("artisan ") {
319        return artisan::compress(c, output);
320    }
321    if c.starts_with("./vendor/bin/pest") || c.starts_with("pest ") {
322        return artisan::compress("php artisan test", output);
323    }
324    if c.starts_with("mix ") || c.starts_with("iex ") {
325        return mix::compress(c, output);
326    }
327    if c.starts_with("bazel ") || c.starts_with("blaze ") {
328        return bazel::compress(c, output);
329    }
330    if c.starts_with("systemctl ") || c.starts_with("journalctl") {
331        return systemd::compress(c, output);
332    }
333    if c.starts_with("jest") || c.starts_with("npx jest") || c.starts_with("pnpm jest") {
334        return test::compress(output);
335    }
336    if c.starts_with("mocha") || c.starts_with("npx mocha") {
337        return test::compress(output);
338    }
339    if c.starts_with("tofu ") {
340        return terraform::compress(c, output);
341    }
342    if c.starts_with("ps ") || c == "ps" {
343        return sysinfo::compress_ps(output);
344    }
345    if c.starts_with("df ") || c == "df" {
346        return sysinfo::compress_df(output);
347    }
348    if c.starts_with("du ") || c == "du" {
349        return sysinfo::compress_du(output);
350    }
351    if c.starts_with("ping ") {
352        return sysinfo::compress_ping(output);
353    }
354    if c.starts_with("jq ") || c == "jq" {
355        return json_schema::compress(output);
356    }
357    if c.starts_with("hadolint") {
358        return eslint::compress(c, output);
359    }
360    if c.starts_with("yamllint") || c.starts_with("npx yamllint") {
361        return eslint::compress(c, output);
362    }
363    if c.starts_with("markdownlint") || c.starts_with("npx markdownlint") {
364        return eslint::compress(c, output);
365    }
366    if c.starts_with("oxlint") || c.starts_with("npx oxlint") {
367        return eslint::compress(c, output);
368    }
369    if c.starts_with("pyright") || c.starts_with("basedpyright") {
370        return mypy::compress(c, output);
371    }
372    if c.starts_with("turbo ") || c.starts_with("npx turbo") {
373        return npm::compress(c, output);
374    }
375    if c.starts_with("nx ") || c.starts_with("npx nx") {
376        return npm::compress(c, output);
377    }
378    if c.starts_with("clang++ ") || c.starts_with("clang ") {
379        return clang::compress(c, output);
380    }
381    if c.starts_with("gcc ")
382        || c.starts_with("g++ ")
383        || c.starts_with("cc ")
384        || c.starts_with("c++ ")
385    {
386        return cmake::compress(c, output);
387    }
388
389    None
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn routes_git_commands() {
398        let output = "On branch main\nnothing to commit";
399        assert!(compress_output("git status", output).is_some());
400    }
401
402    #[test]
403    fn routes_cargo_commands() {
404        let output = "   Compiling lean-ctx v2.1.1\n    Finished `release` profile [optimized] target(s) in 30.5s";
405        assert!(compress_output("cargo build --release", output).is_some());
406    }
407
408    #[test]
409    fn routes_npm_commands() {
410        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";
411        assert!(compress_output("npm install", output).is_some());
412    }
413
414    #[test]
415    fn routes_docker_commands() {
416        let output = "CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES";
417        // docker ps is Verbatim (via is_container_listing), so compress_output
418        // correctly returns None (policy gate). docker build should still compress.
419        assert!(compress_output("docker ps", output).is_none());
420        let build_output =
421            "Step 1/5 : FROM node:18\n ---> abc123\nStep 2/5 : COPY . .\nSuccessfully built def456";
422        assert!(compress_output("docker build .", build_output).is_some());
423    }
424
425    #[test]
426    fn routes_mypy_commands() {
427        let output = "src/main.py:10: error: Missing return  [return]\nFound 1 error in 1 file (checked 3 source files)";
428        assert!(compress_output("mypy .", output).is_some());
429        assert!(compress_output("python -m mypy src/", output).is_some());
430    }
431
432    #[test]
433    fn routes_pytest_commands() {
434        let output = "===== test session starts =====\ncollected 5 items\ntest_main.py ..... [100%]\n===== 5 passed in 0.5s =====";
435        assert!(compress_output("pytest", output).is_some());
436        assert!(compress_output("python -m pytest tests/", output).is_some());
437    }
438
439    #[test]
440    fn unknown_command_returns_none() {
441        assert!(compress_output("some-unknown-tool --version", "v1.0").is_none());
442    }
443
444    #[test]
445    fn case_insensitive_routing() {
446        let output = "On branch main\nnothing to commit";
447        assert!(compress_output("Git Status", output).is_some());
448        assert!(compress_output("GIT STATUS", output).is_some());
449    }
450
451    #[test]
452    fn routes_vp_and_vite_plus() {
453        let output = "  VITE v5.0.0  ready in 200 ms\n\n  -> Local:   http://localhost:5173/\n  -> Network: http://192.168.1.2:5173/";
454        assert!(compress_output("vp build", output).is_some());
455        assert!(compress_output("vite-plus build", output).is_some());
456    }
457
458    #[test]
459    fn routes_bunx_commands() {
460        let output = "1 pass tests\n0 fail tests\n3 skip tests\nDone 12ms\nsome extra line\nmore output here";
461        let result = compress_output("bunx test", output);
462        assert!(
463            result.is_some(),
464            "bunx should compress when output is large enough"
465        );
466        assert!(result.unwrap().contains("bun test: 1 passed"));
467    }
468
469    #[test]
470    fn routes_deno_task() {
471        let output = "Task dev deno run --allow-net server.ts\nListening on http://localhost:8000";
472        assert!(try_specific_pattern("deno task dev", output).is_some());
473    }
474
475    #[test]
476    fn routes_jest_commands() {
477        let output = "PASS  tests/main.test.js\nTest Suites: 1 passed, 1 total\nTests:       5 passed, 5 total\nTime:        2.5 s";
478        assert!(try_specific_pattern("jest", output).is_some());
479        assert!(try_specific_pattern("npx jest --coverage", output).is_some());
480    }
481
482    #[test]
483    fn routes_mocha_commands() {
484        let output = "  3 passing (50ms)\n  1 failing\n\n  1) Array #indexOf():\n     Error: expected -1 to equal 0";
485        assert!(try_specific_pattern("mocha", output).is_some());
486        assert!(try_specific_pattern("npx mocha tests/", output).is_some());
487    }
488
489    #[test]
490    fn routes_tofu_commands() {
491        let output = "Initializing the backend...\nInitializing provider plugins...\nTerraform has been successfully initialized!";
492        assert!(try_specific_pattern("tofu init", output).is_some());
493    }
494
495    #[test]
496    fn routes_ps_commands() {
497        let mut lines = vec!["USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND".to_string()];
498        for i in 0..20 {
499            lines.push(format!("user {i} 0.0 0.1 1234 123 ? S 10:00 0:00 proc_{i}"));
500        }
501        let output = lines.join("\n");
502        assert!(try_specific_pattern("ps aux", &output).is_some());
503    }
504
505    #[test]
506    fn routes_ping_commands() {
507        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";
508        assert!(try_specific_pattern("ping -c 3 google.com", output).is_some());
509    }
510
511    #[test]
512    fn routes_jq_to_json_schema() {
513        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}]}";
514        assert!(try_specific_pattern("jq '.items' data.json", output).is_some());
515    }
516
517    #[test]
518    fn routes_linting_tools() {
519        let lint_output = "src/main.py:10: error: Missing return\nsrc/main.py:20: error: Unused var\nFound 2 errors";
520        assert!(try_specific_pattern("hadolint Dockerfile", lint_output).is_some());
521        assert!(try_specific_pattern("oxlint src/", lint_output).is_some());
522        assert!(try_specific_pattern("pyright src/", lint_output).is_some());
523        assert!(try_specific_pattern("basedpyright src/", lint_output).is_some());
524    }
525
526    #[test]
527    fn routes_fd_commands() {
528        let output = "src/main.rs\nsrc/lib.rs\nsrc/util/helpers.rs\nsrc/util/math.rs\ntests/integration.rs\n";
529        assert!(try_specific_pattern("fd --extension rs", output).is_some());
530        assert!(try_specific_pattern("fdfind .rs", output).is_some());
531    }
532
533    #[test]
534    fn routes_just_commands() {
535        let output = "Available recipes:\n    build\n    test\n    lint\n";
536        assert!(try_specific_pattern("just --list", output).is_some());
537        assert!(try_specific_pattern("just build", output).is_some());
538    }
539
540    #[test]
541    fn routes_ninja_commands() {
542        let output = "[1/10] Compiling foo.c\n[10/10] Linking app\n";
543        assert!(try_specific_pattern("ninja", output).is_some());
544        assert!(try_specific_pattern("ninja -j4", output).is_some());
545    }
546
547    #[test]
548    fn routes_clang_commands() {
549        let output =
550            "src/main.c:10:5: error: use of undeclared identifier 'foo'\n1 error generated.\n";
551        assert!(try_specific_pattern("clang src/main.c", output).is_some());
552        assert!(try_specific_pattern("clang++ -std=c++17 main.cpp", output).is_some());
553    }
554
555    #[test]
556    fn routes_cargo_run() {
557        let output = "   Compiling foo v0.1.0\n    Finished `dev` profile\nHello, world!";
558        assert!(try_specific_pattern("cargo run", output).is_some());
559    }
560
561    #[test]
562    fn routes_cargo_bench() {
563        let output = "   Compiling foo v0.1.0\ntest bench_parse ... bench: 1234 ns/iter";
564        assert!(try_specific_pattern("cargo bench", output).is_some());
565    }
566
567    #[test]
568    fn routes_build_tools() {
569        let build_output = "   Compiling foo v0.1.0\n    Finished release [optimized]";
570        assert!(try_specific_pattern("gcc -o main main.c", build_output).is_some());
571        assert!(try_specific_pattern("g++ -o main main.cpp", build_output).is_some());
572    }
573
574    #[test]
575    fn routes_monorepo_tools() {
576        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";
577        assert!(try_specific_pattern("turbo install", output).is_some());
578        assert!(try_specific_pattern("nx install", output).is_some());
579    }
580
581    #[test]
582    fn gh_api_passthrough_never_compresses() {
583        let huge = "line\n".repeat(5000);
584        assert!(
585            compress_output("gh api repos/owner/repo/actions/jobs/123/logs", &huge).is_none(),
586            "gh api must never be compressed, even for large output"
587        );
588        assert!(compress_output("gh api repos/owner/repo/actions/runs/123/logs", &huge).is_none());
589    }
590
591    #[test]
592    fn gh_log_flags_passthrough() {
593        let huge = "line\n".repeat(5000);
594        assert!(compress_output("gh run view 123 --log-failed", &huge).is_none());
595        assert!(compress_output("gh run view 123 --log", &huge).is_none());
596    }
597
598    #[test]
599    fn gh_structured_commands_still_compress() {
600        let output = "On branch main\nnothing to commit";
601        assert!(try_specific_pattern("gh pr list", output).is_some());
602        assert!(try_specific_pattern("gh run list", output).is_some());
603    }
604}