Skip to main content

lean_ctx/core/patterns/
mod.rs

1//! Shell-output compression patterns.
2//!
3//! The patterns themselves live in the `lean-ctx-patterns` crate and are
4//! re-exported here unchanged. This facade adds what needs the engine: the
5//! protected-command policy gate, the leading-`cd` unwrap (shell segmenting),
6//! the user filter rules and the configured curl passthrough URLs.
7
8pub use lean_ctx_patterns::*;
9
10pub mod tool_output;
11
12/// The class the output of `command` is admitted with (pz21 §1.8): an
13/// outside origin for a command whose content comes from outside the
14/// repository ([`tool_output::classify`]), else the project's own output.
15pub fn output_class(command: &str) -> crate::core::context_admission::origin::SourceClass {
16    tool_output::classify(command).map_or(
17        crate::core::context_admission::origin::SourceClass::Project,
18        |found| found.class(),
19    )
20}
21
22/// Compress `output` of `command` with its pattern, or `None` when no pattern
23/// applies or none makes it shorter. Protected commands are never compressed.
24pub fn compress_output(command: &str, output: &str) -> Option<String> {
25    // Policy gate: protected commands (Passthrough + Verbatim) must
26    // never be pattern-compressed. The caller (compress_if_beneficial
27    // or ctx_shell::handle) should have already checked, but we
28    // defend in depth here.
29    let policy = crate::shell::output_policy::classify(command, &[]);
30    if policy.is_protected() {
31        return None;
32    }
33    let command = strip_leading_cd(command);
34
35    let stripped;
36    let clean_output = {
37        let s = crate::core::compressor::strip_ansi(output);
38        if s.len() < output.len() {
39            stripped = s;
40            &stripped
41        } else {
42            output
43        }
44    };
45
46    if let Some(engine) = crate::core::filters::FilterEngine::load()
47        && let Some(filtered) = engine.apply(command, clean_output)
48    {
49        return shorter_only(filtered, output);
50    }
51
52    compress_clean(&EngineEnv, command, clean_output, output)
53}
54
55/// Route `cmd` to its dedicated compressor, with the engine's configuration.
56pub fn try_specific_pattern(cmd: &str, output: &str) -> Option<String> {
57    try_specific_pattern_with(&EngineEnv, cmd, output)
58}
59
60/// `cd <dir> && <cmd>` produces exactly `<cmd>`'s output, so it must hit the
61/// same pattern as `<cmd>` (and as `git -C <dir> …`) instead of falling through
62/// to the generic truncation (#1893). Only that exact two-segment shape is
63/// unwrapped; anything longer mixes outputs and is left alone.
64pub(crate) fn strip_leading_cd(command: &str) -> &str {
65    use crate::core::shell_allowlist::Separator;
66    let segments = crate::core::shell_allowlist::segments_with_separators(command);
67    let [(first, Some(Separator::And)), (rest, None)] = segments.as_slice() else {
68        return command;
69    };
70    let is_plain_cd = first
71        .strip_prefix("cd ")
72        .is_some_and(|dir| !dir.trim().is_empty() && !dir.contains(['$', '`', '(']));
73    if !is_plain_cd {
74        return command;
75    }
76    command
77        .rfind(rest.as_str())
78        .map_or(command, |at| &command[at..])
79}
80
81/// The engine's [`PatternEnv`]: configuration is loaded only when a pattern
82/// asks, so commands without such a question pay nothing.
83struct EngineEnv;
84
85impl PatternEnv for EngineEnv {
86    fn is_curl_passthrough(&self, command: &str) -> bool {
87        let cfg = crate::core::config::Config::load();
88        curl::is_passthrough(command, &cfg.passthrough_urls)
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::core::tokens::count_tokens;
96
97    #[test]
98    fn routes_git_commands() {
99        let output = "On branch main\nnothing to commit";
100        assert!(compress_output("git status", output).is_some());
101    }
102
103    #[test]
104    fn vcs_path_never_inflates_tokens() {
105        // Regression for the `compress_output_never_inflates_tokens` property
106        // (Windows CI): the VCS branch returned the git compressor's reshaped
107        // output without the `shorter_only` guard the other paths use, so this
108        // tiny adversarial `git status` body grew 10 -> 11 tokens. The result
109        // must now never tokenize larger than its input (None == use original).
110        let output = " Abu a\nAa aa_A00A\n\n-";
111        if let Some(compressed) = compress_output("git status", output) {
112            assert!(
113                count_tokens(&compressed) <= count_tokens(output),
114                "VCS compress inflated: {} > {}",
115                count_tokens(&compressed),
116                count_tokens(output),
117            );
118        }
119    }
120
121    #[test]
122    fn routes_cargo_commands() {
123        let output = "   Compiling lean-ctx v2.1.1\n    Finished `release` profile [optimized] target(s) in 30.5s";
124        assert!(compress_output("cargo build --release", output).is_some());
125    }
126
127    #[test]
128    fn routes_npm_commands() {
129        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";
130        assert!(compress_output("npm install", output).is_some());
131    }
132
133    #[test]
134    fn routes_docker_commands() {
135        let output = "CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES";
136        // docker ps is Verbatim (via is_container_listing), so compress_output
137        // correctly returns None (policy gate). docker build should still compress.
138        assert!(compress_output("docker ps", output).is_none());
139        let build_output =
140            "Step 1/5 : FROM node:18\n ---> abc123\nStep 2/5 : COPY . .\nSuccessfully built def456";
141        assert!(compress_output("docker build .", build_output).is_some());
142    }
143
144    #[test]
145    fn routes_mypy_commands() {
146        let output = "src/main.py:10: error: Missing return  [return]\nFound 1 error in 1 file (checked 3 source files)";
147        assert!(compress_output("mypy .", output).is_some());
148        assert!(compress_output("python -m mypy src/", output).is_some());
149    }
150
151    #[test]
152    fn routes_pytest_commands() {
153        let output = "===== test session starts =====\ncollected 5 items\ntest_main.py ..... [100%]\n===== 5 passed in 0.5s =====";
154        assert!(compress_output("pytest", output).is_some());
155        assert!(compress_output("python -m pytest tests/", output).is_some());
156    }
157
158    #[test]
159    fn routes_data_domain() {
160        let dbt =
161            "20:14:02  Found 12 models\n20:14:20  Done. PASS=11 WARN=0 ERROR=1 SKIP=0 TOTAL=12";
162        assert!(
163            compress_output("dbt run", dbt).is_some(),
164            "dbt routed+compressible"
165        );
166        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";
167        assert!(compress_output("alembic upgrade head", alembic).is_some());
168        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";
169        assert!(compress_output("flyway migrate", flyway).is_some());
170        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";
171        assert!(compress_output("spark-submit app.py", spark).is_some());
172    }
173
174    #[test]
175    fn routes_ai_domain() {
176        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";
177        assert!(compress_output("ollama list", ollama).is_some());
178        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 ===";
179        assert!(compress_output("mlflow run .", mlflow).is_some());
180    }
181
182    #[test]
183    fn routes_security_domain() {
184        let trivy = "2024-01-01T12:00:00.000Z\tINFO\tscanning\nnginx:latest (debian 12.1)\n=====\nTotal: 45 (LOW: 20, HIGH: 8, CRITICAL: 2)";
185        assert!(compress_output("trivy image nginx", trivy).is_some());
186        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";
187        assert!(compress_output("grype nginx", grype).is_some());
188        let syft = "NAME       VERSION    TYPE\nadduser    3.118      deb\napt        2.6.1      deb\nlodash     4.17.21    npm";
189        assert!(compress_output("syft nginx", syft).is_some());
190        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.";
191        assert!(compress_output("semgrep scan", semgrep).is_some());
192        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.";
193        assert!(compress_output("swiftlint", swiftlint).is_some());
194    }
195
196    #[test]
197    fn routes_vcs_toolchain_domain() {
198        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~";
199        assert!(compress_output("jj log", jj).is_some());
200        let mise = "node    20.10.0  ~/.config/mise/config.toml\npython  3.12.0   ~/.tool-versions\nrust    1.75.0   ~/.config/mise/config.toml";
201        assert!(compress_output("mise ls", mise).is_some());
202        let buf_lines: Vec<String> = (0..30)
203            .map(|i| format!("proto/f{i}.proto:{i}:1:Field name should be lower_snake_case here."))
204            .collect();
205        let buf = buf_lines.join("\n");
206        assert!(compress_output("buf lint", &buf).is_some());
207        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";
208        assert!(compress_output("gem install rails", gem).is_some());
209        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";
210        assert!(compress_output("uv add pandas", uv).is_some());
211    }
212
213    #[test]
214    fn routes_edge_infra_domain() {
215        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";
216        assert!(compress_output("pulumi up", pulumi).is_some());
217        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 ×";
218        assert!(compress_output("linkerd check", linkerd).is_some());
219        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";
220        assert!(compress_output("argocd app get myapp", argocd).is_some());
221        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]";
222        assert!(compress_output("vercel deploy --prod", vercel).is_some());
223        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";
224        assert!(compress_output("wrangler deploy", wrangler).is_some());
225    }
226
227    #[test]
228    fn unknown_command_returns_none() {
229        assert!(compress_output("some-unknown-tool --version", "v1.0").is_none());
230    }
231
232    #[test]
233    fn case_insensitive_routing() {
234        let output = "On branch main\nnothing to commit";
235        assert!(compress_output("Git Status", output).is_some());
236        assert!(compress_output("GIT STATUS", output).is_some());
237    }
238
239    #[test]
240    fn routes_vp_and_vite_plus() {
241        let output = "  VITE v5.0.0  ready in 200 ms\n\n  -> Local:   http://localhost:5173/\n  -> Network: http://192.168.1.2:5173/";
242        assert!(compress_output("vp build", output).is_some());
243        assert!(compress_output("vite-plus build", output).is_some());
244    }
245
246    #[test]
247    fn routes_bunx_commands() {
248        let output = "1 pass tests\n0 fail tests\n3 skip tests\nDone 12ms\nsome extra line\nmore output here";
249        let result = compress_output("bunx test", output);
250        assert!(
251            result.is_some(),
252            "bunx should compress when output is large enough"
253        );
254        assert!(result.unwrap().contains("bun test: 1 passed"));
255    }
256
257    #[test]
258    fn routes_deno_task() {
259        let output = "Task dev deno run --allow-net server.ts\nListening on http://localhost:8000";
260        assert!(try_specific_pattern("deno task dev", output).is_some());
261    }
262
263    #[test]
264    fn routes_jest_commands() {
265        let output = "PASS  tests/main.test.js\nTest Suites: 1 passed, 1 total\nTests:       5 passed, 5 total\nTime:        2.5 s";
266        assert!(try_specific_pattern("jest", output).is_some());
267        assert!(try_specific_pattern("npx jest --coverage", output).is_some());
268    }
269
270    #[test]
271    fn routes_mocha_commands() {
272        let output = "  3 passing (50ms)\n  1 failing\n\n  1) Array #indexOf():\n     Error: expected -1 to equal 0";
273        assert!(try_specific_pattern("mocha", output).is_some());
274        assert!(try_specific_pattern("npx mocha tests/", output).is_some());
275    }
276
277    #[test]
278    fn routes_tofu_commands() {
279        let output = "Initializing the backend...\nInitializing provider plugins...\nTerraform has been successfully initialized!";
280        assert!(try_specific_pattern("tofu init", output).is_some());
281    }
282
283    #[test]
284    fn routes_ps_commands() {
285        let mut lines = vec!["USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND".to_string()];
286        for i in 0..20 {
287            lines.push(format!("user {i} 0.0 0.1 1234 123 ? S 10:00 0:00 proc_{i}"));
288        }
289        let output = lines.join("\n");
290        assert!(try_specific_pattern("ps aux", &output).is_some());
291    }
292
293    #[test]
294    fn routes_ping_commands() {
295        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";
296        assert!(try_specific_pattern("ping -c 3 google.com", output).is_some());
297    }
298
299    #[test]
300    fn routes_jq_to_json_schema() {
301        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}]}";
302        assert!(try_specific_pattern("jq '.items' data.json", output).is_some());
303    }
304
305    #[test]
306    fn routes_linting_tools() {
307        let lint_output = "src/main.py:10: error: Missing return\nsrc/main.py:20: error: Unused var\nFound 2 errors";
308        assert!(try_specific_pattern("hadolint Dockerfile", lint_output).is_some());
309        assert!(try_specific_pattern("oxlint src/", lint_output).is_some());
310        assert!(try_specific_pattern("pyright src/", lint_output).is_some());
311        assert!(try_specific_pattern("basedpyright src/", lint_output).is_some());
312    }
313
314    #[test]
315    fn routes_fd_commands() {
316        let output = "src/main.rs\nsrc/lib.rs\nsrc/util/helpers.rs\nsrc/util/math.rs\ntests/integration.rs\n";
317        assert!(try_specific_pattern("fd --extension rs", output).is_some());
318        assert!(try_specific_pattern("fdfind .rs", output).is_some());
319    }
320
321    #[test]
322    fn routes_just_commands() {
323        let output = "Available recipes:\n    build\n    test\n    lint\n";
324        assert!(try_specific_pattern("just --list", output).is_some());
325        assert!(try_specific_pattern("just build", output).is_some());
326    }
327
328    #[test]
329    fn routes_ninja_commands() {
330        let output = "[1/10] Compiling foo.c\n[10/10] Linking app\n";
331        assert!(try_specific_pattern("ninja", output).is_some());
332        assert!(try_specific_pattern("ninja -j4", output).is_some());
333    }
334
335    #[test]
336    fn routes_clang_commands() {
337        let output =
338            "src/main.c:10:5: error: use of undeclared identifier 'foo'\n1 error generated.\n";
339        assert!(try_specific_pattern("clang src/main.c", output).is_some());
340        assert!(try_specific_pattern("clang++ -std=c++17 main.cpp", output).is_some());
341    }
342
343    #[test]
344    fn routes_cargo_run() {
345        let output = "   Compiling foo v0.1.0\n    Finished `dev` profile\nHello, world!";
346        assert!(try_specific_pattern("cargo run", output).is_some());
347    }
348
349    #[test]
350    fn routes_cargo_bench() {
351        let output = "   Compiling foo v0.1.0\ntest bench_parse ... bench: 1234 ns/iter";
352        assert!(try_specific_pattern("cargo bench", output).is_some());
353    }
354
355    #[test]
356    fn routes_build_tools() {
357        let build_output = "   Compiling foo v0.1.0\n    Finished release [optimized]";
358        assert!(try_specific_pattern("gcc -o main main.c", build_output).is_some());
359        assert!(try_specific_pattern("g++ -o main main.cpp", build_output).is_some());
360    }
361
362    #[test]
363    fn routes_monorepo_tools() {
364        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";
365        assert!(try_specific_pattern("turbo install", output).is_some());
366        assert!(try_specific_pattern("nx install", output).is_some());
367    }
368
369    #[test]
370    fn gh_api_passthrough_never_compresses() {
371        let huge = "line\n".repeat(5000);
372        assert!(
373            compress_output("gh api repos/owner/repo/actions/jobs/123/logs", &huge).is_none(),
374            "gh api must never be compressed, even for large output"
375        );
376        assert!(compress_output("gh api repos/owner/repo/actions/runs/123/logs", &huge).is_none());
377    }
378
379    #[test]
380    fn gh_log_flags_passthrough() {
381        let huge = "line\n".repeat(5000);
382        assert!(compress_output("gh run view 123 --log-failed", &huge).is_none());
383        assert!(compress_output("gh run view 123 --log", &huge).is_none());
384    }
385
386    #[test]
387    fn strip_leading_cd_unwraps_only_plain_two_segment_cd() {
388        assert_eq!(strip_leading_cd("cd /x && git log -5"), "git log -5");
389        assert_eq!(strip_leading_cd("cd 'a b' && git status"), "git status");
390        assert_eq!(strip_leading_cd("git log -5"), "git log -5");
391        for kept in [
392            "cd /x && git log && git status",
393            "cd /x; git log",
394            "cd /x || git log",
395            "cd $HOME && git log",
396            "cd $(pwd) && git log",
397            "pushd /x && git log",
398        ] {
399            assert_eq!(strip_leading_cd(kept), kept, "{kept}");
400        }
401    }
402
403    #[test]
404    fn cd_prefixed_git_log_uses_git_pattern() {
405        let mut out = String::new();
406        for i in 0..30 {
407            out.push_str(&format!(
408                "commit {i:040x}\nAuthor: A <a@b.c>\nDate:   Mon Jan 1 00:00:00 2026 +0000\n\n    subject {i}\n\n    - bullet body line\n\n src/f{i}.rs | 4 ++--\n 1 file changed, 2 insertions(+), 2 deletions(-)\n\n"
409            ));
410        }
411        let plain = compress_output("git -C /x log --stat -30", &out);
412        let cd = compress_output("cd /x && git log --stat -30", &out);
413        assert!(plain.is_some());
414        assert_eq!(cd, plain);
415        assert!(cd.unwrap().contains("+60/-60 total"));
416    }
417
418    #[test]
419    fn gh_structured_commands_still_compress() {
420        let output = "On branch main\nnothing to commit";
421        assert!(try_specific_pattern("gh pr list", output).is_some());
422        assert!(try_specific_pattern("gh run list", output).is_some());
423    }
424}