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
65pub 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
83fn 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
122pub 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
158fn is_cli_api_data_command(command: &str) -> bool {
161 let cl = command.trim().to_ascii_lowercase();
162
163 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 if cl.starts_with("glab ") && cl.starts_with("glab api ") {
181 return true;
182 }
183
184 if cl.starts_with("jira ") && (cl.contains(" view") || cl.contains(" list")) {
186 return true;
187 }
188
189 if cl.starts_with("linear ") {
191 return true;
192 }
193
194 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 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 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
233fn 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
246fn is_verbatim_compound_tail(command: &str) -> bool {
250 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 "head" | "tail" => !command.contains("-f") && !command.contains("--follow"),
280 "sed" | "awk" | "gawk" | "mawk" | "nawk" => !has_in_place_flag(command),
287 _ => false,
288 }
289}
290
291fn 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 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 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
671fn 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
769fn first_binary(command: &str) -> &str {
771 let first = command.split_whitespace().next().unwrap_or("");
772 first.rsplit('/').next().unwrap_or(first)
773}
774
775fn 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
785fn 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
820fn has_patch_flag(args: &[&str]) -> bool {
822 args.iter()
823 .any(|a| *a == "-p" || *a == "--patch" || a.starts_with("-p"))
824}
825
826fn has_stat_flag(args: &[&str]) -> bool {
828 args.iter()
829 .any(|a| *a == "--stat" || a.starts_with("--stat="))
830}
831
832enum ToonHeader {
833 Tabular,
835 LengthArray,
837}
838
839fn 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
865pub(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 if tabular_headers > 0 {
902 return true;
903 }
904 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 #[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 #[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}