Skip to main content

lean_ctx/shell/compress/
engine.rs

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            // On failure, label the stderr block so the agent can attribute the
35            // error (mirrors `shell::combine_streams`); success keeps the plain
36            // join for byte-stable output (#498).
37            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
55/// Compress one stream, but never lossily for a command that actually FAILED.
56///
57/// A non-zero exit keeps the output verbatim (size-capped via `truncate_verbatim`
58/// with safety-needle-preserving head/tail truncation) so the real error always
59/// reaches the model and the agent never has to re-run the command without
60/// lean-ctx (#809 / #810). This generalizes the build-tool-error guard inside
61/// `compress_if_beneficial` to ANY non-zero exit. Empty output and explicit
62/// `<lc_safe>` spans keep the normal pipeline (the latter so its markers are
63/// stripped correctly); a succeeding command still compresses as before.
64pub 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
89/// Opt-in (#936) lossless crush of a *verbatim* data command's JSON. Returns a
90/// savings-footer'd, fully reconstructible reshape only when `enabled` and the
91/// crush both pays (at least halves the bytes, via `crush_verbatim`) and clears
92/// the output-token floor; otherwise `None`, so the caller keeps the output
93/// verbatim. Kept pure (env read stays in the caller) so the gate is unit-tested
94/// without mutating the process environment.
95pub(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
126/// Distinct-value ratio at/above which the lossy stage drops an all-present
127/// column. Conservative: only near-unique noise (timestamps, UUIDs) is dropped,
128/// so genuinely varying-but-meaningful columns are kept.
129/// #1129: minimum token savings to justify compression overhead (tee-log pointer
130/// + potential read-back round-trip). Below this floor, verbatim is cheaper.
131const MIN_USEFUL_SAVINGS: usize = 50;
132
133const LOSSY_DROP_ENTROPY: f64 = 0.9;
134
135/// Opt-in (#936) **lossy** escalation for a verbatim data command's JSON, used
136/// only after [`verbatim_json_crush`] (lossless) did not pay. Drops near-unique
137/// high-entropy columns and — because data is then lost — persists the verbatim
138/// original to the shared CCR store, appending a `ctx_expand` handle so a dropped
139/// datum is always recoverable out-of-band (never from the text). Returns `None`
140/// unless enabled, the crush both drops a column and clears the token floor, and
141/// the original is large enough to persist. The embedded handle is content-
142/// addressed, so the rewritten output stays byte-stable across turns (#448/#498).
143pub(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    // Dropped columns must be recoverable out-of-band; bail if we cannot persist
174    // (then the lossless/verbatim path keeps the data) rather than lose it.
175    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
183/// Try the columnar crusher with the comma then the tab delimiter, returning the
184/// first that crushes. Shell output carries no file extension, so the delimiter
185/// is inferred by trying the two common ones; the crusher self-guards (returns
186/// `None` unless the text is a genuinely redundant rectangular table).
187fn 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
191/// Opt-in (#982) lossless crush of a *verbatim* command's delimited (CSV/TSV)
192/// output, tried after the JSON crush did not pay. Hoists constant columns via
193/// the columnar crusher — fully reconstructible, so no CCR handle is needed.
194/// Returns a footer'd reshape only when `enabled` and the crush both pays (at
195/// least halves the bytes) and clears the token floor; otherwise `None`.
196pub(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
213/// Opt-in (#982) **lossy** escalation for a verbatim command's CSV/TSV output,
214/// used only after [`verbatim_tabular_crush_for`] (lossless) did not pay. Drops
215/// near-unique high-entropy columns and — because data is then lost — persists
216/// the verbatim original to the shared CCR store, appending a `ctx_expand` handle
217/// so a dropped datum is always recoverable out-of-band (never from the text).
218/// The embedded handle is content-addressed, so the output stays byte-stable
219/// across turns (#448/#498).
220pub(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
245/// Opt-in (#985) lossless crush of a *verbatim* command's YAML output (e.g.
246/// `kubectl get -o yaml`, `helm get values`), tried after the JSON and tabular
247/// crushers did not pay. Maps the document onto the JSON value model and compacts
248/// it through the shared crusher — fully reconstructible to the parsed value, so
249/// no CCR handle is needed. The crusher self-guards (returns `None` unless the
250/// text is a genuinely structured, redundant document). Returns a footer'd
251/// reshape only when `enabled` and the crush clears both the reduction gate and
252/// the token floor; otherwise `None`.
253pub(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
269/// Opt-in (#985) **lossy** escalation for a verbatim command's YAML output, used
270/// only after [`verbatim_yaml_crush_for`] (lossless) did not pay. Drops near-unique
271/// high-entropy columns and — because data is then lost — persists the verbatim
272/// original to the shared CCR store, appending a `ctx_expand` handle so a dropped
273/// datum is always recoverable out-of-band (never from the text). The embedded
274/// handle is content-addressed, so the output stays byte-stable across turns
275/// (#448/#498).
276pub(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
299/// HTML content extractor (#1124): extracts article/main content from web
300/// pages and converts to clean markdown. Triggered for HTML content in the
301/// verbatim crusher ladder (curl, wget, fetch outputs). The full HTML is
302/// persisted to CCR under the `html_` prefix for recovery via `ctx_expand`.
303pub(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
334/// Per-tool minimum token threshold for compression.
335/// Test runners and structured commands benefit from compression even at lower
336/// token counts because their output has high structural redundancy.
337fn 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    // #709: honour explicit <lc_safe>…</lc_safe> spans. Secret redaction has
373    // already run upstream (ctx_shell::handle → redact_shell_output_secrets), so
374    // the pipeline order is redact → protect → compress and a marker can never
375    // smuggle a secret past redaction. Protected spans pass through verbatim;
376    // each unprotected segment flows through the normal pipeline (footer stripped),
377    // and a single savings footer is recomputed over the spliced result.
378    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    // Test-runner output: structurally compress successful runs through the
395    // dedicated test-pattern compressors (cargo test, pytest, jest, etc.).
396    // Failed runs (exit_code != 0) stay verbatim to preserve failure diagnostics.
397    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    // Compiler errors, type errors, and lint findings must be preserved verbatim
413    // so the agent can see file paths, line numbers, and full diagnostics.
414    // Warning-only successful builds intentionally fall through to the normal
415    // pattern pipeline, where their dedicated compressor can summarize them.
416    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        // A clean build is progress lines plus a verdict. Build commands are
424        // otherwise kept verbatim by the output policy below, so without this
425        // a *successful* `cargo build` shipped hundreds of `Compiling …` lines
426        // while a failing one had them folded.
427        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    // #1129: small outputs are cheaper verbatim than compressed + tee-log round-trip.
444    // The tee-log pointer alone costs ~50 tokens; a second read-back call doubles
445    // the token cost. Outputs below this floor are never worth compressing.
446    // Tool-specific thresholds: test runners and structured commands benefit from
447    // compression even at lower token counts.
448    if original_tokens < min_compression_tokens(command) {
449        return output.to_string();
450    }
451
452    // #1387: detect record boundaries in multi-record output. When found,
453    // compress each segment independently to prevent cross-record content
454    // reattribution (e.g. for-loop output with `===== #N` delimiters).
455    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        // Opt-in (#936): a verbatim *data* command emitting array-heavy JSON
476        // (gh api, jq, kubectl get -o json, curl) can be losslessly crushed —
477        // reconstructible, never a dropped datum — when it at least halves the
478        // payload. Passthrough (auth/dev servers/streaming) is never touched.
479        if policy == crate::shell::output_policy::OutputPolicy::Verbatim {
480            let enabled = cfg.crush_verbatim_json_enabled();
481            // Lossless first (fully reconstructible). Only if it does not pay does
482            // the lossy stage drop high-entropy noise — and always behind a CCR
483            // handle, so a dropped datum is never irrecoverable (#936).
484            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            // Non-JSON delimited data (CSV/TSV): same lossless-then-lossy ladder,
499            // self-guarding so only a genuinely redundant table is ever reshaped.
500            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            // Structured YAML (kubectl/helm -o yaml): same lossless-then-lossy
519            // ladder, self-guarding so only a genuinely structured, redundant
520            // document is ever reshaped.
521            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    // Format-aware passthrough (#342): output already in a compact, token-oriented
545    // format the user opted to preserve (TOON by default) is kept verbatim.
546    // Recompressing it saves little and rewrites the exact line/field shape an
547    // agent relies on to validate a CLI output contract. This is output-shape
548    // based, so any tool emitting the format is covered without listing commands.
549    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    // `cd <dir> && git log …` is `git log …` for compression purposes (#1893).
563    let command = patterns::strip_leading_cd(command);
564
565    // Structural output AND version-control history are owned by their
566    // dedicated compressor: apply it if it yields a gain, otherwise return the
567    // output verbatim. Never let the generic terse/dedup/truncate fallbacks
568    // below reshape it — they would corrupt commit subjects/hashes or drop
569    // commits the caller explicitly requested (`git log --oneline -40`).
570    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            // #1286: the terse result may only REPLACE the pattern compressor's
598            // output when it is actually smaller. It used to win on
599            // quality_passed alone — measured: `ls -la` had its ~80%-saving
600            // structural compression displaced by a ~6% terse dictionary pass.
601            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
666/// Strip a trailing `\n[lean-ctx: …]` savings footer so per-segment results can
667/// be spliced (protect spans, #709) before a single footer is recomputed.
668fn strip_shell_footer(s: &str) -> &str {
669    match s.rfind("\n[lean-ctx: ") {
670        Some(pos) => &s[..pos],
671        None => s,
672    }
673}
674
675/// Detects whether the output contains error diagnostics from a build/check/lint tool.
676/// When true, compression is bypassed to preserve file paths, line numbers, and messages.
677/// #848: Deduplicate identical diagnostic lines in build output.
678/// MSBuild parallel builds print each warning/error twice: once per build-node
679/// and once in the summary section. This removes exact duplicates while
680/// preserving order (first occurrence wins).
681pub(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        // We can't push a &str that references a local — collect into owned
704        let mut out: String = result.join("\n");
705        out.push('\n');
706        out.push_str(&note);
707        return out;
708    }
709    output.to_string()
710}
711
712/// Recognises MSBuild / compiler diagnostic lines (file(line): warning CS1234: ...)
713/// and generic compiler diagnostics (file:line: warning/error: ...).
714/// Normalize a diagnostic line for dedup: strip trailing `[project]` suffixes
715/// that MSBuild appends per build node, so identical diagnostics from parallel
716/// nodes collapse into one.
717fn normalize_diagnostic_key(line: &str) -> String {
718    let trimmed = line.trim();
719    // MSBuild appends ` [/path/to/project.csproj]` — strip it.
720    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    // MSBuild: path(line,col): warning CS1234: message
733    // MSBuild: path(line,col): error CS1234: message
734    if (line.contains("): warning ") || line.contains("): error ")) && line.contains('(') {
735        return true;
736    }
737    // GCC/Clang/Rust: path:line:col: warning: / error: / note:
738    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    /// No errors or warnings — clean build.
749    Clean,
750    /// Only warnings, no errors — can be pattern-compressed.
751    WarningsOnly,
752    /// Has actual errors — preserve diagnostics verbatim.
753    HasErrors,
754    /// Not a build tool command.
755    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
838/// Strips leading `VAR=value` environment assignments from a command segment so
839/// `RUST_BACKTRACE=1 cargo test` / `CI=true pytest` are still recognized as the
840/// underlying test runner.
841fn 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        // An env assignment is a single token containing '=' before any '/' so it
848        // isn't confused with a path or a flag like `--threads=4`.
849        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
861/// Detects test-runner commands across ecosystems. Their output must never be
862/// semantically compressed/deduplicated — only verbatim head/tail truncation
863/// (with middle test/error lines preserved). Matched even for fully-passing
864/// runs so per-suite summaries always survive. Checks each pipeline segment so
865/// `cargo test … | grep …` / `pytest … | tail` are caught too.
866fn 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
908/// For verbatim commands: never transform content, only head/tail truncate if huge.
909///
910/// Even when truncating, every safety- and test-relevant line from the omitted
911/// middle is preserved (test-result summaries, panics, failures, errors). This
912/// guarantees a large test run — even a fully passing one with dozens of
913/// per-suite `test result:` lines — never silently loses its outcome lines,
914/// regardless of OS or client (issue: compression must never swallow signal).
915fn 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    // Preserve up to 200 safety/test/diagnostic lines from the omitted middle so
929    // buried failures and per-suite summaries survive head/tail truncation.
930    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
966/// Does this line have the `path:line:` shape every `grep -n` / `rg` match has?
967///
968/// The prefix must not be all digits, which is what separates a path from a
969/// `12:34:56` timestamp in a log — the one common shape that would otherwise
970/// read as a match.
971fn 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
989/// Is this output a search result rather than a log?
990fn 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
1002/// GH #1663: search results are not logs, and must never be sampled.
1003///
1004/// Head+tail sampling assumes the interesting content sits at the edges. For a
1005/// search result every line is a discrete answer and the decisive one is at an
1006/// arbitrary position — usually the *unusual* one, the single reader among two
1007/// hundred writers. Sampling is biased against exactly the line that matters,
1008/// and `[222 lines omitted]` reads as "more of the same" rather than "the
1009/// answer may be in here". A reporter concluded a struct field was dead code on
1010/// a sampled result whose dropped middle held the line proving it was read.
1011///
1012/// So: keep a contiguous prefix, in order, and say plainly that this is a
1013/// sample and how many matches were not shown. The full set stays reachable
1014/// through the archive reference the caller already gets.
1015fn 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
1152/// Pure dot-run lines (pytest/unittest progress) of any length. Lines with any
1153/// other character (e.g. `....F...`, which encodes a failure) are never folded.
1154fn 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
1204/// Detects shell command chains (`&&`, `;`) outside of quotes.
1205///
1206/// Chained commands must skip pattern-based compression because each pattern
1207/// matcher assumes a single command's output shape — applying the first
1208/// segment's pattern to the whole chain silently drops later segments (#1130).
1209pub(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
1240/// Preserve build/test output verbatim, applying only the safety-line-preserving
1241/// head/tail truncation when it is oversized.
1242///
1243/// The proxy funnel uses this when a foreign shell tool produced unmistakable
1244/// build/test output but supplied no recognizable command — the engine's
1245/// command-gated verbatim guards cannot fire, yet compiler errors, panics and
1246/// test summaries must still reach the model intact for a bug-fix task.
1247pub 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    /// 236 grep matches, the decisive one at 205 — the reporter's real case.
1301    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    /// What is shown is a contiguous, ordered prefix — so "not in the output"
1333    /// can never be mistaken for "not in the results".
1334    #[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    /// A log with timestamps must keep the sampler: `12:34:56` is not a path.
1350    #[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    /// A short result set is delivered whole — no notice, no sampling.
1360    #[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    /// #1893: `cd <repo> && git log --stat` must be compressed by the git
1374    /// pattern exactly like `git -C <repo> log --stat`, not by generic truncation.
1375    #[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}