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