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
63pub 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
81fn 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
120pub 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
155fn is_cli_api_data_command(command: &str) -> bool {
158 let cl = command.trim().to_ascii_lowercase();
159
160 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 if cl.starts_with("glab ") && cl.starts_with("glab api ") {
178 return true;
179 }
180
181 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 if cl.starts_with("jira ") && (cl.contains(" view") || cl.contains(" list")) {
194 return true;
195 }
196
197 if cl.starts_with("linear ") {
199 return true;
200 }
201
202 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 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 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
241fn 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
254fn is_verbatim_compound_tail(command: &str) -> bool {
258 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 "head" | "tail" => !command.contains("-f") && !command.contains("--follow"),
288 "sed" | "awk" | "gawk" | "mawk" | "nawk" => !has_in_place_flag(command),
295 _ => false,
296 }
297}
298
299fn 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 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 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 "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
682fn 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
780fn first_binary(command: &str) -> &str {
782 let first = command.split_whitespace().next().unwrap_or("");
783 first.rsplit('/').next().unwrap_or(first)
784}
785
786fn 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
796fn 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
831fn has_patch_flag(args: &[&str]) -> bool {
833 args.iter()
834 .any(|a| *a == "-p" || *a == "--patch" || a.starts_with("-p"))
835}
836
837fn has_stat_flag(args: &[&str]) -> bool {
839 args.iter()
840 .any(|a| *a == "--stat" || a.starts_with("--stat="))
841}
842
843enum ToonHeader {
844 Tabular,
846 LengthArray,
848}
849
850fn 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
876pub(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 if tabular_headers > 0 {
913 return true;
914 }
915 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 #[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 #[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}