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