1use crate::core::patterns;
2use crate::core::tokens::{COUNTING_FAMILY, TokenizerFamily, count_tokens_for};
3
4use super::classification::{
5 has_structural_output, is_search_output, is_verbatim_output, looks_like_toon,
6};
7use super::footer::shell_savings_footer;
8
9pub(in crate::shell) fn compress_and_measure(
10 command: &str,
11 stdout: &str,
12 stderr: &str,
13 exit_code: i32,
14) -> (String, usize) {
15 compress_and_measure_for(command, stdout, stderr, exit_code, COUNTING_FAMILY)
16}
17
18pub(in crate::shell) fn compress_and_measure_for(
19 command: &str,
20 stdout: &str,
21 stderr: &str,
22 exit_code: i32,
23 family: TokenizerFamily,
24) -> (String, usize) {
25 let compressed_stdout = compress_for_outcome_for(command, stdout, exit_code, family);
26 let compressed_stderr = compress_for_outcome_for(command, stderr, exit_code, family);
27
28 let mut result = String::new();
29 if !compressed_stdout.is_empty() {
30 result.push_str(&compressed_stdout);
31 }
32 if !compressed_stderr.is_empty() {
33 if !result.is_empty() {
34 if exit_code != 0 {
38 result.push('\n');
39 result.push_str(crate::shell::STDERR_LABEL);
40 }
41 result.push('\n');
42 }
43 result.push_str(&compressed_stderr);
44 }
45
46 let content_for_counting = if let Some(pos) = result.rfind("\n[lean-ctx: ") {
47 &result[..pos]
48 } else {
49 &result
50 };
51 let output_tokens = count_tokens_for(content_for_counting, family);
52 (result, output_tokens)
53}
54
55pub fn compress_for_outcome(command: &str, output: &str, exit_code: i32) -> String {
65 compress_for_outcome_for(command, output, exit_code, COUNTING_FAMILY)
66}
67
68pub(crate) fn compress_for_outcome_for(
69 command: &str,
70 output: &str,
71 exit_code: i32,
72 family: TokenizerFamily,
73) -> String {
74 if exit_code != 0 && !output.trim().is_empty() && !crate::core::protect::has_markers(output) {
75 let tokens = count_tokens_for(output, family);
76 if tokens <= 2000 {
77 return truncate_verbatim(output, tokens, family);
78 }
79 let compressed = compress_if_beneficial_with_exit(command, output, 0, family);
80 let compressed_tokens = count_tokens_for(&compressed, family);
81 if compressed_tokens < tokens {
82 return compressed;
83 }
84 return truncate_verbatim(output, tokens, family);
85 }
86 compress_if_beneficial_with_exit(command, output, exit_code, family)
87}
88
89pub(crate) fn verbatim_json_crush(
96 output: &str,
97 original_tokens: usize,
98 min_output_tokens: usize,
99 enabled: bool,
100) -> Option<String> {
101 verbatim_json_crush_for(
102 output,
103 original_tokens,
104 min_output_tokens,
105 enabled,
106 COUNTING_FAMILY,
107 )
108}
109
110pub(crate) fn verbatim_json_crush_for(
111 output: &str,
112 original_tokens: usize,
113 min_output_tokens: usize,
114 enabled: bool,
115 family: TokenizerFamily,
116) -> Option<String> {
117 if !enabled {
118 return None;
119 }
120 let crushed = patterns::json_schema::crush_verbatim(output)?;
121 let crushed_tokens = count_tokens_for(&crushed, family);
122 (crushed_tokens >= min_output_tokens && crushed_tokens < original_tokens)
123 .then(|| shell_savings_footer(&crushed, original_tokens, crushed_tokens))
124}
125
126const MIN_USEFUL_SAVINGS: usize = 50;
132
133const LOSSY_DROP_ENTROPY: f64 = 0.9;
134
135pub(crate) fn verbatim_json_crush_lossy(
144 output: &str,
145 original_tokens: usize,
146 min_output_tokens: usize,
147 enabled: bool,
148) -> Option<String> {
149 verbatim_json_crush_lossy_for(
150 output,
151 original_tokens,
152 min_output_tokens,
153 enabled,
154 COUNTING_FAMILY,
155 )
156}
157
158pub(crate) fn verbatim_json_crush_lossy_for(
159 output: &str,
160 original_tokens: usize,
161 min_output_tokens: usize,
162 enabled: bool,
163 family: TokenizerFamily,
164) -> Option<String> {
165 if !enabled {
166 return None;
167 }
168 let res = crate::core::json_crush::crush_text_lossy_if_beneficial(output, LOSSY_DROP_ENTROPY)?;
169 let crushed_tokens = count_tokens_for(&res.text, family);
170 if crushed_tokens < min_output_tokens || crushed_tokens >= original_tokens {
171 return None;
172 }
173 let handle = crate::core::ccr::persist_json(output)?;
176 let body = shell_savings_footer(&res.text, original_tokens, crushed_tokens);
177 Some(format!(
178 "{body}\n[lean-ctx: high-entropy column(s) dropped — full data at {handle}, \
179 ctx_expand(id=\"{handle}\", json_path=\"…\"|search=\"…\") for a slice]"
180 ))
181}
182
183fn tabular_delim_crush<T>(output: &str, crush: impl Fn(&str, char) -> Option<T>) -> Option<T> {
188 [',', '\t'].into_iter().find_map(|d| crush(output, d))
189}
190
191pub(crate) fn verbatim_tabular_crush_for(
197 output: &str,
198 original_tokens: usize,
199 min_output_tokens: usize,
200 enabled: bool,
201 family: TokenizerFamily,
202) -> Option<String> {
203 if !enabled {
204 return None;
205 }
206 let crushed =
207 tabular_delim_crush(output, crate::core::tabular_crush::crush_text_if_beneficial)?;
208 let crushed_tokens = count_tokens_for(&crushed, family);
209 (crushed_tokens >= min_output_tokens && crushed_tokens < original_tokens)
210 .then(|| shell_savings_footer(&crushed, original_tokens, crushed_tokens))
211}
212
213pub(crate) fn verbatim_tabular_crush_lossy_for(
221 output: &str,
222 original_tokens: usize,
223 min_output_tokens: usize,
224 enabled: bool,
225 family: TokenizerFamily,
226) -> Option<String> {
227 if !enabled {
228 return None;
229 }
230 let res = tabular_delim_crush(output, |text, delim| {
231 crate::core::tabular_crush::crush_text_lossy_if_beneficial(text, delim, LOSSY_DROP_ENTROPY)
232 })?;
233 let crushed_tokens = count_tokens_for(&res.text, family);
234 if crushed_tokens < min_output_tokens || crushed_tokens >= original_tokens {
235 return None;
236 }
237 let handle = crate::core::ccr::persist_tabular(output)?;
238 let body = shell_savings_footer(&res.text, original_tokens, crushed_tokens);
239 Some(format!(
240 "{body}\n[lean-ctx: high-entropy column(s) dropped — full data at {handle}, \
241 ctx_expand(id=\"{handle}\", search=\"…\") for a slice]"
242 ))
243}
244
245pub(crate) fn verbatim_yaml_crush_for(
254 output: &str,
255 original_tokens: usize,
256 min_output_tokens: usize,
257 enabled: bool,
258 family: TokenizerFamily,
259) -> Option<String> {
260 if !enabled {
261 return None;
262 }
263 let crushed = crate::core::yaml_crush::crush_text_if_beneficial(output)?;
264 let crushed_tokens = count_tokens_for(&crushed, family);
265 (crushed_tokens >= min_output_tokens && crushed_tokens < original_tokens)
266 .then(|| shell_savings_footer(&crushed, original_tokens, crushed_tokens))
267}
268
269pub(crate) fn verbatim_yaml_crush_lossy_for(
277 output: &str,
278 original_tokens: usize,
279 min_output_tokens: usize,
280 enabled: bool,
281 family: TokenizerFamily,
282) -> Option<String> {
283 if !enabled {
284 return None;
285 }
286 let res = crate::core::yaml_crush::crush_text_lossy_if_beneficial(output, LOSSY_DROP_ENTROPY)?;
287 let crushed_tokens = count_tokens_for(&res.text, family);
288 if crushed_tokens < min_output_tokens || crushed_tokens >= original_tokens {
289 return None;
290 }
291 let handle = crate::core::ccr::persist_yaml(output)?;
292 let body = shell_savings_footer(&res.text, original_tokens, crushed_tokens);
293 Some(format!(
294 "{body}\n[lean-ctx: high-entropy column(s) dropped — full data at {handle}, \
295 ctx_expand(id=\"{handle}\", search=\"…\") for a slice]"
296 ))
297}
298
299pub(crate) fn verbatim_html_crush_for(
304 output: &str,
305 original_tokens: usize,
306 min_output_tokens: usize,
307 enabled: bool,
308 family: TokenizerFamily,
309) -> Option<String> {
310 if !enabled {
311 return None;
312 }
313 let result = crate::core::html_crush::crush_if_beneficial(output)?;
314 let crushed_tokens = count_tokens_for(&result.text, family);
315 if crushed_tokens < min_output_tokens || crushed_tokens >= original_tokens {
316 return None;
317 }
318 let handle = crate::core::ccr::persist_html(output)?;
319 let body = shell_savings_footer(&result.text, original_tokens, crushed_tokens);
320 Some(format!(
321 "{body}\n[lean-ctx: HTML extracted to markdown — full page at {handle}, \
322 ctx_expand(id=\"{handle}\", search=\"…\") for a section]"
323 ))
324}
325
326pub(crate) fn compress_if_beneficial(command: &str, output: &str) -> String {
327 compress_if_beneficial_for(command, output, COUNTING_FAMILY)
328}
329
330pub fn compress_if_beneficial_for(command: &str, output: &str, family: TokenizerFamily) -> String {
331 compress_if_beneficial_with_exit(command, output, -1, family)
332}
333
334fn min_compression_tokens(command: &str) -> usize {
338 let cmd = command.to_ascii_lowercase();
339 if cmd.starts_with("cargo test")
340 || cmd.starts_with("pytest")
341 || cmd.starts_with("python -m pytest")
342 || cmd.starts_with("go test")
343 || cmd.starts_with("npm test")
344 || cmd.starts_with("npx jest")
345 || cmd.starts_with("dotnet test")
346 {
347 return 100;
348 }
349 if cmd.starts_with("git ") {
350 return 80;
351 }
352 if cmd.starts_with("npm audit") || cmd.starts_with("pip list") || cmd.starts_with("pip install")
353 {
354 return 100;
355 }
356 if cmd.starts_with("kubectl ") || cmd.starts_with("docker ") {
357 return 100;
358 }
359 120
360}
361
362fn compress_if_beneficial_with_exit(
363 command: &str,
364 output: &str,
365 exit_code: i32,
366 family: TokenizerFamily,
367) -> String {
368 if output.trim().is_empty() {
369 return String::new();
370 }
371
372 if crate::core::protect::has_markers(output) {
379 let original_tokens = count_tokens_for(output, family);
380 let spliced = crate::core::protect::compress_preserving(output, |seg| {
381 strip_shell_footer(&compress_if_beneficial_with_exit(
382 command, seg, exit_code, family,
383 ))
384 .to_string()
385 });
386 let spliced_tokens = count_tokens_for(&spliced, family);
387 return if spliced_tokens < original_tokens {
388 shell_savings_footer(&spliced, original_tokens, spliced_tokens)
389 } else {
390 spliced
391 };
392 }
393
394 if is_test_runner_command(command) {
398 let base = maybe_fold_progress(output, count_tokens_for(output, family), family)
399 .unwrap_or_else(|| output.to_string());
400 if exit_code == 0
401 && let Some(compressed) = patterns::test::compress(&base)
402 {
403 let original_tokens = count_tokens_for(output, family);
404 let compressed_tokens = count_tokens_for(&compressed, family);
405 if compressed_tokens < original_tokens {
406 return shell_savings_footer(&compressed, original_tokens, compressed_tokens);
407 }
408 }
409 return truncate_verbatim(&base, count_tokens_for(&base, family), family);
410 }
411
412 match classify_build_output(command, output, exit_code) {
417 BuildOutputKind::HasErrors => {
418 let base = maybe_fold_progress(output, count_tokens_for(output, family), family)
419 .unwrap_or_else(|| output.to_string());
420 let base = dedup_build_diagnostics(&base);
421 return truncate_verbatim(&base, count_tokens_for(&base, family), family);
422 }
423 BuildOutputKind::Clean => {
428 if let Some(folded) =
429 maybe_fold_progress(output, count_tokens_for(output, family), family)
430 {
431 return truncate_verbatim(&folded, count_tokens_for(&folded, family), family);
432 }
433 }
434 BuildOutputKind::WarningsOnly | BuildOutputKind::NotBuildTool => {}
435 }
436
437 if !is_search_output(command) && crate::shell::command_text::contains_auth_flow(output) {
438 return output.to_string();
439 }
440
441 let original_tokens = count_tokens_for(output, family);
442
443 if original_tokens < min_compression_tokens(command) {
449 return output.to_string();
450 }
451
452 if let Some(boundary_spans) = super::boundaries::detect_record_boundaries(output) {
456 if let Some(result) = super::boundaries::compress_preserving_boundaries(
457 command,
458 output,
459 exit_code,
460 family,
461 &boundary_spans,
462 original_tokens,
463 ) {
464 return result;
465 }
466 }
467
468 let min_output_tokens = 15;
469
470 let cfg = crate::core::config::Config::load();
471 let policy = crate::shell::output_policy::classify(command, &cfg.excluded_commands);
472 if policy == crate::shell::output_policy::OutputPolicy::Verbatim
473 || policy == crate::shell::output_policy::OutputPolicy::Passthrough
474 {
475 if policy == crate::shell::output_policy::OutputPolicy::Verbatim {
480 let enabled = cfg.crush_verbatim_json_enabled();
481 if let Some(crushed) =
485 verbatim_json_crush_for(output, original_tokens, min_output_tokens, enabled, family)
486 {
487 return crushed;
488 }
489 if let Some(crushed) = verbatim_json_crush_lossy_for(
490 output,
491 original_tokens,
492 min_output_tokens,
493 enabled,
494 family,
495 ) {
496 return crushed;
497 }
498 if let Some(crushed) = verbatim_tabular_crush_for(
501 output,
502 original_tokens,
503 min_output_tokens,
504 enabled,
505 family,
506 ) {
507 return crushed;
508 }
509 if let Some(crushed) = verbatim_tabular_crush_lossy_for(
510 output,
511 original_tokens,
512 min_output_tokens,
513 enabled,
514 family,
515 ) {
516 return crushed;
517 }
518 if let Some(crushed) =
522 verbatim_yaml_crush_for(output, original_tokens, min_output_tokens, enabled, family)
523 {
524 return crushed;
525 }
526 if let Some(crushed) = verbatim_yaml_crush_lossy_for(
527 output,
528 original_tokens,
529 min_output_tokens,
530 enabled,
531 family,
532 ) {
533 return crushed;
534 }
535 if let Some(crushed) =
536 verbatim_html_crush_for(output, original_tokens, min_output_tokens, enabled, family)
537 {
538 return crushed;
539 }
540 }
541 return truncate_verbatim(output, original_tokens, family);
542 }
543
544 if cfg
550 .preserve_compact_formats
551 .iter()
552 .any(|f| f.eq_ignore_ascii_case("toon"))
553 && looks_like_toon(output)
554 {
555 return truncate_verbatim(output, original_tokens, family);
556 }
557
558 if is_verbatim_output(command) {
559 return truncate_verbatim(output, original_tokens, family);
560 }
561
562 let command = patterns::strip_leading_cd(command);
564
565 if !is_chained_command(command)
571 && (has_structural_output(command) || patterns::has_vcs_owner(command))
572 {
573 let cl = command.to_ascii_lowercase();
574 if let Some(compressed) = patterns::try_specific_pattern(&cl, output)
575 && !compressed.trim().is_empty()
576 {
577 let compressed_tokens = count_tokens_for(&compressed, family);
578 let savings = original_tokens.saturating_sub(compressed_tokens);
579 if compressed_tokens >= min_output_tokens
580 && compressed_tokens < original_tokens
581 && savings >= MIN_USEFUL_SAVINGS
582 {
583 return shell_savings_footer(&compressed, original_tokens, compressed_tokens);
584 }
585 }
586 return output.to_string();
587 }
588
589 if !is_chained_command(command)
590 && let Some(mut compressed) = patterns::compress_output(command, output)
591 && !compressed.trim().is_empty()
592 {
593 let level = crate::core::config::CompressionLevel::effective(&cfg);
594 if level.is_active() {
595 let terse_result =
596 crate::core::terse::pipeline::compress(output, &level, Some(&compressed));
597 if terse_result.quality_passed
602 && count_tokens_for(&terse_result.output, family)
603 < count_tokens_for(&compressed, family)
604 {
605 compressed = terse_result.output;
606 }
607 }
608
609 let compressed_tokens = count_tokens_for(&compressed, family);
610 let savings = original_tokens.saturating_sub(compressed_tokens);
611 if compressed_tokens >= min_output_tokens
612 && compressed_tokens < original_tokens
613 && savings >= MIN_USEFUL_SAVINGS
614 {
615 let ratio = compressed_tokens as f64 / original_tokens as f64;
616 if ratio < 0.05 && original_tokens > 100 && original_tokens < 2000 {
617 tracing::warn!("compression removed >95% of small output, returning original");
618 return output.to_string();
619 }
620 return shell_savings_footer(&compressed, original_tokens, compressed_tokens);
621 }
622 if compressed_tokens < min_output_tokens {
623 return output.to_string();
624 }
625 }
626
627 {
628 let level = crate::core::config::CompressionLevel::effective(&cfg);
629 if level.is_active() {
630 let terse_result = crate::core::terse::pipeline::compress(output, &level, None);
631 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
632 return shell_savings_footer(
633 &terse_result.output,
634 terse_result.tokens_before as usize,
635 terse_result.tokens_after as usize,
636 );
637 }
638 }
639 }
640
641 let cleaned = crate::core::compressor::lightweight_cleanup(output);
642 let cleaned_tokens = count_tokens_for(&cleaned, family);
643 if cleaned_tokens < original_tokens {
644 let lines: Vec<&str> = cleaned.lines().collect();
645 if lines.len() > 30 {
646 let compressed = truncate_with_safety_scan(&lines, original_tokens, family);
647 if let Some(c) = compressed {
648 return c;
649 }
650 }
651 if cleaned_tokens < original_tokens {
652 return shell_savings_footer(&cleaned, original_tokens, cleaned_tokens);
653 }
654 }
655
656 let lines: Vec<&str> = output.lines().collect();
657 if lines.len() > 30
658 && let Some(c) = truncate_with_safety_scan(&lines, original_tokens, family)
659 {
660 return c;
661 }
662
663 output.to_string()
664}
665
666fn strip_shell_footer(s: &str) -> &str {
669 match s.rfind("\n[lean-ctx: ") {
670 Some(pos) => &s[..pos],
671 None => s,
672 }
673}
674
675pub(crate) fn dedup_build_diagnostics(output: &str) -> String {
682 let lines: Vec<&str> = output.lines().collect();
683 if lines.len() < 10 {
684 return output.to_string();
685 }
686 let mut seen = std::collections::HashSet::new();
687 let mut deduped_count = 0usize;
688 let mut result = Vec::with_capacity(lines.len());
689 for line in &lines {
690 let trimmed = line.trim();
691 if is_diagnostic_line(trimmed) {
692 let key = normalize_diagnostic_key(trimmed);
693 if !seen.insert(key) {
694 deduped_count += 1;
695 continue;
696 }
697 }
698 result.push(*line);
699 }
700 if deduped_count > 0 {
701 result.push("");
702 let note = format!("[lean-ctx: {deduped_count} duplicate diagnostic(s) removed]");
703 let mut out: String = result.join("\n");
705 out.push('\n');
706 out.push_str(¬e);
707 return out;
708 }
709 output.to_string()
710}
711
712fn normalize_diagnostic_key(line: &str) -> String {
718 let trimmed = line.trim();
719 if let Some(bracket) = trimmed.rfind(" [")
721 && trimmed.ends_with(']')
722 {
723 return trimmed[..bracket].to_string();
724 }
725 trimmed.to_string()
726}
727
728fn is_diagnostic_line(line: &str) -> bool {
729 if line.is_empty() {
730 return false;
731 }
732 if (line.contains("): warning ") || line.contains("): error ")) && line.contains('(') {
735 return true;
736 }
737 if (line.contains(": warning:") || line.contains(": error:") || line.contains(": warning["))
739 && line.contains(':')
740 {
741 return true;
742 }
743 false
744}
745
746#[derive(Debug, Eq, PartialEq)]
747enum BuildOutputKind {
748 Clean,
750 WarningsOnly,
752 HasErrors,
754 NotBuildTool,
756}
757
758fn classify_build_output(command: &str, output: &str, exit_code: i32) -> BuildOutputKind {
759 let command = strip_env_prefix(command);
760 if !is_build_tool(command) {
761 return BuildOutputKind::NotBuildTool;
762 }
763 if exit_code != 0
764 || output.contains("error[E")
765 || output.contains("error:")
766 || output.contains("could not compile")
767 {
768 return BuildOutputKind::HasErrors;
769 }
770 if output.contains("warning:") || output.contains("warning[") {
771 return BuildOutputKind::WarningsOnly;
772 }
773 BuildOutputKind::Clean
774}
775
776fn is_build_tool(command: &str) -> bool {
777 let cmd = command.trim().to_ascii_lowercase();
778 cmd.starts_with("cargo check")
779 || cmd.starts_with("cargo build")
780 || cmd.starts_with("cargo clippy")
781 || cmd.starts_with("cargo test")
782 || cmd.starts_with("cargo fmt")
783 || cmd.starts_with("cargo run")
784 || cmd.starts_with("rustc ")
785 || cmd.starts_with("gcc ")
786 || cmd.starts_with("g++ ")
787 || cmd.starts_with("clang ")
788 || cmd.starts_with("clang++ ")
789 || cmd.starts_with("make ")
790 || cmd.starts_with("cmake ")
791 || cmd.starts_with("go build")
792 || cmd.starts_with("go vet")
793 || cmd.starts_with("go test")
794 || cmd.starts_with("golangci-lint")
795 || cmd.starts_with("tsc ")
796 || cmd.starts_with("tsc\t")
797 || cmd == "tsc"
798 || cmd.starts_with("npx tsc")
799 || cmd.starts_with("eslint")
800 || cmd.starts_with("npx eslint")
801 || cmd.starts_with("biome ")
802 || cmd.starts_with("prettier ")
803 || cmd.starts_with("mypy ")
804 || cmd.starts_with("pyright ")
805 || cmd.starts_with("pylint ")
806 || cmd.starts_with("ruff check")
807 || cmd.starts_with("flake8")
808 || cmd.starts_with("black --check")
809 || cmd.starts_with("swift build")
810 || cmd.starts_with("swiftc ")
811 || cmd.starts_with("xcodebuild ")
812 || cmd.starts_with("javac ")
813 || cmd.starts_with("gradle ")
814 || cmd.starts_with("./gradlew ")
815 || cmd.starts_with("mvn ")
816 || cmd.starts_with("./mvnw ")
817 || cmd.starts_with("dotnet build")
818 || cmd.starts_with("dotnet test")
819 || cmd.starts_with("msbuild")
820 || cmd.starts_with("zig build")
821 || cmd.starts_with("nim c ")
822 || cmd.starts_with("ghc ")
823 || cmd.starts_with("stack build")
824 || cmd.starts_with("cabal build")
825 || cmd.starts_with("mix compile")
826 || cmd.starts_with("mix test")
827 || cmd.starts_with("mix credo")
828 || cmd.starts_with("shellcheck ")
829 || cmd.starts_with("hadolint ")
830 || cmd.starts_with("terraform validate")
831 || cmd.starts_with("terraform plan")
832 || cmd.starts_with("ansible-lint")
833 || cmd.starts_with("rubocop ")
834 || cmd.starts_with("solhint ")
835 || cmd.starts_with("slither ")
836}
837
838fn strip_env_prefix(segment: &str) -> &str {
842 let mut rest = segment.trim_start();
843 loop {
844 let Some(first) = rest.split_whitespace().next() else {
845 return rest;
846 };
847 let is_env_assignment = first.contains('=')
850 && !first.starts_with('-')
851 && first.split('=').next().is_some_and(|name| {
852 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
853 });
854 if !is_env_assignment {
855 return rest;
856 }
857 rest = rest[first.len()..].trim_start();
858 }
859}
860
861fn is_test_runner_command(command: &str) -> bool {
867 command
868 .split('|')
869 .map(|seg| strip_env_prefix(seg.trim()).to_ascii_lowercase())
870 .any(|seg| {
871 seg.starts_with("cargo test")
872 || seg.starts_with("cargo nextest")
873 || seg.starts_with("nextest")
874 || seg.starts_with("pytest")
875 || seg.starts_with("python -m pytest")
876 || seg.starts_with("python3 -m pytest")
877 || seg.starts_with("py.test")
878 || seg.starts_with("go test")
879 || seg.starts_with("gotestsum")
880 || seg.starts_with("npm test")
881 || seg.starts_with("npm run test")
882 || seg.starts_with("pnpm test")
883 || seg.starts_with("pnpm run test")
884 || seg.starts_with("yarn test")
885 || seg.starts_with("bun test")
886 || seg.starts_with("deno test")
887 || seg.starts_with("jest")
888 || seg.starts_with("npx jest")
889 || seg.starts_with("vitest")
890 || seg.starts_with("npx vitest")
891 || seg.starts_with("mocha")
892 || seg.starts_with("npx mocha")
893 || seg.starts_with("dotnet test")
894 || seg.starts_with("mix test")
895 || seg.starts_with("rspec")
896 || seg.starts_with("bundle exec rspec")
897 || seg.starts_with("phpunit")
898 || seg.starts_with("./vendor/bin/phpunit")
899 || seg.starts_with("./gradlew test")
900 || seg.starts_with("gradle test")
901 || seg.starts_with("mvn test")
902 || seg.starts_with("ctest")
903 })
904}
905
906const MAX_VERBATIM_TOKENS: usize = 4000;
907
908fn truncate_verbatim(output: &str, original_tokens: usize, family: TokenizerFamily) -> String {
916 if original_tokens <= MAX_VERBATIM_TOKENS {
917 return output.to_string();
918 }
919 let lines: Vec<&str> = output.lines().collect();
920 let total = lines.len();
921 if total <= 60 {
922 return output.to_string();
923 }
924 let head = 30.min(total);
925 let tail = 20.min(total.saturating_sub(head));
926 let middle = &lines[head..total - tail];
927
928 let preserved = crate::core::safety_needles::extract_safety_lines(middle, 200);
931 let omitted = middle.len() - preserved.len();
932
933 let mut result = String::with_capacity(output.len() / 2);
934 for line in &lines[..head] {
935 result.push_str(line);
936 result.push('\n');
937 }
938 if preserved.is_empty() {
939 result.push_str(&format!(
940 "\n[{omitted} lines omitted — output too large for context window]\n\n"
941 ));
942 } else {
943 result.push_str(&format!(
944 "\n[{omitted} lines omitted, {} test/diagnostic lines preserved]\n",
945 preserved.len()
946 ));
947 for line in &preserved {
948 result.push_str(line);
949 result.push('\n');
950 }
951 result.push('\n');
952 }
953 for line in lines.iter().skip(total - tail) {
954 result.push_str(line);
955 result.push('\n');
956 }
957 let truncated_tokens = count_tokens_for(&result, family);
958 if crate::core::protocol::savings_footer_visible() {
959 result.push_str(&format!(
960 "[lean-ctx: {original_tokens}→{truncated_tokens} tok, verbatim truncated]"
961 ));
962 }
963 result
964}
965
966fn looks_like_match_line(line: &str) -> bool {
972 let Some(first) = line.find(':') else {
973 return false;
974 };
975 let path = &line[..first];
976 if path.is_empty()
977 || path.bytes().all(|b| b.is_ascii_digit())
978 || path.chars().any(char::is_whitespace)
979 {
980 return false;
981 }
982 let rest = &line[first + 1..];
983 let Some(second) = rest.find(':') else {
984 return false;
985 };
986 second > 0 && rest[..second].bytes().all(|b| b.is_ascii_digit())
987}
988
989fn is_match_shaped(lines: &[&str]) -> bool {
991 let considered: Vec<&&str> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
992 if considered.len() < 20 {
993 return false;
994 }
995 let matched = considered
996 .iter()
997 .filter(|l| looks_like_match_line(l))
998 .count();
999 matched * 10 >= considered.len() * 9
1000}
1001
1002fn truncate_match_lines(
1016 lines: &[&str],
1017 original_tokens: usize,
1018 family: TokenizerFamily,
1019) -> Option<String> {
1020 const SHOWN: usize = 40;
1021 if lines.len() <= SHOWN {
1022 return None;
1023 }
1024 let total = lines.len();
1025 let hidden = total - SHOWN;
1026
1027 let mut compressed = lines[..SHOWN].join("\n");
1028 compressed.push_str(&format!(
1029 "\n[⚠ SAMPLE — showing the first {SHOWN} of {total} matching lines, in order; \
1030 {hidden} not shown. This is NOT a complete result set: nothing here rules \
1031 out a match further down. Narrow the pattern or use ctx_search before \
1032 concluding anything from absence.]"
1033 ));
1034
1035 let ct = count_tokens_for(&compressed, family);
1036 if ct >= original_tokens {
1037 return None;
1038 }
1039 Some(shell_savings_footer(&compressed, original_tokens, ct))
1040}
1041
1042fn truncate_with_safety_scan(
1043 lines: &[&str],
1044 original_tokens: usize,
1045 family: TokenizerFamily,
1046) -> Option<String> {
1047 use crate::core::safety_needles;
1048
1049 if is_match_shaped(lines) {
1050 return truncate_match_lines(lines, original_tokens, family);
1051 }
1052
1053 let first = &lines[..5];
1054 let last = &lines[lines.len() - 5..];
1055 let middle = &lines[5..lines.len() - 5];
1056
1057 let safety_lines = safety_needles::extract_safety_lines(middle, 80);
1058 let safety_count = safety_lines.len();
1059 let omitted = middle.len() - safety_count;
1060
1061 let mut parts = Vec::new();
1062 parts.push(first.join("\n"));
1063 if safety_count > 0 {
1064 parts.push(format!(
1065 "[{omitted} lines omitted, {safety_count} safety-relevant lines preserved]"
1066 ));
1067 parts.push(safety_lines.join("\n"));
1068 } else {
1069 parts.push(format!("[{omitted} lines omitted]"));
1070 }
1071 parts.push(last.join("\n"));
1072
1073 let compressed = parts.join("\n");
1074 let ct = count_tokens_for(&compressed, family);
1075 if ct >= original_tokens {
1076 return None;
1077 }
1078 Some(shell_savings_footer(&compressed, original_tokens, ct))
1079}
1080
1081fn fold_repetitive_progress(output: &str) -> Option<String> {
1082 let mut out: Vec<String> = Vec::new();
1083 let mut pending_kind: Option<ProgressKind> = None;
1084 let mut pending: Vec<&str> = Vec::new();
1085 let mut omitted_low_signal = 0usize;
1086
1087 for line in output.lines() {
1088 if is_low_signal_progress(line) {
1089 omitted_low_signal += 1;
1090 continue;
1091 }
1092
1093 let kind = classify_foldable_progress(line);
1094 if kind.is_some() && kind == pending_kind {
1095 pending.push(line);
1096 continue;
1097 }
1098
1099 flush_progress_run(&mut out, pending_kind, &pending);
1100 pending.clear();
1101 pending_kind = kind;
1102 if kind.is_some() {
1103 pending.push(line);
1104 } else {
1105 out.push(line.to_string());
1106 }
1107 }
1108
1109 flush_progress_run(&mut out, pending_kind, &pending);
1110 if omitted_low_signal > 0 {
1111 out.push(format!(
1112 "[{omitted_low_signal} low-signal progress lines omitted]"
1113 ));
1114 }
1115
1116 let folded = out.join("\n") + "\n";
1117 (folded.len() < output.len()).then_some(folded)
1118}
1119
1120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1121enum ProgressKind {
1122 CargoCompile,
1123 CargoFresh,
1124 CargoTestOk,
1125 PytestPassed,
1126 NpmProgress,
1127}
1128
1129fn classify_foldable_progress(line: &str) -> Option<ProgressKind> {
1130 let trimmed = line.trim_start();
1131 if trimmed.starts_with("Compiling ") || trimmed.starts_with("Checking ") {
1132 return Some(ProgressKind::CargoCompile);
1133 }
1134 if trimmed.starts_with("Fresh ")
1135 || trimmed.starts_with("Downloaded ")
1136 || trimmed.starts_with("Downloading ")
1137 {
1138 return Some(ProgressKind::CargoFresh);
1139 }
1140 if trimmed.starts_with("test ") && trimmed.ends_with("... ok") {
1141 return Some(ProgressKind::CargoTestOk);
1142 }
1143 if line.contains(" PASSED [") {
1144 return Some(ProgressKind::PytestPassed);
1145 }
1146 if trimmed.starts_with('[') && trimmed.contains('%') && trimmed.contains('/') {
1147 return Some(ProgressKind::NpmProgress);
1148 }
1149 None
1150}
1151
1152fn is_low_signal_progress(line: &str) -> bool {
1155 let trimmed = line.trim();
1156 !trimmed.is_empty() && trimmed.bytes().all(|b| b == b'.')
1157}
1158
1159fn flush_progress_run(out: &mut Vec<String>, kind: Option<ProgressKind>, lines: &[&str]) {
1160 let Some(kind) = kind else {
1161 return;
1162 };
1163 if lines.is_empty() {
1164 return;
1165 }
1166 let threshold = match kind {
1167 ProgressKind::CargoCompile | ProgressKind::PytestPassed | ProgressKind::CargoTestOk => 3,
1168 ProgressKind::CargoFresh | ProgressKind::NpmProgress => 5,
1169 };
1170 if lines.len() < threshold {
1171 out.extend(lines.iter().map(|line| (*line).to_string()));
1172 return;
1173 }
1174
1175 out.push(format!(
1176 "[{} {} lines folded]",
1177 lines.len(),
1178 match kind {
1179 ProgressKind::CargoCompile => "cargo compile/check",
1180 ProgressKind::CargoFresh => "cargo download/fresh",
1181 ProgressKind::CargoTestOk => "cargo test ok",
1182 ProgressKind::PytestPassed => "pytest PASSED",
1183 ProgressKind::NpmProgress => "package-manager progress",
1184 }
1185 ));
1186 out.push(lines.first().unwrap_or(&"").to_string());
1187 if lines.len() > 2 {
1188 out.push("…".to_string());
1189 }
1190 if lines.len() > 1 {
1191 out.push(lines.last().unwrap_or(&"").to_string());
1192 }
1193}
1194
1195fn maybe_fold_progress(
1196 output: &str,
1197 original_tokens: usize,
1198 family: TokenizerFamily,
1199) -> Option<String> {
1200 let folded = fold_repetitive_progress(output)?;
1201 (count_tokens_for(&folded, family) < original_tokens).then_some(folded)
1202}
1203
1204pub(crate) fn is_chained_command(command: &str) -> bool {
1210 let mut in_single = false;
1211 let mut in_double = false;
1212 let bytes = command.as_bytes();
1213 let len = bytes.len();
1214 for i in 0..len {
1215 match bytes[i] {
1216 b'\'' if !in_double => in_single = !in_single,
1217 b'"' if !in_single => in_double = !in_double,
1218 b'&' if !in_single && !in_double && i + 1 < len && bytes[i + 1] == b'&' => {
1219 return true;
1220 }
1221 b';' if !in_single && !in_double => return true,
1222 _ => {}
1223 }
1224 }
1225 false
1226}
1227
1228pub fn compress_if_beneficial_pub(command: &str, output: &str) -> String {
1229 compress_if_beneficial_pub_for(command, output, COUNTING_FAMILY)
1230}
1231
1232pub(crate) fn compress_if_beneficial_pub_for(
1233 command: &str,
1234 output: &str,
1235 family: TokenizerFamily,
1236) -> String {
1237 compress_if_beneficial_for(command, output, family)
1238}
1239
1240pub fn preserve_verbatim_pub_for(output: &str, family: TokenizerFamily) -> String {
1248 truncate_verbatim(output, count_tokens_for(output, family), family)
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::{BuildOutputKind, classify_build_output};
1254
1255 #[test]
1256 fn test_classify_clean_build() {
1257 assert_eq!(
1258 classify_build_output("cargo build", "Finished dev profile", 0),
1259 BuildOutputKind::Clean
1260 );
1261 }
1262
1263 #[test]
1264 fn test_classify_warnings_only() {
1265 assert_eq!(
1266 classify_build_output("cargo check", "warning: unused variable", 0),
1267 BuildOutputKind::WarningsOnly
1268 );
1269 }
1270
1271 #[test]
1272 fn test_classify_has_errors() {
1273 assert_eq!(
1274 classify_build_output("cargo build", "error[E0308]: mismatched types", 1),
1275 BuildOutputKind::HasErrors
1276 );
1277 }
1278
1279 #[test]
1280 fn test_classify_exit_nonzero() {
1281 assert_eq!(
1282 classify_build_output("cargo clippy", "lint output", 1),
1283 BuildOutputKind::HasErrors
1284 );
1285 }
1286
1287 #[test]
1288 fn test_classify_with_env_prefix() {
1289 assert_eq!(
1290 classify_build_output("RUST_LOG=debug cargo build", "warning: unused variable", 0),
1291 BuildOutputKind::WarningsOnly
1292 );
1293 }
1294}
1295
1296#[cfg(test)]
1297mod gh1663 {
1298 use super::*;
1299
1300 fn grep_output() -> Vec<String> {
1302 let mut v: Vec<String> = (0..236)
1303 .map(|i| format!("./interp/typecheck.go:{}:\t\t\tc0 = c0.child[0]", 90 + i))
1304 .collect();
1305 v[204] = "./interp/debugger.go:720:\tfor _, ch := range sc.child {".to_string();
1306 v
1307 }
1308
1309 #[test]
1310 fn grep_shaped_output_is_never_sampled_head_and_tail() {
1311 let owned = grep_output();
1312 let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
1313 assert!(is_match_shaped(&lines), "precondition: reads as matches");
1314
1315 let out = truncate_with_safety_scan(&lines, 10_000, TokenizerFamily::Cl100k)
1316 .expect("output is long enough to compress");
1317
1318 assert!(
1319 !out.contains("safety-relevant lines preserved"),
1320 "the head+tail sampler must not run on search results: {out}"
1321 );
1322 assert!(
1323 out.contains("SAMPLE"),
1324 "the sample must announce itself: {out}"
1325 );
1326 assert!(
1327 out.contains("of 236 matching lines"),
1328 "and name the true match count: {out}"
1329 );
1330 }
1331
1332 #[test]
1335 fn the_shown_matches_are_a_contiguous_prefix() {
1336 let owned = grep_output();
1337 let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
1338 let out = truncate_with_safety_scan(&lines, 10_000, TokenizerFamily::Cl100k).unwrap();
1339
1340 let body: Vec<&str> = out
1341 .lines()
1342 .take_while(|l| !l.starts_with("[⚠ SAMPLE"))
1343 .collect();
1344 for (i, line) in body.iter().enumerate() {
1345 assert_eq!(*line, lines[i], "line {i} is out of order or substituted");
1346 }
1347 }
1348
1349 #[test]
1351 fn timestamped_logs_are_not_mistaken_for_matches() {
1352 let owned: Vec<String> = (0..120)
1353 .map(|i| format!("12:34:{:02} INFO worker {i} still running", i % 60))
1354 .collect();
1355 let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
1356 assert!(!is_match_shaped(&lines), "a timestamp is not a path");
1357 }
1358
1359 #[test]
1361 fn a_small_match_set_is_not_truncated_at_all() {
1362 let owned: Vec<String> = (0..25)
1363 .map(|i| format!("src/main.rs:{i}: let x = {i};"))
1364 .collect();
1365 let lines: Vec<&str> = owned.iter().map(String::as_str).collect();
1366 assert!(is_match_shaped(&lines));
1367 assert!(truncate_match_lines(&lines, 10_000, TokenizerFamily::Cl100k).is_none());
1368 }
1369}
1370
1371#[cfg(test)]
1372mod cd_prefix_tests {
1373 #[test]
1376 fn cd_prefixed_git_log_matches_git_dash_c() {
1377 let mut out = String::new();
1378 for i in 0..40 {
1379 out.push_str(&format!(
1380 "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"
1381 ));
1382 }
1383 let plain = super::compress_if_beneficial("git -C /repo log --stat -40", &out);
1384 let cd = super::compress_if_beneficial("cd /repo && git log --stat -40", &out);
1385 assert_eq!(cd, plain);
1386 assert!(cd.contains("+80/-80 total"), "{cd}");
1387 }
1388}