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