1pub const PATTERN_ENGINE_VERSION: u32 = 2;
18
19pub mod ansi;
20pub mod json_crush;
21pub mod safety_needles;
22pub mod tokens;
23
24pub mod alembic;
25pub mod ansible;
26pub mod argocd;
27pub mod artisan;
28pub mod aws;
29pub mod bazel;
30pub mod buf;
31pub mod bun;
32pub mod cargo;
33pub mod clang;
34pub mod cmake;
35pub mod composer;
36pub mod cosign;
37pub mod curl;
38pub mod dbt;
39pub mod deno;
40pub mod deploy;
41pub mod deps_cmd;
42pub mod docker;
43pub mod dotnet;
44pub mod emspace;
45pub mod env_filter;
46pub mod eslint;
47pub mod fd;
48pub mod find;
49pub mod flutter;
50pub mod flyway;
51pub mod gem;
52pub mod gh;
53pub mod git;
54pub mod glab;
55pub mod golang;
56pub mod grep;
57pub mod grype;
58pub mod helm;
59pub mod jj;
60pub mod json_schema;
61pub mod just;
62pub mod kubectl;
63pub mod linkerd;
64pub mod log_dedup;
65pub mod lossless_collapse;
66pub mod ls;
67pub mod make;
68pub mod maven;
69pub mod mise;
70pub mod mix;
71pub mod mlflow;
72pub mod mypy;
73pub mod mysql;
74pub mod next_build;
75pub mod ninja;
76pub mod npm;
77pub mod ollama;
78pub mod php;
79pub mod pip;
80pub mod playwright;
81pub mod pnpm;
82pub mod poetry;
83pub mod prettier;
84pub mod prisma;
85pub mod psql;
86pub mod pulumi;
87pub mod pytest;
88pub mod ruby;
89pub mod ruff;
90pub mod semgrep;
91pub mod spark;
92pub mod swift;
93pub mod swiftlint;
94pub mod syft;
95pub mod sysinfo;
96pub mod systemd;
97pub mod template_collapse;
98pub mod terraform;
99pub mod test;
100pub mod trivy;
101pub mod typescript;
102pub mod wget;
103pub mod zig;
104
105use crate::tokens::count_tokens;
106
107pub trait PatternEnv {
111 fn is_curl_passthrough(&self, command: &str) -> bool;
114}
115
116pub struct NoEnv;
118
119impl PatternEnv for NoEnv {
120 fn is_curl_passthrough(&self, _command: &str) -> bool {
121 false
122 }
123}
124
125pub fn compress_clean(
130 env: &dyn PatternEnv,
131 command: &str,
132 clean_output: &str,
133 output: &str,
134) -> Option<String> {
135 if has_vcs_owner(command) {
144 return try_specific_pattern_with(env, command, clean_output)
151 .filter(|c| !c.trim().is_empty())
152 .filter(|c| !inflates_tokens(c, output));
153 }
154
155 if let Some(compressed) = try_specific_pattern_with(env, command, clean_output)
156 && let Some(r) = shorter_only(compressed, output)
157 {
158 return Some(r);
159 }
160
161 if let Some(r) = json_schema::compress(clean_output)
162 && let Some(r) = shorter_only(r, output)
163 {
164 return Some(r);
165 }
166
167 if let Some(r) = log_dedup::compress(clean_output)
168 && let Some(r) = shorter_only(r, output)
169 {
170 return Some(r);
171 }
172
173 if let Some(r) = test::compress(clean_output)
174 && let Some(r) = shorter_only(r, output)
175 {
176 return Some(r);
177 }
178
179 None
180}
181
182pub fn has_vcs_owner(command: &str) -> bool {
187 let c = command.trim_start().to_ascii_lowercase();
188 c.starts_with("git ")
189 || c.starts_with("jj ")
190 || c.starts_with("gh ")
191 || c.starts_with("glab ")
192 || c.starts_with("hg ")
193 || c.starts_with("emspace ")
194}
195
196fn normalize_shell_tokens(text: &str) -> String {
198 text.split_whitespace().collect::<Vec<_>>().join(" ")
199}
200
201pub fn inflates_tokens(compressed: &str, original: &str) -> bool {
206 count_tokens(compressed) > count_tokens(original)
207}
208
209pub fn shorter_only(compressed: String, original: &str) -> Option<String> {
212 let orig_n = normalize_shell_tokens(original);
213 let comp_n = normalize_shell_tokens(&compressed);
214 let ct_c = count_tokens(&comp_n);
215 let ct_o = count_tokens(&orig_n);
216 if ct_c < ct_o || (ct_c == ct_o && comp_n.len() < orig_n.len()) {
217 Some(compressed)
218 } else {
219 None
220 }
221}
222
223pub fn elision_marker(omitted: usize) -> String {
237 let plural = if omitted == 1 { "line" } else { "lines" };
238 format!("[lean-ctx: {omitted} {plural} elided — re-run with raw=true for the full output]")
239}
240
241type PatternMatcher = fn(&str) -> bool;
242type PatternHandler = fn(&str, &str) -> Option<String>;
243
244pub fn pattern_count() -> usize {
252 PATTERNS.len()
253}
254
255const PATTERNS: &[(PatternMatcher, PatternHandler)] = &[
256 (
257 |c| c.starts_with("git "),
258 |c, output| git::compress(c, output),
259 ),
260 (
261 |c| c.starts_with("gh "),
262 |c, output| gh::compress(c, output),
263 ),
264 (
265 |c| c.starts_with("glab "),
266 |c, output| glab::try_glab_pattern(c, output),
267 ),
268 (
269 |c| c.starts_with("emspace "),
270 |c, output| emspace::compress(c, output),
271 ),
272 (
273 |c| c == "terraform" || c.starts_with("terraform "),
274 |c, output| terraform::compress(c, output),
275 ),
276 (
277 |c| c == "make" || c.starts_with("make "),
278 |c, output| make::compress(c, output),
279 ),
280 (
281 |c| c == "just" || c.starts_with("just "),
282 |c, output| just::compress(c, output),
283 ),
284 (
285 |c| {
286 c.starts_with("mvn ")
287 || c.starts_with("./mvnw ")
288 || c.starts_with("mvnw ")
289 || c.starts_with("gradle ")
290 || c.starts_with("./gradlew ")
291 || c.starts_with("gradlew ")
292 },
293 |c, output| maven::compress(c, output),
294 ),
295 (
296 |c| c.starts_with("kubectl ") || c.starts_with("k "),
297 |c, output| kubectl::compress(c, output),
298 ),
299 (
300 |c| c.starts_with("helm "),
301 |c, output| helm::compress(c, output),
302 ),
303 (
304 |c| c.starts_with("pnpm "),
305 |c, output| pnpm::compress(c, output),
306 ),
307 (
308 |c| c.starts_with("bun ") || c.starts_with("bunx "),
309 |c, output| bun::compress(c, output),
310 ),
311 (
312 |c| c.starts_with("deno "),
313 |c, output| deno::compress(c, output),
314 ),
315 (
316 |c| c.starts_with("npm ") || c.starts_with("yarn "),
317 |c, output| npm::compress(c, output),
318 ),
319 (
320 |c| c.starts_with("cargo "),
321 |c, output| cargo::compress(c, output),
322 ),
323 (
324 |c| c.starts_with("docker ") || c.starts_with("docker-compose "),
325 |c, output| docker::compress(c, output),
326 ),
327 (
328 |c| c.starts_with("pip ") || c.starts_with("pip3 ") || c.starts_with("python -m pip"),
329 |c, output| pip::compress(c, output),
330 ),
331 (
332 |c| c.starts_with("mypy") || c.starts_with("python -m mypy") || c.starts_with("dmypy "),
333 |c, output| mypy::compress(c, output),
334 ),
335 (
336 |c| c.starts_with("pytest") || c.starts_with("python -m pytest"),
337 |c, output| pytest::compress(c, output).or_else(|| test::compress(output)),
338 ),
339 (
340 |c| c.starts_with("ruff "),
341 |c, output| ruff::compress(c, output),
342 ),
343 (
344 |c| {
345 c.starts_with("eslint")
346 || c.starts_with("npx eslint")
347 || c.starts_with("biome ")
348 || c.starts_with("stylelint")
349 },
350 |c, output| eslint::compress(c, output),
351 ),
352 (
353 |c| c.starts_with("prettier") || c.starts_with("npx prettier"),
354 |_c, output| prettier::compress(output),
355 ),
356 (
357 |c| c.starts_with("go ") || c.starts_with("golangci-lint") || c.starts_with("golint"),
358 |c, output| golang::compress(c, output),
359 ),
360 (
361 |c| {
362 c.starts_with("playwright")
363 || c.starts_with("npx playwright")
364 || c.starts_with("cypress")
365 || c.starts_with("npx cypress")
366 },
367 |c, output| playwright::compress(c, output),
368 ),
369 (
370 |c| c.starts_with("vitest") || c.starts_with("npx vitest") || c.starts_with("pnpm vitest"),
371 |_c, output| test::compress(output),
372 ),
373 (
374 |c| {
375 c.starts_with("next ")
376 || c.starts_with("npx next")
377 || c.starts_with("vite ")
378 || c.starts_with("npx vite")
379 || c.starts_with("vp ")
380 || c.starts_with("vite-plus ")
381 },
382 |c, output| next_build::compress(c, output),
383 ),
384 (
385 |c| c.starts_with("tsc") || c.contains("typescript"),
386 |_c, output| typescript::compress(output),
387 ),
388 (
389 |c| {
390 c.starts_with("rubocop")
391 || c.starts_with("bundle ")
392 || c.starts_with("rake ")
393 || c.starts_with("rails test")
394 || c.starts_with("rspec")
395 },
396 |c, output| ruby::compress(c, output),
397 ),
398 (|c| is_grep_family(c), |_c, output| grep::compress(output)),
399 (
400 |c| c.starts_with("find "),
401 |_c, output| find::compress(output),
402 ),
403 (
404 |c| c.starts_with("fd ") || c.starts_with("fdfind "),
405 |_c, output| fd::compress(output),
406 ),
407 (
408 |c| c.starts_with("ls ") || c == "ls",
409 |_c, output| ls::compress(output),
410 ),
411 (
412 |c| c.starts_with("curl "),
413 |_c, output| curl::compress(output),
414 ),
415 (
416 |c| c.starts_with("wget "),
417 |_c, output| wget::compress(output),
418 ),
419 (
420 |c| c == "env" || c.starts_with("env ") || c.starts_with("printenv"),
421 |_c, output| env_filter::compress(output),
422 ),
423 (
424 |c| c.starts_with("dotnet "),
425 |c, output| dotnet::compress(c, output),
426 ),
427 (
428 |c| {
429 c.starts_with("flutter ")
430 || (c.starts_with("dart ") && (c.contains(" analyze") || c.ends_with(" analyze")))
431 },
432 |c, output| flutter::compress(c, output),
433 ),
434 (
435 |c| {
436 c.starts_with("poetry ")
437 || c.starts_with("uv ")
438 || c.starts_with("conda ")
439 || c.starts_with("mamba ")
440 || c.starts_with("pipx ")
441 },
442 |c, output| poetry::compress(c, output),
443 ),
444 (
445 |c| c.starts_with("aws "),
446 |c, output| aws::compress(c, output),
447 ),
448 (
449 |c| c.starts_with("psql ") || c.starts_with("pg_"),
450 |c, output| psql::compress(c, output),
451 ),
452 (
453 |c| c.starts_with("mysql ") || c.starts_with("mariadb "),
454 |c, output| mysql::compress(c, output),
455 ),
456 (
457 |c| c.starts_with("prisma ") || c.starts_with("npx prisma"),
458 |c, output| prisma::compress(c, output),
459 ),
460 (
461 |c| c.starts_with("swift "),
462 |c, output| swift::compress(c, output),
463 ),
464 (
465 |c| c.starts_with("zig "),
466 |c, output| zig::compress(c, output),
467 ),
468 (
469 |c| c.starts_with("cmake ") || c.starts_with("ctest"),
470 |c, output| cmake::compress(c, output),
471 ),
472 (
473 |c| c.starts_with("ninja"),
474 |c, output| ninja::compress(c, output),
475 ),
476 (
477 |c| c.starts_with("ansible") || c.starts_with("ansible-playbook"),
478 |c, output| ansible::compress(c, output),
479 ),
480 (
481 |c| c.starts_with("composer "),
482 |c, output| composer::compress(c, output),
483 ),
484 (
485 |c| c.starts_with("php artisan") || c.starts_with("artisan "),
486 |c, output| artisan::compress(c, output),
487 ),
488 (
489 |c| c.starts_with("./vendor/bin/pest") || c.starts_with("pest "),
490 |_c, output| artisan::compress("php artisan test", output),
491 ),
492 (
493 |c| c.starts_with("mix ") || c.starts_with("iex "),
494 |c, output| mix::compress(c, output),
495 ),
496 (
497 |c| c.starts_with("bazel ") || c.starts_with("blaze "),
498 |c, output| bazel::compress(c, output),
499 ),
500 (
501 |c| c.starts_with("systemctl ") || c.starts_with("journalctl"),
502 |c, output| systemd::compress(c, output),
503 ),
504 (
505 |c| c.starts_with("jest") || c.starts_with("npx jest") || c.starts_with("pnpm jest"),
506 |_c, output| test::compress(output),
507 ),
508 (
509 |c| c.starts_with("mocha") || c.starts_with("npx mocha"),
510 |_c, output| test::compress(output),
511 ),
512 (
513 |c| c.starts_with("tofu "),
514 |c, output| terraform::compress(c, output),
515 ),
516 (
517 |c| c.starts_with("ps ") || c == "ps",
518 |_c, output| sysinfo::compress_ps(output),
519 ),
520 (
521 |c| c.starts_with("df ") || c == "df",
522 |_c, output| sysinfo::compress_df(output),
523 ),
524 (
525 |c| c.starts_with("du ") || c == "du",
526 |_c, output| sysinfo::compress_du(output),
527 ),
528 (
529 |c| c.starts_with("ping "),
530 |_c, output| sysinfo::compress_ping(output),
531 ),
532 (
533 |c| c.starts_with("jq ") || c == "jq",
534 |_c, output| json_schema::compress(output),
535 ),
536 (
537 |c| c.starts_with("hadolint"),
538 |c, output| eslint::compress(c, output),
539 ),
540 (
541 |c| c.starts_with("yamllint") || c.starts_with("npx yamllint"),
542 |c, output| eslint::compress(c, output),
543 ),
544 (
545 |c| c.starts_with("markdownlint") || c.starts_with("npx markdownlint"),
546 |c, output| eslint::compress(c, output),
547 ),
548 (
549 |c| c.starts_with("oxlint") || c.starts_with("npx oxlint"),
550 |c, output| eslint::compress(c, output),
551 ),
552 (
553 |c| c.starts_with("pyright") || c.starts_with("basedpyright"),
554 |c, output| mypy::compress(c, output),
555 ),
556 (
557 |c| c.starts_with("turbo ") || c.starts_with("npx turbo"),
558 |c, output| npm::compress(c, output),
559 ),
560 (
561 |c| c.starts_with("nx ") || c.starts_with("npx nx"),
562 |c, output| npm::compress(c, output),
563 ),
564 (
565 |c| c.starts_with("clang++ ") || c.starts_with("clang "),
566 |c, output| clang::compress(c, output),
567 ),
568 (
569 |c| {
570 c.starts_with("gcc ")
571 || c.starts_with("g++ ")
572 || c.starts_with("cc ")
573 || c.starts_with("c++ ")
574 },
575 |c, output| cmake::compress(c, output),
576 ),
577 (
579 |c| c == "dbt" || c.starts_with("dbt "),
580 |c, output| dbt::compress(c, output),
581 ),
582 (
583 |c| c == "alembic" || c.starts_with("alembic "),
584 |c, output| alembic::compress(c, output),
585 ),
586 (
587 |c| c == "flyway" || c.starts_with("flyway "),
588 |c, output| flyway::compress(c, output),
589 ),
590 (
591 |c| c.starts_with("spark-submit") || c.starts_with("spark-sql") || c.starts_with("pyspark"),
592 |c, output| spark::compress(c, output),
593 ),
594 (
596 |c| c == "ollama" || c.starts_with("ollama "),
597 |c, output| ollama::compress(c, output),
598 ),
599 (
600 |c| c.starts_with("mlflow "),
601 |c, output| mlflow::compress(c, output),
602 ),
603 (
605 |c| c.starts_with("semgrep "),
606 |c, output| semgrep::compress(c, output),
607 ),
608 (
609 |c| c.starts_with("trivy "),
610 |c, output| trivy::compress(c, output),
611 ),
612 (
613 |c| c.starts_with("grype "),
614 |c, output| grype::compress(c, output),
615 ),
616 (
617 |c| c.starts_with("syft "),
618 |c, output| syft::compress(c, output),
619 ),
620 (
621 |c| c.starts_with("cosign "),
622 |c, output| cosign::compress(c, output),
623 ),
624 (
625 |c| c.starts_with("swiftlint"),
626 |c, output| swiftlint::compress(c, output),
627 ),
628 (
630 |c| c == "jj" || c.starts_with("jj "),
631 |c, output| jj::compress(c, output),
632 ),
633 (
634 |c| c == "mise" || c.starts_with("mise "),
635 |c, output| mise::compress(c, output),
636 ),
637 (
638 |c| c == "buf" || c.starts_with("buf "),
639 |c, output| buf::compress(c, output),
640 ),
641 (
642 |c| c.starts_with("gem "),
643 |c, output| gem::compress(c, output),
644 ),
645 (
647 |c| c == "pulumi" || c.starts_with("pulumi "),
648 |c, output| pulumi::compress(c, output),
649 ),
650 (
651 |c| c.starts_with("linkerd "),
652 |c, output| linkerd::compress(c, output),
653 ),
654 (
655 |c| c.starts_with("argocd "),
656 |c, output| argocd::compress(c, output),
657 ),
658 (
659 |c| {
660 c == "vercel"
661 || c.starts_with("vercel ")
662 || c == "fly"
663 || c.starts_with("fly ")
664 || c.starts_with("flyctl ")
665 || c.starts_with("wrangler ")
666 || c.starts_with("skaffold ")
667 || c.starts_with("supabase ")
668 },
669 |c, output| deploy::compress(c, output),
670 ),
671];
672
673fn is_grep_family(cmd: &str) -> bool {
680 let first = cmd.split_whitespace().next().unwrap_or("");
681 let base = first.rsplit('/').next().unwrap_or(first);
682 matches!(
683 base,
684 "grep" | "egrep" | "fgrep" | "rg" | "ag" | "ack" | "ugrep" | "sift"
685 )
686}
687
688pub fn try_specific_pattern_with(env: &dyn PatternEnv, cmd: &str, output: &str) -> Option<String> {
691 let cl = cmd.to_ascii_lowercase();
692 let c = cl.as_str();
693
694 if c.starts_with("curl ") && env.is_curl_passthrough(c) {
697 return None;
698 }
699
700 PATTERNS
701 .iter()
702 .find(|(matches, _)| matches(c))
703 .and_then(|(_, handle)| handle(c, output))
704}
705
706#[cfg(test)]
707mod gh1679 {
708 use super::*;
709
710 #[test]
717 fn an_elision_announces_itself_as_ours() {
718 let marker = elision_marker(34);
719
720 assert!(
721 marker.starts_with("[lean-ctx:"),
722 "must be unmistakably ours, not content-shaped: {marker}"
723 );
724 assert!(
725 !marker.contains("more lines)"),
726 "the old content-shaped wording must be gone: {marker}"
727 );
728 assert!(marker.contains("34"), "{marker}");
729 }
730
731 #[test]
735 fn the_marker_names_a_recovery_that_always_exists() {
736 assert!(
737 elision_marker(1).contains("raw=true"),
738 "{}",
739 elision_marker(1)
740 );
741 }
742
743 #[test]
744 fn one_line_is_not_called_lines() {
745 assert!(
746 elision_marker(1).contains("1 line elided"),
747 "{}",
748 elision_marker(1)
749 );
750 assert!(
751 elision_marker(2).contains("2 lines elided"),
752 "{}",
753 elision_marker(2)
754 );
755 }
756
757 #[test]
761 fn no_compressor_hand_rolls_the_old_marker() {
762 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
763 let mut offenders = Vec::new();
764
765 for entry in std::fs::read_dir(&dir).expect("patterns dir") {
766 let path = entry.expect("dir entry").path();
767 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
768 continue;
769 }
770 let source = std::fs::read_to_string(&path).expect("read pattern source");
771 let production = source
774 .split_once("#[cfg(test)]")
775 .map_or(source.as_str(), |(head, _)| head);
776 if production.contains("more lines)")
777 && path.file_name().and_then(|n| n.to_str()) != Some("lib.rs")
778 {
779 offenders.push(path.display().to_string());
780 }
781 }
782
783 assert!(
784 offenders.is_empty(),
785 "these compressors elide with the old content-shaped marker; \
786 use super::elision_marker() instead: {offenders:?}"
787 );
788 }
789}