Skip to main content

lean_ctx_patterns/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Deterministic shell-output compression patterns for LeanCTX | OSS.
4//!
5//! Every pattern is a pure text transform: command line plus output in,
6//! compressed output (or `None`) out. Engine policy (protected commands,
7//! user filters, configuration) stays in the engine, which reaches this crate
8//! through its `core::patterns` facade. Engine-supplied facts enter through
9//! [`PatternEnv`], so this crate never depends on the engine.
10
11/// Pattern engine version. Bump when output format changes to maintain
12/// shell-output determinism guarantees. Consumers (proofs, benchmarks)
13/// can embed this version to detect pattern-breaking updates.
14///
15/// v2 (#936): `json_schema::compress` now prefers the lossless `json_crush`
16/// form over the schema outline for redundant array-of-object payloads.
17pub const PATTERN_ENGINE_VERSION: u32 = 2;
18
19pub mod ansi;
20pub mod json_crush;
21pub mod safety_needles;
22pub mod tokens;
23
24pub mod alembic;
25pub mod ansible;
26pub mod argocd;
27pub mod artisan;
28pub mod aws;
29pub mod bazel;
30pub mod buf;
31pub mod bun;
32pub mod cargo;
33pub mod clang;
34pub mod cmake;
35pub mod composer;
36pub mod cosign;
37pub mod curl;
38pub mod dbt;
39pub mod deno;
40pub mod deploy;
41pub mod deps_cmd;
42pub mod docker;
43pub mod dotnet;
44pub mod emspace;
45pub mod env_filter;
46pub mod eslint;
47pub mod fd;
48pub mod find;
49pub mod flutter;
50pub mod flyway;
51pub mod gem;
52pub mod gh;
53pub mod git;
54pub mod glab;
55pub mod golang;
56pub mod grep;
57pub mod grype;
58pub mod helm;
59pub mod jj;
60pub mod json_schema;
61pub mod just;
62pub mod kubectl;
63pub mod linkerd;
64pub mod log_dedup;
65pub mod lossless_collapse;
66pub mod ls;
67pub mod make;
68pub mod maven;
69pub mod mise;
70pub mod mix;
71pub mod mlflow;
72pub mod mypy;
73pub mod mysql;
74pub mod next_build;
75pub mod ninja;
76pub mod npm;
77pub mod ollama;
78pub mod php;
79pub mod pip;
80pub mod playwright;
81pub mod pnpm;
82pub mod poetry;
83pub mod prettier;
84pub mod prisma;
85pub mod psql;
86pub mod pulumi;
87pub mod pytest;
88pub mod ruby;
89pub mod ruff;
90pub mod semgrep;
91pub mod spark;
92pub mod swift;
93pub mod swiftlint;
94pub mod syft;
95pub mod sysinfo;
96pub mod systemd;
97pub mod template_collapse;
98pub mod terraform;
99pub mod test;
100pub mod trivy;
101pub mod typescript;
102pub mod wget;
103pub mod zig;
104
105use crate::tokens::count_tokens;
106
107/// Engine-supplied facts a pattern may need. The engine implements this from
108/// its configuration. [`NoEnv`] is the neutral environment for callers without
109/// one (tests, tools that run outside an engine).
110pub trait PatternEnv {
111    /// True when `command` (lower-cased) fetches a configured passthrough URL;
112    /// the curl pattern then leaves the response to the generic fallbacks.
113    fn is_curl_passthrough(&self, command: &str) -> bool;
114}
115
116/// The neutral [`PatternEnv`]: no passthrough URLs.
117pub struct NoEnv;
118
119impl PatternEnv for NoEnv {
120    fn is_curl_passthrough(&self, _command: &str) -> bool {
121        false
122    }
123}
124
125/// The policy-free part of the engine's `compress_output`: everything after the
126/// protected-command gate, the leading-`cd` unwrap, ANSI stripping and the
127/// user filter rules, which the engine applies first. `clean_output` is the
128/// ANSI-stripped output, `output` the raw one every result is measured against.
129pub fn compress_clean(
130    env: &dyn PatternEnv,
131    command: &str,
132    clean_output: &str,
133    output: &str,
134) -> Option<String> {
135    // VCS history (git/jj/gh/glab/hg) is owned by its dedicated compressor. Its
136    // lines look log-ish (one per commit) but are NOT application logs, so the
137    // generic json/log/test fallbacks would mis-summarize them — e.g. truncating
138    // an explicit `git log --oneline -40` to "last 15" or reading a commit
139    // subject like "fix: pending_errors" as an error line. Return the dedicated
140    // compressor's result directly — even when it is not shorter, so an already
141    // compact oneline log is preserved verbatim (full history intact) instead of
142    // being reshaped by a generic heuristic.
143    if has_vcs_owner(command) {
144        // The VCS compressor is authoritative and is kept even when it is not
145        // strictly *shorter*, so a compact `git log --oneline` is preserved
146        // verbatim (full history intact). It must still never return *more*
147        // tokens than its input, though — a tiny adversarial `git status` body
148        // could otherwise reshape into a one-token-larger summary and break the
149        // never-inflate invariant. Allow equal (verbatim), reject only growth.
150        return try_specific_pattern_with(env, command, clean_output)
151            .filter(|c| !c.trim().is_empty())
152            .filter(|c| !inflates_tokens(c, output));
153    }
154
155    if let Some(compressed) = try_specific_pattern_with(env, command, clean_output)
156        && let Some(r) = shorter_only(compressed, output)
157    {
158        return Some(r);
159    }
160
161    if let Some(r) = json_schema::compress(clean_output)
162        && let Some(r) = shorter_only(r, output)
163    {
164        return Some(r);
165    }
166
167    if let Some(r) = log_dedup::compress(clean_output)
168        && let Some(r) = shorter_only(r, output)
169    {
170        return Some(r);
171    }
172
173    if let Some(r) = test::compress(clean_output)
174        && let Some(r) = shorter_only(r, output)
175    {
176        return Some(r);
177    }
178
179    None
180}
181
182/// True for version-control commands whose output is authoritative under their
183/// own compressor and must not be reinterpreted by the generic log/test
184/// fallbacks (commit history is not application log output). emspace is owned
185/// the same way: its page bodies are `page edit` anchors and must stay exact.
186pub fn has_vcs_owner(command: &str) -> bool {
187    let c = command.trim_start().to_ascii_lowercase();
188    c.starts_with("git ")
189        || c.starts_with("jj ")
190        || c.starts_with("gh ")
191        || c.starts_with("glab ")
192        || c.starts_with("hg ")
193        || c.starts_with("emspace ")
194}
195
196/// Collapse whitespace into single spaces so comparisons align with logical word tokens.
197fn normalize_shell_tokens(text: &str) -> String {
198    text.split_whitespace().collect::<Vec<_>>().join(" ")
199}
200
201/// True when `compressed` carries *more* tokens than the raw `original` — i.e.
202/// the transform inflated rather than compressed. Compared against the raw
203/// (pre-ANSI-strip) output so the guard matches the public `compress_output`
204/// invariant exactly: the returned text never tokenizes larger than its input.
205pub fn inflates_tokens(compressed: &str, original: &str) -> bool {
206    count_tokens(compressed) > count_tokens(original)
207}
208
209/// `Some(compressed)` only when it carries fewer tokens than `original`
210/// (ties broken by length), compared on whitespace-normalized text.
211pub fn shorter_only(compressed: String, original: &str) -> Option<String> {
212    let orig_n = normalize_shell_tokens(original);
213    let comp_n = normalize_shell_tokens(&compressed);
214    let ct_c = count_tokens(&comp_n);
215    let ct_o = count_tokens(&orig_n);
216    if ct_c < ct_o || (ct_c == ct_o && comp_n.len() < orig_n.len()) {
217        Some(compressed)
218    } else {
219        None
220    }
221}
222
223/// How an elided block announces itself (GH #1679).
224///
225/// The old marker was `... (N more lines)`, which reads as content: a caller
226/// could not tell it from a line the command actually printed. Worse, it was the
227/// *only* trace of the cut — the savings footer that would have announced it is
228/// hidden on the MCP path by default (`SavingsFooter::Auto` → invisible in an
229/// MCP context), which is the primary path. So a lossy result was
230/// indistinguishable from a complete one, with nothing to recover from.
231///
232/// This marker is unmistakably ours, and names the way back. `raw=true` is what
233/// the server instructions and every other compression annotation already point
234/// at, and unlike an archive handle it is always available — it does not depend
235/// on the response having been archived or on a hint tier being enabled.
236pub fn elision_marker(omitted: usize) -> String {
237    let plural = if omitted == 1 { "line" } else { "lines" };
238    format!("[lean-ctx: {omitted} {plural} elided — re-run with raw=true for the full output]")
239}
240
241type PatternMatcher = fn(&str) -> bool;
242type PatternHandler = fn(&str, &str) -> Option<String>;
243
244/// Ordered prefix → compressor dispatch table (#660 CC reduction): first
245/// matching entry wins, mirroring the previous `if c.starts_with(..) { return
246/// ...; }` cascade exactly — including that a matched entry's `None` is
247/// final, never falls through to a later entry. Registering a new CLI tool
248/// is now a one-line addition here instead of a new branch in
249/// `try_specific_pattern`.
250/// Number of registered shell-output patterns, for the docs drift check (#1896).
251pub fn pattern_count() -> usize {
252    PATTERNS.len()
253}
254
255const PATTERNS: &[(PatternMatcher, PatternHandler)] = &[
256    (
257        |c| c.starts_with("git "),
258        |c, output| git::compress(c, output),
259    ),
260    (
261        |c| c.starts_with("gh "),
262        |c, output| gh::compress(c, output),
263    ),
264    (
265        |c| c.starts_with("glab "),
266        |c, output| glab::try_glab_pattern(c, output),
267    ),
268    (
269        |c| c.starts_with("emspace "),
270        |c, output| emspace::compress(c, output),
271    ),
272    (
273        |c| c == "terraform" || c.starts_with("terraform "),
274        |c, output| terraform::compress(c, output),
275    ),
276    (
277        |c| c == "make" || c.starts_with("make "),
278        |c, output| make::compress(c, output),
279    ),
280    (
281        |c| c == "just" || c.starts_with("just "),
282        |c, output| just::compress(c, output),
283    ),
284    (
285        |c| {
286            c.starts_with("mvn ")
287                || c.starts_with("./mvnw ")
288                || c.starts_with("mvnw ")
289                || c.starts_with("gradle ")
290                || c.starts_with("./gradlew ")
291                || c.starts_with("gradlew ")
292        },
293        |c, output| maven::compress(c, output),
294    ),
295    (
296        |c| c.starts_with("kubectl ") || c.starts_with("k "),
297        |c, output| kubectl::compress(c, output),
298    ),
299    (
300        |c| c.starts_with("helm "),
301        |c, output| helm::compress(c, output),
302    ),
303    (
304        |c| c.starts_with("pnpm "),
305        |c, output| pnpm::compress(c, output),
306    ),
307    (
308        |c| c.starts_with("bun ") || c.starts_with("bunx "),
309        |c, output| bun::compress(c, output),
310    ),
311    (
312        |c| c.starts_with("deno "),
313        |c, output| deno::compress(c, output),
314    ),
315    (
316        |c| c.starts_with("npm ") || c.starts_with("yarn "),
317        |c, output| npm::compress(c, output),
318    ),
319    (
320        |c| c.starts_with("cargo "),
321        |c, output| cargo::compress(c, output),
322    ),
323    (
324        |c| c.starts_with("docker ") || c.starts_with("docker-compose "),
325        |c, output| docker::compress(c, output),
326    ),
327    (
328        |c| c.starts_with("pip ") || c.starts_with("pip3 ") || c.starts_with("python -m pip"),
329        |c, output| pip::compress(c, output),
330    ),
331    (
332        |c| c.starts_with("mypy") || c.starts_with("python -m mypy") || c.starts_with("dmypy "),
333        |c, output| mypy::compress(c, output),
334    ),
335    (
336        |c| c.starts_with("pytest") || c.starts_with("python -m pytest"),
337        |c, output| pytest::compress(c, output).or_else(|| test::compress(output)),
338    ),
339    (
340        |c| c.starts_with("ruff "),
341        |c, output| ruff::compress(c, output),
342    ),
343    (
344        |c| {
345            c.starts_with("eslint")
346                || c.starts_with("npx eslint")
347                || c.starts_with("biome ")
348                || c.starts_with("stylelint")
349        },
350        |c, output| eslint::compress(c, output),
351    ),
352    (
353        |c| c.starts_with("prettier") || c.starts_with("npx prettier"),
354        |_c, output| prettier::compress(output),
355    ),
356    (
357        |c| c.starts_with("go ") || c.starts_with("golangci-lint") || c.starts_with("golint"),
358        |c, output| golang::compress(c, output),
359    ),
360    (
361        |c| {
362            c.starts_with("playwright")
363                || c.starts_with("npx playwright")
364                || c.starts_with("cypress")
365                || c.starts_with("npx cypress")
366        },
367        |c, output| playwright::compress(c, output),
368    ),
369    (
370        |c| c.starts_with("vitest") || c.starts_with("npx vitest") || c.starts_with("pnpm vitest"),
371        |_c, output| test::compress(output),
372    ),
373    (
374        |c| {
375            c.starts_with("next ")
376                || c.starts_with("npx next")
377                || c.starts_with("vite ")
378                || c.starts_with("npx vite")
379                || c.starts_with("vp ")
380                || c.starts_with("vite-plus ")
381        },
382        |c, output| next_build::compress(c, output),
383    ),
384    (
385        |c| c.starts_with("tsc") || c.contains("typescript"),
386        |_c, output| typescript::compress(output),
387    ),
388    (
389        |c| {
390            c.starts_with("rubocop")
391                || c.starts_with("bundle ")
392                || c.starts_with("rake ")
393                || c.starts_with("rails test")
394                || c.starts_with("rspec")
395        },
396        |c, output| ruby::compress(c, output),
397    ),
398    (|c| is_grep_family(c), |_c, output| grep::compress(output)),
399    (
400        |c| c.starts_with("find "),
401        |_c, output| find::compress(output),
402    ),
403    (
404        |c| c.starts_with("fd ") || c.starts_with("fdfind "),
405        |_c, output| fd::compress(output),
406    ),
407    (
408        |c| c.starts_with("ls ") || c == "ls",
409        |_c, output| ls::compress(output),
410    ),
411    (
412        |c| c.starts_with("curl "),
413        |_c, output| curl::compress(output),
414    ),
415    (
416        |c| c.starts_with("wget "),
417        |_c, output| wget::compress(output),
418    ),
419    (
420        |c| c == "env" || c.starts_with("env ") || c.starts_with("printenv"),
421        |_c, output| env_filter::compress(output),
422    ),
423    (
424        |c| c.starts_with("dotnet "),
425        |c, output| dotnet::compress(c, output),
426    ),
427    (
428        |c| {
429            c.starts_with("flutter ")
430                || (c.starts_with("dart ") && (c.contains(" analyze") || c.ends_with(" analyze")))
431        },
432        |c, output| flutter::compress(c, output),
433    ),
434    (
435        |c| {
436            c.starts_with("poetry ")
437                || c.starts_with("uv ")
438                || c.starts_with("conda ")
439                || c.starts_with("mamba ")
440                || c.starts_with("pipx ")
441        },
442        |c, output| poetry::compress(c, output),
443    ),
444    (
445        |c| c.starts_with("aws "),
446        |c, output| aws::compress(c, output),
447    ),
448    (
449        |c| c.starts_with("psql ") || c.starts_with("pg_"),
450        |c, output| psql::compress(c, output),
451    ),
452    (
453        |c| c.starts_with("mysql ") || c.starts_with("mariadb "),
454        |c, output| mysql::compress(c, output),
455    ),
456    (
457        |c| c.starts_with("prisma ") || c.starts_with("npx prisma"),
458        |c, output| prisma::compress(c, output),
459    ),
460    (
461        |c| c.starts_with("swift "),
462        |c, output| swift::compress(c, output),
463    ),
464    (
465        |c| c.starts_with("zig "),
466        |c, output| zig::compress(c, output),
467    ),
468    (
469        |c| c.starts_with("cmake ") || c.starts_with("ctest"),
470        |c, output| cmake::compress(c, output),
471    ),
472    (
473        |c| c.starts_with("ninja"),
474        |c, output| ninja::compress(c, output),
475    ),
476    (
477        |c| c.starts_with("ansible") || c.starts_with("ansible-playbook"),
478        |c, output| ansible::compress(c, output),
479    ),
480    (
481        |c| c.starts_with("composer "),
482        |c, output| composer::compress(c, output),
483    ),
484    (
485        |c| c.starts_with("php artisan") || c.starts_with("artisan "),
486        |c, output| artisan::compress(c, output),
487    ),
488    (
489        |c| c.starts_with("./vendor/bin/pest") || c.starts_with("pest "),
490        |_c, output| artisan::compress("php artisan test", output),
491    ),
492    (
493        |c| c.starts_with("mix ") || c.starts_with("iex "),
494        |c, output| mix::compress(c, output),
495    ),
496    (
497        |c| c.starts_with("bazel ") || c.starts_with("blaze "),
498        |c, output| bazel::compress(c, output),
499    ),
500    (
501        |c| c.starts_with("systemctl ") || c.starts_with("journalctl"),
502        |c, output| systemd::compress(c, output),
503    ),
504    (
505        |c| c.starts_with("jest") || c.starts_with("npx jest") || c.starts_with("pnpm jest"),
506        |_c, output| test::compress(output),
507    ),
508    (
509        |c| c.starts_with("mocha") || c.starts_with("npx mocha"),
510        |_c, output| test::compress(output),
511    ),
512    (
513        |c| c.starts_with("tofu "),
514        |c, output| terraform::compress(c, output),
515    ),
516    (
517        |c| c.starts_with("ps ") || c == "ps",
518        |_c, output| sysinfo::compress_ps(output),
519    ),
520    (
521        |c| c.starts_with("df ") || c == "df",
522        |_c, output| sysinfo::compress_df(output),
523    ),
524    (
525        |c| c.starts_with("du ") || c == "du",
526        |_c, output| sysinfo::compress_du(output),
527    ),
528    (
529        |c| c.starts_with("ping "),
530        |_c, output| sysinfo::compress_ping(output),
531    ),
532    (
533        |c| c.starts_with("jq ") || c == "jq",
534        |_c, output| json_schema::compress(output),
535    ),
536    (
537        |c| c.starts_with("hadolint"),
538        |c, output| eslint::compress(c, output),
539    ),
540    (
541        |c| c.starts_with("yamllint") || c.starts_with("npx yamllint"),
542        |c, output| eslint::compress(c, output),
543    ),
544    (
545        |c| c.starts_with("markdownlint") || c.starts_with("npx markdownlint"),
546        |c, output| eslint::compress(c, output),
547    ),
548    (
549        |c| c.starts_with("oxlint") || c.starts_with("npx oxlint"),
550        |c, output| eslint::compress(c, output),
551    ),
552    (
553        |c| c.starts_with("pyright") || c.starts_with("basedpyright"),
554        |c, output| mypy::compress(c, output),
555    ),
556    (
557        |c| c.starts_with("turbo ") || c.starts_with("npx turbo"),
558        |c, output| npm::compress(c, output),
559    ),
560    (
561        |c| c.starts_with("nx ") || c.starts_with("npx nx"),
562        |c, output| npm::compress(c, output),
563    ),
564    (
565        |c| c.starts_with("clang++ ") || c.starts_with("clang "),
566        |c, output| clang::compress(c, output),
567    ),
568    (
569        |c| {
570            c.starts_with("gcc ")
571                || c.starts_with("g++ ")
572                || c.starts_with("cc ")
573                || c.starts_with("c++ ")
574        },
575        |c, output| cmake::compress(c, output),
576    ),
577    // --- data domain (#657) ---
578    (
579        |c| c == "dbt" || c.starts_with("dbt "),
580        |c, output| dbt::compress(c, output),
581    ),
582    (
583        |c| c == "alembic" || c.starts_with("alembic "),
584        |c, output| alembic::compress(c, output),
585    ),
586    (
587        |c| c == "flyway" || c.starts_with("flyway "),
588        |c, output| flyway::compress(c, output),
589    ),
590    (
591        |c| c.starts_with("spark-submit") || c.starts_with("spark-sql") || c.starts_with("pyspark"),
592        |c, output| spark::compress(c, output),
593    ),
594    // --- ai domain (#658) ---
595    (
596        |c| c == "ollama" || c.starts_with("ollama "),
597        |c, output| ollama::compress(c, output),
598    ),
599    (
600        |c| c.starts_with("mlflow "),
601        |c, output| mlflow::compress(c, output),
602    ),
603    // --- security / supply-chain (#659) ---
604    (
605        |c| c.starts_with("semgrep "),
606        |c, output| semgrep::compress(c, output),
607    ),
608    (
609        |c| c.starts_with("trivy "),
610        |c, output| trivy::compress(c, output),
611    ),
612    (
613        |c| c.starts_with("grype "),
614        |c, output| grype::compress(c, output),
615    ),
616    (
617        |c| c.starts_with("syft "),
618        |c, output| syft::compress(c, output),
619    ),
620    (
621        |c| c.starts_with("cosign "),
622        |c, output| cosign::compress(c, output),
623    ),
624    (
625        |c| c.starts_with("swiftlint"),
626        |c, output| swiftlint::compress(c, output),
627    ),
628    // --- vcs / toolchain (#660) ---
629    (
630        |c| c == "jj" || c.starts_with("jj "),
631        |c, output| jj::compress(c, output),
632    ),
633    (
634        |c| c == "mise" || c.starts_with("mise "),
635        |c, output| mise::compress(c, output),
636    ),
637    (
638        |c| c == "buf" || c.starts_with("buf "),
639        |c, output| buf::compress(c, output),
640    ),
641    (
642        |c| c.starts_with("gem "),
643        |c, output| gem::compress(c, output),
644    ),
645    // --- edge / infra (#661) ---
646    (
647        |c| c == "pulumi" || c.starts_with("pulumi "),
648        |c, output| pulumi::compress(c, output),
649    ),
650    (
651        |c| c.starts_with("linkerd "),
652        |c, output| linkerd::compress(c, output),
653    ),
654    (
655        |c| c.starts_with("argocd "),
656        |c, output| argocd::compress(c, output),
657    ),
658    (
659        |c| {
660            c == "vercel"
661                || c.starts_with("vercel ")
662                || c == "fly"
663                || c.starts_with("fly ")
664                || c.starts_with("flyctl ")
665                || c.starts_with("wrangler ")
666                || c.starts_with("skaffold ")
667                || c.starts_with("supabase ")
668        },
669        |c, output| deploy::compress(c, output),
670    ),
671];
672
673/// A grep-family search command, bare or with arguments (#980, #985).
674///
675/// The bare form matters: `proxy::compress::infer_command` synthesises `grep`
676/// (no arguments) from tool names like `search_files`. The old `starts_with("grep ")`
677/// required a trailing space and never matched, so those results fell through to the
678/// terse pipeline which corrupted source identifiers.
679fn is_grep_family(cmd: &str) -> bool {
680    let first = cmd.split_whitespace().next().unwrap_or("");
681    let base = first.rsplit('/').next().unwrap_or(first);
682    matches!(
683        base,
684        "grep" | "egrep" | "fgrep" | "rg" | "ag" | "ack" | "ugrep" | "sift"
685    )
686}
687
688/// Route `cmd` to its dedicated compressor: the first matching entry of the
689/// dispatch table wins, and a matched entry's `None` is final.
690pub fn try_specific_pattern_with(env: &dyn PatternEnv, cmd: &str, output: &str) -> Option<String> {
691    let cl = cmd.to_ascii_lowercase();
692    let c = cl.as_str();
693
694    // No earlier table entry matches `curl `, so answering here is the same as
695    // the curl entry declining.
696    if c.starts_with("curl ") && env.is_curl_passthrough(c) {
697        return None;
698    }
699
700    PATTERNS
701        .iter()
702        .find(|(matches, _)| matches(c))
703        .and_then(|(_, handle)| handle(c, output))
704}
705
706#[cfg(test)]
707mod gh1679 {
708    use super::*;
709
710    /// The reported failure: a `gh issue view --comments` result had 34 lines
711    /// removed from the middle, marked only by `... (34 more lines)`. That
712    /// string is shaped like content — the caller could not tell it from a line
713    /// the command printed — and it was the only trace, because the savings
714    /// footer that would have announced the cut is hidden on the MCP path by
715    /// default. A lossy result was indistinguishable from a complete one.
716    #[test]
717    fn an_elision_announces_itself_as_ours() {
718        let marker = elision_marker(34);
719
720        assert!(
721            marker.starts_with("[lean-ctx:"),
722            "must be unmistakably ours, not content-shaped: {marker}"
723        );
724        assert!(
725            !marker.contains("more lines)"),
726            "the old content-shaped wording must be gone: {marker}"
727        );
728        assert!(marker.contains("34"), "{marker}");
729    }
730
731    /// The marker is the only thing guaranteed to survive, so the way back has
732    /// to be in it. `raw=true` always works — unlike an archive handle, it does
733    /// not depend on the response having been archived or on a hint tier.
734    #[test]
735    fn the_marker_names_a_recovery_that_always_exists() {
736        assert!(
737            elision_marker(1).contains("raw=true"),
738            "{}",
739            elision_marker(1)
740        );
741    }
742
743    #[test]
744    fn one_line_is_not_called_lines() {
745        assert!(
746            elision_marker(1).contains("1 line elided"),
747            "{}",
748            elision_marker(1)
749        );
750        assert!(
751            elision_marker(2).contains("2 lines elided"),
752            "{}",
753            elision_marker(2)
754        );
755    }
756
757    /// Every compressor that elides must use the shared marker. A new one that
758    /// hand-rolls `... (N more lines)` would reintroduce exactly this bug, so
759    /// the source tree is the assertion.
760    #[test]
761    fn no_compressor_hand_rolls_the_old_marker() {
762        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
763        let mut offenders = Vec::new();
764
765        for entry in std::fs::read_dir(&dir).expect("patterns dir") {
766            let path = entry.expect("dir entry").path();
767            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
768                continue;
769            }
770            let source = std::fs::read_to_string(&path).expect("read pattern source");
771            // Only the production half: tests legitimately name the old string
772            // to assert it is gone, and this module documents it.
773            let production = source
774                .split_once("#[cfg(test)]")
775                .map_or(source.as_str(), |(head, _)| head);
776            if production.contains("more lines)")
777                && path.file_name().and_then(|n| n.to_str()) != Some("lib.rs")
778            {
779                offenders.push(path.display().to_string());
780            }
781        }
782
783        assert!(
784            offenders.is_empty(),
785            "these compressors elide with the old content-shaped marker; \
786             use super::elision_marker() instead: {offenders:?}"
787        );
788    }
789}