Skip to main content

lean_ctx/shell/compress/
classification.rs

1use super::passthrough::{BUILTIN_PASSTHROUGH, DEV_SCRIPT_KEYWORDS, SCRIPT_RUNNER_PREFIXES};
2
3fn is_dev_script_runner(cmd: &str) -> bool {
4    for prefix in SCRIPT_RUNNER_PREFIXES {
5        if let Some(rest) = cmd.strip_prefix(prefix) {
6            let script_name = rest.split_whitespace().next().unwrap_or("");
7            for kw in DEV_SCRIPT_KEYWORDS {
8                if script_name.contains(kw) {
9                    return true;
10                }
11            }
12        }
13    }
14    false
15}
16
17pub(in crate::shell) fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
18    let cmd = command.trim().to_lowercase();
19    for pattern in BUILTIN_PASSTHROUGH {
20        if pattern.starts_with("--") {
21            if cmd.contains(pattern) {
22                return true;
23            }
24        } else if pattern.ends_with(' ') || pattern.ends_with('\t') {
25            if cmd == pattern.trim() || cmd.starts_with(pattern) {
26                return true;
27            }
28        } else if cmd == *pattern
29            || cmd.starts_with(&format!("{pattern} "))
30            || cmd.starts_with(&format!("{pattern}\t"))
31            || cmd.contains(&format!(" {pattern} "))
32            || cmd.contains(&format!(" {pattern}\t"))
33            || cmd.contains(&format!("|{pattern} "))
34            || cmd.contains(&format!("|{pattern}\t"))
35            || cmd.ends_with(&format!(" {pattern}"))
36            || cmd.ends_with(&format!("|{pattern}"))
37        {
38            return true;
39        }
40    }
41
42    if is_dev_script_runner(&cmd) {
43        return true;
44    }
45
46    if excluded.is_empty() {
47        return false;
48    }
49    excluded.iter().any(|excl| {
50        let excl_lower = excl.trim().to_lowercase();
51        cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
52    })
53}
54
55pub(super) fn is_search_output(command: &str) -> bool {
56    let c = command.trim_start();
57    c.starts_with("grep ")
58        || c.starts_with("rg ")
59        || c.starts_with("find ")
60        || c.starts_with("fd ")
61        || c.starts_with("ag ")
62        || c.starts_with("ack ")
63}
64
65/// Returns true for commands whose output structure is critical for developer
66/// readability. Pattern compression (light cleanup like removing `index` lines
67/// or limiting context) still applies, but the terse pipeline and generic
68/// compressors are skipped so diff hunks, blame annotations, etc. remain
69/// fully readable.
70pub fn has_structural_output(command: &str) -> bool {
71    if is_verbatim_output(command) {
72        return true;
73    }
74    if is_standalone_diff_command(command) {
75        return true;
76    }
77    if is_source_search(command) {
78        return true;
79    }
80    is_structural_git_command(command)
81}
82
83/// Grep-family search commands (#980, #985). Routed to the dedicated
84/// `patterns::grep` compressor (groups matches per file, caps count) which
85/// never applies terse dictionaries. This is the correct middle tier: verbatim
86/// wastes tokens, terse corrupts source identifiers.
87fn is_source_search(command: &str) -> bool {
88    is_grep_binary(first_binary(command))
89        || is_source_search_pipe_tail(command)
90        || is_source_search_compound_tail(command)
91}
92
93fn is_grep_binary(bin: &str) -> bool {
94    matches!(
95        bin,
96        "grep" | "egrep" | "fgrep" | "rg" | "ag" | "ack" | "ugrep" | "sift"
97    )
98}
99
100fn is_source_search_pipe_tail(command: &str) -> bool {
101    if !command.contains('|') {
102        return false;
103    }
104    let last = command.rsplit('|').next().unwrap_or("").trim();
105    !last.is_empty() && is_grep_binary(first_binary(last))
106}
107
108fn is_source_search_compound_tail(command: &str) -> bool {
109    let last = command
110        .rsplit("&&")
111        .next()
112        .and_then(|s| s.rsplit("||").next())
113        .and_then(|s| s.rsplit(';').next())
114        .unwrap_or("")
115        .trim();
116    if last.is_empty() || last == command.trim() {
117        return false;
118    }
119    is_grep_binary(first_binary(last))
120}
121
122/// Returns true for commands where the output IS the purpose of the command.
123/// These must never have their content transformed — only size-limited if huge.
124/// Checks both the full command AND the last pipe segment for comprehensive coverage.
125pub fn is_verbatim_output(command: &str) -> bool {
126    is_verbatim_single(command)
127        || is_verbatim_pipe_tail(command)
128        || is_verbatim_compound_tail(command)
129}
130
131fn is_verbatim_single(command: &str) -> bool {
132    is_http_client(command)
133        || is_file_viewer(command)
134        || is_data_format_tool(command)
135        || is_binary_viewer(command)
136        || is_infra_inspection(command)
137        || is_crypto_command(command)
138        || is_database_query(command)
139        || is_dns_network_inspection(command)
140        || is_language_one_liner(command)
141        || is_container_listing(command)
142        || is_file_listing(command)
143        || is_system_query(command)
144        || is_cloud_cli_query(command)
145        || is_cli_api_data_command(command)
146        || is_package_manager_info(command)
147        || is_version_or_help(command)
148        || is_config_viewer(command)
149        || is_log_viewer(command)
150        || is_archive_listing(command)
151        || is_clipboard_tool(command)
152        || is_git_data_command(command)
153        || is_git_write_command(command)
154        || is_task_dry_run(command)
155        || is_env_dump(command)
156}
157
158/// CLI tools that fetch or output raw API/structured data.
159/// These MUST never be compressed -- compression destroys the payload.
160fn is_cli_api_data_command(command: &str) -> bool {
161    let cl = command.trim().to_ascii_lowercase();
162
163    // gh (GitHub CLI) -- api, run view --log, search, release view, gist view
164    if cl.starts_with("gh ")
165        && (cl.starts_with("gh api ")
166            || cl.starts_with("gh api\t")
167            || cl.contains(" --json")
168            || cl.contains(" --jq ")
169            || cl.contains(" --template ")
170            || (cl.contains("run view") && (cl.contains("--log") || cl.contains("log-failed")))
171            || cl.starts_with("gh search ")
172            || cl.starts_with("gh release view")
173            || cl.starts_with("gh gist view")
174            || cl.starts_with("gh gist list"))
175    {
176        return true;
177    }
178
179    // GitLab CLI (glab)
180    if cl.starts_with("glab ") && cl.starts_with("glab api ") {
181        return true;
182    }
183
184    // Jira CLI
185    if cl.starts_with("jira ") && (cl.contains(" view") || cl.contains(" list")) {
186        return true;
187    }
188
189    // Linear CLI
190    if cl.starts_with("linear ") {
191        return true;
192    }
193
194    // Stripe, Twilio, Vercel, Netlify, Fly, Railway, Supabase CLIs
195    let first = first_binary(command);
196    if matches!(
197        first,
198        "stripe" | "twilio" | "vercel" | "netlify" | "flyctl" | "fly" | "railway" | "supabase"
199    ) && (cl.contains(" list")
200        || cl.contains(" get")
201        || cl.contains(" show")
202        || cl.contains(" status")
203        || cl.contains(" info")
204        || cl.contains(" logs")
205        || cl.contains(" inspect")
206        || cl.contains(" export")
207        || cl.contains(" describe"))
208    {
209        return true;
210    }
211
212    // Cloudflare (wrangler)
213    if cl.starts_with("wrangler ")
214        && !cl.starts_with("wrangler dev")
215        && (cl.contains(" tail") || cl.contains(" secret list") || cl.contains(" kv "))
216    {
217        return true;
218    }
219
220    // Heroku
221    if cl.starts_with("heroku ")
222        && (cl.contains(" config")
223            || cl.contains(" logs")
224            || cl.contains(" ps")
225            || cl.contains(" info"))
226    {
227        return true;
228    }
229
230    false
231}
232
233/// For piped commands like `kubectl get pods -o json | jq '.items[]'`,
234/// check if the LAST command in the pipe is a verbatim tool.
235fn is_verbatim_pipe_tail(command: &str) -> bool {
236    if !command.contains('|') {
237        return false;
238    }
239    let last_segment = command.rsplit('|').next().unwrap_or("").trim();
240    if last_segment.is_empty() {
241        return false;
242    }
243    is_verbatim_single(last_segment)
244}
245
246/// #980: `cd /repo && cat file` — classification only inspected the first token
247/// (`cd`), missing that the *last* compound segment is a file viewer.  Analogous
248/// to [`is_verbatim_pipe_tail`] but for `&&` / `||` / `;` operators.
249fn is_verbatim_compound_tail(command: &str) -> bool {
250    // Only split on compound operators, not pipes (handled by pipe_tail).
251    let last = command
252        .rsplit("&&")
253        .next()
254        .and_then(|s| s.rsplit("||").next())
255        .and_then(|s| s.rsplit(';').next())
256        .unwrap_or("")
257        .trim();
258    if last.is_empty() || last == command.trim() {
259        return false;
260    }
261    is_verbatim_single(last)
262}
263
264fn is_http_client(command: &str) -> bool {
265    let first = first_binary(command);
266    matches!(
267        first,
268        "curl" | "wget" | "http" | "https" | "xh" | "curlie" | "grpcurl" | "grpc_cli"
269    )
270}
271
272fn is_file_viewer(command: &str) -> bool {
273    let first = first_binary(command);
274    match first {
275        "cat" | "bat" | "batcat" | "pygmentize" | "highlight" => true,
276        // #980/#985: grep-family routes to `has_structural_output` via
277        // `is_source_search` — the dedicated `patterns::grep` compressor
278        // handles them dictionary-free. NOT in is_file_viewer.
279        "head" | "tail" => !command.contains("-f") && !command.contains("--follow"),
280        // sed/awk are commonly used as range/pattern file viewers
281        // (`sed -n '10,50p' file`, `awk '{print}' file`, GH #688). Their stdout
282        // is file payload and must never enter the generic terse pipeline —
283        // the dictionary layer word-substitutes code identifiers
284        // (`function`→`fn`, `return`→`ret`) with no code-awareness. In-place
285        // edit invocations are excluded: they print nothing to compress.
286        "sed" | "awk" | "gawk" | "mawk" | "nawk" => !has_in_place_flag(command),
287        _ => false,
288    }
289}
290
291/// True when a sed/awk invocation carries an in-place edit flag: `-i`,
292/// `-i.bak`, a short-flag cluster like `-ni`, `--in-place[=suffix]`, or
293/// gawk's `-i inplace`. Detection is token-based — a *filename* containing
294/// "-i" (`my-input.txt`, `data-import.csv`) must not match, or the dump falls
295/// back into the terse pipeline: the exact corruption this classification
296/// prevents (GH #688).
297fn has_in_place_flag(command: &str) -> bool {
298    command.split_whitespace().any(|tok| {
299        if let Some(long) = tok.strip_prefix("--") {
300            return long.starts_with("in-place");
301        }
302        match tok.strip_prefix('-') {
303            // Short-flag cluster: any `i` before a suffix (`-i`, `-i.bak`,
304            // `-ni`). The cluster part is everything before the first '.'.
305            Some(rest) if !rest.is_empty() => rest
306                .split('.')
307                .next()
308                .is_some_and(|cluster| cluster.contains('i')),
309            _ => false,
310        }
311    })
312}
313
314fn is_data_format_tool(command: &str) -> bool {
315    let first = first_binary(command);
316    matches!(
317        first,
318        "jq" | "yq"
319            | "xq"
320            | "fx"
321            | "gron"
322            | "mlr"
323            | "miller"
324            | "dasel"
325            | "csvlook"
326            | "csvcut"
327            | "csvgrep"
328            | "csvjson"
329            | "in2csv"
330            | "sql2csv"
331    )
332}
333
334fn is_binary_viewer(command: &str) -> bool {
335    let first = first_binary(command);
336    matches!(first, "xxd" | "hexdump" | "od" | "strings" | "file")
337}
338
339fn is_infra_inspection(command: &str) -> bool {
340    let cl = command.trim().to_ascii_lowercase();
341    if cl.starts_with("terraform output")
342        || cl.starts_with("terraform show")
343        || cl.starts_with("terraform state show")
344        || cl.starts_with("terraform state list")
345        || cl.starts_with("terraform state pull")
346        || cl.starts_with("tofu output")
347        || cl.starts_with("tofu show")
348        || cl.starts_with("tofu state show")
349        || cl.starts_with("tofu state list")
350        || cl.starts_with("tofu state pull")
351        || cl.starts_with("pulumi stack output")
352        || cl.starts_with("pulumi stack export")
353    {
354        return true;
355    }
356    if cl.starts_with("docker inspect") || cl.starts_with("podman inspect") {
357        return true;
358    }
359    if (cl.starts_with("kubectl get") || cl.starts_with("k get"))
360        && (cl.contains("-o yaml")
361            || cl.contains("-o json")
362            || cl.contains("-oyaml")
363            || cl.contains("-ojson")
364            || cl.contains("--output yaml")
365            || cl.contains("--output json")
366            || cl.contains("--output=yaml")
367            || cl.contains("--output=json"))
368    {
369        return true;
370    }
371    if cl.starts_with("kubectl describe") || cl.starts_with("k describe") {
372        return true;
373    }
374    if cl.starts_with("helm get") || cl.starts_with("helm template") {
375        return true;
376    }
377    false
378}
379
380fn is_crypto_command(command: &str) -> bool {
381    let first = first_binary(command);
382    if first == "openssl" {
383        return true;
384    }
385    matches!(first, "gpg" | "age" | "ssh-keygen" | "certutil")
386}
387
388fn is_database_query(command: &str) -> bool {
389    let cl = command.to_ascii_lowercase();
390    if cl.starts_with("psql ") && (cl.contains(" -c ") || cl.contains("--command")) {
391        return true;
392    }
393    if cl.starts_with("mysql ") && (cl.contains(" -e ") || cl.contains("--execute")) {
394        return true;
395    }
396    if cl.starts_with("mariadb ") && (cl.contains(" -e ") || cl.contains("--execute")) {
397        return true;
398    }
399    if cl.starts_with("sqlite3 ") && cl.contains('"') {
400        return true;
401    }
402    if cl.starts_with("mongosh ") && cl.contains("--eval") {
403        return true;
404    }
405    false
406}
407
408fn is_dns_network_inspection(command: &str) -> bool {
409    let first = first_binary(command);
410    matches!(
411        first,
412        "dig" | "nslookup" | "host" | "whois" | "drill" | "resolvectl"
413    )
414}
415
416fn is_language_one_liner(command: &str) -> bool {
417    let cl = command.to_ascii_lowercase();
418    (cl.starts_with("python ") || cl.starts_with("python3 "))
419        && (cl.contains(" -c ") || cl.contains(" -c\"") || cl.contains(" -c'"))
420        || (cl.starts_with("node ") && (cl.contains(" -e ") || cl.contains(" --eval")))
421        || (cl.starts_with("ruby ") && cl.contains(" -e "))
422        || (cl.starts_with("perl ") && cl.contains(" -e "))
423        || (cl.starts_with("php ") && cl.contains(" -r "))
424}
425
426fn is_container_listing(command: &str) -> bool {
427    let cl = command.trim().to_ascii_lowercase();
428    if cl.starts_with("docker ps") || cl.starts_with("docker images") {
429        return true;
430    }
431    if cl.starts_with("podman ps") || cl.starts_with("podman images") {
432        return true;
433    }
434    // kubectl get is handled by the kubectl pattern compressor (not verbatim)
435    if cl.starts_with("helm list") || cl.starts_with("helm ls") {
436        return true;
437    }
438    if cl.starts_with("docker compose ps") || cl.starts_with("docker-compose ps") {
439        return true;
440    }
441    false
442}
443
444fn is_file_listing(command: &str) -> bool {
445    let first = first_binary(command);
446    matches!(
447        first,
448        "find" | "fd" | "fdfind" | "ls" | "exa" | "eza" | "lsd"
449    )
450}
451
452fn is_system_query(command: &str) -> bool {
453    let first = first_binary(command);
454    matches!(
455        first,
456        "stat"
457            | "wc"
458            | "du"
459            | "df"
460            | "free"
461            | "uname"
462            | "id"
463            | "whoami"
464            | "hostname"
465            | "uptime"
466            | "lscpu"
467            | "lsblk"
468            | "ip"
469            | "ifconfig"
470            | "route"
471            | "ss"
472            | "netstat"
473            | "base64"
474            | "sha256sum"
475            | "sha1sum"
476            | "md5sum"
477            | "cksum"
478            | "readlink"
479            | "realpath"
480            | "which"
481            | "type"
482            | "command"
483    )
484}
485
486fn is_cloud_cli_query(command: &str) -> bool {
487    let cl = command.trim().to_ascii_lowercase();
488    let cloud_query_verbs = [
489        "describe",
490        "get",
491        "list",
492        "show",
493        "export",
494        "inspect",
495        "info",
496        "status",
497        "whoami",
498        "caller-identity",
499        "account",
500    ];
501
502    let is_aws = cl.starts_with("aws ") && !cl.starts_with("aws configure");
503    let is_gcloud =
504        cl.starts_with("gcloud ") && !cl.starts_with("gcloud auth") && !cl.contains(" deploy");
505    let is_az = cl.starts_with("az ") && !cl.starts_with("az login");
506
507    if !(is_aws || is_gcloud || is_az) {
508        return false;
509    }
510
511    cloud_query_verbs
512        .iter()
513        .any(|verb| cl.contains(&format!(" {verb}")))
514}
515
516fn is_package_manager_info(command: &str) -> bool {
517    let cl = command.trim().to_ascii_lowercase();
518
519    if cl.starts_with("npm ") {
520        return cl.starts_with("npm list")
521            || cl.starts_with("npm ls")
522            || cl.starts_with("npm info")
523            || cl.starts_with("npm view")
524            || cl.starts_with("npm show")
525            || cl.starts_with("npm outdated")
526            || cl.starts_with("npm audit");
527    }
528    if cl.starts_with("yarn ") {
529        return cl.starts_with("yarn list")
530            || cl.starts_with("yarn info")
531            || cl.starts_with("yarn why")
532            || cl.starts_with("yarn outdated")
533            || cl.starts_with("yarn audit");
534    }
535    if cl.starts_with("pnpm ") {
536        return cl.starts_with("pnpm list")
537            || cl.starts_with("pnpm ls")
538            || cl.starts_with("pnpm why")
539            || cl.starts_with("pnpm outdated")
540            || cl.starts_with("pnpm audit");
541    }
542    if cl.starts_with("pip ") || cl.starts_with("pip3 ") {
543        return cl.contains(" list") || cl.contains(" show") || cl.contains(" freeze");
544    }
545    if cl.starts_with("gem ") {
546        return cl.starts_with("gem list")
547            || cl.starts_with("gem info")
548            || cl.starts_with("gem specification");
549    }
550    if cl.starts_with("cargo ") {
551        return cl.starts_with("cargo metadata")
552            || cl.starts_with("cargo tree")
553            || cl.starts_with("cargo pkgid");
554    }
555    if cl.starts_with("go ") {
556        return cl.starts_with("go list") || cl.starts_with("go version");
557    }
558    if cl.starts_with("composer ") {
559        return cl.starts_with("composer show")
560            || cl.starts_with("composer info")
561            || cl.starts_with("composer outdated");
562    }
563    if cl.starts_with("brew ") {
564        return cl.starts_with("brew list")
565            || cl.starts_with("brew info")
566            || cl.starts_with("brew deps")
567            || cl.starts_with("brew outdated");
568    }
569    if cl.starts_with("apt ") || cl.starts_with("dpkg ") {
570        return cl.starts_with("apt list")
571            || cl.starts_with("apt show")
572            || cl.starts_with("dpkg -l")
573            || cl.starts_with("dpkg --list")
574            || cl.starts_with("dpkg -s");
575    }
576    false
577}
578
579fn is_version_or_help(command: &str) -> bool {
580    let parts: Vec<&str> = command.split_whitespace().collect();
581    if parts.len() < 2 || parts.len() > 3 {
582        return false;
583    }
584    parts.iter().any(|p| {
585        *p == "--version"
586            || *p == "-V"
587            || p.eq_ignore_ascii_case("version")
588            || *p == "--help"
589            || *p == "-h"
590            || p.eq_ignore_ascii_case("help")
591    })
592}
593
594fn is_config_viewer(command: &str) -> bool {
595    let cl = command.trim().to_ascii_lowercase();
596    if cl.starts_with("git config") && !cl.contains("--set") && !cl.contains("--unset") {
597        return true;
598    }
599    if cl.starts_with("npm config list") || cl.starts_with("npm config get") {
600        return true;
601    }
602    if cl.starts_with("yarn config") && !cl.contains(" set") {
603        return true;
604    }
605    if cl.starts_with("pip config list") || cl.starts_with("pip3 config list") {
606        return true;
607    }
608    if cl.starts_with("rustup show") || cl.starts_with("rustup target list") {
609        return true;
610    }
611    if cl.starts_with("docker context ls") || cl.starts_with("docker context list") {
612        return true;
613    }
614    if cl.starts_with("kubectl config")
615        && (cl.contains("view") || cl.contains("get-contexts") || cl.contains("current-context"))
616    {
617        return true;
618    }
619    false
620}
621
622fn is_log_viewer(command: &str) -> bool {
623    let cl = command.trim().to_ascii_lowercase();
624    if cl.starts_with("journalctl") && !cl.contains("-f") && !cl.contains("--follow") {
625        return true;
626    }
627    if cl.starts_with("dmesg") && !cl.contains("-w") && !cl.contains("--follow") {
628        return true;
629    }
630    if cl.starts_with("docker logs") && !cl.contains("-f") && !cl.contains("--follow") {
631        return true;
632    }
633    if cl.starts_with("kubectl logs") && !cl.contains("-f") && !cl.contains("--follow") {
634        return true;
635    }
636    if cl.starts_with("docker compose logs") && !cl.contains("-f") && !cl.contains("--follow") {
637        return true;
638    }
639    false
640}
641
642fn is_archive_listing(command: &str) -> bool {
643    let cl = command.trim().to_ascii_lowercase();
644    if cl.starts_with("tar ") && (cl.contains(" -tf") || cl.contains(" -t") || cl.contains(" tf")) {
645        return true;
646    }
647    if cl.starts_with("unzip -l") || cl.starts_with("unzip -Z") {
648        return true;
649    }
650    let first = first_binary(command);
651    matches!(first, "zipinfo" | "lsar" | "7z" if cl.contains(" l ") || cl.contains(" l\t"))
652        || first == "zipinfo"
653        || first == "lsar"
654}
655
656fn is_clipboard_tool(command: &str) -> bool {
657    let first = first_binary(command);
658    if matches!(first, "pbpaste" | "wl-paste") {
659        return true;
660    }
661    let cl = command.trim().to_ascii_lowercase();
662    if cl.starts_with("xclip") && cl.contains("-o") {
663        return true;
664    }
665    if cl.starts_with("xsel") && (cl.contains("-o") || cl.contains("--output")) {
666        return true;
667    }
668    false
669}
670
671/// Git write-commands produce minimal output that agents must see verbatim.
672/// Compressing these risks abbreviating subcommand names (e.g. "commit" → "cmt")
673/// which agents then misinterpret as valid commands.
674fn is_git_write_command(command: &str) -> bool {
675    let cl = command.trim().to_ascii_lowercase();
676    if !cl.starts_with("git ") {
677        return false;
678    }
679    let git_write_subs = [
680        "commit",
681        "push",
682        "pull",
683        "merge",
684        "rebase",
685        "cherry-pick",
686        "tag",
687        "reset",
688    ];
689    let mut skip_next = false;
690    for arg in cl.split_whitespace().skip(1) {
691        if skip_next {
692            skip_next = false;
693            continue;
694        }
695        if arg == "-c" || arg == "-C" || arg == "--git-dir" || arg == "--work-tree" {
696            skip_next = true;
697            continue;
698        }
699        if arg.starts_with('-') {
700            continue;
701        }
702        return git_write_subs.contains(&arg);
703    }
704    false
705}
706
707pub(super) fn is_git_data_command(command: &str) -> bool {
708    let cl = command.trim().to_ascii_lowercase();
709    if !cl.contains("git") {
710        return false;
711    }
712    let exact_data_subs = [
713        "remote",
714        "rev-parse",
715        "rev-list",
716        "ls-files",
717        "ls-tree",
718        "ls-remote",
719        "shortlog",
720        "for-each-ref",
721        "cat-file",
722        "name-rev",
723        "describe",
724        "merge-base",
725    ];
726
727    let mut tokens = cl.split_whitespace();
728    while let Some(tok) = tokens.next() {
729        let base = tok.rsplit('/').next().unwrap_or(tok);
730        if base != "git" {
731            continue;
732        }
733        let mut skip_next = false;
734        for arg in tokens.by_ref() {
735            if skip_next {
736                skip_next = false;
737                continue;
738            }
739            if arg == "-c" || arg == "-C" || arg == "--git-dir" || arg == "--work-tree" {
740                skip_next = true;
741                continue;
742            }
743            if arg.starts_with('-') {
744                continue;
745            }
746            return exact_data_subs.contains(&arg);
747        }
748        return false;
749    }
750    false
751}
752
753fn is_task_dry_run(command: &str) -> bool {
754    let cl = command.trim().to_ascii_lowercase();
755    if cl.starts_with("make ") && (cl.contains(" -n") || cl.contains(" --dry-run")) {
756        return true;
757    }
758    if cl.starts_with("ansible") && (cl.contains("--check") || cl.contains("--diff")) {
759        return true;
760    }
761    false
762}
763
764fn is_env_dump(command: &str) -> bool {
765    let first = first_binary(command);
766    matches!(first, "env" | "printenv" | "set" | "export" | "locale")
767}
768
769/// Extracts the binary name (basename, no path) from the first token of a command.
770fn first_binary(command: &str) -> &str {
771    let first = command.split_whitespace().next().unwrap_or("");
772    first.rsplit('/').next().unwrap_or(first)
773}
774
775/// Non-git diff tools: `diff`, `colordiff`, `icdiff`, `delta`.
776fn is_standalone_diff_command(command: &str) -> bool {
777    let first = command.split_whitespace().next().unwrap_or("");
778    let base = first.rsplit('/').next().unwrap_or(first);
779    base.eq_ignore_ascii_case("diff")
780        || base.eq_ignore_ascii_case("colordiff")
781        || base.eq_ignore_ascii_case("icdiff")
782        || base.eq_ignore_ascii_case("delta")
783}
784
785/// Git subcommands that produce structural output the developer must read verbatim.
786fn is_structural_git_command(command: &str) -> bool {
787    let mut tokens = command.split_whitespace();
788    while let Some(tok) = tokens.next() {
789        let base = tok.rsplit('/').next().unwrap_or(tok);
790        if !base.eq_ignore_ascii_case("git") {
791            continue;
792        }
793        let mut skip_next = false;
794        let remaining: Vec<&str> = tokens.collect();
795        for arg in &remaining {
796            if skip_next {
797                skip_next = false;
798                continue;
799            }
800            if *arg == "-C" || *arg == "-c" || *arg == "--git-dir" || *arg == "--work-tree" {
801                skip_next = true;
802                continue;
803            }
804            if arg.starts_with('-') {
805                continue;
806            }
807            let sub = arg.to_ascii_lowercase();
808            return match sub.as_str() {
809                "diff" | "show" | "blame" => true,
810                "log" => has_patch_flag(&remaining) || has_stat_flag(&remaining),
811                "stash" => remaining.iter().any(|a| a.eq_ignore_ascii_case("show")),
812                _ => false,
813            };
814        }
815        return false;
816    }
817    false
818}
819
820/// Returns true if the argument list contains `-p` or `--patch`.
821fn has_patch_flag(args: &[&str]) -> bool {
822    args.iter()
823        .any(|a| *a == "-p" || *a == "--patch" || a.starts_with("-p"))
824}
825
826/// Returns true if the argument list contains `--stat`.
827fn has_stat_flag(args: &[&str]) -> bool {
828    args.iter()
829        .any(|a| *a == "--stat" || a.starts_with("--stat="))
830}
831
832enum ToonHeader {
833    /// Tabular array header: `key[N]{field,field}:`
834    Tabular,
835    /// Length-prefixed array header: `key[N]:` (optionally with inline values).
836    LengthArray,
837}
838
839/// Classifies a single, already left-trimmed line as a TOON array header.
840///
841/// Keys on TOON's bracketed length marker so prose like `see [1] above` or a
842/// stray `[lean-ctx: …]` footer is rejected (the key must be a single,
843/// space-free identifier token preceding `[`).
844fn toon_header_kind(line: &str) -> Option<ToonHeader> {
845    let open = line.find('[')?;
846    if open == 0 || line[..open].contains(char::is_whitespace) {
847        return None;
848    }
849    let after = &line[open + 1..];
850    let close = after.find(']')?;
851    let len_part = &after[..close];
852    if len_part.is_empty() || !len_part.bytes().all(|b| b.is_ascii_digit()) {
853        return None;
854    }
855    let rest = after[close + 1..].trim_start();
856    if rest.starts_with('{') && rest.contains('}') && rest.trim_end().ends_with(':') {
857        return Some(ToonHeader::Tabular);
858    }
859    if rest.starts_with(':') {
860        return Some(ToonHeader::LengthArray);
861    }
862    None
863}
864
865/// Heuristically detects output already encoded in TOON (Token-Oriented Object
866/// Notation) so it can be preserved verbatim instead of recompressed (#342).
867///
868/// TOON is itself a compact, token-oriented encoding; a second compression pass
869/// saves little and rewrites the exact line/field shape an agent relies on to
870/// validate a CLI output contract. Detection keys on TOON's unambiguous
871/// structural markers — the tabular `key[N]{f1,f2}:` header and the
872/// length-prefixed `key[N]:` array header — rather than generic indentation, so
873/// plain YAML, JSON, or logs are not misclassified as TOON.
874pub(super) fn looks_like_toon(output: &str) -> bool {
875    let mut tabular_headers = 0usize;
876    let mut length_arrays = 0usize;
877    let mut indented = 0usize;
878    let mut non_empty = 0usize;
879
880    for raw in output.lines() {
881        let trimmed_end = raw.trim_end();
882        let body = trimmed_end.trim_start();
883        if body.is_empty() {
884            continue;
885        }
886        non_empty += 1;
887        if body.len() != trimmed_end.len() {
888            indented += 1;
889        }
890        match toon_header_kind(body) {
891            Some(ToonHeader::Tabular) => tabular_headers += 1,
892            Some(ToonHeader::LengthArray) => length_arrays += 1,
893            None => {}
894        }
895    }
896
897    if non_empty < 2 {
898        return false;
899    }
900    // A tabular array header is near-unambiguous TOON — one is enough.
901    if tabular_headers > 0 {
902        return true;
903    }
904    // Otherwise require a length-prefixed array header plus a mostly-indented
905    // body, which together stay very TOON-specific while rejecting flat
906    // `key: value` logs that merely contain a bracketed token.
907    length_arrays > 0 && indented * 2 >= non_empty
908}
909
910#[cfg(test)]
911mod toon_tests {
912    use super::looks_like_toon;
913
914    #[test]
915    fn detects_tabular_array_header() {
916        let toon = "users[2]{id,name,role}:\n  1,alice,admin\n  2,bob,user";
917        assert!(looks_like_toon(toon));
918    }
919
920    #[test]
921    fn detects_nested_tabular_header() {
922        let toon = "result:\n  tasks[3]{id,status,title}:\n    1,open,First\n    2,done,Second\n    3,open,Third";
923        assert!(looks_like_toon(toon));
924    }
925
926    #[test]
927    fn detects_length_prefixed_array_with_indent() {
928        let toon = "config:\n  tags[3]: alpha,beta,gamma\n  ports[2]: 80,443";
929        assert!(looks_like_toon(toon));
930    }
931
932    #[test]
933    fn rejects_plain_yaml_without_toon_markers() {
934        let yaml = "name: lean-ctx\nversion: 3.7.2\nfeatures:\n  - compress\n  - read";
935        assert!(!looks_like_toon(yaml));
936    }
937
938    #[test]
939    fn rejects_json_payload() {
940        let json = "{\n  \"id\": 1,\n  \"items\": [1, 2, 3],\n  \"ok\": true\n}";
941        assert!(!looks_like_toon(json));
942    }
943
944    #[test]
945    fn rejects_prose_with_bracketed_reference() {
946        let prose = "See note [1] above for details.\nAnother line of plain log output here.";
947        assert!(!looks_like_toon(prose));
948    }
949
950    #[test]
951    fn rejects_lean_ctx_footer_line() {
952        let line = "[lean-ctx: 120->40 tok, compressed]\nsome other content line";
953        assert!(!looks_like_toon(line));
954    }
955
956    #[test]
957    fn rejects_single_line() {
958        assert!(!looks_like_toon("users[2]{id,name}:"));
959    }
960}
961
962#[cfg(test)]
963mod verbatim_classification_tests {
964    use super::*;
965
966    // #980/#985: grep-family routes to structural (patterns::grep), NOT verbatim/terse.
967    #[test]
968    fn grep_is_structural_not_terse() {
969        for cmd in [
970            "grep -rn 'fn main' src/",
971            "rg 'TODO' --type rust",
972            "ag 'pattern' src/",
973            "grep",
974        ] {
975            assert!(
976                has_structural_output(cmd),
977                "'{cmd}' must be structural so the dictionary never sees source lines"
978            );
979            assert!(
980                !is_verbatim_output(cmd),
981                "'{cmd}' must stay compressible via patterns::grep"
982            );
983        }
984    }
985
986    // #980: `cd /repo && cat file` — last compound segment is a file viewer.
987    #[test]
988    fn compound_cd_then_cat_is_verbatim() {
989        assert!(
990            is_verbatim_output("cd /repo && cat file.yaml"),
991            "cd && cat must be verbatim (#980)"
992        );
993    }
994
995    #[test]
996    fn compound_cd_then_grep_is_structural() {
997        assert!(has_structural_output("cd /repo && grep -n 'error' log.txt"));
998        assert!(has_structural_output("cat file.go | grep func"));
999    }
1000
1001    #[test]
1002    fn compound_with_semicolon_last_is_viewer() {
1003        assert!(is_verbatim_output("echo start; cat config.toml"));
1004    }
1005
1006    #[test]
1007    fn compound_or_last_is_viewer() {
1008        assert!(is_verbatim_output("test -f x || cat fallback.txt"));
1009    }
1010
1011    #[test]
1012    fn simple_cat_still_verbatim() {
1013        assert!(is_verbatim_output("cat src/main.rs"));
1014    }
1015
1016    #[test]
1017    fn non_viewer_compound_not_verbatim() {
1018        assert!(!is_verbatim_output("cd /repo && cargo build"));
1019    }
1020}