1use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::borrow::Cow;
13
14const TIER_INSTANT_REJECT: &str = "instant_reject";
16const TIER_STRUCTURE_ANALYSIS: &str = "structure_analysis";
17const TIER_KEYWORD_FILTER: &str = "keyword_filter";
18const TIER_NEVER_INTERCEPT: &str = "never_intercept";
19const TIER_FULL_CLASSIFICATION: &str = "full_classification";
20
21pub static COMPILATION_KEYWORDS: &[&str] = &[
24 "cargo",
25 "cargo-zigbuild",
29 "cargo-xwin",
30 "rustc",
31 "gcc",
32 "g++",
33 "clang",
34 "clang++",
35 "make",
36 "cmake",
37 "ninja",
38 "meson",
39 "cc",
40 "c++",
41 "bun",
42 "nextest",
43 "nix",
44 "nix-build",
45 "go",
46 "tsc",
47 "npx",
48];
49
50pub static NEVER_INTERCEPT: &[&str] = &[
53 "cargo install",
55 "cargo login",
58 "cargo fmt",
59 "cargo fix",
60 "cargo clean",
61 "cargo new",
62 "cargo init",
63 "cargo add",
64 "cargo remove",
65 "cargo update",
66 "cargo generate-lockfile",
67 "cargo watch",
68 "cargo --version",
69 "cargo -V",
70 "rustc --version",
72 "rustc -V",
73 "gcc --version",
74 "gcc -v",
75 "clang --version",
76 "clang -v",
77 "make --version",
78 "make -v",
79 "cmake --version",
80 "bun install",
82 "bun add",
83 "bun remove",
84 "bun link",
85 "bun unlink",
86 "bun pm",
87 "bun init",
88 "bun create",
89 "bun upgrade",
90 "bun completions",
91 "bun run",
93 "bun build",
94 "bun --help",
95 "bun -h",
96 "bun --version",
97 "bun -v",
98 "bun dev",
100 "bun repl",
101 "cargo nextest list", "cargo nextest archive", "cargo nextest show", "go get", "go mod", "go work", "go install", "go run", "go generate", "go fmt", "go clean", "go env", "go doc", "go version", "go tool", "go list", "tsc --version",
122 "tsc -v",
123 "tsc --init",
124];
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
128pub struct Classification {
129 pub is_compilation: bool,
131 pub confidence: f64,
133 pub kind: Option<CompilationKind>,
135 pub reason: Cow<'static, str>,
137 pub command_prefix: Option<String>,
141 pub extracted_command: Option<String>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum TierDecision {
151 Pass,
152 Reject,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
157pub struct ClassificationTier {
158 pub tier: u8,
160 pub name: Cow<'static, str>,
162 pub decision: TierDecision,
164 pub reason: Cow<'static, str>,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
170pub struct ClassificationDetails {
171 pub original: String,
173 pub normalized: String,
175 pub tiers: Vec<ClassificationTier>,
177 pub classification: Classification,
179}
180
181impl Classification {
182 pub fn not_compilation(reason: impl Into<Cow<'static, str>>) -> Self {
184 Self {
185 is_compilation: false,
186 confidence: 0.0,
187 kind: None,
188 reason: reason.into(),
189 command_prefix: None,
190 extracted_command: None,
191 }
192 }
193
194 pub fn compilation(
196 kind: CompilationKind,
197 confidence: f64,
198 reason: impl Into<Cow<'static, str>>,
199 ) -> Self {
200 Self {
201 is_compilation: true,
202 confidence,
203 kind: Some(kind),
204 reason: reason.into(),
205 command_prefix: None,
206 extracted_command: None,
207 }
208 }
209
210 pub fn compound_compilation(
214 kind: CompilationKind,
215 confidence: f64,
216 reason: impl Into<Cow<'static, str>>,
217 prefix: String,
218 extracted: String,
219 ) -> Self {
220 Self {
221 is_compilation: true,
222 confidence,
223 kind: Some(kind),
224 reason: reason.into(),
225 command_prefix: Some(prefix),
226 extracted_command: Some(extracted),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "snake_case")]
234pub enum CompilationKind {
235 CargoBuild,
238 CargoTest,
239 CargoCheck,
240 CargoClippy,
241 CargoDoc,
242 CargoNextest,
244 CargoBench,
246 CargoZigbuild,
253 Rustc,
255
256 Gcc,
259 Gpp,
261 Clang,
263 Clangpp,
265
266 Make,
269 CmakeBuild,
271 Ninja,
273 Meson,
275
276 BunTest,
279 BunTypecheck,
281
282 GoBuild,
291 GoTest,
293 GoVet,
295
296 Tsc,
299
300 NixBuild,
309
310 Job,
322}
323
324impl CompilationKind {
325 pub fn is_test_command(&self) -> bool {
331 matches!(
332 self,
333 CompilationKind::CargoTest
334 | CompilationKind::CargoNextest
335 | CompilationKind::CargoBench
336 | CompilationKind::BunTest
337 | CompilationKind::GoTest
338 )
339 }
340
341 pub fn command_base(&self) -> &'static str {
346 match self {
347 CompilationKind::CargoBuild
349 | CompilationKind::CargoTest
350 | CompilationKind::CargoCheck
351 | CompilationKind::CargoClippy
352 | CompilationKind::CargoDoc
353 | CompilationKind::CargoBench => "cargo",
354 CompilationKind::CargoNextest => "cargo", CompilationKind::CargoZigbuild => "cargo",
359 CompilationKind::Rustc => "rustc",
360 CompilationKind::Gcc => "gcc",
362 CompilationKind::Gpp => "g++",
363 CompilationKind::Clang => "clang",
364 CompilationKind::Clangpp => "clang++",
365 CompilationKind::Make => "make",
367 CompilationKind::CmakeBuild => "cmake",
368 CompilationKind::Ninja => "ninja",
369 CompilationKind::Meson => "meson",
370 CompilationKind::BunTest | CompilationKind::BunTypecheck => "bun",
372 CompilationKind::NixBuild => "nix",
375 CompilationKind::GoBuild | CompilationKind::GoTest | CompilationKind::GoVet => "go",
377 CompilationKind::Tsc => "tsc",
380 CompilationKind::Job => "job",
386 }
387 }
388}
389
390pub fn classify_command(cmd: &str) -> Classification {
400 classify_command_inner(cmd, 0)
401}
402
403pub fn declined_compilation_due_to_structure(cmd: &str) -> Option<&'static str> {
418 let trimmed = cmd.trim();
419 if trimmed.is_empty() {
420 return None;
421 }
422 if classify_command(trimmed).is_compilation {
425 return None;
426 }
427 let reason = check_structure(trimmed)?;
430 let normalized = normalize_command(trimmed);
431 if starts_with_compilation_command(normalized.as_ref()) {
432 Some(reason)
433 } else {
434 None
435 }
436}
437
438#[allow(dead_code)] const MAX_CLASSIFY_DEPTH: u8 = 1;
442
443#[allow(dead_code)] const MAX_SPLIT_INPUT_LEN: usize = 10 * 1024;
447
448fn classify_command_inner(cmd: &str, depth: u8) -> Classification {
449 let cmd = cmd.trim();
450
451 if cmd.is_empty() {
453 return Classification::not_compilation("empty command");
454 }
455
456 if depth == 0
460 && cmd.len() < MAX_SPLIT_INPUT_LEN
461 && let Some(result) = try_classify_compound_command(cmd)
462 {
463 return result;
464 }
465
466 if depth == 0 && cmd.len() < MAX_SPLIT_INPUT_LEN {
481 if let Some(result) = try_classify_wrapped_command(cmd) {
482 return result;
483 }
484 if let Some(result) = try_classify_benign_suffix_command(cmd) {
485 return result;
486 }
487 }
488
489 if let Some(reason) = check_structure(cmd) {
491 return Classification::not_compilation(reason);
492 }
493
494 let normalized_cow = normalize_command(cmd);
496 let normalized = normalized_cow.as_ref();
497
498 if !starts_with_compilation_command(normalized) {
504 return Classification::not_compilation("no compilation keyword");
505 }
506
507 for pattern in NEVER_INTERCEPT {
510 if let Some(rest) = normalized.strip_prefix(pattern) {
511 if rest.is_empty() || rest.starts_with(' ') {
514 return Classification::not_compilation("matches never-intercept pattern");
515 }
516 }
517 }
518
519 classify_full(normalized)
521}
522
523fn try_classify_compound_command(cmd: &str) -> Option<Classification> {
536 if !cmd.contains("&&") {
538 return None;
539 }
540
541 if cmd.contains('|') {
544 return None; }
546 if has_file_redirect(cmd) {
547 return None; }
549 if cmd.contains('(') || cmd.contains('`') || cmd.contains("$(") {
550 return None; }
552 if cmd.contains(';') {
553 return None; }
555
556 let segments = split_and_chain(cmd)?;
558 if segments.is_empty() {
559 return None;
560 }
561
562 let last_segment = segments.last()?.trim();
564 if last_segment.is_empty() {
565 return None;
566 }
567
568 let last_classification = classify_command_inner(last_segment, 1);
570
571 if !last_classification.is_compilation {
572 return None;
573 }
574
575 if segments.len() == 1 {
576 return None;
578 }
579
580 let mut prefix = String::with_capacity(cmd.len() + segments.len() * EXEC_REWRITE_PREFIX.len());
590 for segment in &segments[..segments.len() - 1] {
591 if classify_command_inner(segment, 1).is_compilation {
592 prefix.push_str(EXEC_REWRITE_PREFIX);
593 }
594 prefix.push_str(segment);
595 prefix.push_str(" && ");
596 }
597
598 Some(Classification::compound_compilation(
600 last_classification.kind?,
601 last_classification.confidence,
602 "compound command with compilation suffix",
603 prefix,
604 last_segment.to_string(),
605 ))
606}
607
608const EXEC_REWRITE_PREFIX: &str = "rch exec -- ";
611
612pub fn compound_local_compilation_segments(cmd: &str) -> Vec<String> {
622 let trimmed = cmd.trim();
623 if trimmed.len() >= MAX_SPLIT_INPUT_LEN {
624 return Vec::new();
625 }
626 let Some(segments) = split_and_chain(trimmed) else {
627 return Vec::new();
628 };
629 if segments.len() < 2 {
630 return Vec::new();
631 }
632 segments[..segments.len() - 1]
633 .iter()
634 .filter(|segment| {
635 !classify_command_inner(segment, 1).is_compilation
636 && starts_with_compilation_command(normalize_command(segment).as_ref())
637 })
638 .map(|segment| (*segment).to_string())
639 .collect()
640}
641
642const BENIGN_PAGER_COMMANDS: &[&str] = &[
651 "tee", "head", "cat", "grep", "less", "more", "tail", "wc", "rg",
652];
653
654fn try_classify_wrapped_command(cmd: &str) -> Option<Classification> {
666 let cmd = cmd.trim();
667
668 let rest = strip_shell_dash_c_prefix(cmd)?;
671 let rest = rest.trim_start();
672
673 let inner = extract_single_quoted_argument(rest)?;
675 if inner.trim().is_empty() {
676 return None;
677 }
678
679 let inner_classification = classify_command_inner(&inner, 0);
682 if !inner_classification.is_compilation {
683 return None;
684 }
685
686 let (prefix, extracted) = match (
691 &inner_classification.command_prefix,
692 &inner_classification.extracted_command,
693 ) {
694 (Some(p), Some(e)) => (p.clone(), e.clone()),
695 _ => (String::new(), inner.trim().to_string()),
696 };
697
698 Some(Classification::compound_compilation(
699 inner_classification.kind?,
700 inner_classification.confidence,
701 "shell -c wrapper with compilation command",
702 prefix,
703 extracted,
704 ))
705}
706
707fn strip_shell_dash_c_prefix(cmd: &str) -> Option<&str> {
710 let trimmed = cmd.trim_start();
714 let mut idx = 0;
715 let bytes = trimmed.as_bytes();
716
717 let word1_end = next_word_end(bytes, idx)?;
719 let shell = &trimmed[idx..word1_end];
720 if !shell_basename_is(shell, "bash") && !shell_basename_is(shell, "sh") {
721 return None;
722 }
723 idx = skip_spaces(bytes, word1_end);
724
725 let word2_end = next_word_end(bytes, idx)?;
727 if &trimmed[idx..word2_end] != "-c" {
728 return None;
729 }
730 idx = skip_spaces(bytes, word2_end);
731
732 if idx >= trimmed.len() {
733 return None;
734 }
735 Some(&trimmed[idx..])
736}
737
738fn shell_basename_is(word: &str, name: &str) -> bool {
740 word.rsplit('/').next().map(|b| b == name).unwrap_or(false)
741}
742
743fn next_word_end(bytes: &[u8], start: usize) -> Option<usize> {
746 if start >= bytes.len() || bytes[start].is_ascii_whitespace() {
747 return None;
748 }
749 let mut i = start;
750 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
751 i += 1;
752 }
753 Some(i)
754}
755
756fn skip_spaces(bytes: &[u8], start: usize) -> usize {
758 let mut i = start;
759 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
760 i += 1;
761 }
762 i
763}
764
765fn extract_single_quoted_argument(s: &str) -> Option<String> {
770 let s = s.trim();
771 let bytes = s.as_bytes();
772 if bytes.len() < 2 {
773 return None;
774 }
775 let quote = bytes[0];
776 if quote != b'"' && quote != b'\'' {
777 return None;
778 }
779
780 let mut out = String::with_capacity(s.len());
781 let mut i = 1;
782 while i < bytes.len() {
783 let b = bytes[i];
784 if quote == b'"' && b == b'\\' && i + 1 < bytes.len() {
786 out.push(bytes[i + 1] as char);
787 i += 2;
788 continue;
789 }
790 if b == quote {
791 if s[i + 1..].trim().is_empty() {
793 return Some(out);
794 }
795 return None;
796 }
797 out.push(b as char);
798 i += 1;
799 }
800 None
802}
803
804fn try_classify_benign_suffix_command(cmd: &str) -> Option<Classification> {
823 let cmd = cmd.trim();
824
825 if cmd.bytes().any(|byte| matches!(byte, b'\n' | b'\r')) {
829 return None;
830 }
831
832 if cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
835 return None;
836 }
837 if cmd.contains('(') || cmd.contains('`') || cmd.contains("$(") {
838 return None;
839 }
840
841 let boundary = find_first_structural_boundary(cmd)?;
844 let (leading_raw, suffix_raw) = cmd.split_at(boundary.byte_pos);
845 let leading = leading_raw.trim();
846 let suffix = suffix_raw; if leading.is_empty() {
848 return None;
849 }
850
851 if !suffix_is_benign(boundary.kind, suffix) {
853 return None;
854 }
855
856 let leading_classification = classify_command_inner(leading, 1);
859 if !leading_classification.is_compilation {
860 return None;
861 }
862
863 let extracted = cmd.to_string();
867
868 Some(Classification::compound_compilation(
869 leading_classification.kind?,
870 leading_classification.confidence,
871 "compilation command with benign trailing redirect/pipe/background",
872 String::new(),
873 extracted,
874 ))
875}
876
877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879enum BoundaryKind {
880 Pipe,
882 Background,
884 OutputRedirect,
886 InputRedirect,
888}
889
890struct StructuralBoundary {
891 byte_pos: usize,
892 kind: BoundaryKind,
893}
894
895fn find_first_structural_boundary(cmd: &str) -> Option<StructuralBoundary> {
899 let bytes = cmd.as_bytes();
900 let len = bytes.len();
901 let mut in_single = false;
902 let mut in_double = false;
903 let mut escaped = false;
904 let mut i = 0;
905
906 while i < len {
907 let b = bytes[i];
908 if escaped {
909 escaped = false;
910 i += 1;
911 continue;
912 }
913 if b == b'\\' && !in_single {
914 escaped = true;
915 i += 1;
916 continue;
917 }
918 if b == b'\'' && !in_double {
919 in_single = !in_single;
920 i += 1;
921 continue;
922 }
923 if b == b'"' && !in_single {
924 in_double = !in_double;
925 i += 1;
926 continue;
927 }
928 if in_single || in_double {
929 i += 1;
930 continue;
931 }
932
933 match b {
934 b'|' => {
935 if i + 1 < len && bytes[i + 1] == b'|' {
938 return None;
939 }
940 return Some(StructuralBoundary {
941 byte_pos: i,
942 kind: BoundaryKind::Pipe,
943 });
944 }
945 b'&' => {
946 if i + 1 < len && bytes[i + 1] == b'&' {
949 return None;
950 }
951 let prev = if i > 0 { bytes[i - 1] } else { 0 };
952 if prev == b'>' {
953 i += 1;
955 continue;
956 }
957 return Some(StructuralBoundary {
958 byte_pos: i,
959 kind: BoundaryKind::Background,
960 });
961 }
962 b'>' => {
963 if i + 1 < len && bytes[i + 1] == b'&' {
965 i += 2;
966 if i < len && bytes[i].is_ascii_digit() {
967 i += 1;
968 }
969 continue;
970 }
971 if i + 1 < len && bytes[i + 1] == b'(' {
973 return None;
974 }
975 return Some(StructuralBoundary {
976 byte_pos: i,
977 kind: BoundaryKind::OutputRedirect,
978 });
979 }
980 b'<' => {
981 if i + 1 < len && bytes[i + 1] == b'(' {
982 return None;
983 }
984 return Some(StructuralBoundary {
985 byte_pos: i,
986 kind: BoundaryKind::InputRedirect,
987 });
988 }
989 _ => {}
990 }
991 i += 1;
992 }
993 None
994}
995
996fn suffix_is_benign(kind: BoundaryKind, suffix: &str) -> bool {
999 match kind {
1004 BoundaryKind::InputRedirect => false,
1005 BoundaryKind::Background => {
1006 let after = suffix.trim_start_matches('&').trim();
1009 after.is_empty()
1010 }
1011 BoundaryKind::OutputRedirect => {
1012 !suffix_has_disallowed_followup(suffix)
1015 }
1016 BoundaryKind::Pipe => {
1017 let rhs = suffix.trim_start_matches('|').trim();
1020 if rhs.is_empty() {
1021 return false;
1022 }
1023 let rhs_cmd = rhs.split_whitespace().next().unwrap_or("");
1024 let rhs_base = rhs_cmd.rsplit('/').next().unwrap_or(rhs_cmd);
1025 if !BENIGN_PAGER_COMMANDS.contains(&rhs_base) {
1026 return false;
1027 }
1028 !suffix_has_disallowed_followup(rhs)
1029 }
1030 }
1031}
1032
1033fn suffix_has_disallowed_followup(s: &str) -> bool {
1044 let mut rest = s;
1045 loop {
1046 match find_first_structural_boundary(rest) {
1047 Some(b) => {
1048 if matches!(
1049 b.kind,
1050 BoundaryKind::Pipe | BoundaryKind::Background | BoundaryKind::InputRedirect
1051 ) {
1052 return true;
1053 }
1054 let next = b.byte_pos + 1;
1057 if next >= rest.len() {
1058 return false;
1059 }
1060 rest = &rest[next..];
1061 }
1062 None => return false,
1063 }
1064 }
1065}
1066
1067fn split_and_chain(cmd: &str) -> Option<Vec<&str>> {
1070 if !cmd.contains("&&") {
1071 return None;
1072 }
1073
1074 let mut segments = Vec::new();
1075 let mut current_start = 0;
1076 let mut in_single = false;
1077 let mut in_double = false;
1078 let mut escaped = false;
1079 let bytes = cmd.as_bytes();
1080 let mut i = 0;
1081
1082 while i < bytes.len() {
1083 let b = bytes[i];
1084
1085 if escaped {
1086 escaped = false;
1087 i += 1;
1088 continue;
1089 }
1090
1091 if b == b'\\' && !in_single {
1096 escaped = true;
1097 i += 1;
1098 continue;
1099 }
1100
1101 if b == b'\'' && !in_double {
1102 in_single = !in_single;
1103 } else if b == b'"' && !in_single {
1104 in_double = !in_double;
1105 } else if !in_single
1106 && !in_double
1107 && b == b'&'
1108 && i + 1 < bytes.len()
1109 && bytes[i + 1] == b'&'
1110 {
1111 let segment = &cmd[current_start..i];
1112 let trimmed = segment.trim();
1113 if !trimmed.is_empty() {
1114 segments.push(trimmed);
1115 }
1116 current_start = i + 2;
1117 i += 1; }
1119
1120 i += 1;
1121 }
1122
1123 let final_segment = &cmd[current_start..];
1125 let trimmed = final_segment.trim();
1126 if !trimmed.is_empty() {
1127 segments.push(trimmed);
1128 }
1129
1130 if segments.len() > 1 {
1131 Some(segments)
1132 } else {
1133 None
1134 }
1135}
1136
1137#[allow(dead_code)] fn split_multi_command(cmd: &str) -> Option<Vec<&str>> {
1143 if !cmd.contains("&&") && !cmd.contains("||") && !cmd.contains(';') {
1145 return None;
1146 }
1147
1148 let mut segments = Vec::new();
1149 let mut current_start = 0;
1150 let mut in_single = false;
1151 let mut in_double = false;
1152 let mut escaped = false;
1153 let chars: Vec<char> = cmd.chars().collect();
1154 let mut i = 0;
1155
1156 while i < chars.len() {
1157 let c = chars[i];
1158
1159 if escaped {
1160 escaped = false;
1161 i += 1;
1162 continue;
1163 }
1164
1165 if c == '\\' && !in_single {
1168 escaped = true;
1169 i += 1;
1170 continue;
1171 }
1172
1173 if c == '\'' && !in_double {
1174 in_single = !in_single;
1175 } else if c == '"' && !in_single {
1176 in_double = !in_double;
1177 } else if !in_single && !in_double {
1178 if c == ';' {
1180 let segment = &cmd[current_start..byte_index(&chars, i)];
1181 let trimmed = segment.trim();
1182 if !trimmed.is_empty() {
1183 segments.push(trimmed);
1184 }
1185 current_start = byte_index(&chars, i + 1);
1186 } else if c == '&' && i + 1 < chars.len() && chars[i + 1] == '&' {
1187 let segment = &cmd[current_start..byte_index(&chars, i)];
1188 let trimmed = segment.trim();
1189 if !trimmed.is_empty() {
1190 segments.push(trimmed);
1191 }
1192 current_start = byte_index(&chars, i + 2);
1193 i += 1; } else if c == '|' && i + 1 < chars.len() && chars[i + 1] == '|' {
1195 let segment = &cmd[current_start..byte_index(&chars, i)];
1196 let trimmed = segment.trim();
1197 if !trimmed.is_empty() {
1198 segments.push(trimmed);
1199 }
1200 current_start = byte_index(&chars, i + 2);
1201 i += 1; }
1203 }
1204
1205 i += 1;
1206 }
1207
1208 let final_segment = &cmd[current_start..];
1210 let trimmed = final_segment.trim();
1211 if !trimmed.is_empty() {
1212 segments.push(trimmed);
1213 }
1214
1215 if segments.len() > 1 {
1217 Some(segments)
1218 } else {
1219 None
1220 }
1221}
1222
1223#[allow(dead_code)] fn byte_index(chars: &[char], char_idx: usize) -> usize {
1226 chars.iter().take(char_idx).map(|c| c.len_utf8()).sum()
1227}
1228
1229#[allow(dead_code)] fn classify_multi_command(segments: &[&str], depth: u8) -> Classification {
1235 let mut best_compilation: Option<Classification> = None;
1236
1237 for &segment in segments {
1238 let result = classify_command_inner(segment, depth + 1);
1239 if result.is_compilation {
1240 let dominated = match &best_compilation {
1241 Some(prev) => result.confidence > prev.confidence,
1242 None => true,
1243 };
1244 if dominated {
1245 best_compilation = Some(result);
1246 }
1247 }
1248 }
1249
1250 if let Some(compilation) = best_compilation {
1251 return compilation;
1252 }
1253
1254 Classification::not_compilation("no sub-command is compilation")
1255}
1256
1257pub fn classify_command_detailed(cmd: &str) -> ClassificationDetails {
1259 let original = cmd.to_string();
1260 let cmd = cmd.trim();
1261 let mut tiers = Vec::new();
1262
1263 if cmd.is_empty() {
1265 let classification = Classification::not_compilation("empty command");
1266 tiers.push(ClassificationTier {
1267 tier: 0,
1268 name: Cow::Borrowed(TIER_INSTANT_REJECT),
1269 decision: TierDecision::Reject,
1270 reason: Cow::Borrowed("empty command"),
1271 });
1272 return ClassificationDetails {
1273 original,
1274 normalized: cmd.to_string(),
1275 tiers,
1276 classification,
1277 };
1278 }
1279
1280 tiers.push(ClassificationTier {
1281 tier: 0,
1282 name: Cow::Borrowed(TIER_INSTANT_REJECT),
1283 decision: TierDecision::Pass,
1284 reason: Cow::Borrowed("command present"),
1285 });
1286
1287 if cmd.len() < MAX_SPLIT_INPUT_LEN
1293 && let Some((classification, structure_reason)) = try_classify_compound_command(cmd)
1294 .map(|c| (c, "safe compound command with compilation suffix"))
1295 .or_else(|| {
1296 try_classify_wrapped_command(cmd)
1297 .map(|c| (c, "shell -c wrapper with compilation command"))
1298 })
1299 .or_else(|| {
1300 try_classify_benign_suffix_command(cmd).map(|c| {
1301 (
1302 c,
1303 "compilation command with benign trailing redirect/pipe/background",
1304 )
1305 })
1306 })
1307 {
1308 let normalized = classification
1309 .extracted_command
1310 .clone()
1311 .unwrap_or_else(|| cmd.to_string());
1312
1313 tiers.push(ClassificationTier {
1314 tier: 1,
1315 name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1316 decision: TierDecision::Pass,
1317 reason: Cow::Borrowed(structure_reason),
1318 });
1319 tiers.push(ClassificationTier {
1320 tier: 2,
1321 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1322 decision: TierDecision::Pass,
1323 reason: Cow::Borrowed("keyword present in extracted command"),
1324 });
1325 tiers.push(ClassificationTier {
1326 tier: 3,
1327 name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1328 decision: TierDecision::Pass,
1329 reason: Cow::Borrowed("extracted command passed never-intercept checks"),
1330 });
1331 tiers.push(ClassificationTier {
1332 tier: 4,
1333 name: Cow::Borrowed(TIER_FULL_CLASSIFICATION),
1334 decision: TierDecision::Pass,
1335 reason: classification.reason.clone(),
1336 });
1337
1338 return ClassificationDetails {
1339 original,
1340 normalized,
1341 tiers,
1342 classification,
1343 };
1344 }
1345
1346 if let Some(reason) = check_structure(cmd) {
1348 let classification = Classification::not_compilation(reason);
1349 tiers.push(ClassificationTier {
1350 tier: 1,
1351 name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1352 decision: TierDecision::Reject,
1353 reason: Cow::Borrowed(reason),
1354 });
1355 return ClassificationDetails {
1356 original,
1357 normalized: cmd.to_string(),
1358 tiers,
1359 classification,
1360 };
1361 }
1362
1363 tiers.push(ClassificationTier {
1364 tier: 1,
1365 name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1366 decision: TierDecision::Pass,
1367 reason: Cow::Borrowed("no pipes/redirects/backgrounding"),
1368 });
1369
1370 let normalized_cow = normalize_command(cmd);
1372 let normalized = normalized_cow.as_ref();
1373
1374 if !starts_with_compilation_command(normalized) {
1376 let classification = Classification::not_compilation("no compilation keyword");
1377 tiers.push(ClassificationTier {
1378 tier: 2,
1379 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1380 decision: TierDecision::Reject,
1381 reason: Cow::Borrowed("no compilation keyword"),
1382 });
1383 return ClassificationDetails {
1384 original,
1385 normalized: normalized.to_string(),
1386 tiers,
1387 classification,
1388 };
1389 }
1390
1391 tiers.push(ClassificationTier {
1392 tier: 2,
1393 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1394 decision: TierDecision::Pass,
1395 reason: Cow::Borrowed("keyword present"),
1396 });
1397
1398 for pattern in NEVER_INTERCEPT {
1400 if let Some(rest) = normalized.strip_prefix(pattern)
1401 && (rest.is_empty() || rest.starts_with(' '))
1402 {
1403 let reason: Cow<'static, str> =
1404 Cow::Owned(format!("matches never-intercept: {pattern}"));
1405 let classification = Classification::not_compilation(reason.clone());
1406 tiers.push(ClassificationTier {
1407 tier: 3,
1408 name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1409 decision: TierDecision::Reject,
1410 reason,
1411 });
1412 return ClassificationDetails {
1413 original,
1414 normalized: normalized.to_string(),
1415 tiers,
1416 classification,
1417 };
1418 }
1419 }
1420
1421 tiers.push(ClassificationTier {
1422 tier: 3,
1423 name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1424 decision: TierDecision::Pass,
1425 reason: Cow::Borrowed("no never-intercept match"),
1426 });
1427
1428 let classification = classify_full(normalized);
1430 let decision = if classification.is_compilation {
1431 TierDecision::Pass
1432 } else {
1433 TierDecision::Reject
1434 };
1435 tiers.push(ClassificationTier {
1436 tier: 4,
1437 name: Cow::Borrowed(TIER_FULL_CLASSIFICATION),
1438 decision,
1439 reason: classification.reason.clone(),
1440 });
1441
1442 ClassificationDetails {
1443 original,
1444 normalized: normalized.to_string(),
1445 tiers,
1446 classification,
1447 }
1448}
1449
1450fn wrapper_flag_takes_value(wrapper: &str, flag: &str) -> bool {
1455 match wrapper {
1456 "env" => matches!(flag, "-u" | "--unset"),
1457 "nice" => matches!(flag, "-n" | "--adjustment"),
1458 "ionice" => matches!(
1459 flag,
1460 "-c" | "--class" | "-n" | "--classdata" | "-p" | "--pid"
1461 ),
1462 "taskset" => matches!(flag, "-c" | "--cpu-list" | "-p" | "--pid"),
1463 "chrt" => matches!(flag, "-p" | "--pid"),
1464 "timeout" => matches!(flag, "-s" | "--signal" | "-k" | "--kill-after"),
1465 _ => false,
1466 }
1467}
1468
1469fn looks_like_duration_or_priority(token: &str) -> bool {
1473 token.chars().next().is_some_and(|c| c.is_ascii_digit())
1474}
1475
1476fn split_wrapper_word(input: &str) -> Option<(&str, &str)> {
1481 let input = input.trim_start();
1482 let first = input.chars().next()?;
1483 if matches!(first, '\'' | '"') {
1484 let end = input[1..].find(first)? + 1;
1485 let word = &input[1..end];
1486 let rest = &input[end + 1..];
1487 if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
1488 return None;
1489 }
1490 if first == '"' && word.contains(['\\', '$', '`']) {
1493 return None;
1494 }
1495 Some((word, rest.trim_start()))
1496 } else {
1497 let (word, rest) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
1498 if word.contains(['\'', '"', '\\']) {
1499 return None;
1500 }
1501 Some((word, rest.trim_start()))
1502 }
1503}
1504
1505pub fn normalize_command(cmd: &str) -> Cow<'_, str> {
1507 let mut result = cmd.trim();
1508
1509 let wrappers = [
1512 "sudo", "env", "time", "timeout", "nice", "ionice", "chrt", "strace", "ltrace", "perf",
1513 "taskset", "numactl",
1514 ];
1515
1516 loop {
1517 let mut changed = false;
1518 for wrapper in wrappers {
1519 if let Some(rest) = result.strip_prefix(wrapper) {
1520 if rest.is_empty() || rest.starts_with(char::is_whitespace) {
1524 result = rest.trim_start();
1525 changed = true;
1526
1527 while let Some((flag, rest_after_flag)) = split_wrapper_word(result) {
1535 if !flag.starts_with('-') {
1536 break;
1537 }
1538 result = rest_after_flag;
1539
1540 if wrapper_flag_takes_value(wrapper, flag) {
1541 let Some((_value, rest_after_value)) = split_wrapper_word(result)
1542 else {
1543 break;
1544 };
1545 result = rest_after_value;
1546 }
1547 }
1548
1549 if matches!(wrapper, "timeout" | "chrt")
1555 && let Some((first, after_first)) = result.split_once(char::is_whitespace)
1556 && !first.starts_with('-')
1557 && looks_like_duration_or_priority(first)
1558 {
1559 result = after_first.trim_start();
1560 }
1561 }
1562 }
1563 }
1564
1565 let chars = result.chars();
1569 let mut token_len = 0;
1570 let mut in_quote = None; let mut escaped = false;
1572 let mut has_equals = false;
1573 let mut has_space = false;
1574
1575 for c in chars {
1576 if escaped {
1577 escaped = false;
1578 token_len += c.len_utf8();
1579 continue;
1580 }
1581
1582 if c == '\\' {
1583 escaped = true;
1584 token_len += c.len_utf8();
1585 continue;
1586 }
1587
1588 if let Some(q) = in_quote {
1589 if c == q {
1590 in_quote = None;
1591 } else if c == '=' {
1592 has_equals = true;
1593 }
1594 token_len += c.len_utf8();
1595 } else if c == '"' || c == '\'' {
1596 in_quote = Some(c);
1597 token_len += c.len_utf8();
1598 } else if c.is_whitespace() {
1599 has_space = true;
1600 break;
1601 } else {
1602 if c == '=' {
1603 has_equals = true;
1604 }
1605 token_len += c.len_utf8();
1606 }
1607 }
1608
1609 if has_equals && in_quote.is_none() && has_space {
1610 result = result[token_len..].trim_start();
1613 changed = true;
1614 }
1615
1616 if result.starts_with('/') {
1619 if let Some(space_idx) = result.find(' ') {
1620 let cmd_part = &result[..space_idx];
1621 if let Some(last_slash) = cmd_part.rfind('/') {
1622 result = &result[last_slash + 1..];
1624 changed = true;
1625 }
1626 } else {
1627 if let Some(last_slash) = result.rfind('/') {
1629 result = &result[last_slash + 1..];
1630 changed = true;
1631 }
1632 }
1633 }
1634
1635 if !changed {
1636 break;
1637 }
1638 }
1639
1640 if result.eq(cmd) {
1641 Cow::Borrowed(cmd)
1642 } else {
1643 Cow::Owned(result.to_string())
1644 }
1645}
1646
1647fn has_file_redirect(cmd: &str) -> bool {
1656 let bytes = cmd.as_bytes();
1657 let len = bytes.len();
1658 let mut in_single = false;
1659 let mut in_double = false;
1660 let mut escaped = false;
1661 let mut i = 0;
1662
1663 while i < len {
1664 let b = bytes[i];
1665
1666 if escaped {
1667 escaped = false;
1668 i += 1;
1669 continue;
1670 }
1671 if b == b'\\' && !in_single {
1676 escaped = true;
1677 i += 1;
1678 continue;
1679 }
1680 match b {
1681 b'\'' if !in_double => {
1682 in_single = !in_single;
1683 }
1684 b'"' if !in_single => {
1685 in_double = !in_double;
1686 }
1687 b'>' if !in_single && !in_double => {
1688 if i + 1 < len && bytes[i + 1] == b'&' {
1690 i += 2; if i < len && bytes[i].is_ascii_digit() {
1694 i += 1;
1695 }
1696 continue;
1697 }
1698 if i + 1 < len && bytes[i + 1] == b'(' {
1701 i += 1;
1702 continue;
1703 }
1704 return true; }
1706 b'<' if !in_single && !in_double => {
1707 if i + 1 < len && bytes[i + 1] == b'(' {
1709 i += 1;
1710 continue;
1711 }
1712 return true; }
1714 _ => {}
1715 }
1716 i += 1;
1717 }
1718 false
1719}
1720
1721fn check_structure(cmd: &str) -> Option<&'static str> {
1738 let bytes = cmd.as_bytes();
1739 let len = bytes.len();
1740
1741 let mut in_single = false;
1742 let mut in_double = false;
1743 let mut escaped = false;
1744
1745 let mut found_backgrounded = false;
1748 let mut found_piped = false;
1749 let mut found_subshell = false;
1750 let mut found_output_redirect = false;
1751 let mut found_input_redirect = false;
1752 let mut found_semicolon = false;
1753 let mut found_and_chain = false;
1754 let mut found_or_chain = false;
1755 let mut found_subshell_capture = false;
1756
1757 let mut i = 0;
1758 while i < len {
1759 let b = bytes[i];
1760
1761 if escaped {
1763 escaped = false;
1764 i += 1;
1765 continue;
1766 }
1767 if b == b'\\' && !in_single {
1772 escaped = true;
1773 i += 1;
1774 continue;
1775 }
1776
1777 if b == b'\'' && !in_double {
1779 in_single = !in_single;
1780 i += 1;
1781 continue;
1782 }
1783 if b == b'"' && !in_single {
1784 in_double = !in_double;
1785 i += 1;
1786 continue;
1787 }
1788
1789 if in_single || in_double {
1791 i += 1;
1792 continue;
1793 }
1794
1795 match b {
1797 b'\n' | b'\r' => return Some("contains embedded newline"),
1799
1800 b'&' => {
1802 if i + 1 < len && bytes[i + 1] == b'&' {
1803 found_and_chain = true;
1804 i += 1; } else {
1806 let prev = if i > 0 { bytes[i - 1] } else { 0 };
1808 let next = if i + 1 < len { bytes[i + 1] } else { 0 };
1809 if prev != b'>' && next != b'>' {
1810 found_backgrounded = true;
1811 }
1812 }
1813 }
1814
1815 b'|' => {
1817 if i + 1 < len && bytes[i + 1] == b'|' {
1818 found_or_chain = true;
1819 i += 1; } else {
1821 found_piped = true;
1822 }
1823 }
1824
1825 b'(' => found_subshell = true,
1827
1828 b'>' => {
1831 if i + 1 < len && bytes[i + 1] == b'(' {
1832 found_subshell = true;
1833 } else if i + 1 < len && bytes[i + 1] == b'&' {
1834 i += 1; if i + 1 < len && bytes[i + 1].is_ascii_digit() {
1839 i += 1; }
1841 } else if i + 1 < len && bytes[i + 1] == b'>' {
1842 found_output_redirect = true;
1844 i += 1; } else {
1846 found_output_redirect = true;
1847 }
1848 }
1849
1850 b'<' => {
1852 if i + 1 < len && bytes[i + 1] == b'(' {
1853 found_subshell = true;
1854 } else {
1855 found_input_redirect = true;
1856 }
1857 }
1858
1859 b';' => found_semicolon = true,
1861
1862 b'`' => found_subshell_capture = true,
1864
1865 b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
1867 found_subshell_capture = true;
1868 }
1869
1870 _ => {}
1871 }
1872
1873 i += 1;
1874 }
1875
1876 if found_backgrounded {
1878 return Some("backgrounded command");
1879 }
1880 if found_piped && !found_or_chain {
1882 return Some("piped command");
1883 }
1884 if found_subshell {
1885 return Some("subshell execution");
1886 }
1887 if found_output_redirect {
1888 return Some("output redirected");
1889 }
1890 if found_input_redirect {
1891 return Some("input redirected");
1892 }
1893 if found_semicolon {
1894 return Some("chained command (;)");
1895 }
1896 if found_and_chain {
1897 return Some("chained command (&&)");
1898 }
1899 if found_or_chain {
1900 return Some("chained command (||)");
1901 }
1902 if found_subshell_capture {
1903 return Some("subshell capture");
1904 }
1905
1906 None
1907}
1908
1909fn starts_with_compilation_command(cmd: &str) -> bool {
1915 let cmd = cmd.trim_start();
1916 COMPILATION_KEYWORDS
1917 .iter()
1918 .any(|keyword| command_word_matches(cmd, keyword))
1919}
1920
1921fn command_word_matches(cmd: &str, keyword: &str) -> bool {
1922 let Some(rest) = cmd.strip_prefix(keyword) else {
1923 return false;
1924 };
1925 rest.is_empty() || rest.starts_with(char::is_whitespace)
1926}
1927
1928fn positional_build_targets<'a>(cmd: &'a str, value_flags: &[&str]) -> Vec<&'a str> {
1939 let mut targets = Vec::new();
1940 let toks: Vec<&str> = cmd.split_whitespace().skip(1).collect();
1941 let mut i = 0;
1942 while i < toks.len() {
1943 let tok = toks[i];
1944 if value_flags.contains(&tok) {
1945 i += 2; } else if tok.starts_with('-') || tok.contains('=') {
1947 i += 1; } else {
1949 targets.push(tok);
1950 i += 1;
1951 }
1952 }
1953 targets
1954}
1955
1956fn classify_full(cmd: &str) -> Classification {
1958 if cmd.starts_with("cargo ") || cmd.eq("cargo") {
1960 return classify_cargo(cmd);
1961 }
1962
1963 if cmd.starts_with("cargo-zigbuild ") || cmd.eq("cargo-zigbuild") {
1966 return classify_cargo_zigbuild(cmd);
1967 }
1968
1969 if cmd.starts_with("cargo-xwin ") || cmd.eq("cargo-xwin") {
1970 return if is_cargo_xwin_build(cmd) {
1971 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo-xwin build")
1972 } else {
1973 Classification::not_compilation("cargo-xwin subcommand not interceptable")
1974 };
1975 }
1976
1977 if cmd.starts_with("rustc ") || cmd.eq("rustc") {
1979 return Classification::compilation(CompilationKind::Rustc, 0.95, "rustc invocation");
1980 }
1981
1982 if cmd.starts_with("gcc ")
1984 && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
1985 {
1986 return Classification::compilation(CompilationKind::Gcc, 0.90, "gcc compilation");
1987 }
1988
1989 if cmd.starts_with("g++ ")
1991 && (cmd.contains(" -c ")
1992 || cmd.contains(" -o ")
1993 || cmd.contains(".cpp")
1994 || cmd.contains(".cc"))
1995 {
1996 return Classification::compilation(CompilationKind::Gpp, 0.90, "g++ compilation");
1997 }
1998
1999 if cmd.starts_with("clang ")
2001 && !cmd.starts_with("clang++ ")
2002 && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
2003 {
2004 return Classification::compilation(CompilationKind::Clang, 0.90, "clang compilation");
2005 }
2006
2007 if cmd.starts_with("clang++ ")
2009 && (cmd.contains(" -c ")
2010 || cmd.contains(" -o ")
2011 || cmd.contains(".cpp")
2012 || cmd.contains(".cc"))
2013 {
2014 return Classification::compilation(CompilationKind::Clangpp, 0.90, "clang++ compilation");
2015 }
2016
2017 if cmd.starts_with("cc ")
2019 && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
2020 {
2021 return Classification::compilation(CompilationKind::Gcc, 0.85, "cc compilation");
2022 }
2023
2024 if cmd.starts_with("c++ ")
2026 && (cmd.contains(" -c ")
2027 || cmd.contains(" -o ")
2028 || cmd.contains(".cpp")
2029 || cmd.contains(".cc"))
2030 {
2031 return Classification::compilation(CompilationKind::Gpp, 0.85, "c++ compilation");
2032 }
2033
2034 if cmd.starts_with("make") && (cmd.eq("make") || cmd.starts_with("make ")) {
2036 const MAKE_VALUE_FLAGS: [&str; 8] = [
2041 "-f",
2042 "-C",
2043 "-I",
2044 "-o",
2045 "-W",
2046 "--file",
2047 "--directory",
2048 "--include-dir",
2049 ];
2050 const MAKE_MAINTENANCE_TARGETS: [&str; 4] = ["clean", "install", "distclean", "uninstall"];
2051 let is_maintenance = positional_build_targets(cmd, &MAKE_VALUE_FLAGS)
2052 .iter()
2053 .any(|target| MAKE_MAINTENANCE_TARGETS.contains(target));
2054 if is_maintenance {
2055 return Classification::not_compilation("make maintenance command");
2056 }
2057 return Classification::compilation(CompilationKind::Make, 0.85, "make build");
2058 }
2059
2060 if cmd.starts_with("cmake ") || cmd.eq("cmake") {
2062 let mut tokens = cmd.split_whitespace();
2063 let _ = tokens.next(); if tokens.any(|token| token.eq("--build") || token.starts_with("--build=")) {
2065 return Classification::compilation(CompilationKind::CmakeBuild, 0.90, "cmake --build");
2066 }
2067 }
2068
2069 if cmd.starts_with("ninja") && (cmd.eq("ninja") || cmd.starts_with("ninja ")) {
2071 const NINJA_VALUE_FLAGS: [&str; 8] = ["-C", "-f", "-j", "-k", "-l", "-d", "-t", "-w"];
2075 let raw: Vec<&str> = cmd.split_whitespace().collect();
2076 let is_clean_tool = raw.windows(2).any(|w| w[0] == "-t" && w[1] == "clean");
2077 let is_clean_target = positional_build_targets(cmd, &NINJA_VALUE_FLAGS).contains(&"clean");
2078 if is_clean_tool || is_clean_target {
2079 return Classification::not_compilation("ninja clean");
2080 }
2081 return Classification::compilation(CompilationKind::Ninja, 0.90, "ninja build");
2082 }
2083
2084 if cmd.starts_with("meson ") || cmd.eq("meson") {
2086 let mut tokens = cmd.split_whitespace();
2087 let _ = tokens.next(); let mut subcommand = None;
2089 for token in tokens {
2090 if token.starts_with('-') {
2091 continue;
2092 }
2093 subcommand = Some(token);
2094 break;
2095 }
2096 if matches!(subcommand, Some("compile")) {
2097 return Classification::compilation(CompilationKind::Meson, 0.85, "meson compile");
2098 }
2099 }
2100
2101 if cmd.eq("nix-build")
2105 || cmd.starts_with("nix-build ")
2106 || cmd.eq("nix")
2107 || cmd.starts_with("nix ")
2108 {
2109 return classify_nix(cmd);
2110 }
2111
2112 let mut tokens = cmd.split_whitespace();
2114 if tokens.next().is_some_and(|token| token.eq("bun")) {
2115 match tokens.next() {
2116 Some("test") => {
2117 if tokens.any(|a| a.eq("-w") || a.eq("--watch")) {
2120 return Classification::not_compilation(
2121 "bun test --watch is interactive (not intercepted)",
2122 );
2123 }
2124 return Classification::compilation(
2125 CompilationKind::BunTest,
2126 0.95,
2127 "bun test command",
2128 );
2129 }
2130 Some("typecheck") => {
2131 if tokens.any(|a| a.eq("-w") || a.eq("--watch")) {
2133 return Classification::not_compilation(
2134 "bun typecheck --watch is interactive (not intercepted)",
2135 );
2136 }
2137 return Classification::compilation(
2138 CompilationKind::BunTypecheck,
2139 0.95,
2140 "bun typecheck command",
2141 );
2142 }
2143 Some("x") => {
2144 return Classification::not_compilation("bun x runs arbitrary packages");
2147 }
2148 _ => {}
2149 }
2150 }
2151
2152 if cmd.starts_with("go ") {
2154 return classify_go(cmd);
2155 }
2156
2157 if let Some(rest) = cmd
2162 .strip_prefix("tsc ")
2163 .or_else(|| cmd.strip_prefix("npx tsc "))
2164 {
2165 return classify_tsc(rest);
2166 }
2167
2168 Classification::not_compilation("no matching pattern")
2169}
2170
2171fn classify_tsc(args: &str) -> Classification {
2181 let tokens = args.split_whitespace();
2182 let mut no_emit = false;
2183 for token in tokens {
2184 if token.eq("-w") || token.eq("--watch") || token.eq("--watchFile") {
2186 return Classification::not_compilation("tsc --watch is interactive (not intercepted)");
2187 }
2188 if token.eq_ignore_ascii_case("--noemit") {
2190 no_emit = true;
2191 }
2192 }
2193
2194 if !no_emit {
2195 return Classification::not_compilation(
2196 "tsc without --noEmit emits output files locally (not intercepted)",
2197 );
2198 }
2199
2200 Classification::compilation(CompilationKind::Tsc, 0.95, "tsc --noEmit typecheck")
2201}
2202
2203fn classify_go(cmd: &str) -> Classification {
2215 let mut tokens = cmd.split_whitespace();
2216 tokens.next();
2218
2219 let subcommand = match tokens.next() {
2220 Some(sub) => sub,
2221 None => return Classification::not_compilation("bare `go` is not a compilation command"),
2222 };
2223
2224 let args: Vec<&str> = tokens.collect();
2226
2227 match subcommand {
2228 "build" => {
2229 if args
2231 .iter()
2232 .any(|a| a.eq(&"-o") || a.starts_with("-o=") || a.eq(&"--output"))
2233 {
2234 return Classification::not_compilation(
2235 "go build -o emits a local binary (not intercepted)",
2236 );
2237 }
2238 Classification::compilation(CompilationKind::GoBuild, 0.95, "go build command")
2239 }
2240 "test" => {
2241 if args
2243 .iter()
2244 .any(|a| a.eq(&"-exec") || a.starts_with("-exec="))
2245 {
2246 return Classification::not_compilation(
2247 "go test -exec runs a local wrapper (not intercepted)",
2248 );
2249 }
2250 if args.iter().any(|a| a.eq(&"-c")) {
2252 return Classification::not_compilation(
2253 "go test -c emits a local test binary (not intercepted)",
2254 );
2255 }
2256 Classification::compilation(CompilationKind::GoTest, 0.95, "go test command")
2257 }
2258 "vet" => Classification::compilation(CompilationKind::GoVet, 0.95, "go vet command"),
2259 _ => Classification::not_compilation("go subcommand is not a compilation command"),
2260 }
2261}
2262
2263pub fn is_cargo_package_verification(command: &str) -> bool {
2271 let normalized = normalize_command(command);
2272 let cmd = normalized.as_ref();
2273 if check_structure(cmd).is_some() || cmd.contains(['\'', '"', '\\', '$', '`']) {
2274 return false;
2275 }
2276
2277 let mut tokens = cmd.split_whitespace();
2278 if tokens.next() != Some("cargo") {
2279 return false;
2280 }
2281
2282 let mut subcommand = None;
2283 let mut dry_run = false;
2284 const VALUE_FLAGS: &[&str] = &[
2285 "--index",
2286 "--registry",
2287 "--token",
2288 "--color",
2289 "--config",
2290 "-Z",
2291 "-C",
2292 "-p",
2293 "--package",
2294 "--exclude",
2295 "-F",
2296 "--features",
2297 "-j",
2298 "--jobs",
2299 "--target",
2300 "--target-dir",
2301 "-m",
2302 "--manifest-path",
2303 "--lockfile-path",
2304 "--message-format",
2305 ];
2306
2307 while let Some(token) = tokens.next() {
2308 if subcommand.is_none() && token.starts_with('+') {
2309 continue;
2310 }
2311 if matches!(token, "package" | "publish") && subcommand.is_none() {
2312 subcommand = Some(token);
2313 continue;
2314 }
2315 if VALUE_FLAGS.contains(&token) {
2316 if tokens.next().is_none_or(|value| value.starts_with('-')) {
2317 return false;
2318 }
2319 continue;
2320 }
2321 if let Some((flag, value)) = token.split_once('=')
2322 && VALUE_FLAGS.contains(&flag)
2323 && !value.is_empty()
2324 {
2325 continue;
2326 }
2327 if ["-j", "-p", "-F", "-m", "-Z"]
2328 .iter()
2329 .any(|flag| token.starts_with(flag) && token.len() > flag.len())
2330 {
2331 continue;
2332 }
2333 match token {
2334 "--dry-run" | "-n" => dry_run = true,
2335 "--workspace"
2336 | "--allow-dirty"
2337 | "--no-metadata"
2338 | "--exclude-lockfile"
2339 | "--all-features"
2340 | "--no-default-features"
2341 | "--keep-going"
2342 | "--locked"
2343 | "--offline"
2344 | "--frozen"
2345 | "--verbose"
2346 | "-v"
2347 | "-vv"
2348 | "-vvv"
2349 | "--quiet"
2350 | "-q" => {}
2351 _ => return false,
2354 }
2355 }
2356
2357 matches!(subcommand, Some("package")) || (subcommand == Some("publish") && dry_run)
2358}
2359
2360#[must_use]
2366pub fn is_cargo_xwin_build(command: &str) -> bool {
2367 let normalized = normalize_command(command);
2368 let cmd = normalized.as_ref();
2369 if check_structure(cmd).is_some() || cmd.contains(['\'', '"', '\\', '$', '`']) {
2370 return false;
2371 }
2372 let mut tokens = cmd.split_whitespace();
2373 match tokens.next() {
2374 Some("cargo-xwin") => {
2375 let mut subcommand = tokens.next();
2376 if subcommand == Some("xwin") {
2377 subcommand = tokens.next();
2378 }
2379 if subcommand != Some("build") {
2380 return false;
2381 }
2382 }
2383 Some("cargo") => loop {
2384 match tokens.next() {
2385 Some("xwin") => {
2386 if tokens.next() != Some("build") {
2387 return false;
2388 }
2389 break;
2390 }
2391 Some("--color" | "--config" | "-Z" | "-C") => {
2392 if tokens.next().is_none_or(|value| value.starts_with('-')) {
2393 return false;
2394 }
2395 }
2396 Some("--locked" | "--offline" | "--frozen" | "-v" | "-vv" | "-q") => {}
2397 Some(token)
2398 if token.starts_with('+')
2399 || token.starts_with("--color=")
2400 || token.starts_with("--config=") => {}
2401 _ => return false,
2402 }
2403 },
2404 _ => return false,
2405 }
2406 !tokens.any(|token| matches!(token, "--help" | "-h" | "--version" | "-V"))
2407}
2408
2409fn classify_cargo(cmd: &str) -> Classification {
2414 let mut tokens = cmd.split_whitespace();
2415
2416 let _cargo = tokens.next();
2418
2419 const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2426 let subcommand = loop {
2427 let Some(tok) = tokens.next() else {
2428 return Classification::not_compilation("bare cargo command");
2429 };
2430 if tok.starts_with('+') {
2431 continue;
2433 }
2434 if tok.starts_with('-') {
2435 if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2436 let _ = tokens.next();
2438 }
2439 continue;
2440 }
2441 break tok;
2442 };
2443
2444 match subcommand {
2445 "build" | "b" => {
2446 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build")
2447 }
2448 "package" | "publish" => {
2449 if is_cargo_package_verification(cmd) {
2450 Classification::compilation(
2451 CompilationKind::CargoBuild,
2452 0.95,
2453 "cargo package verification (includes build)",
2454 )
2455 } else {
2456 Classification::not_compilation(
2457 "cargo package/publish without supported verification (never-intercept)",
2458 )
2459 }
2460 }
2461 "test" | "t" => Classification::compilation(CompilationKind::CargoTest, 0.95, "cargo test"),
2462 "check" | "c" => {
2463 Classification::compilation(CompilationKind::CargoCheck, 0.90, "cargo check")
2464 }
2465 "clippy" => Classification::compilation(CompilationKind::CargoClippy, 0.90, "cargo clippy"),
2466 "doc" => Classification::compilation(CompilationKind::CargoDoc, 0.85, "cargo doc"),
2467 "run" | "r" => {
2468 Classification::compilation(
2470 CompilationKind::CargoBuild,
2471 0.85,
2472 "cargo run (includes build)",
2473 )
2474 }
2475 "bench" => Classification::compilation(CompilationKind::CargoBench, 0.90, "cargo bench"),
2476 "zigbuild" => {
2481 Classification::compilation(CompilationKind::CargoZigbuild, 0.95, "cargo zigbuild")
2482 }
2483 "xwin" => {
2484 if is_cargo_xwin_build(cmd) {
2485 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo xwin build")
2486 } else {
2487 Classification::not_compilation("cargo xwin subcommand not interceptable")
2488 }
2489 }
2490 "nextest" => {
2491 let Some(nextest_sub) = tokens.next() else {
2495 return Classification::not_compilation("bare cargo nextest without subcommand");
2496 };
2497
2498 match nextest_sub {
2499 "run" | "r" => Classification::compilation(
2500 CompilationKind::CargoNextest,
2501 0.95,
2502 "cargo nextest run",
2503 ),
2504 _ => Classification::not_compilation("cargo nextest subcommand not interceptable"),
2505 }
2506 }
2507 _ => Classification::not_compilation("cargo subcommand not interceptable"),
2508 }
2509}
2510
2511fn classify_cargo_zigbuild(cmd: &str) -> Classification {
2521 let mut tokens = cmd.split_whitespace();
2522 let _bin = tokens.next();
2524
2525 const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2528 let subcommand = loop {
2529 let Some(tok) = tokens.next() else {
2530 return Classification::not_compilation("bare cargo-zigbuild command");
2531 };
2532 if tok.starts_with('+') {
2533 continue;
2534 }
2535 if tok.starts_with('-') {
2536 if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2537 let _ = tokens.next();
2538 }
2539 continue;
2540 }
2541 break tok;
2542 };
2543
2544 match subcommand {
2545 "zigbuild" | "build" | "b" => Classification::compilation(
2546 CompilationKind::CargoZigbuild,
2547 0.95,
2548 "cargo-zigbuild build",
2549 ),
2550 _ => Classification::not_compilation("cargo-zigbuild subcommand not interceptable"),
2551 }
2552}
2553
2554const NIX_GLOBAL_VALUE_FLAGS: &[&str] = &[
2563 "--extra-experimental-features",
2564 "--experimental-features",
2565 "--log-format",
2566 "--max-jobs",
2567 "-j",
2568 "--cores",
2569 "--builders",
2570 "--store",
2571 "--file",
2572 "-f",
2573 "-I",
2574 "--include",
2575];
2576
2577fn classify_nix(cmd: &str) -> Classification {
2598 if cmd.eq("nix-build") || cmd.starts_with("nix-build ") {
2600 return Classification::compilation(
2601 CompilationKind::NixBuild,
2602 0.9,
2603 "nix-build (legacy) derivation build",
2604 );
2605 }
2606
2607 let mut tokens = cmd.split_whitespace();
2608 let _nix = tokens.next(); let subcommand = loop {
2612 let Some(tok) = tokens.next() else {
2613 return Classification::not_compilation("bare nix command (no subcommand)");
2614 };
2615 if tok.starts_with('-') {
2616 if tok.eq("--option") {
2617 let _ = tokens.next();
2619 let _ = tokens.next();
2620 } else if !tok.contains('=') && NIX_GLOBAL_VALUE_FLAGS.contains(&tok) {
2621 let _ = tokens.next();
2622 }
2623 continue;
2624 }
2625 break tok;
2626 };
2627
2628 let rest: Vec<&str> = tokens.collect();
2630 let wants_help = rest.iter().any(|t| t.eq(&"--help") || t.eq(&"-h"));
2631 if wants_help {
2632 return Classification::not_compilation("nix --help (not a build)");
2633 }
2634 let has_command_flag = rest.iter().any(|t| t.eq(&"-c") || t.eq(&"--command"));
2635
2636 match subcommand {
2637 "build" => {
2639 Classification::compilation(CompilationKind::NixBuild, 0.9, "nix build derivation")
2640 }
2641 "flake" => {
2645 let flake_sub = rest.iter().find(|t| !t.starts_with('-'));
2646 if flake_sub == Some(&"check") {
2647 Classification::compilation(
2648 CompilationKind::NixBuild,
2649 0.9,
2650 "nix flake check (evaluates + builds all outputs)",
2651 )
2652 } else {
2653 Classification::not_compilation("nix flake subcommand is not a build")
2654 }
2655 }
2656 "develop" | "shell" => {
2660 if has_command_flag {
2661 let reason = if subcommand == "develop" {
2662 "nix develop -c (non-interactive command in dev shell)"
2663 } else {
2664 "nix shell -c (non-interactive command with packages)"
2665 };
2666 Classification::compilation(CompilationKind::NixBuild, 0.9, reason)
2667 } else {
2668 Classification::not_compilation(
2669 "interactive nix shell (no -c/--command; not intercepted)",
2670 )
2671 }
2672 }
2673 _ => Classification::not_compilation("nix subcommand is not an offloadable build"),
2677 }
2678}
2679
2680pub fn split_shell_commands(cmd: &str) -> Vec<&str> {
2691 let bytes = cmd.as_bytes();
2692 let len = bytes.len();
2693 let mut segments: Vec<&str> = Vec::new();
2694 let mut start = 0;
2695 let mut i = 0;
2696
2697 let mut in_single = false;
2699 let mut in_double = false;
2700 let mut in_backtick = false;
2701 let mut escaped = false;
2702
2703 while i < len {
2704 let b = bytes[i];
2705
2706 if escaped {
2707 escaped = false;
2708 i += 1;
2709 continue;
2710 }
2711
2712 if b == b'\\' && !in_single {
2717 escaped = true;
2718 i += 1;
2719 continue;
2720 }
2721
2722 if !in_double && !in_backtick && b == b'\'' {
2724 in_single = !in_single;
2725 i += 1;
2726 continue;
2727 }
2728 if !in_single && !in_backtick && b == b'"' {
2729 in_double = !in_double;
2730 i += 1;
2731 continue;
2732 }
2733 if !in_single && !in_double && b == b'`' {
2734 in_backtick = !in_backtick;
2735 i += 1;
2736 continue;
2737 }
2738
2739 if in_single || in_double || in_backtick {
2741 i += 1;
2742 continue;
2743 }
2744
2745 if b == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
2747 let seg = cmd[start..i].trim();
2748 if !seg.is_empty() {
2749 segments.push(seg);
2750 }
2751 i += 2;
2752 start = i;
2753 continue;
2754 }
2755
2756 if b == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
2758 let seg = cmd[start..i].trim();
2759 if !seg.is_empty() {
2760 segments.push(seg);
2761 }
2762 i += 2;
2763 start = i;
2764 continue;
2765 }
2766
2767 if b == b';' {
2769 let seg = cmd[start..i].trim();
2770 if !seg.is_empty() {
2771 segments.push(seg);
2772 }
2773 i += 1;
2774 start = i;
2775 continue;
2776 }
2777
2778 i += 1;
2779 }
2780
2781 let seg = cmd[start..].trim();
2783 if !seg.is_empty() {
2784 segments.push(seg);
2785 }
2786
2787 segments
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792 use super::*;
2793
2794 #[test]
2795 fn cargo_xwin_builds_use_the_cargo_build_contract() {
2796 for command in [
2797 "cargo xwin build --release --locked --target x86_64-pc-windows-msvc",
2798 "cargo +nightly-2026-08-31 xwin build --target=aarch64-pc-windows-msvc",
2799 "cargo --color never --locked xwin build",
2800 "env CARGO_HOME=/root/.cargo cargo xwin build --release",
2801 "cargo-xwin build --release",
2802 "cargo-xwin xwin build --release",
2803 "/home/ubuntu/.local/bin/cargo-xwin xwin build --release",
2804 ] {
2805 assert!(is_cargo_xwin_build(command), "{command}");
2806 let classification = classify_command(command);
2807 assert!(classification.is_compilation, "{command}");
2808 assert_eq!(
2809 classification.kind,
2810 Some(CompilationKind::CargoBuild),
2811 "{command}"
2812 );
2813 }
2814 }
2815
2816 #[test]
2817 fn cargo_xwin_recognition_declines_non_builds_and_argument_mentions() {
2818 for command in [
2819 "cargo xwin",
2820 "cargo xwin env",
2821 "cargo xwin cache clear",
2822 "cargo xwin check",
2823 "cargo xwin publish",
2824 "cargo xwin build --help",
2825 "cargo-xwin --version",
2826 "cargo-xwin cache clear",
2827 "cargo-xwin xwin build --help",
2828 "cargo --config xwin build --target x86_64-pc-windows-msvc",
2829 "cargo build --manifest-path cargo-xwin --target x86_64-pc-windows-msvc",
2830 "echo cargo xwin build",
2831 "cargo xwin build && cargo build",
2832 ] {
2833 assert!(!is_cargo_xwin_build(command), "{command}");
2834 }
2835 for command in [
2836 "cargo xwin env",
2837 "cargo xwin cache clear",
2838 "cargo-xwin --version",
2839 ] {
2840 assert!(!classify_command(command).is_compilation, "{command}");
2841 }
2842 }
2843 use crate::test_guard;
2844
2845 #[test]
2846 fn test_cargo_build_with_toolchain() {
2847 let _guard = test_guard!();
2848 let result = classify_command("cargo +nightly build");
2849 assert!(result.is_compilation);
2850 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
2851 }
2852
2853 #[test]
2856 fn test_nix_build_offloadable() {
2857 let _guard = test_guard!();
2858 for cmd in [
2859 "nix build",
2860 "nix build .#foo",
2861 "nix build .#packages.x86_64-linux.default",
2862 "nix build /path/to/flake#pkg",
2863 "nix build --extra-experimental-features 'nix-command flakes' .#foo",
2864 "nix --option sandbox true build .#foo",
2865 "nix -j4 build .#foo",
2866 "nix build nixpkgs#hello -o result-hello",
2867 ] {
2868 let result = classify_command(cmd);
2869 assert!(result.is_compilation, "expected offload for `{cmd}`");
2870 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
2871 assert!(result.confidence >= 0.85, "`{cmd}` confidence too low");
2872 }
2873 }
2874
2875 #[test]
2876 fn test_nix_build_legacy_offloadable() {
2877 let _guard = test_guard!();
2878 for cmd in ["nix-build", "nix-build .", "nix-build -A hello"] {
2879 let result = classify_command(cmd);
2880 assert!(result.is_compilation, "expected offload for `{cmd}`");
2881 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
2882 }
2883 }
2884
2885 #[test]
2886 fn test_nix_flake_check_offloadable_but_metadata_not() {
2887 let _guard = test_guard!();
2888 let check = classify_command("nix flake check");
2889 assert!(check.is_compilation, "nix flake check should offload");
2890 assert_eq!(check.kind, Some(CompilationKind::NixBuild));
2891
2892 for cmd in [
2893 "nix flake metadata",
2894 "nix flake show",
2895 "nix flake update",
2896 "nix flake lock",
2897 "nix flake info",
2898 ] {
2899 let result = classify_command(cmd);
2900 assert!(!result.is_compilation, "`{cmd}` must NOT offload");
2901 }
2902 }
2903
2904 #[test]
2905 fn test_nix_develop_and_shell_only_with_command() {
2906 let _guard = test_guard!();
2907 for cmd in [
2909 "nix develop -c make",
2910 "nix develop --command cargo build",
2911 "nix develop .#dev -c bun test",
2912 "nix shell nixpkgs#gcc -c make",
2913 "nix shell nixpkgs#hello --command hello",
2914 ] {
2915 let result = classify_command(cmd);
2916 assert!(result.is_compilation, "expected offload for `{cmd}`");
2917 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
2918 }
2919 for cmd in ["nix develop", "nix develop .#dev", "nix shell nixpkgs#gcc"] {
2921 let result = classify_command(cmd);
2922 assert!(
2923 !result.is_compilation,
2924 "`{cmd}` is interactive; must run local"
2925 );
2926 }
2927 }
2928
2929 #[test]
2930 fn test_nix_execution_and_mutation_not_offloaded() {
2931 let _guard = test_guard!();
2932 for cmd in [
2933 "nix run .#app",
2934 "nix run nixpkgs#hello",
2935 "nix repl",
2936 "nix eval .#foo",
2937 "nix search nixpkgs hello",
2938 "nix profile install nixpkgs#hello",
2939 "nix registry list",
2940 "nix store gc",
2941 "nix copy --to ssh://host .#foo",
2942 "nix path-info .#foo",
2943 "nix --version",
2944 "nix build --help",
2945 "nix",
2946 ] {
2947 let result = classify_command(cmd);
2948 assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
2949 }
2950 }
2951
2952 #[test]
2953 fn test_nix_legacy_mutating_tools_have_no_keyword() {
2954 let _guard = test_guard!();
2955 for cmd in [
2959 "nix-env -iA nixpkgs.hello",
2960 "nix-shell",
2961 "nix-shell -p gcc",
2962 "nix-store --gc",
2963 "nix-collect-garbage -d",
2964 "nix-channel --update",
2965 ] {
2966 assert!(
2967 !starts_with_compilation_command(cmd),
2968 "`{cmd}` should not match any compilation keyword"
2969 );
2970 let result = classify_command(cmd);
2971 assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
2972 }
2973 }
2974
2975 #[test]
2976 fn test_nix_build_maps_to_nix_runtime_base() {
2977 let _guard = test_guard!();
2978 assert_eq!(CompilationKind::NixBuild.command_base(), "nix");
2979 assert!(!CompilationKind::NixBuild.is_test_command());
2980 }
2981
2982 #[test]
2983 fn test_nix_build_compound_and_wrapped() {
2984 let _guard = test_guard!();
2985 let compound = classify_command("cd /repo && nix build .#foo");
2987 assert!(compound.is_compilation, "compound nix build should offload");
2988 assert_eq!(compound.kind, Some(CompilationKind::NixBuild));
2989 let wrapped = classify_command("bash -c \"nix build .#foo\"");
2991 assert!(wrapped.is_compilation, "wrapped nix build should offload");
2992 assert_eq!(wrapped.kind, Some(CompilationKind::NixBuild));
2993 }
2994
2995 #[test]
2996 fn test_job_kind_serde_and_base_token() {
2997 let _guard = test_guard!();
2998 let json = serde_json::to_string(&CompilationKind::Job).unwrap();
3000 assert_eq!(json, "\"job\"");
3001 let round: CompilationKind = serde_json::from_str(&json).unwrap();
3002 assert_eq!(round, CompilationKind::Job);
3003 assert_eq!(CompilationKind::Job.command_base(), "job");
3006 assert!(!CompilationKind::Job.is_test_command());
3007 }
3008
3009 #[test]
3010 fn test_cargo_test_with_toolchain() {
3011 let _guard = test_guard!();
3012 let result = classify_command("cargo +1.80.0 test");
3013 assert!(result.is_compilation);
3014 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3015 }
3016
3017 #[test]
3018 fn test_cargo_nextest_with_toolchain() {
3019 let _guard = test_guard!();
3020 let result = classify_command("cargo +nightly nextest run");
3021 assert!(result.is_compilation);
3022 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
3023 }
3024
3025 #[test]
3026 fn test_cargo_build() {
3027 let _guard = test_guard!();
3028 let result = classify_command("cargo build");
3029 assert!(result.is_compilation);
3030 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3031 assert!(result.confidence >= 0.90);
3032 }
3033
3034 #[test]
3035 fn test_cargo_build_release() {
3036 let _guard = test_guard!();
3037 let result = classify_command("cargo build --release");
3038 assert!(result.is_compilation);
3039 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3040 }
3041
3042 #[test]
3043 fn test_cargo_test() {
3044 let _guard = test_guard!();
3045 let result = classify_command("cargo test");
3046 assert!(result.is_compilation);
3047 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3048 }
3049
3050 #[test]
3051 fn test_cargo_fmt_not_intercepted() {
3052 let _guard = test_guard!();
3053 let result = classify_command("cargo fmt");
3054 assert!(!result.is_compilation);
3055 assert!(result.reason.contains("never-intercept"));
3056 }
3057
3058 #[test]
3059 fn test_cargo_install_not_intercepted() {
3060 let _guard = test_guard!();
3061 let result = classify_command("cargo install ripgrep");
3062 assert!(!result.is_compilation);
3063 assert!(result.reason.contains("never-intercept"));
3064 }
3065
3066 #[test]
3067 fn test_piped_to_benign_pager_intercepted() {
3068 let _guard = test_guard!();
3069 let result = classify_command("cargo build 2>&1 | grep error");
3073 assert!(result.is_compilation, "got: {:?}", result.reason);
3074 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3075 assert_eq!(
3076 result.extracted_command.as_deref(),
3077 Some("cargo build 2>&1 | grep error")
3078 );
3079 }
3080
3081 #[test]
3082 fn test_piped_to_nonbenign_command_not_intercepted() {
3083 let _guard = test_guard!();
3084 let result = classify_command("cargo build | xargs rm");
3086 assert!(!result.is_compilation);
3087 assert!(result.reason.contains("piped"));
3088 }
3089
3090 #[test]
3091 fn test_backgrounded_intercepted() {
3092 let _guard = test_guard!();
3093 let result = classify_command("cargo build &");
3095 assert!(result.is_compilation, "got: {:?}", result.reason);
3096 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3097 assert_eq!(result.extracted_command.as_deref(), Some("cargo build &"));
3098 }
3099
3100 #[test]
3101 fn test_newline_injection_not_intercepted() {
3102 let _guard = test_guard!();
3103 let result = classify_command("cargo build\nrm -rf /");
3105 assert!(!result.is_compilation);
3106 assert!(result.reason.contains("newline"));
3107
3108 let result = classify_command("cargo build\r\nrm -rf /");
3109 assert!(!result.is_compilation);
3110 assert!(result.reason.contains("newline"));
3111
3112 for command in [
3113 "cargo build > out.log\nrm -rf /",
3114 "cargo build | tee out.log\r\nrm -rf /",
3115 "cargo build &\nrm -rf /",
3116 ] {
3117 let result = classify_command(command);
3118 assert!(
3119 !result.is_compilation,
3120 "newline-bearing benign suffix must fail closed: {command:?}"
3121 );
3122 assert!(result.reason.contains("newline"), "{result:?}");
3123 }
3124 }
3125
3126 #[test]
3127 fn test_redirected_intercepted() {
3128 let _guard = test_guard!();
3129 let result = classify_command("cargo build > log.txt");
3132 assert!(result.is_compilation, "got: {:?}", result.reason);
3133 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3134 assert_eq!(
3135 result.extracted_command.as_deref(),
3136 Some("cargo build > log.txt")
3137 );
3138
3139 let result = classify_command("cargo test --workspace > out.log 2>&1");
3140 assert!(result.is_compilation, "got: {:?}", result.reason);
3141 assert_eq!(
3142 result.extracted_command.as_deref(),
3143 Some("cargo test --workspace > out.log 2>&1")
3144 );
3145
3146 for command in [
3149 "cargo build 2>errors.txt",
3150 "cargo build 2>>errors.txt",
3151 "cargo build >out.log 2>&1",
3152 ] {
3153 let result = classify_command(command);
3154 assert!(result.is_compilation, "got: {:?}", result.reason);
3155 assert_eq!(result.extracted_command.as_deref(), Some(command));
3156 }
3157 }
3158
3159 #[test]
3160 fn test_input_redirected_not_intercepted() {
3161 let _guard = test_guard!();
3162 let result = classify_command("cargo build < input.txt");
3164 assert!(!result.is_compilation);
3165 assert!(result.reason.contains("input redirected"));
3166 }
3167
3168 #[test]
3169 fn test_process_substitution_not_intercepted() {
3170 let _guard = test_guard!();
3171 let result = classify_command("cargo build --config <(echo ...)");
3172 assert!(!result.is_compilation);
3173 assert!(result.reason.contains("subshell execution"));
3174 }
3175
3176 #[test]
3177 fn test_subshell_not_intercepted() {
3178 let _guard = test_guard!();
3179 let result = classify_command("(cargo build)");
3180 assert!(!result.is_compilation);
3181 assert!(result.reason.contains("subshell execution"));
3182 }
3183
3184 #[test]
3185 fn test_make_maintenance_matches_whole_target_tokens() {
3186 let _guard = test_guard!();
3189
3190 for cmd in [
3192 "make clean",
3193 "make install",
3194 "make distclean",
3195 "make uninstall",
3196 ] {
3197 let r = classify_command(cmd);
3198 assert!(!r.is_compilation, "{cmd} should not be intercepted");
3199 assert!(r.reason.contains("make maintenance command"), "{cmd}");
3200 }
3201 let r = classify_command("make -j8 clean");
3203 assert!(!r.is_compilation);
3204
3205 for cmd in [
3207 "make CFLAGS=-Iinstall/include",
3208 "make build INSTALL_DIR=/x",
3209 "make TARGET=cleanup",
3210 "make clean-all", "make -C install", ] {
3213 let r = classify_command(cmd);
3214 assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3215 assert_eq!(r.kind, Some(CompilationKind::Make), "{cmd}");
3216 }
3217 }
3218
3219 #[test]
3220 fn test_ninja_clean_matches_tool_and_whole_target() {
3221 let _guard = test_guard!();
3224
3225 for cmd in ["ninja clean", "ninja -t clean", "ninja -j8 clean"] {
3226 let r = classify_command(cmd);
3227 assert!(!r.is_compilation, "{cmd} should not be intercepted");
3228 assert!(r.reason.contains("ninja clean"), "{cmd}");
3229 }
3230
3231 for cmd in [
3232 "ninja build/clean_obj.o", "ninja -C clean", "ninja -j4",
3235 ] {
3236 let r = classify_command(cmd);
3237 assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3238 assert_eq!(r.kind, Some(CompilationKind::Ninja), "{cmd}");
3239 }
3240 }
3241
3242 #[test]
3243 fn test_gcc_compile() {
3244 let _guard = test_guard!();
3245 let result = classify_command("gcc -c main.c -o main.o");
3246 assert!(result.is_compilation);
3247 assert_eq!(result.kind, Some(CompilationKind::Gcc));
3248 }
3249
3250 #[test]
3251 fn test_make() {
3252 let _guard = test_guard!();
3253 let result = classify_command("make -j8");
3254 assert!(result.is_compilation);
3255 assert_eq!(result.kind, Some(CompilationKind::Make));
3256 }
3257
3258 #[test]
3259 fn test_make_clean_not_intercepted() {
3260 let _guard = test_guard!();
3261 let result = classify_command("make clean");
3262 assert!(!result.is_compilation);
3263 assert!(result.reason.contains("make maintenance command"));
3264 }
3265
3266 #[test]
3267 fn test_cmake_build_requires_cmake_command() {
3268 let _guard = test_guard!();
3269 let result = classify_command("echo cmake --build .");
3270 assert!(!result.is_compilation);
3271 }
3272
3273 #[test]
3274 fn test_cmake_build_with_equals_flag() {
3275 let _guard = test_guard!();
3276 let result = classify_command("cmake --build=build");
3277 assert!(result.is_compilation);
3278 assert_eq!(result.kind, Some(CompilationKind::CmakeBuild));
3279 }
3280
3281 #[test]
3282 fn test_meson_compile_requires_meson_command() {
3283 let _guard = test_guard!();
3284 let result = classify_command("echo meson compile");
3285 assert!(!result.is_compilation);
3286 }
3287
3288 #[test]
3289 fn test_non_compilation() {
3290 let _guard = test_guard!();
3291 let result = classify_command("ls -la");
3292 assert!(!result.is_compilation);
3293 assert!(result.reason.contains("no compilation keyword"));
3294 }
3295
3296 #[test]
3297 fn test_empty_command() {
3298 let _guard = test_guard!();
3299 let result = classify_command("");
3300 assert!(!result.is_compilation);
3301 assert!(result.reason.contains("empty command"));
3302 }
3303
3304 #[test]
3307 fn test_bun_keyword_detected() {
3308 let _guard = test_guard!();
3309 assert!(starts_with_compilation_command("bun test"));
3312 assert!(starts_with_compilation_command("bun typecheck"));
3313 assert!(starts_with_compilation_command("bun install"));
3314 }
3315
3316 #[test]
3317 fn test_bun_install_not_intercepted() {
3318 let _guard = test_guard!();
3319 let result = classify_command("bun install");
3321 assert!(!result.is_compilation);
3322 assert!(result.reason.contains("never-intercept"));
3323 }
3324
3325 #[test]
3326 fn test_bun_add_not_intercepted() {
3327 let _guard = test_guard!();
3328 let result = classify_command("bun add lodash");
3330 assert!(!result.is_compilation);
3331 assert!(result.reason.contains("never-intercept"));
3332 }
3333
3334 #[test]
3335 fn test_bun_remove_not_intercepted() {
3336 let _guard = test_guard!();
3337 let result = classify_command("bun remove lodash");
3338 assert!(!result.is_compilation);
3339 assert!(result.reason.contains("never-intercept"));
3340 }
3341
3342 #[test]
3343 fn test_bun_run_not_intercepted() {
3344 let _guard = test_guard!();
3345 let result = classify_command("bun run build");
3347 assert!(!result.is_compilation);
3348 assert!(result.reason.contains("never-intercept"));
3349 }
3350
3351 #[test]
3352 fn test_bun_build_not_intercepted() {
3353 let _guard = test_guard!();
3354 let result = classify_command("bun build ./src/index.ts");
3356 assert!(!result.is_compilation);
3357 assert!(result.reason.contains("never-intercept"));
3358 }
3359
3360 #[test]
3361 fn test_bun_version_not_intercepted() {
3362 let _guard = test_guard!();
3363 let result = classify_command("bun --version");
3364 assert!(!result.is_compilation);
3365 assert!(result.reason.contains("never-intercept"));
3366
3367 let result = classify_command("bun -v");
3368 assert!(!result.is_compilation);
3369 assert!(result.reason.contains("never-intercept"));
3370 }
3371
3372 #[test]
3373 fn test_bun_dev_not_intercepted() {
3374 let _guard = test_guard!();
3375 let result = classify_command("bun dev");
3377 assert!(!result.is_compilation);
3378 assert!(result.reason.contains("never-intercept"));
3379 }
3380
3381 #[test]
3382 fn test_bun_repl_not_intercepted() {
3383 let _guard = test_guard!();
3384 let result = classify_command("bun repl");
3386 assert!(!result.is_compilation);
3387 assert!(result.reason.contains("never-intercept"));
3388 }
3389
3390 #[test]
3391 fn test_bun_link_not_intercepted() {
3392 let _guard = test_guard!();
3393 let result = classify_command("bun link");
3394 assert!(!result.is_compilation);
3395 assert!(result.reason.contains("never-intercept"));
3396 }
3397
3398 #[test]
3399 fn test_bun_init_not_intercepted() {
3400 let _guard = test_guard!();
3401 let result = classify_command("bun init");
3402 assert!(!result.is_compilation);
3403 assert!(result.reason.contains("never-intercept"));
3404 }
3405
3406 #[test]
3407 fn test_bun_create_not_intercepted() {
3408 let _guard = test_guard!();
3409 let result = classify_command("bun create next-app");
3410 assert!(!result.is_compilation);
3411 assert!(result.reason.contains("never-intercept"));
3412 }
3413
3414 #[test]
3415 fn test_bun_pm_not_intercepted() {
3416 let _guard = test_guard!();
3417 let result = classify_command("bun pm cache");
3418 assert!(!result.is_compilation);
3419 assert!(result.reason.contains("never-intercept"));
3420 }
3421
3422 #[test]
3423 fn test_bun_help_not_intercepted() {
3424 let _guard = test_guard!();
3425 let result = classify_command("bun --help");
3426 assert!(!result.is_compilation);
3427 assert!(result.reason.contains("never-intercept"));
3428
3429 let result = classify_command("bun -h");
3430 assert!(!result.is_compilation);
3431 assert!(result.reason.contains("never-intercept"));
3432 }
3433
3434 #[test]
3437 fn test_bun_test_classification() {
3438 let _guard = test_guard!();
3439 let result = classify_command("bun test");
3441 assert!(result.is_compilation);
3442 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3443 assert!((result.confidence - 0.95).abs() < 0.001);
3444
3445 let result = classify_command("bun test src/");
3447 assert!(result.is_compilation);
3448 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3449
3450 let result = classify_command("bun test --coverage");
3452 assert!(result.is_compilation);
3453 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3454
3455 let result = classify_command("bun test auth.test.ts");
3457 assert!(result.is_compilation);
3458 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3459
3460 let result = classify_command("bun test --bail --timeout 5000");
3462 assert!(result.is_compilation);
3463 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3464
3465 let result = classify_command("bun test --reporter json");
3467 assert!(result.is_compilation);
3468 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3469 }
3470
3471 #[test]
3472 fn test_bun_test_watch_not_intercepted() {
3473 let _guard = test_guard!();
3474 let result = classify_command("bun test --watch");
3476 assert!(!result.is_compilation);
3477 assert!(result.reason.contains("interactive"));
3478
3479 let result = classify_command("bun test --watch --coverage");
3481 assert!(!result.is_compilation);
3482 assert!(result.reason.contains("interactive"));
3483
3484 let result = classify_command("bun test src/ --watch");
3486 assert!(!result.is_compilation);
3487
3488 let result = classify_command("bun test -w");
3490 assert!(!result.is_compilation);
3491
3492 let result = classify_command("bun test -w src/");
3494 assert!(!result.is_compilation);
3495 }
3496
3497 #[test]
3498 fn test_bun_typecheck_watch_not_intercepted() {
3499 let _guard = test_guard!();
3500 let result = classify_command("bun typecheck --watch");
3502 assert!(!result.is_compilation);
3503 assert!(result.reason.contains("interactive"));
3504
3505 let result = classify_command("bun typecheck -w");
3507 assert!(!result.is_compilation);
3508 }
3509
3510 #[test]
3511 fn test_bun_typecheck_classification() {
3512 let _guard = test_guard!();
3513 let result = classify_command("bun typecheck");
3515 assert!(result.is_compilation);
3516 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3517 assert!((result.confidence - 0.95).abs() < 0.001);
3518
3519 let result = classify_command("bun typecheck src/");
3521 assert!(result.is_compilation);
3522 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3523
3524 }
3527
3528 #[test]
3529 fn test_bun_edge_cases_not_matched() {
3530 let _guard = test_guard!();
3531 let result = classify_command("bun testing");
3533 assert!(!result.is_compilation);
3534 assert!(result.reason.contains("no matching pattern"));
3535
3536 let result = classify_command("bun typechecker");
3537 assert!(!result.is_compilation);
3538 assert!(result.reason.contains("no matching pattern"));
3539
3540 let result = classify_command("bun type");
3541 assert!(!result.is_compilation);
3542 assert!(result.reason.contains("no matching pattern"));
3543
3544 let result = classify_command("bun x eslint");
3546 assert!(!result.is_compilation);
3547 assert!(result.reason.contains("bun x runs arbitrary packages"));
3548
3549 let result = classify_command("bun x prettier --write .");
3550 assert!(!result.is_compilation);
3551 assert!(result.reason.contains("bun x runs arbitrary packages"));
3552
3553 let result = classify_command("bun x vitest run");
3554 assert!(!result.is_compilation);
3555 assert!(result.reason.contains("bun x runs arbitrary packages"));
3556 }
3557
3558 #[test]
3559 fn test_bun_test_vs_never_intercept() {
3560 let _guard = test_guard!();
3561 let result = classify_command("bun test");
3563 assert!(!result.reason.contains("never-intercept"));
3564 assert!(result.is_compilation);
3565 }
3566
3567 #[test]
3568 fn test_bun_typecheck_vs_never_intercept() {
3569 let _guard = test_guard!();
3570 let result = classify_command("bun typecheck");
3572 assert!(!result.reason.contains("never-intercept"));
3573 assert!(result.is_compilation);
3574 }
3575
3576 #[test]
3579 fn test_bun_compilation_kind_serde() {
3580 let _guard = test_guard!();
3581 let kind = CompilationKind::BunTest;
3583 let json = serde_json::to_string(&kind).unwrap();
3584 assert_eq!(json, "\"bun_test\"");
3585 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3586 assert_eq!(parsed, CompilationKind::BunTest);
3587
3588 let kind = CompilationKind::BunTypecheck;
3590 let json = serde_json::to_string(&kind).unwrap();
3591 assert_eq!(json, "\"bun_typecheck\"");
3592 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3593 assert_eq!(parsed, CompilationKind::BunTypecheck);
3594 }
3595
3596 #[test]
3597 fn test_bun_kinds_are_distinct() {
3598 let _guard = test_guard!();
3599 assert_ne!(CompilationKind::BunTest, CompilationKind::BunTypecheck);
3601 assert_ne!(CompilationKind::BunTest, CompilationKind::CargoTest);
3603 }
3604
3605 #[test]
3606 fn test_wrapped_command_classification_repro() {
3607 let _guard = test_guard!();
3608 let result = classify_command("time cargo build");
3611 assert!(
3612 result.is_compilation,
3613 "Should classify 'time cargo build' as compilation"
3614 );
3615 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3616
3617 let result = classify_command("sudo cargo check");
3618 assert!(
3619 result.is_compilation,
3620 "Should classify 'sudo cargo check' as compilation"
3621 );
3622
3623 let result = classify_command("env RUST_BACKTRACE=1 cargo test");
3624 assert!(
3625 result.is_compilation,
3626 "Should classify env-wrapped cargo test as compilation"
3627 );
3628
3629 let result = classify_command("env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build");
3630 assert!(
3631 result.is_compilation,
3632 "Should classify shell-quoted env assignment before cargo build as compilation"
3633 );
3634 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3635 }
3636
3637 #[test]
3642 fn test_bun_piped_to_benign_pager_intercepted() {
3643 let _guard = test_guard!();
3644 let result = classify_command("bun test | grep error");
3646 assert!(result.is_compilation, "got: {:?}", result.reason);
3647 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3648 assert_eq!(
3649 result.extracted_command.as_deref(),
3650 Some("bun test | grep error")
3651 );
3652
3653 let result = classify_command("bun test 2>&1 | tee output.log");
3655 assert!(result.is_compilation, "got: {:?}", result.reason);
3656 assert_eq!(
3657 result.extracted_command.as_deref(),
3658 Some("bun test 2>&1 | tee output.log")
3659 );
3660
3661 let result = classify_command("bun typecheck | head -20");
3663 assert!(result.is_compilation, "got: {:?}", result.reason);
3664 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3665 }
3666
3667 #[test]
3668 fn test_bunx_tsc_not_intercepted() {
3669 let _guard = test_guard!();
3670 let result = classify_command("bun x tsc --noEmit");
3673 assert!(!result.is_compilation);
3674 assert!(result.reason.contains("bun x"));
3675
3676 let result = classify_command("bun x eslint .");
3678 assert!(!result.is_compilation);
3679
3680 let result = classify_command("bun x prettier --write .");
3681 assert!(!result.is_compilation);
3682
3683 let result = classify_command("bun x vitest run");
3684 assert!(!result.is_compilation);
3685 }
3686
3687 #[test]
3688 fn test_bun_redirected_intercepted() {
3689 let _guard = test_guard!();
3690 let result = classify_command("bun test > results.txt");
3692 assert!(result.is_compilation, "got: {:?}", result.reason);
3693 assert_eq!(
3694 result.extracted_command.as_deref(),
3695 Some("bun test > results.txt")
3696 );
3697
3698 let result = classify_command("bun typecheck > errors.log");
3699 assert!(result.is_compilation, "got: {:?}", result.reason);
3700 }
3701
3702 #[test]
3703 fn test_bun_backgrounded_intercepted() {
3704 let _guard = test_guard!();
3705 let result = classify_command("bun test &");
3707 assert!(result.is_compilation, "got: {:?}", result.reason);
3708 assert_eq!(result.extracted_command.as_deref(), Some("bun test &"));
3709 }
3710
3711 #[test]
3712 fn test_bun_chained_commands_classified() {
3713 let _guard = test_guard!();
3714 let result = classify_command("bun test && echo done");
3716 assert!(
3717 !result.is_compilation,
3718 "chained commands should be rejected"
3719 );
3720 assert!(result.reason.contains("chained"));
3721
3722 let result = classify_command("bun typecheck; bun test");
3723 assert!(
3724 !result.is_compilation,
3725 "chained commands should be rejected"
3726 );
3727 assert!(result.reason.contains("chained"));
3728 }
3729
3730 #[test]
3731 fn test_bun_subshell_capture_not_intercepted() {
3732 let _guard = test_guard!();
3733 let result = classify_command("bun test $(echo src/)");
3735 assert!(!result.is_compilation);
3736 assert!(result.reason.contains("subshell"));
3737 }
3738
3739 #[test]
3740 fn test_bun_wrapped_commands() {
3741 let _guard = test_guard!();
3742 let result = classify_command("time bun test");
3745 if result.is_compilation {
3748 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3749 }
3750
3751 let result = classify_command("env DEBUG=1 bun test");
3752 if result.is_compilation {
3753 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3754 }
3755 }
3756
3757 #[test]
3758 fn test_classify_command_detailed_matches_basic() {
3759 let _guard = test_guard!();
3760 let commands = [
3761 "cargo build",
3762 "cargo test --release",
3763 "bun typecheck",
3764 "gcc -c main.c -o main.o",
3765 "cd /tmp && cargo build --release",
3766 "ls -la",
3767 ];
3768
3769 for cmd in commands {
3770 let basic = classify_command(cmd);
3771 let detailed = classify_command_detailed(cmd);
3772 assert_eq!(basic, detailed.classification);
3773 }
3774 }
3775
3776 #[test]
3777 fn test_classify_command_detailed_rejects_piped() {
3778 let _guard = test_guard!();
3779 let detailed = classify_command_detailed("cargo build | xargs rm");
3782 assert!(!detailed.classification.is_compilation);
3783 let tier1 = detailed.tiers.iter().find(|t| t.tier.eq(&1)).unwrap();
3784 assert_eq!(tier1.decision, TierDecision::Reject);
3785 assert!(tier1.reason.contains("piped"));
3786
3787 let detailed = classify_command_detailed("cargo build | tee log.txt");
3789 assert!(detailed.classification.is_compilation);
3790 assert_eq!(
3791 detailed.classification.extracted_command.as_deref(),
3792 Some("cargo build | tee log.txt")
3793 );
3794 }
3795
3796 #[test]
3797 fn test_classify_command_detailed_normalizes_wrappers() {
3798 let _guard = test_guard!();
3799 for (cmd, normalized) in [
3800 ("sudo cargo check", "cargo check"),
3801 ("env -u RUST_LOG cargo test", "cargo test"),
3802 ] {
3803 let detailed = classify_command_detailed(cmd);
3804 assert_eq!(
3805 (
3806 detailed.normalized.as_str(),
3807 detailed.classification.is_compilation
3808 ),
3809 (normalized, true),
3810 "{cmd}"
3811 );
3812 }
3813 }
3814
3815 #[test]
3816 fn test_classify_command_detailed_matches_compound_command() {
3817 let _guard = test_guard!();
3818 let detailed = classify_command_detailed("cd /tmp && cargo build --release");
3819
3820 assert!(detailed.classification.is_compilation);
3821 assert_eq!(
3822 detailed.classification.kind,
3823 Some(CompilationKind::CargoBuild)
3824 );
3825 assert_eq!(
3826 detailed.classification.command_prefix.as_deref(),
3827 Some("cd /tmp && ")
3828 );
3829 assert_eq!(
3830 detailed.classification.extracted_command.as_deref(),
3831 Some("cargo build --release")
3832 );
3833 assert_eq!(detailed.normalized, "cargo build --release");
3834 assert_eq!(detailed.tiers.len(), 5);
3835 assert!(
3836 detailed
3837 .tiers
3838 .iter()
3839 .all(|tier| tier.decision == TierDecision::Pass)
3840 );
3841 }
3842
3843 #[test]
3848 fn test_cargo_test_release() {
3849 let _guard = test_guard!();
3850 let result = classify_command("cargo test --release");
3851 assert!(
3852 result.is_compilation,
3853 "cargo test --release should be classified as compilation"
3854 );
3855 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3856 assert!(result.confidence >= 0.90);
3857 }
3858
3859 #[test]
3860 fn test_cargo_test_specific_test_name() {
3861 let _guard = test_guard!();
3862 let result = classify_command("cargo test my_test_function");
3863 assert!(
3864 result.is_compilation,
3865 "cargo test with specific test name should be classified"
3866 );
3867 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3868 }
3869
3870 #[test]
3871 fn test_cargo_test_with_nocapture() {
3872 let _guard = test_guard!();
3873 let result = classify_command("cargo test -- --nocapture");
3874 assert!(
3875 result.is_compilation,
3876 "cargo test -- --nocapture should be classified"
3877 );
3878 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3879 }
3880
3881 #[test]
3882 fn test_cargo_test_workspace() {
3883 let _guard = test_guard!();
3884 let result = classify_command("cargo test --workspace");
3885 assert!(
3886 result.is_compilation,
3887 "cargo test --workspace should be classified"
3888 );
3889 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3890 }
3891
3892 #[test]
3893 fn test_cargo_test_package() {
3894 let _guard = test_guard!();
3895 let result = classify_command("cargo test -p rch-common");
3896 assert!(result.is_compilation, "cargo test -p should be classified");
3897 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3898 }
3899
3900 #[test]
3901 fn test_cargo_test_short_alias() {
3902 let _guard = test_guard!();
3903 let result = classify_command("cargo t");
3905 assert!(
3906 result.is_compilation,
3907 "cargo t (short alias) should be classified"
3908 );
3909 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3910 }
3911
3912 #[test]
3913 fn test_cargo_test_all_features() {
3914 let _guard = test_guard!();
3915 let result = classify_command("cargo test --all-features");
3916 assert!(
3917 result.is_compilation,
3918 "cargo test --all-features should be classified"
3919 );
3920 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3921 }
3922
3923 #[test]
3924 fn test_cargo_test_no_default_features() {
3925 let _guard = test_guard!();
3926 let result = classify_command("cargo test --no-default-features");
3927 assert!(
3928 result.is_compilation,
3929 "cargo test --no-default-features should be classified"
3930 );
3931 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3932 }
3933
3934 #[test]
3935 fn test_cargo_test_with_env_var() {
3936 let _guard = test_guard!();
3937 let result = classify_command("RUST_BACKTRACE=1 cargo test");
3938 assert!(
3939 result.is_compilation,
3940 "cargo test with env var should be classified"
3941 );
3942 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3943 }
3944
3945 #[test]
3946 fn test_cargo_test_with_multiple_flags() {
3947 let _guard = test_guard!();
3948 let result = classify_command("cargo test --release --workspace -p rch -- --nocapture");
3949 assert!(
3950 result.is_compilation,
3951 "cargo test with multiple flags should be classified"
3952 );
3953 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3954 }
3955
3956 #[test]
3957 fn test_cargo_test_with_jobs() {
3958 let _guard = test_guard!();
3959 let result = classify_command("cargo test -j 8");
3960 assert!(result.is_compilation, "cargo test -j should be classified");
3961 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3962 }
3963
3964 #[test]
3965 fn test_cargo_test_target() {
3966 let _guard = test_guard!();
3967 let result = classify_command("cargo test --target x86_64-unknown-linux-gnu");
3968 assert!(
3969 result.is_compilation,
3970 "cargo test --target should be classified"
3971 );
3972 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3973 }
3974
3975 #[test]
3976 fn test_cargo_test_lib() {
3977 let _guard = test_guard!();
3978 let result = classify_command("cargo test --lib");
3979 assert!(
3980 result.is_compilation,
3981 "cargo test --lib should be classified"
3982 );
3983 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3984 }
3985
3986 #[test]
3987 fn test_cargo_test_bins() {
3988 let _guard = test_guard!();
3989 let result = classify_command("cargo test --bins");
3990 assert!(
3991 result.is_compilation,
3992 "cargo test --bins should be classified"
3993 );
3994 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3995 }
3996
3997 #[test]
3998 fn test_cargo_test_doc() {
3999 let _guard = test_guard!();
4000 let result = classify_command("cargo test --doc");
4001 assert!(
4002 result.is_compilation,
4003 "cargo test --doc should be classified"
4004 );
4005 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4006 }
4007
4008 #[test]
4009 fn test_cargo_test_filter_pattern() {
4010 let _guard = test_guard!();
4011 let result = classify_command("cargo test test_classification");
4012 assert!(
4013 result.is_compilation,
4014 "cargo test with filter pattern should be classified"
4015 );
4016 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4017 }
4018
4019 #[test]
4020 fn test_cargo_test_exact() {
4021 let _guard = test_guard!();
4022 let result = classify_command("cargo test --exact my_test");
4023 assert!(
4024 result.is_compilation,
4025 "cargo test --exact should be classified"
4026 );
4027 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4028 }
4029
4030 #[test]
4035 fn test_nextest_keyword_detected() {
4036 let _guard = test_guard!();
4037 assert!(starts_with_compilation_command("cargo nextest run"));
4039 assert!(starts_with_compilation_command("cargo nextest list"));
4040 }
4041
4042 #[test]
4043 fn test_cargo_nextest_run_classification() {
4044 let _guard = test_guard!();
4045 let result = classify_command("cargo nextest run");
4047 assert!(
4048 result.is_compilation,
4049 "cargo nextest run should be classified"
4050 );
4051 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4052 assert!((result.confidence - 0.95).abs() < 0.001);
4053 }
4054
4055 #[test]
4056 fn test_cargo_nextest_run_with_flags() {
4057 let _guard = test_guard!();
4058 let result = classify_command("cargo nextest run --release");
4060 assert!(result.is_compilation);
4061 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4062
4063 let result = classify_command("cargo nextest run --cargo-profile ci");
4065 assert!(result.is_compilation);
4066 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4067
4068 let result = classify_command("cargo nextest run --workspace");
4070 assert!(result.is_compilation);
4071 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4072
4073 let result = classify_command("cargo nextest run -p rch-common");
4075 assert!(result.is_compilation);
4076 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4077
4078 let result = classify_command("cargo nextest run test_classification");
4080 assert!(result.is_compilation);
4081 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4082
4083 let result = classify_command("cargo nextest run --release --no-fail-fast -j 8");
4085 assert!(result.is_compilation);
4086 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4087 }
4088
4089 #[test]
4090 fn test_cargo_nextest_run_short_alias() {
4091 let _guard = test_guard!();
4092 let result = classify_command("cargo nextest r");
4094 assert!(
4095 result.is_compilation,
4096 "cargo nextest r should be classified"
4097 );
4098 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4099 }
4100
4101 #[test]
4102 fn test_cargo_nextest_list_not_intercepted() {
4103 let _guard = test_guard!();
4104 let result = classify_command("cargo nextest list");
4106 assert!(!result.is_compilation);
4107 assert!(result.reason.contains("never-intercept"));
4108 }
4109
4110 #[test]
4111 fn test_cargo_nextest_archive_not_intercepted() {
4112 let _guard = test_guard!();
4113 let result = classify_command("cargo nextest archive");
4115 assert!(!result.is_compilation);
4116 assert!(result.reason.contains("never-intercept"));
4117 }
4118
4119 #[test]
4120 fn test_cargo_nextest_show_not_intercepted() {
4121 let _guard = test_guard!();
4122 let result = classify_command("cargo nextest show");
4124 assert!(!result.is_compilation);
4125 assert!(result.reason.contains("never-intercept"));
4126 }
4127
4128 #[test]
4129 fn test_bare_cargo_nextest_not_intercepted() {
4130 let _guard = test_guard!();
4131 let result = classify_command("cargo nextest");
4133 assert!(!result.is_compilation);
4134 assert!(result.reason.contains("without subcommand"));
4135 }
4136
4137 #[test]
4138 fn test_cargo_nextest_wrapped_commands() {
4139 let _guard = test_guard!();
4140 let result = classify_command("time cargo nextest run");
4142 assert!(
4143 result.is_compilation,
4144 "time cargo nextest run should be classified"
4145 );
4146 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4147
4148 let result = classify_command("RUST_BACKTRACE=1 cargo nextest run");
4149 assert!(
4150 result.is_compilation,
4151 "env-wrapped cargo nextest run should be classified"
4152 );
4153 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4154 }
4155
4156 #[test]
4157 fn test_cargo_nextest_compilation_kind_serde() {
4158 let _guard = test_guard!();
4159 let kind = CompilationKind::CargoNextest;
4161 let json = serde_json::to_string(&kind).unwrap();
4162 assert_eq!(json, "\"cargo_nextest\"");
4163 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4164 assert_eq!(parsed, CompilationKind::CargoNextest);
4165 }
4166
4167 #[test]
4168 fn test_cargo_nextest_vs_cargo_test_distinct() {
4169 let _guard = test_guard!();
4170 assert_ne!(CompilationKind::CargoNextest, CompilationKind::CargoTest);
4172
4173 let nextest_result = classify_command("cargo nextest run");
4175 let test_result = classify_command("cargo test");
4176 assert!(nextest_result.is_compilation);
4177 assert!(test_result.is_compilation);
4178 assert_eq!(nextest_result.kind, Some(CompilationKind::CargoNextest));
4179 assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4180 }
4181
4182 #[test]
4183 fn test_cargo_nextest_piped_to_pager_intercepted() {
4184 let _guard = test_guard!();
4185 let result = classify_command("cargo nextest run | grep FAIL");
4187 assert!(result.is_compilation, "got: {:?}", result.reason);
4188 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4189 assert_eq!(
4190 result.extracted_command.as_deref(),
4191 Some("cargo nextest run | grep FAIL")
4192 );
4193 }
4194
4195 #[test]
4196 fn test_cargo_nextest_redirected_intercepted() {
4197 let _guard = test_guard!();
4198 let result = classify_command("cargo nextest run > results.txt");
4200 assert!(result.is_compilation, "got: {:?}", result.reason);
4201 assert_eq!(
4202 result.extracted_command.as_deref(),
4203 Some("cargo nextest run > results.txt")
4204 );
4205 }
4206
4207 #[test]
4208 fn test_cargo_nextest_backgrounded_intercepted() {
4209 let _guard = test_guard!();
4210 let result = classify_command("cargo nextest run &");
4212 assert!(result.is_compilation, "got: {:?}", result.reason);
4213 assert_eq!(
4214 result.extracted_command.as_deref(),
4215 Some("cargo nextest run &")
4216 );
4217 }
4218
4219 #[test]
4224 fn test_cargo_bench_classification() {
4225 let _guard = test_guard!();
4226 let result = classify_command("cargo bench");
4227 assert!(result.is_compilation, "cargo bench should be classified");
4228 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4229 assert!((result.confidence - 0.90).abs() < 0.001);
4230 }
4231
4232 #[test]
4233 fn test_cargo_bench_with_filter() {
4234 let _guard = test_guard!();
4235 let result = classify_command("cargo bench my_bench");
4237 assert!(result.is_compilation);
4238 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4239 }
4240
4241 #[test]
4242 fn test_cargo_bench_with_flags() {
4243 let _guard = test_guard!();
4244 let result = classify_command("cargo bench --release");
4246 assert!(result.is_compilation);
4247 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4248
4249 let result = classify_command("cargo bench -p rch-common");
4251 assert!(result.is_compilation);
4252 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4253
4254 let result = classify_command("cargo bench --features benchmarks");
4256 assert!(result.is_compilation);
4257 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4258
4259 let result = classify_command("cargo bench --bench criterion_bench");
4261 assert!(result.is_compilation);
4262 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4263 }
4264
4265 #[test]
4266 fn test_cargo_bench_wrapped() {
4267 let _guard = test_guard!();
4268 let result = classify_command("time cargo bench");
4270 assert!(
4271 result.is_compilation,
4272 "time cargo bench should be classified"
4273 );
4274 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4275
4276 let result = classify_command("CARGO_INCREMENTAL=0 cargo bench");
4278 assert!(result.is_compilation);
4279 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4280 }
4281
4282 #[test]
4283 fn test_cargo_bench_serde() {
4284 let _guard = test_guard!();
4285 let kind = CompilationKind::CargoBench;
4287 let json = serde_json::to_string(&kind).unwrap();
4288 assert_eq!(json, "\"cargo_bench\"");
4289 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4290 assert_eq!(parsed, CompilationKind::CargoBench);
4291 }
4292
4293 #[test]
4294 fn test_cargo_bench_distinct_from_test() {
4295 let _guard = test_guard!();
4296 assert_ne!(CompilationKind::CargoBench, CompilationKind::CargoTest);
4298
4299 let bench_result = classify_command("cargo bench");
4301 let test_result = classify_command("cargo test");
4302 assert!(bench_result.is_compilation);
4303 assert!(test_result.is_compilation);
4304 assert_eq!(bench_result.kind, Some(CompilationKind::CargoBench));
4305 assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4306 }
4307
4308 #[test]
4309 fn test_cargo_bench_piped_to_pager_intercepted() {
4310 let _guard = test_guard!();
4311 let result = classify_command("cargo bench | tee output.txt");
4313 assert!(result.is_compilation, "got: {:?}", result.reason);
4314 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4315 assert_eq!(
4316 result.extracted_command.as_deref(),
4317 Some("cargo bench | tee output.txt")
4318 );
4319 }
4320
4321 #[test]
4322
4323 fn test_normalize_path_and_wrapper_bug_repro() {
4324 let _guard = test_guard!();
4325 let normalized = normalize_command("/usr/bin/time cargo build");
4334
4335 assert_eq!(normalized, "cargo build");
4336
4337 let result = classify_command("/usr/bin/time cargo build");
4338
4339 assert!(result.is_compilation);
4340 }
4341
4342 #[test]
4347 fn test_command_base_rust() {
4348 let _guard = test_guard!();
4349 assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
4350 assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
4351 assert_eq!(CompilationKind::CargoCheck.command_base(), "cargo");
4352 assert_eq!(CompilationKind::CargoClippy.command_base(), "cargo");
4353 assert_eq!(CompilationKind::CargoDoc.command_base(), "cargo");
4354 assert_eq!(CompilationKind::CargoBench.command_base(), "cargo");
4355 assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
4356 assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
4357 }
4358
4359 #[test]
4360 fn test_command_base_c_cpp() {
4361 let _guard = test_guard!();
4362 assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
4363 assert_eq!(CompilationKind::Gpp.command_base(), "g++");
4364 assert_eq!(CompilationKind::Clang.command_base(), "clang");
4365 assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
4366 }
4367
4368 #[test]
4369 fn test_command_base_build_systems() {
4370 let _guard = test_guard!();
4371 assert_eq!(CompilationKind::Make.command_base(), "make");
4372 assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
4373 assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
4374 assert_eq!(CompilationKind::Meson.command_base(), "meson");
4375 }
4376
4377 #[test]
4378 fn test_command_base_bun() {
4379 let _guard = test_guard!();
4380 assert_eq!(CompilationKind::BunTest.command_base(), "bun");
4381 assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
4382 }
4383
4384 mod proptest_classification {
4389 use super::*;
4390 use proptest::prelude::*;
4391
4392 fn arbitrary_string() -> impl Strategy<Value = String> {
4394 prop::string::string_regex(".{0,500}").unwrap()
4395 }
4396
4397 fn command_like_string() -> impl Strategy<Value = String> {
4399 let prefixes = prop::sample::select(vec![
4400 "cargo", "rustc", "gcc", "g++", "clang", "make", "cmake", "ninja", "bun", "ls",
4401 "cd", "echo", "cat", "grep", "find", "rm", "mv", "cp", "mkdir",
4402 ]);
4403 (prefixes, "[ a-zA-Z0-9_.-]{0,200}")
4404 .prop_map(|(prefix, suffix)| format!("{}{}", prefix, suffix))
4405 }
4406
4407 fn known_command_with_flags() -> impl Strategy<Value = String> {
4409 let base_commands = prop::sample::select(vec![
4410 "cargo build",
4411 "cargo test",
4412 "cargo check",
4413 "cargo clippy",
4414 "cargo run",
4415 "rustc",
4416 "gcc",
4417 "g++",
4418 "make",
4419 "bun test",
4420 "bun typecheck",
4421 ]);
4422 let flags = prop::collection::vec(
4423 prop::sample::select(vec![
4424 "--release",
4425 "--verbose",
4426 "-j8",
4427 "-p",
4428 "--all",
4429 "--workspace",
4430 "--lib",
4431 "--bin",
4432 "-o",
4433 "-c",
4434 ]),
4435 0..5,
4436 );
4437 (base_commands, flags).prop_map(|(cmd, flags)| {
4438 if flags.is_empty() {
4439 cmd.to_string()
4440 } else {
4441 format!("{} {}", cmd, flags.join(" "))
4442 }
4443 })
4444 }
4445
4446 fn unicode_and_control_chars() -> impl Strategy<Value = String> {
4448 prop::string::string_regex(
4449 r"[\x00-\x1f\x80-\xff\u{100}-\u{10FFFF}a-zA-Z0-9 |>&;$()]{0,100}",
4450 )
4451 .unwrap()
4452 }
4453
4454 fn shell_special_commands() -> impl Strategy<Value = String> {
4456 let components = prop::sample::select(vec![
4457 "cargo build",
4458 "ls -la",
4459 "echo test",
4460 "cat file.txt",
4461 "grep pattern",
4462 ]);
4463 let operators = prop::sample::select(vec![
4464 " | ", " && ", " || ", " ; ", " > ", " < ", " & ", " 2>&1 ", " $(", " `",
4465 ]);
4466 prop::collection::vec((components, operators), 1..4).prop_map(|pairs| {
4467 let mut result = String::new();
4468 for (i, (comp, op)) in pairs.iter().enumerate() {
4469 if i > 0 {
4470 result.push_str(op);
4471 }
4472 result.push_str(comp);
4473 }
4474 result
4475 })
4476 }
4477
4478 fn wrapped_commands() -> impl Strategy<Value = String> {
4480 let wrappers = prop::sample::select(vec![
4481 "sudo ",
4482 "env ",
4483 "time ",
4484 "nice ",
4485 "/usr/bin/time ",
4486 "RUST_BACKTRACE=1 ",
4487 "CC=clang ",
4488 "",
4489 ]);
4490 let commands = prop::sample::select(vec![
4491 "cargo build",
4492 "cargo test",
4493 "make",
4494 "gcc -c main.c",
4495 "ls -la",
4496 ]);
4497 (wrappers, commands).prop_map(|(wrapper, cmd)| format!("{}{}", wrapper, cmd))
4498 }
4499
4500 proptest! {
4501 #![proptest_config(ProptestConfig::with_cases(1000))]
4503
4504 #[test]
4506 fn test_classify_arbitrary_no_panic(s in arbitrary_string()) {
4507 let _ = classify_command(&s);
4509 }
4510
4511 #[test]
4513 fn test_classify_command_like_no_panic(s in command_like_string()) {
4514 let _ = classify_command(&s);
4515 }
4516
4517 #[test]
4519 fn test_classify_known_commands_valid(s in known_command_with_flags()) {
4520 let result = classify_command(&s);
4521 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0,
4523 "Confidence {} out of range for command: {}", result.confidence, s);
4524 if result.is_compilation {
4526 prop_assert!(result.kind.is_some(),
4527 "is_compilation=true but kind=None for: {}", s);
4528 }
4529 }
4530
4531 #[test]
4533 fn test_classify_unicode_no_panic(s in unicode_and_control_chars()) {
4534 let _ = classify_command(&s);
4535 }
4536
4537 #[test]
4539 fn test_classify_shell_special(s in shell_special_commands()) {
4540 let result = classify_command(&s);
4541 if s.contains(" | ") || s.contains(" > ") || s.contains(" < ") || s.contains(" & ") {
4544 let _ = result; }
4547 }
4548
4549 #[test]
4551 fn test_classify_wrapped_commands(s in wrapped_commands()) {
4552 let result = classify_command(&s);
4553 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4554 }
4555
4556 #[test]
4558 fn test_classify_deterministic(s in arbitrary_string()) {
4559 let result1 = classify_command(&s);
4560 let result2 = classify_command(&s);
4561 prop_assert_eq!(result1, result2,
4562 "Non-deterministic classification for: {}", s);
4563 }
4564
4565 #[test]
4567 fn test_classify_whitespace_variants(s in "[ \t\n\r]{0,20}") {
4568 let _guard = test_guard!();
4569 let result = classify_command(&s);
4570 prop_assert!(!result.is_compilation,
4571 "Whitespace-only command should not be classified as compilation: {:?}", s);
4572 prop_assert!(result.reason.contains("empty") || result.reason.contains("keyword"),
4573 "Unexpected reason for whitespace: {}", result.reason);
4574 }
4575
4576 #[test]
4578 fn test_classify_long_commands(
4579 prefix in "cargo (build|test|check)",
4580 suffix in "[a-zA-Z0-9_ -]{0,10000}"
4581 ) {
4582 let long_cmd = format!("{} {}", prefix, suffix);
4583 let result = classify_command(&long_cmd);
4584 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4585 }
4586
4587 #[test]
4589 fn test_classify_special_bytes(s in prop::collection::vec(any::<u8>(), 0..200)) {
4590 if let Ok(valid_str) = String::from_utf8(s.clone()) {
4591 let _ = classify_command(&valid_str);
4592 }
4593 }
4595 }
4596
4597 proptest! {
4600 #![proptest_config(ProptestConfig::with_cases(500))]
4601
4602 #[test]
4604 fn test_cargo_subcommand_robustness(
4605 subcommand in "(build|test|check|clippy|fmt|clean|run|doc|bench|nextest)",
4606 suffix in "[a-zA-Z0-9_ -]{0,50}"
4607 ) {
4608 let cmd = format!("cargo {} {}", subcommand, suffix);
4609 let result = classify_command(&cmd);
4610 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4612 if subcommand.eq("fmt") || subcommand.eq("clean") {
4614 prop_assert!(!result.is_compilation,
4615 "cargo {} should not be compilation: {}", subcommand, cmd);
4616 }
4617 }
4618
4619 #[test]
4621 fn test_bun_command_robustness(
4622 subcommand in "(test|typecheck|install|add|remove|run|build|dev)",
4623 suffix in "[a-zA-Z0-9_ -]{0,30}"
4624 ) {
4625 let cmd = format!("bun {} {}", subcommand, suffix);
4626 let result = classify_command(&cmd);
4627 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4628 if matches!(subcommand.as_str(), "install" | "add" | "remove" | "run" | "build" | "dev") {
4630 prop_assert!(!result.is_compilation,
4631 "bun {} should not be compilation", subcommand);
4632 }
4633 }
4634
4635 #[test]
4637 fn test_c_compiler_robustness(
4638 compiler in "(gcc|g\\+\\+|clang|clang\\+\\+)",
4639 flags in "(-[cCoOgW][a-z0-9]* )*",
4640 file in "[a-zA-Z_][a-zA-Z0-9_]*\\.(c|cpp|cc|h|hpp)"
4641 ) {
4642 let cmd = format!("{} {}{}", compiler, flags, file);
4643 let result = classify_command(&cmd);
4644 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4645 }
4646
4647 #[test]
4649 fn test_quoted_args_handling(
4650 base in "(cargo build|cargo test|gcc|make)",
4651 quoted_content in "[a-zA-Z0-9 _-]{0,30}"
4652 ) {
4653 let cmd_single = format!("{} '{}'", base, quoted_content);
4655 let _ = classify_command(&cmd_single);
4656
4657 let cmd_double = format!("{} \"{}\"", base, quoted_content);
4659 let _ = classify_command(&cmd_double);
4660 }
4661 }
4662
4663 #[test]
4665 fn test_known_edge_cases() {
4666 let _guard = test_guard!();
4667 let edge_cases = [
4669 "",
4670 " ",
4671 "\t",
4672 "\n",
4673 "cargo",
4674 "cargo ",
4675 "cargo ",
4676 "cargo\tbuild",
4677 "cargo\nbuild",
4678 "cargo\rbuild",
4679 "cargo+nightly",
4680 "cargo +nightly",
4681 "cargo +nightly build",
4682 " cargo build ",
4683 "CARGO_TARGET_DIR=/tmp cargo build",
4684 "/usr/local/bin/cargo build",
4685 "~/.cargo/bin/cargo build",
4686 "./cargo build",
4687 "../cargo build",
4688 "cargo build 'test file.rs'",
4689 "cargo build \"test file.rs\"",
4690 "cargo build test\\ file.rs",
4691 "cargo build -- --test-threads=1",
4692 "cargo build --features \"feat1 feat2\"",
4693 "cargo build 2>&1",
4694 "cargo build 2>/dev/null",
4695 "gcc -DFOO=\"bar baz\" main.c",
4696 "make -j$(nproc)",
4697 "ninja -C build",
4698 "cmake --build . --target all",
4699 "bun test --timeout 5000 src/",
4700 "env -i PATH=/usr/bin cargo build",
4701 ];
4702
4703 for cmd in edge_cases {
4704 let result = classify_command(cmd);
4705 assert!(
4706 result.confidence >= 0.0 && result.confidence <= 1.0,
4707 "Invalid confidence for: {:?}",
4708 cmd
4709 );
4710 }
4711 }
4712
4713 #[test]
4715 fn test_detailed_matches_simple_proptest() {
4716 let _guard = test_guard!();
4717 let test_cases = [
4718 "cargo build --release",
4719 "cargo test",
4720 "make -j8",
4721 "ls -la",
4722 "echo hello",
4723 ];
4724
4725 for cmd in test_cases {
4726 let simple = classify_command(cmd);
4727 let detailed = classify_command_detailed(cmd);
4728 assert_eq!(simple, detailed.classification, "Mismatch for: {}", cmd);
4729 }
4730 }
4731
4732 #[test]
4735 fn test_split_single_command() {
4736 let _guard = test_guard!();
4737 assert_eq!(split_shell_commands("cargo build"), vec!["cargo build"]);
4738 }
4739
4740 #[test]
4741 fn test_split_and_operator() {
4742 let _guard = test_guard!();
4743 assert_eq!(
4744 split_shell_commands("cargo fmt && cargo build"),
4745 vec!["cargo fmt", "cargo build"]
4746 );
4747 }
4748
4749 #[test]
4750 fn test_split_quoted_operator() {
4751 let _guard = test_guard!();
4752 assert_eq!(
4753 split_shell_commands("echo '&&' && cargo build"),
4754 vec!["echo '&&'", "cargo build"]
4755 );
4756 }
4757
4758 #[test]
4759 fn test_split_mixed_operators() {
4760 let _guard = test_guard!();
4761 assert_eq!(
4762 split_shell_commands("cd /tmp && make -j4 || echo fail"),
4763 vec!["cd /tmp", "make -j4", "echo fail"]
4764 );
4765 }
4766
4767 #[test]
4768 fn test_split_semicolons() {
4769 let _guard = test_guard!();
4770 assert_eq!(split_shell_commands("a ; b ; c"), vec!["a", "b", "c"]);
4771 }
4772
4773 #[test]
4774 fn test_split_quoted_semicolon() {
4775 let _guard = test_guard!();
4776 assert_eq!(
4777 split_shell_commands("echo 'hello;world' && make"),
4778 vec!["echo 'hello;world'", "make"]
4779 );
4780 }
4781
4782 #[test]
4783 fn test_split_pipe_preserved() {
4784 let _guard = test_guard!();
4785 assert_eq!(
4787 split_shell_commands("cargo build 2>&1 | tee log"),
4788 vec!["cargo build 2>&1 | tee log"]
4789 );
4790 }
4791
4792 #[test]
4793 fn test_split_double_quoted_operator() {
4794 let _guard = test_guard!();
4795 assert_eq!(
4796 split_shell_commands(r#"echo "&&" && cargo build"#),
4797 vec![r#"echo "&&""#, "cargo build"]
4798 );
4799 }
4800
4801 #[test]
4802 fn test_split_nested_quotes() {
4803 let _guard = test_guard!();
4804 assert_eq!(
4805 split_shell_commands("echo \"he said 'hello && bye'\" && cargo test"),
4806 vec!["echo \"he said 'hello && bye'\"", "cargo test"]
4807 );
4808 }
4809
4810 #[test]
4811 fn test_split_escaped_quote() {
4812 let _guard = test_guard!();
4813 assert_eq!(
4815 split_shell_commands(r"echo it\'s && cargo build"),
4816 vec![r"echo it\'s", "cargo build"]
4817 );
4818 }
4819
4820 #[test]
4821 fn test_split_backslash_literal_in_single_quotes() {
4822 let _guard = test_guard!();
4823 assert_eq!(
4828 split_shell_commands(r"cargo build 'a\' && rm -rf x"),
4829 vec![r"cargo build 'a\'", "rm -rf x"]
4830 );
4831 }
4832
4833 #[test]
4834 fn test_split_empty_string() {
4835 let _guard = test_guard!();
4836 assert!(split_shell_commands("").is_empty());
4837 }
4838
4839 #[test]
4840 fn test_split_only_whitespace() {
4841 let _guard = test_guard!();
4842 assert!(split_shell_commands(" ").is_empty());
4843 }
4844
4845 #[test]
4846 fn test_split_backtick_quoting() {
4847 let _guard = test_guard!();
4848 assert_eq!(
4849 split_shell_commands("echo `echo && fail` && cargo build"),
4850 vec!["echo `echo && fail`", "cargo build"]
4851 );
4852 }
4853
4854 #[test]
4855 fn test_split_trailing_operator() {
4856 let _guard = test_guard!();
4857 assert_eq!(split_shell_commands("cargo build &&"), vec!["cargo build"]);
4859 }
4860
4861 #[test]
4864 fn test_split_unclosed_single_quote() {
4865 let _guard = test_guard!();
4866 let result = split_shell_commands("echo 'hello && cargo build");
4868 assert_eq!(result, vec!["echo 'hello && cargo build"]);
4869 }
4870
4871 #[test]
4872 fn test_split_unclosed_double_quote() {
4873 let _guard = test_guard!();
4874 let result = split_shell_commands("echo \"hello && cargo build");
4875 assert_eq!(result, vec!["echo \"hello && cargo build"]);
4876 }
4877
4878 #[test]
4879 fn test_split_unclosed_backtick() {
4880 let _guard = test_guard!();
4881 let result = split_shell_commands("echo `hello && cargo build");
4882 assert_eq!(result, vec!["echo `hello && cargo build"]);
4883 }
4884
4885 #[test]
4886 fn test_split_embedded_nulls() {
4887 let _guard = test_guard!();
4888 let input = "cargo build\0 && echo done";
4890 let result = split_shell_commands(input);
4891 assert_eq!(result.len(), 2);
4892 }
4893
4894 #[test]
4895 fn test_split_unicode_input() {
4896 let _guard = test_guard!();
4897 let result = split_shell_commands("echo 'こんにちは' && cargo build");
4898 assert_eq!(result, vec!["echo 'こんにちは'", "cargo build"]);
4899 }
4900
4901 #[test]
4902 fn test_split_extremely_long_input() {
4903 let _guard = test_guard!();
4904 let long_cmd = format!("echo {} && cargo build", "x".repeat(20_000));
4906 let result = split_shell_commands(&long_cmd);
4907 assert_eq!(result.len(), 2);
4908 }
4909
4910 #[test]
4911 fn test_classify_long_input_skips_splitting() {
4912 let _guard = test_guard!();
4913 let long_cmd = format!("cargo build && echo {}", "x".repeat(11_000));
4915 let result = classify_command(&long_cmd);
4916 assert!(!result.is_compilation);
4918 assert!(
4919 result.reason.to_string().contains("chained"),
4920 "long input should be rejected by check_structure, not split"
4921 );
4922 }
4923
4924 #[test]
4925 fn test_split_only_operators() {
4926 let _guard = test_guard!();
4927 let result = split_shell_commands("&& || ;");
4929 assert!(
4930 result.is_empty(),
4931 "only operators should yield empty result"
4932 );
4933 }
4934
4935 #[test]
4936 fn test_split_consecutive_operators() {
4937 let _guard = test_guard!();
4938 let result = split_shell_commands("cargo build && && echo done");
4939 assert_eq!(result, vec!["cargo build", "echo done"]);
4941 }
4942
4943 #[test]
4944 fn test_classify_empty_after_split() {
4945 let _guard = test_guard!();
4946 let result = classify_command("echo hello && ls -la || pwd");
4948 assert!(!result.is_compilation);
4949 }
4950
4951 #[test]
4956 fn test_split_three_segment_chain() {
4957 let _guard = test_guard!();
4958 assert_eq!(
4959 split_shell_commands("cd /proj && cmake .. && make"),
4960 vec!["cd /proj", "cmake ..", "make"]
4961 );
4962 }
4963
4964 #[test]
4965 fn test_split_single_with_flags() {
4966 let _guard = test_guard!();
4967 assert_eq!(
4968 split_shell_commands("cargo build --release"),
4969 vec!["cargo build --release"]
4970 );
4971 }
4972
4973 #[test]
4974 fn test_split_nested_quotes_semicolon() {
4975 let _guard = test_guard!();
4976 assert_eq!(
4977 split_shell_commands("echo \"it's && done\" && make"),
4978 vec!["echo \"it's && done\"", "make"]
4979 );
4980 }
4981
4982 #[test]
4983 fn test_split_pipe_then_and() {
4984 let _guard = test_guard!();
4985 assert_eq!(
4987 split_shell_commands("make 2>&1 | grep error && echo done"),
4988 vec!["make 2>&1 | grep error", "echo done"]
4989 );
4990 }
4991
4992 #[test]
4993 fn test_split_leading_operator() {
4994 let _guard = test_guard!();
4995 assert_eq!(split_shell_commands("&& cargo build"), vec!["cargo build"]);
4997 }
4998
4999 #[test]
5004 fn test_classify_cargo_fmt_and_build() {
5005 let _guard = test_guard!();
5006 let result = classify_command("cargo fmt && cargo build");
5008 assert!(
5009 result.is_compilation,
5010 "compound command with compilation suffix should be accepted"
5011 );
5012 assert!(result.reason.contains("compound"));
5013 }
5014
5015 #[test]
5016 fn test_classify_cd_and_make() {
5017 let _guard = test_guard!();
5018 let result = classify_command("cd /project && make -j8");
5020 assert!(
5021 result.is_compilation,
5022 "compound command with compilation suffix should be accepted"
5023 );
5024 assert!(result.reason.contains("compound"));
5025 }
5026 #[test]
5027 fn test_classify_export_and_cargo_build() {
5028 let _guard = test_guard!();
5029 let result =
5031 classify_command("export RUSTFLAGS='-C opt-level=3' && cargo build --release");
5032 assert!(
5033 result.is_compilation,
5034 "compound command with compilation suffix should be accepted"
5035 );
5036 assert!(result.reason.contains("compound"));
5037 }
5038 #[test]
5039 fn test_classify_mkdir_cmake_chain() {
5040 let _guard = test_guard!();
5041 let result =
5043 classify_command("mkdir -p build && cmake -B build && cmake --build build");
5044 assert!(
5045 result.is_compilation,
5046 "compound command with compilation suffix should be accepted"
5047 );
5048 assert!(result.reason.contains("compound"));
5049 }
5050 #[test]
5051 fn test_classify_echo_and_cargo_test() {
5052 let _guard = test_guard!();
5053 let result = classify_command("echo 'Starting...' && cargo test");
5055 assert!(
5056 result.is_compilation,
5057 "compound command with compilation suffix should be accepted"
5058 );
5059 assert!(result.reason.contains("compound"));
5060 }
5061 #[test]
5062 fn test_classify_semicolon_chain_with_compilation() {
5063 let _guard = test_guard!();
5064 let result = classify_command("cargo fmt; cargo build; cargo test");
5066 assert!(
5067 !result.is_compilation,
5068 "semicolon chained commands should be rejected"
5069 );
5070 assert!(result.reason.contains("chained"));
5071 }
5072 #[test]
5075 fn test_classify_echo_chain_non_compilation() {
5076 let _guard = test_guard!();
5077 let result = classify_command("echo hello && echo world");
5078 assert!(!result.is_compilation);
5079 }
5080
5081 #[test]
5082 fn test_classify_ls_cat_non_compilation() {
5083 let _guard = test_guard!();
5084 let result = classify_command("ls -la && cat file.txt");
5085 assert!(!result.is_compilation);
5086 }
5087
5088 #[test]
5089 fn test_classify_git_chain_non_compilation() {
5090 let _guard = test_guard!();
5091 let result = classify_command("git status && git log");
5092 assert!(!result.is_compilation);
5093 }
5094
5095 #[test]
5098 fn test_classify_multi_command_performance() {
5099 let _guard = test_guard!();
5100 let start = std::time::Instant::now();
5101 for _ in 0..100 {
5102 let _ = classify_command("cargo fmt && cargo build && cargo test");
5103 }
5104 let elapsed = start.elapsed();
5105 let per_call_us = elapsed.as_micros() / 100;
5106 assert!(
5108 per_call_us < 5_000,
5109 "classify_command took {}us per call, should be <5000us",
5110 per_call_us
5111 );
5112 eprintln!(
5113 "[perf] classify_command multi-command: {}us avg per call",
5114 per_call_us
5115 );
5116 }
5117
5118 #[test]
5121 fn test_classify_pipe_to_pager_preserved() {
5122 let _guard = test_guard!();
5123 let result = classify_command("cargo build 2>&1 | tee log");
5126 assert!(result.is_compilation, "got: {:?}", result.reason);
5127 assert_eq!(
5128 result.extracted_command.as_deref(),
5129 Some("cargo build 2>&1 | tee log")
5130 );
5131 }
5132
5133 #[test]
5134 fn test_classify_pipe_segment_and_operator() {
5135 let _guard = test_guard!();
5136 let result = classify_command("make 2>&1 | grep error && cargo build");
5138 assert!(
5140 !result.is_compilation,
5141 "chained commands should be rejected"
5142 );
5143 assert!(result.reason.contains("piped") || result.reason.contains("chained"));
5148 }
5149 }
5150
5151 #[allow(clippy::ptr_arg)]
5162 fn assert_cow_borrowed(cow: &Cow<'static, str>, context: &str) {
5163 assert!(
5164 matches!(cow, Cow::Borrowed(_)),
5165 "{context}: expected Cow::Borrowed, got Cow::Owned({:?})",
5166 cow,
5167 );
5168 }
5169
5170 #[test]
5173 fn test_cow_reject_empty_command_correctness() {
5174 let _guard = test_guard!();
5175 let result = classify_command("");
5176 assert!(!result.is_compilation);
5177 assert_eq!(result.confidence, 0.0);
5178 assert_eq!(result.kind, None);
5179 assert!(result.reason.contains("empty"), "reason: {}", result.reason);
5180 }
5181
5182 #[test]
5183 fn test_cow_reject_non_compilation_commands() {
5184 let _guard = test_guard!();
5185 let non_compilation = [
5186 "ls -la",
5187 "cat file.txt",
5188 "echo hello",
5189 "git status",
5190 "pwd",
5191 "cd /tmp",
5192 ];
5193 for cmd in non_compilation {
5194 let result = classify_command(cmd);
5195 assert!(
5196 !result.is_compilation,
5197 "'{cmd}' should NOT be classified as compilation"
5198 );
5199 assert_eq!(result.confidence, 0.0, "'{cmd}' should have 0.0 confidence");
5200 assert_eq!(result.kind, None, "'{cmd}' should have no CompilationKind");
5201 assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5202 }
5203 }
5204
5205 #[test]
5206 fn test_cow_accept_compilation_commands() {
5207 let _guard = test_guard!();
5208 let cases: &[(&str, CompilationKind)] = &[
5209 ("cargo build", CompilationKind::CargoBuild),
5210 ("cargo test", CompilationKind::CargoTest),
5211 ("cargo check", CompilationKind::CargoCheck),
5212 ("cargo clippy", CompilationKind::CargoClippy),
5213 ("gcc -o hello hello.c", CompilationKind::Gcc),
5214 ("make", CompilationKind::Make),
5215 ("bun test", CompilationKind::BunTest),
5216 ];
5217 for &(cmd, expected_kind) in cases {
5218 let result = classify_command(cmd);
5219 assert!(
5220 result.is_compilation,
5221 "'{cmd}' should be classified as compilation"
5222 );
5223 assert_eq!(
5224 result.kind,
5225 Some(expected_kind),
5226 "'{cmd}' should have kind {expected_kind:?}"
5227 );
5228 assert!(
5229 result.confidence > 0.0,
5230 "'{cmd}' should have positive confidence"
5231 );
5232 assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5233 }
5234 }
5235
5236 #[test]
5239 fn test_cow_borrowed_on_tier0_reject() {
5240 let _guard = test_guard!();
5241 let result = classify_command("");
5243 assert_cow_borrowed(&result.reason, "Tier 0 empty command reason");
5244 }
5245
5246 #[test]
5247 fn test_cow_borrowed_on_tier1_reject() {
5248 let _guard = test_guard!();
5249 let result = classify_command("cargo build | xargs rm");
5252 assert!(!result.is_compilation);
5253 assert_cow_borrowed(&result.reason, "Tier 1 piped command reason");
5254
5255 let result = classify_command("cargo build < input.txt");
5257 assert!(!result.is_compilation);
5258 assert_cow_borrowed(&result.reason, "Tier 1 input redirect reason");
5259
5260 let result = classify_command("cargo build $(echo args)");
5262 assert!(!result.is_compilation);
5263 assert_cow_borrowed(&result.reason, "Tier 1 subshell reason");
5264 }
5265
5266 #[test]
5267 fn test_cow_borrowed_on_tier2_reject() {
5268 let _guard = test_guard!();
5269 let non_keyword_commands = [
5271 "ls -la",
5272 "cat file.txt",
5273 "echo hello world",
5274 "git status",
5275 "pwd",
5276 "cd /tmp",
5277 "grep pattern file",
5278 "find . -name '*.rs'",
5279 "cp src dst",
5280 "rm file.txt",
5281 ];
5282 for cmd in non_keyword_commands {
5283 let result = classify_command(cmd);
5284 assert!(!result.is_compilation, "'{cmd}' should be rejected");
5285 assert_cow_borrowed(&result.reason, &format!("Tier 2 reject for '{cmd}'"));
5286 }
5287 }
5288
5289 #[test]
5290 fn test_cow_borrowed_on_tier3_reject() {
5291 let _guard = test_guard!();
5292 let never_intercept = ["cargo fmt", "cargo install ripgrep", "cargo clean"];
5294 for cmd in never_intercept {
5295 let result = classify_command(cmd);
5296 assert!(!result.is_compilation, "'{cmd}' should be rejected");
5297 assert!(
5298 result.reason.contains("never-intercept"),
5299 "'{cmd}' reason should mention never-intercept: {}",
5300 result.reason
5301 );
5302 assert_cow_borrowed(&result.reason, &format!("Tier 3 reject for '{cmd}'"));
5304 }
5305 }
5306
5307 #[test]
5310 fn test_detailed_tiers_use_borrowed_names() {
5311 let _guard = test_guard!();
5312 let details = classify_command_detailed("ls -la");
5315 for tier in &details.tiers {
5316 assert_cow_borrowed(&tier.name, &format!("Tier {} name", tier.tier));
5317 }
5318 }
5319
5320 #[test]
5321 fn test_detailed_tier_reasons_borrowed_on_reject() {
5322 let _guard = test_guard!();
5323 let details = classify_command_detailed("ls -la");
5325 assert!(!details.classification.is_compilation);
5326 for tier in &details.tiers {
5327 assert_cow_borrowed(
5328 &tier.reason,
5329 &format!("Tier {} reason for 'ls -la'", tier.tier),
5330 );
5331 }
5332 }
5333
5334 #[test]
5335 fn test_detailed_compilation_tier_names_borrowed() {
5336 let _guard = test_guard!();
5337 let details = classify_command_detailed("cargo build");
5339 assert!(details.classification.is_compilation);
5340 for tier in &details.tiers {
5341 assert_cow_borrowed(
5342 &tier.name,
5343 &format!("Tier {} name for 'cargo build'", tier.tier),
5344 );
5345 }
5346 }
5347
5348 #[test]
5351 fn test_classification_serde_roundtrip_borrowed() {
5352 let _guard = test_guard!();
5353 let original = Classification::not_compilation("no compilation keyword");
5354 assert_cow_borrowed(&original.reason, "before serialize");
5355
5356 let json = serde_json::to_string(&original).expect("serialize");
5357 let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5358
5359 assert_eq!(deserialized.is_compilation, original.is_compilation);
5360 assert_eq!(deserialized.confidence, original.confidence);
5361 assert_eq!(deserialized.kind, original.kind);
5362 assert_eq!(deserialized.reason, original.reason);
5363 }
5366
5367 #[test]
5368 fn test_classification_serde_roundtrip_compilation() {
5369 let _guard = test_guard!();
5370 let original =
5371 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build detected");
5372
5373 let json = serde_json::to_string(&original).expect("serialize");
5374 let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5375
5376 assert_eq!(deserialized.is_compilation, original.is_compilation);
5377 assert_eq!(deserialized.confidence, original.confidence);
5378 assert_eq!(deserialized.kind, original.kind);
5379 assert_eq!(deserialized.reason.as_ref(), original.reason.as_ref());
5380 }
5381
5382 #[test]
5383 fn test_classification_tier_serde_roundtrip() {
5384 let _guard = test_guard!();
5385 let tier = ClassificationTier {
5386 tier: 2,
5387 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
5388 decision: TierDecision::Reject,
5389 reason: Cow::Borrowed("no compilation keyword"),
5390 };
5391 assert_cow_borrowed(&tier.name, "tier name before serialize");
5392 assert_cow_borrowed(&tier.reason, "tier reason before serialize");
5393
5394 let json = serde_json::to_string(&tier).expect("serialize");
5395 let deserialized: ClassificationTier = serde_json::from_str(&json).expect("deserialize");
5396
5397 assert_eq!(deserialized.tier, tier.tier);
5398 assert_eq!(deserialized.name.as_ref(), tier.name.as_ref());
5399 assert_eq!(deserialized.decision, tier.decision);
5400 assert_eq!(deserialized.reason.as_ref(), tier.reason.as_ref());
5401 }
5402
5403 #[test]
5404 fn test_classification_details_serde_roundtrip() {
5405 let _guard = test_guard!();
5406 let details = classify_command_detailed("cargo build");
5407 assert!(details.classification.is_compilation);
5408
5409 let json = serde_json::to_string(&details).expect("serialize");
5410 let deserialized: ClassificationDetails = serde_json::from_str(&json).expect("deserialize");
5411
5412 assert_eq!(deserialized.original, details.original);
5413 assert_eq!(deserialized.normalized, details.normalized);
5414 assert_eq!(deserialized.tiers.len(), details.tiers.len());
5415 assert_eq!(
5416 deserialized.classification.is_compilation,
5417 details.classification.is_compilation
5418 );
5419 assert_eq!(
5420 deserialized.classification.kind,
5421 details.classification.kind
5422 );
5423 }
5424
5425 #[test]
5428 fn test_cow_reason_display() {
5429 let _guard = test_guard!();
5430 let result = classify_command("ls");
5431 let displayed = format!("{}", result.reason);
5433 assert!(!displayed.is_empty());
5434 let debugged = format!("{:?}", result.reason);
5435 assert!(!debugged.is_empty());
5436 }
5437
5438 #[test]
5439 fn test_cow_reason_comparison() {
5440 let _guard = test_guard!();
5441 let borrowed: Cow<'static, str> = Cow::Borrowed("no compilation keyword");
5443 let owned: Cow<'static, str> = Cow::Owned("no compilation keyword".to_string());
5444 assert_eq!(borrowed, owned);
5445
5446 let r1 = classify_command("ls");
5448 let r2 = classify_command("pwd");
5449 assert_eq!(r1.reason, r2.reason);
5451 }
5452}
5453
5454#[cfg(test)]
5455mod tests_bun_whitespace {
5456 use super::*;
5457 use crate::test_guard;
5458
5459 #[test]
5460 fn test_bun_whitespace_resilience() {
5461 let _guard = test_guard!();
5462
5463 let result = classify_command("bun test");
5465 assert!(result.is_compilation, "bun test failed");
5466 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5467
5468 let result = classify_command("bun test");
5470 assert!(result.is_compilation, "bun test failed");
5471 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5472
5473 let result = classify_command("bun\ttest");
5475 assert!(result.is_compilation, "bun\\ttest failed");
5476 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5477
5478 let result = classify_command("bun typecheck src/");
5480 assert!(result.is_compilation, "bun typecheck with spaces failed");
5481 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
5482 }
5483
5484 #[test]
5485 fn test_bun_watch_with_whitespace() {
5486 let _guard = test_guard!();
5487
5488 let result = classify_command("bun test --watch");
5490 assert!(
5491 !result.is_compilation,
5492 "bun test --watch should be rejected"
5493 );
5494 assert!(result.reason.contains("interactive"));
5495
5496 let result = classify_command("bun\ttypecheck\t-w");
5498 assert!(
5499 !result.is_compilation,
5500 "bun typecheck -w should be rejected"
5501 );
5502 assert!(result.reason.contains("interactive"));
5503 }
5504
5505 #[test]
5506 fn test_bun_x_whitespace() {
5507 let _guard = test_guard!();
5508
5509 let result = classify_command("bun x vitest");
5511 assert!(!result.is_compilation, "bun x should be rejected");
5512 assert!(result.reason.contains("bun x"));
5513 }
5514}
5515
5516#[cfg(test)]
5517mod tests_normalize_whitespace {
5518 use super::*;
5519 use crate::test_guard;
5520
5521 #[test]
5522 fn test_wrapper_whitespace_resilience() {
5523 let _guard = test_guard!();
5524
5525 assert_eq!(normalize_command("sudo cargo"), "cargo");
5527
5528 assert_eq!(normalize_command("sudo\tcargo"), "cargo");
5530
5531 assert_eq!(normalize_command("time\tsudo cargo"), "cargo");
5533
5534 assert_eq!(normalize_command("sudocargo"), "sudocargo");
5536
5537 assert_eq!(normalize_command("sudo"), "");
5539 }
5540
5541 #[test]
5542 fn wrapper_value_flags_consume_their_value_token() {
5543 let _guard = test_guard!();
5547 assert_eq!(normalize_command("nice -n 10 cargo build"), "cargo build");
5548 assert_eq!(
5549 normalize_command("taskset -c 0-3 cargo build"),
5550 "cargo build"
5551 );
5552 assert_eq!(normalize_command("ionice -c 2 cargo test"), "cargo test");
5553 assert_eq!(
5554 normalize_command("ionice -c 2 -n 4 cargo build"),
5555 "cargo build"
5556 );
5557 assert_eq!(normalize_command("chrt -r 10 cargo build"), "cargo build");
5558 assert_eq!(normalize_command("timeout 60 cargo build"), "cargo build");
5559 assert_eq!(
5560 normalize_command("timeout -s KILL 90 cargo build"),
5561 "cargo build"
5562 );
5563 assert_eq!(
5564 normalize_command("sudo nice -n 5 cargo build"),
5565 "cargo build"
5566 );
5567 assert_eq!(normalize_command("nice -n10 cargo build"), "cargo build");
5569 assert_eq!(normalize_command("ionice -c2 cargo build"), "cargo build");
5570 assert_eq!(
5572 normalize_command("env -u RUSTFLAGS cargo build"),
5573 "cargo build"
5574 );
5575 }
5576
5577 #[test]
5578 fn positional_wrapper_does_not_eat_a_non_numeric_command() {
5579 let _guard = test_guard!();
5582 assert_eq!(normalize_command("timeout cargo build"), "cargo build");
5583 }
5584
5585 #[test]
5586 fn wrapped_cargo_build_is_still_classified_as_compilation() {
5587 let _guard = test_guard!();
5588 for cmd in [
5589 "nice -n 10 cargo build",
5590 "taskset -c 0-3 cargo build",
5591 "ionice -c 2 cargo build",
5592 "chrt -r 10 cargo build",
5593 "timeout 60 cargo build",
5594 "timeout -s KILL 90 cargo test",
5595 "time '-f%U' cargo build",
5596 "time \"-f%U\" cargo build",
5597 "nice '--adjustment=+5' cargo build",
5598 "nice '-n' '5' cargo build",
5599 "env '--unset' 'RUSTFLAGS' cargo build",
5600 ] {
5601 assert!(
5602 classify_command(cmd).is_compilation,
5603 "wrapper must not defeat offload: {cmd}"
5604 );
5605 }
5606 assert!(!classify_command("nice -n 10 ls -la").is_compilation);
5608 assert!(!classify_command("timeout 60 echo hi").is_compilation);
5609 assert!(!classify_command("time '-f%U' echo cargo build").is_compilation);
5610 assert!(!classify_command("time '-f%U cargo build").is_compilation);
5611 assert!(!classify_command("time '-f%U'echo cargo build").is_compilation);
5612 }
5613
5614 #[test]
5615 fn test_env_var_whitespace() {
5616 let _guard = test_guard!();
5617
5618 for (cmd, normalized) in [
5619 ("env RUST_BACKTRACE=1 cargo", "cargo"),
5621 ("env\tRUST_BACKTRACE=1\tcargo", "cargo"),
5623 ("env -u RUST_LOG cargo test", "cargo test"),
5624 ("env --unset RUST_LOG cargo test", "cargo test"),
5625 ("env --unset=RUST_LOG cargo test", "cargo test"),
5626 ] {
5627 assert_eq!(normalize_command(cmd), normalized, "{cmd}");
5628 }
5629 }
5630}
5631
5632#[cfg(test)]
5641mod regression_classification {
5642 use super::*;
5643 use crate::test_guard;
5644
5645 fn assert_golden_json(
5646 actual: serde_json::Value,
5647 expected: &serde_json::Value,
5648 fixture_path: &str,
5649 ) {
5650 if &actual == expected {
5651 return;
5652 }
5653
5654 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
5655 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
5656 panic!(
5657 "golden mismatch in {fixture_path}\n\
5658 expected_blake3={}\n\
5659 actual_blake3={}\n\
5660 bless path: review the semantic diff, update the fixture expected_* field, \
5661 and record both hashes in the Beads closeout.\n\
5662 expected:\n{expected_json}\n\
5663 actual:\n{actual_json}",
5664 blake3::hash(expected_json.as_bytes()),
5665 blake3::hash(actual_json.as_bytes())
5666 );
5667 }
5668
5669 struct Case {
5671 cmd: &'static str,
5672 expect_compilation: bool,
5673 expected_kind: Option<CompilationKind>,
5674 reason_contains: &'static str,
5676 min_confidence: f64,
5678 }
5679
5680 fn run_cases(cases: &[Case]) {
5681 for (i, c) in cases.iter().enumerate() {
5682 let result = classify_command(c.cmd);
5683 assert_eq!(
5684 result.is_compilation, c.expect_compilation,
5685 "Case {i} ({:?}): expected is_compilation={}, got={}. reason={:?}",
5686 c.cmd, c.expect_compilation, result.is_compilation, result.reason
5687 );
5688 if c.expect_compilation {
5689 assert_eq!(
5690 result.kind, c.expected_kind,
5691 "Case {i} ({:?}): expected kind={:?}, got={:?}",
5692 c.cmd, c.expected_kind, result.kind
5693 );
5694 assert!(
5695 result.confidence >= c.min_confidence,
5696 "Case {i} ({:?}): expected confidence >= {}, got={}",
5697 c.cmd,
5698 c.min_confidence,
5699 result.confidence
5700 );
5701 }
5702 if !c.reason_contains.is_empty() {
5703 assert!(
5704 result.reason.contains(c.reason_contains),
5705 "Case {i} ({:?}): expected reason containing {:?}, got {:?}",
5706 c.cmd,
5707 c.reason_contains,
5708 result.reason
5709 );
5710 }
5711 }
5712 }
5713
5714 #[test]
5719 fn regression_cargo_compilation_positive() {
5720 let _guard = test_guard!();
5721 let cases = [
5722 Case {
5723 cmd: "cargo build",
5724 expect_compilation: true,
5725 expected_kind: Some(CompilationKind::CargoBuild),
5726 reason_contains: "cargo build",
5727 min_confidence: 0.90,
5728 },
5729 Case {
5730 cmd: "cargo build --release",
5731 expect_compilation: true,
5732 expected_kind: Some(CompilationKind::CargoBuild),
5733 reason_contains: "cargo build",
5734 min_confidence: 0.90,
5735 },
5736 Case {
5737 cmd: "cargo build --workspace",
5738 expect_compilation: true,
5739 expected_kind: Some(CompilationKind::CargoBuild),
5740 reason_contains: "cargo build",
5741 min_confidence: 0.90,
5742 },
5743 Case {
5744 cmd: "cargo build -p my-crate",
5745 expect_compilation: true,
5746 expected_kind: Some(CompilationKind::CargoBuild),
5747 reason_contains: "cargo build",
5748 min_confidence: 0.90,
5749 },
5750 Case {
5751 cmd: "cargo b",
5752 expect_compilation: true,
5753 expected_kind: Some(CompilationKind::CargoBuild),
5754 reason_contains: "cargo build",
5755 min_confidence: 0.90,
5756 },
5757 Case {
5758 cmd: "cargo test",
5759 expect_compilation: true,
5760 expected_kind: Some(CompilationKind::CargoTest),
5761 reason_contains: "cargo test",
5762 min_confidence: 0.90,
5763 },
5764 Case {
5765 cmd: "cargo test --release",
5766 expect_compilation: true,
5767 expected_kind: Some(CompilationKind::CargoTest),
5768 reason_contains: "cargo test",
5769 min_confidence: 0.90,
5770 },
5771 Case {
5772 cmd: "cargo test my_test",
5773 expect_compilation: true,
5774 expected_kind: Some(CompilationKind::CargoTest),
5775 reason_contains: "cargo test",
5776 min_confidence: 0.90,
5777 },
5778 Case {
5779 cmd: "cargo test -- --nocapture",
5780 expect_compilation: true,
5781 expected_kind: Some(CompilationKind::CargoTest),
5782 reason_contains: "cargo test",
5783 min_confidence: 0.90,
5784 },
5785 Case {
5786 cmd: "cargo test --workspace",
5787 expect_compilation: true,
5788 expected_kind: Some(CompilationKind::CargoTest),
5789 reason_contains: "cargo test",
5790 min_confidence: 0.90,
5791 },
5792 Case {
5793 cmd: "cargo test -p rch-common",
5794 expect_compilation: true,
5795 expected_kind: Some(CompilationKind::CargoTest),
5796 reason_contains: "cargo test",
5797 min_confidence: 0.90,
5798 },
5799 Case {
5800 cmd: "cargo t",
5801 expect_compilation: true,
5802 expected_kind: Some(CompilationKind::CargoTest),
5803 reason_contains: "cargo test",
5804 min_confidence: 0.90,
5805 },
5806 Case {
5807 cmd: "cargo test --lib",
5808 expect_compilation: true,
5809 expected_kind: Some(CompilationKind::CargoTest),
5810 reason_contains: "cargo test",
5811 min_confidence: 0.90,
5812 },
5813 Case {
5814 cmd: "cargo test --bins",
5815 expect_compilation: true,
5816 expected_kind: Some(CompilationKind::CargoTest),
5817 reason_contains: "cargo test",
5818 min_confidence: 0.90,
5819 },
5820 Case {
5821 cmd: "cargo test --doc",
5822 expect_compilation: true,
5823 expected_kind: Some(CompilationKind::CargoTest),
5824 reason_contains: "cargo test",
5825 min_confidence: 0.90,
5826 },
5827 Case {
5828 cmd: "cargo test -- --test-threads=4",
5829 expect_compilation: true,
5830 expected_kind: Some(CompilationKind::CargoTest),
5831 reason_contains: "cargo test",
5832 min_confidence: 0.90,
5833 },
5834 Case {
5835 cmd: "cargo check",
5836 expect_compilation: true,
5837 expected_kind: Some(CompilationKind::CargoCheck),
5838 reason_contains: "cargo check",
5839 min_confidence: 0.85,
5840 },
5841 Case {
5842 cmd: "cargo check --workspace --all-targets",
5843 expect_compilation: true,
5844 expected_kind: Some(CompilationKind::CargoCheck),
5845 reason_contains: "cargo check",
5846 min_confidence: 0.85,
5847 },
5848 Case {
5849 cmd: "cargo c",
5850 expect_compilation: true,
5851 expected_kind: Some(CompilationKind::CargoCheck),
5852 reason_contains: "cargo check",
5853 min_confidence: 0.85,
5854 },
5855 Case {
5856 cmd: "cargo clippy",
5857 expect_compilation: true,
5858 expected_kind: Some(CompilationKind::CargoClippy),
5859 reason_contains: "cargo clippy",
5860 min_confidence: 0.85,
5861 },
5862 Case {
5863 cmd: "cargo clippy --workspace --all-targets -- -D warnings",
5864 expect_compilation: true,
5865 expected_kind: Some(CompilationKind::CargoClippy),
5866 reason_contains: "cargo clippy",
5867 min_confidence: 0.85,
5868 },
5869 Case {
5870 cmd: "cargo doc",
5871 expect_compilation: true,
5872 expected_kind: Some(CompilationKind::CargoDoc),
5873 reason_contains: "cargo doc",
5874 min_confidence: 0.80,
5875 },
5876 Case {
5877 cmd: "cargo doc --no-deps",
5878 expect_compilation: true,
5879 expected_kind: Some(CompilationKind::CargoDoc),
5880 reason_contains: "cargo doc",
5881 min_confidence: 0.80,
5882 },
5883 Case {
5884 cmd: "cargo run",
5885 expect_compilation: true,
5886 expected_kind: Some(CompilationKind::CargoBuild),
5887 reason_contains: "cargo run",
5888 min_confidence: 0.80,
5889 },
5890 Case {
5891 cmd: "cargo run --release",
5892 expect_compilation: true,
5893 expected_kind: Some(CompilationKind::CargoBuild),
5894 reason_contains: "cargo run",
5895 min_confidence: 0.80,
5896 },
5897 Case {
5898 cmd: "cargo r",
5899 expect_compilation: true,
5900 expected_kind: Some(CompilationKind::CargoBuild),
5901 reason_contains: "cargo run",
5902 min_confidence: 0.80,
5903 },
5904 Case {
5905 cmd: "cargo bench",
5906 expect_compilation: true,
5907 expected_kind: Some(CompilationKind::CargoBench),
5908 reason_contains: "cargo bench",
5909 min_confidence: 0.85,
5910 },
5911 Case {
5912 cmd: "cargo bench --bench my_bench",
5913 expect_compilation: true,
5914 expected_kind: Some(CompilationKind::CargoBench),
5915 reason_contains: "cargo bench",
5916 min_confidence: 0.85,
5917 },
5918 Case {
5919 cmd: "cargo nextest run",
5920 expect_compilation: true,
5921 expected_kind: Some(CompilationKind::CargoNextest),
5922 reason_contains: "cargo nextest run",
5923 min_confidence: 0.90,
5924 },
5925 Case {
5926 cmd: "cargo nextest run --workspace",
5927 expect_compilation: true,
5928 expected_kind: Some(CompilationKind::CargoNextest),
5929 reason_contains: "cargo nextest run",
5930 min_confidence: 0.90,
5931 },
5932 Case {
5933 cmd: "cargo nextest r",
5934 expect_compilation: true,
5935 expected_kind: Some(CompilationKind::CargoNextest),
5936 reason_contains: "cargo nextest run",
5937 min_confidence: 0.90,
5938 },
5939 Case {
5941 cmd: "cargo +nightly build",
5942 expect_compilation: true,
5943 expected_kind: Some(CompilationKind::CargoBuild),
5944 reason_contains: "cargo build",
5945 min_confidence: 0.90,
5946 },
5947 Case {
5948 cmd: "cargo +1.80.0 test",
5949 expect_compilation: true,
5950 expected_kind: Some(CompilationKind::CargoTest),
5951 reason_contains: "cargo test",
5952 min_confidence: 0.90,
5953 },
5954 Case {
5955 cmd: "cargo +nightly nextest run",
5956 expect_compilation: true,
5957 expected_kind: Some(CompilationKind::CargoNextest),
5958 reason_contains: "cargo nextest run",
5959 min_confidence: 0.90,
5960 },
5961 Case {
5964 cmd: "cargo --offline build",
5965 expect_compilation: true,
5966 expected_kind: Some(CompilationKind::CargoBuild),
5967 reason_contains: "cargo build",
5968 min_confidence: 0.90,
5969 },
5970 Case {
5971 cmd: "cargo -q test",
5972 expect_compilation: true,
5973 expected_kind: Some(CompilationKind::CargoTest),
5974 reason_contains: "cargo test",
5975 min_confidence: 0.90,
5976 },
5977 Case {
5978 cmd: "cargo --locked --frozen check",
5979 expect_compilation: true,
5980 expected_kind: Some(CompilationKind::CargoCheck),
5981 reason_contains: "cargo check",
5982 min_confidence: 0.85,
5983 },
5984 Case {
5985 cmd: "cargo --color never build",
5986 expect_compilation: true,
5987 expected_kind: Some(CompilationKind::CargoBuild),
5988 reason_contains: "cargo build",
5989 min_confidence: 0.90,
5990 },
5991 Case {
5992 cmd: "cargo +nightly --offline -Z unstable-options build",
5993 expect_compilation: true,
5994 expected_kind: Some(CompilationKind::CargoBuild),
5995 reason_contains: "cargo build",
5996 min_confidence: 0.90,
5997 },
5998 ];
5999 run_cases(&cases);
6000 }
6001
6002 #[test]
6003 fn regression_cargo_package_verification_is_compilation() {
6004 let _guard = test_guard!();
6005 for command in [
6006 "cargo package",
6007 "cargo package --workspace --locked -j2",
6008 "cargo package -p asupersync --features tls --target x86_64-unknown-linux-gnu",
6009 "cargo +nightly --offline package --allow-dirty --no-metadata",
6010 "cargo --color never package --target-dir=/data/tmp/package-check",
6011 "cargo publish --dry-run",
6012 "cargo publish -n --workspace --locked --keep-going -j 2",
6013 "cargo publish --registry crates-io -p asupersync --dry-run",
6014 "cargo +nightly --locked publish --dry-run --features=tls",
6015 "env CARGO_INCREMENTAL=0 cargo publish --dry-run --workspace",
6016 ] {
6017 let classification = classify_command(command);
6018 assert!(
6019 classification.is_compilation,
6020 "{command}: {classification:?}"
6021 );
6022 assert_eq!(classification.kind, Some(CompilationKind::CargoBuild));
6023 assert_eq!(
6024 classify_command_detailed(command).classification,
6025 classification,
6026 "diagnosis must agree with execution for {command}"
6027 );
6028 }
6029 }
6030
6031 #[test]
6032 fn regression_cargo_package_verification_declines_publication_and_metadata() {
6033 let _guard = test_guard!();
6034 for command in [
6035 "cargo publish",
6036 "cargo publish --workspace --locked",
6037 "cargo +nightly --offline publish",
6038 "cargo publish --no-verify",
6039 "cargo publish --dry-run --no-verify",
6040 "cargo publish --no-verify -n",
6041 "cargo publish -qn --no-verify",
6042 "cargo package --no-verify",
6043 "cargo package --list",
6044 "cargo package -l",
6045 "cargo package -vl",
6046 "cargo package --help",
6047 "cargo --help package",
6048 "cargo publish --dry-run -h",
6049 "cargo publish -- --dry-run",
6050 "cargo publish --dry-run -- --no-verify",
6051 "cargo publish --dry-run=false",
6052 "cargo publish --registry=--dry-run",
6053 "cargo publish -p--dry-run",
6054 "cargo publish --token=--dry-run",
6055 "cargo publish --config --dry-run",
6056 "cargo publish --config alias.note=--dry-run",
6057 "cargo publish --unknown-option --dry-run",
6058 "cargo package --target --no-verify",
6059 "cargo package --package --list",
6060 "cargo package --config",
6061 "cargo publish --config 'alias.note=\"--dry-run\"'",
6062 "cargo publish --registry 'example --dry-run registry'",
6063 "cargo publish --dry-run --config 'alias.note=\"--no-verify\"'",
6064 "cargo publish --dry-run $EXTRA_CARGO_OPTIONS",
6065 ] {
6066 let classification = classify_command(command);
6067 assert!(
6068 !classification.is_compilation,
6069 "{command}: {classification:?}"
6070 );
6071 let detailed = classify_command_detailed(command).classification;
6072 assert!(!detailed.is_compilation, "{command}: {detailed:?}");
6073 assert!(!is_cargo_package_verification(command), "{command}");
6074 }
6075 }
6076
6077 #[test]
6082 fn regression_cargo_never_intercept() {
6083 let _guard = test_guard!();
6084 let cases = [
6085 Case {
6086 cmd: "cargo install ripgrep",
6087 expect_compilation: false,
6088 expected_kind: None,
6089 reason_contains: "never-intercept",
6090 min_confidence: 0.0,
6091 },
6092 Case {
6093 cmd: "cargo publish",
6094 expect_compilation: false,
6095 expected_kind: None,
6096 reason_contains: "never-intercept",
6097 min_confidence: 0.0,
6098 },
6099 Case {
6100 cmd: "cargo login",
6101 expect_compilation: false,
6102 expected_kind: None,
6103 reason_contains: "never-intercept",
6104 min_confidence: 0.0,
6105 },
6106 Case {
6107 cmd: "cargo fmt",
6108 expect_compilation: false,
6109 expected_kind: None,
6110 reason_contains: "never-intercept",
6111 min_confidence: 0.0,
6112 },
6113 Case {
6114 cmd: "cargo fmt --check",
6115 expect_compilation: false,
6116 expected_kind: None,
6117 reason_contains: "never-intercept",
6118 min_confidence: 0.0,
6119 },
6120 Case {
6121 cmd: "cargo fix",
6122 expect_compilation: false,
6123 expected_kind: None,
6124 reason_contains: "never-intercept",
6125 min_confidence: 0.0,
6126 },
6127 Case {
6128 cmd: "cargo clean",
6129 expect_compilation: false,
6130 expected_kind: None,
6131 reason_contains: "never-intercept",
6132 min_confidence: 0.0,
6133 },
6134 Case {
6135 cmd: "cargo new my_project",
6136 expect_compilation: false,
6137 expected_kind: None,
6138 reason_contains: "never-intercept",
6139 min_confidence: 0.0,
6140 },
6141 Case {
6142 cmd: "cargo init",
6143 expect_compilation: false,
6144 expected_kind: None,
6145 reason_contains: "never-intercept",
6146 min_confidence: 0.0,
6147 },
6148 Case {
6149 cmd: "cargo add serde",
6150 expect_compilation: false,
6151 expected_kind: None,
6152 reason_contains: "never-intercept",
6153 min_confidence: 0.0,
6154 },
6155 Case {
6156 cmd: "cargo remove serde",
6157 expect_compilation: false,
6158 expected_kind: None,
6159 reason_contains: "never-intercept",
6160 min_confidence: 0.0,
6161 },
6162 Case {
6163 cmd: "cargo update",
6164 expect_compilation: false,
6165 expected_kind: None,
6166 reason_contains: "never-intercept",
6167 min_confidence: 0.0,
6168 },
6169 Case {
6170 cmd: "cargo generate-lockfile",
6171 expect_compilation: false,
6172 expected_kind: None,
6173 reason_contains: "never-intercept",
6174 min_confidence: 0.0,
6175 },
6176 Case {
6177 cmd: "cargo watch -x test",
6178 expect_compilation: false,
6179 expected_kind: None,
6180 reason_contains: "never-intercept",
6181 min_confidence: 0.0,
6182 },
6183 Case {
6184 cmd: "cargo --version",
6185 expect_compilation: false,
6186 expected_kind: None,
6187 reason_contains: "never-intercept",
6188 min_confidence: 0.0,
6189 },
6190 Case {
6191 cmd: "cargo -V",
6192 expect_compilation: false,
6193 expected_kind: None,
6194 reason_contains: "never-intercept",
6195 min_confidence: 0.0,
6196 },
6197 Case {
6199 cmd: "cargo nextest list",
6200 expect_compilation: false,
6201 expected_kind: None,
6202 reason_contains: "never-intercept",
6203 min_confidence: 0.0,
6204 },
6205 Case {
6206 cmd: "cargo nextest archive",
6207 expect_compilation: false,
6208 expected_kind: None,
6209 reason_contains: "never-intercept",
6210 min_confidence: 0.0,
6211 },
6212 Case {
6213 cmd: "cargo nextest show",
6214 expect_compilation: false,
6215 expected_kind: None,
6216 reason_contains: "never-intercept",
6217 min_confidence: 0.0,
6218 },
6219 Case {
6221 cmd: "cargo",
6222 expect_compilation: false,
6223 expected_kind: None,
6224 reason_contains: "bare cargo",
6225 min_confidence: 0.0,
6226 },
6227 Case {
6228 cmd: "cargo nextest",
6229 expect_compilation: false,
6230 expected_kind: None,
6231 reason_contains: "bare cargo nextest",
6232 min_confidence: 0.0,
6233 },
6234 Case {
6236 cmd: "cargo tree",
6237 expect_compilation: false,
6238 expected_kind: None,
6239 reason_contains: "not interceptable",
6240 min_confidence: 0.0,
6241 },
6242 Case {
6243 cmd: "cargo metadata",
6244 expect_compilation: false,
6245 expected_kind: None,
6246 reason_contains: "not interceptable",
6247 min_confidence: 0.0,
6248 },
6249 Case {
6250 cmd: "cargo search serde",
6251 expect_compilation: false,
6252 expected_kind: None,
6253 reason_contains: "not interceptable",
6254 min_confidence: 0.0,
6255 },
6256 Case {
6257 cmd: "cargo vendor",
6258 expect_compilation: false,
6259 expected_kind: None,
6260 reason_contains: "not interceptable",
6261 min_confidence: 0.0,
6262 },
6263 ];
6264 run_cases(&cases);
6265 }
6266
6267 #[test]
6272 fn regression_bun_compilation_positive() {
6273 let _guard = test_guard!();
6274 let cases = [
6275 Case {
6276 cmd: "bun test",
6277 expect_compilation: true,
6278 expected_kind: Some(CompilationKind::BunTest),
6279 reason_contains: "bun test",
6280 min_confidence: 0.90,
6281 },
6282 Case {
6283 cmd: "bun test src/",
6284 expect_compilation: true,
6285 expected_kind: Some(CompilationKind::BunTest),
6286 reason_contains: "bun test",
6287 min_confidence: 0.90,
6288 },
6289 Case {
6290 cmd: "bun test --bail",
6291 expect_compilation: true,
6292 expected_kind: Some(CompilationKind::BunTest),
6293 reason_contains: "bun test",
6294 min_confidence: 0.90,
6295 },
6296 Case {
6297 cmd: "bun test --timeout 5000",
6298 expect_compilation: true,
6299 expected_kind: Some(CompilationKind::BunTest),
6300 reason_contains: "bun test",
6301 min_confidence: 0.90,
6302 },
6303 Case {
6304 cmd: "bun typecheck",
6305 expect_compilation: true,
6306 expected_kind: Some(CompilationKind::BunTypecheck),
6307 reason_contains: "bun typecheck",
6308 min_confidence: 0.90,
6309 },
6310 Case {
6311 cmd: "bun typecheck src/",
6312 expect_compilation: true,
6313 expected_kind: Some(CompilationKind::BunTypecheck),
6314 reason_contains: "bun typecheck",
6315 min_confidence: 0.90,
6316 },
6317 ];
6318 run_cases(&cases);
6319 }
6320
6321 #[test]
6326 fn regression_bun_never_intercept() {
6327 let _guard = test_guard!();
6328 let cases = [
6329 Case {
6331 cmd: "bun install",
6332 expect_compilation: false,
6333 expected_kind: None,
6334 reason_contains: "never-intercept",
6335 min_confidence: 0.0,
6336 },
6337 Case {
6338 cmd: "bun add react",
6339 expect_compilation: false,
6340 expected_kind: None,
6341 reason_contains: "never-intercept",
6342 min_confidence: 0.0,
6343 },
6344 Case {
6345 cmd: "bun remove react",
6346 expect_compilation: false,
6347 expected_kind: None,
6348 reason_contains: "never-intercept",
6349 min_confidence: 0.0,
6350 },
6351 Case {
6352 cmd: "bun link",
6353 expect_compilation: false,
6354 expected_kind: None,
6355 reason_contains: "never-intercept",
6356 min_confidence: 0.0,
6357 },
6358 Case {
6359 cmd: "bun unlink",
6360 expect_compilation: false,
6361 expected_kind: None,
6362 reason_contains: "never-intercept",
6363 min_confidence: 0.0,
6364 },
6365 Case {
6366 cmd: "bun pm ls",
6367 expect_compilation: false,
6368 expected_kind: None,
6369 reason_contains: "never-intercept",
6370 min_confidence: 0.0,
6371 },
6372 Case {
6373 cmd: "bun init",
6374 expect_compilation: false,
6375 expected_kind: None,
6376 reason_contains: "never-intercept",
6377 min_confidence: 0.0,
6378 },
6379 Case {
6380 cmd: "bun create my-app",
6381 expect_compilation: false,
6382 expected_kind: None,
6383 reason_contains: "never-intercept",
6384 min_confidence: 0.0,
6385 },
6386 Case {
6387 cmd: "bun upgrade",
6388 expect_compilation: false,
6389 expected_kind: None,
6390 reason_contains: "never-intercept",
6391 min_confidence: 0.0,
6392 },
6393 Case {
6395 cmd: "bun run dev",
6396 expect_compilation: false,
6397 expected_kind: None,
6398 reason_contains: "never-intercept",
6399 min_confidence: 0.0,
6400 },
6401 Case {
6402 cmd: "bun build src/index.ts",
6403 expect_compilation: false,
6404 expected_kind: None,
6405 reason_contains: "never-intercept",
6406 min_confidence: 0.0,
6407 },
6408 Case {
6409 cmd: "bun dev",
6410 expect_compilation: false,
6411 expected_kind: None,
6412 reason_contains: "never-intercept",
6413 min_confidence: 0.0,
6414 },
6415 Case {
6416 cmd: "bun repl",
6417 expect_compilation: false,
6418 expected_kind: None,
6419 reason_contains: "never-intercept",
6420 min_confidence: 0.0,
6421 },
6422 Case {
6424 cmd: "bun x vitest",
6425 expect_compilation: false,
6426 expected_kind: None,
6427 reason_contains: "bun x",
6428 min_confidence: 0.0,
6429 },
6430 Case {
6431 cmd: "bun x tsc --noEmit",
6432 expect_compilation: false,
6433 expected_kind: None,
6434 reason_contains: "bun x",
6435 min_confidence: 0.0,
6436 },
6437 Case {
6439 cmd: "bun test --watch",
6440 expect_compilation: false,
6441 expected_kind: None,
6442 reason_contains: "interactive",
6443 min_confidence: 0.0,
6444 },
6445 Case {
6446 cmd: "bun test -w",
6447 expect_compilation: false,
6448 expected_kind: None,
6449 reason_contains: "interactive",
6450 min_confidence: 0.0,
6451 },
6452 Case {
6453 cmd: "bun typecheck --watch",
6454 expect_compilation: false,
6455 expected_kind: None,
6456 reason_contains: "interactive",
6457 min_confidence: 0.0,
6458 },
6459 Case {
6460 cmd: "bun typecheck -w",
6461 expect_compilation: false,
6462 expected_kind: None,
6463 reason_contains: "interactive",
6464 min_confidence: 0.0,
6465 },
6466 Case {
6468 cmd: "bun --version",
6469 expect_compilation: false,
6470 expected_kind: None,
6471 reason_contains: "never-intercept",
6472 min_confidence: 0.0,
6473 },
6474 Case {
6475 cmd: "bun -v",
6476 expect_compilation: false,
6477 expected_kind: None,
6478 reason_contains: "never-intercept",
6479 min_confidence: 0.0,
6480 },
6481 Case {
6482 cmd: "bun --help",
6483 expect_compilation: false,
6484 expected_kind: None,
6485 reason_contains: "never-intercept",
6486 min_confidence: 0.0,
6487 },
6488 Case {
6489 cmd: "bun -h",
6490 expect_compilation: false,
6491 expected_kind: None,
6492 reason_contains: "never-intercept",
6493 min_confidence: 0.0,
6494 },
6495 Case {
6496 cmd: "bun completions",
6497 expect_compilation: false,
6498 expected_kind: None,
6499 reason_contains: "never-intercept",
6500 min_confidence: 0.0,
6501 },
6502 ];
6503 run_cases(&cases);
6504 }
6505
6506 #[test]
6511 fn regression_c_cpp_compilation_positive() {
6512 let _guard = test_guard!();
6513 let cases = [
6514 Case {
6515 cmd: "gcc -c main.c -o main.o",
6516 expect_compilation: true,
6517 expected_kind: Some(CompilationKind::Gcc),
6518 reason_contains: "gcc",
6519 min_confidence: 0.85,
6520 },
6521 Case {
6522 cmd: "gcc main.c -o main",
6523 expect_compilation: true,
6524 expected_kind: Some(CompilationKind::Gcc),
6525 reason_contains: "gcc",
6526 min_confidence: 0.85,
6527 },
6528 Case {
6529 cmd: "g++ -c main.cpp -o main.o",
6530 expect_compilation: true,
6531 expected_kind: Some(CompilationKind::Gpp),
6532 reason_contains: "g++",
6533 min_confidence: 0.85,
6534 },
6535 Case {
6536 cmd: "g++ main.cc -o main",
6537 expect_compilation: true,
6538 expected_kind: Some(CompilationKind::Gpp),
6539 reason_contains: "g++",
6540 min_confidence: 0.85,
6541 },
6542 Case {
6543 cmd: "clang -c main.c -o main.o",
6544 expect_compilation: true,
6545 expected_kind: Some(CompilationKind::Clang),
6546 reason_contains: "clang",
6547 min_confidence: 0.85,
6548 },
6549 Case {
6550 cmd: "clang++ -c main.cpp -o main.o",
6551 expect_compilation: true,
6552 expected_kind: Some(CompilationKind::Clangpp),
6553 reason_contains: "clang++",
6554 min_confidence: 0.85,
6555 },
6556 Case {
6557 cmd: "clang++ main.cc -o main",
6558 expect_compilation: true,
6559 expected_kind: Some(CompilationKind::Clangpp),
6560 reason_contains: "clang++",
6561 min_confidence: 0.85,
6562 },
6563 Case {
6564 cmd: "cc -c main.c -o main.o",
6565 expect_compilation: true,
6566 expected_kind: Some(CompilationKind::Gcc),
6567 reason_contains: "cc",
6568 min_confidence: 0.80,
6569 },
6570 Case {
6571 cmd: "c++ -c main.cpp -o main.o",
6572 expect_compilation: true,
6573 expected_kind: Some(CompilationKind::Gpp),
6574 reason_contains: "c++",
6575 min_confidence: 0.80,
6576 },
6577 ];
6578 run_cases(&cases);
6579 }
6580
6581 #[test]
6586 fn regression_c_cpp_version_checks_not_intercepted() {
6587 let _guard = test_guard!();
6588 let cases = [
6589 Case {
6590 cmd: "rustc --version",
6591 expect_compilation: false,
6592 expected_kind: None,
6593 reason_contains: "never-intercept",
6594 min_confidence: 0.0,
6595 },
6596 Case {
6597 cmd: "rustc -V",
6598 expect_compilation: false,
6599 expected_kind: None,
6600 reason_contains: "never-intercept",
6601 min_confidence: 0.0,
6602 },
6603 Case {
6604 cmd: "gcc --version",
6605 expect_compilation: false,
6606 expected_kind: None,
6607 reason_contains: "never-intercept",
6608 min_confidence: 0.0,
6609 },
6610 Case {
6611 cmd: "gcc -v",
6612 expect_compilation: false,
6613 expected_kind: None,
6614 reason_contains: "never-intercept",
6615 min_confidence: 0.0,
6616 },
6617 Case {
6618 cmd: "clang --version",
6619 expect_compilation: false,
6620 expected_kind: None,
6621 reason_contains: "never-intercept",
6622 min_confidence: 0.0,
6623 },
6624 Case {
6625 cmd: "clang -v",
6626 expect_compilation: false,
6627 expected_kind: None,
6628 reason_contains: "never-intercept",
6629 min_confidence: 0.0,
6630 },
6631 Case {
6632 cmd: "make --version",
6633 expect_compilation: false,
6634 expected_kind: None,
6635 reason_contains: "never-intercept",
6636 min_confidence: 0.0,
6637 },
6638 Case {
6639 cmd: "make -v",
6640 expect_compilation: false,
6641 expected_kind: None,
6642 reason_contains: "never-intercept",
6643 min_confidence: 0.0,
6644 },
6645 Case {
6646 cmd: "cmake --version",
6647 expect_compilation: false,
6648 expected_kind: None,
6649 reason_contains: "never-intercept",
6650 min_confidence: 0.0,
6651 },
6652 ];
6653 run_cases(&cases);
6654 }
6655
6656 #[test]
6661 fn regression_build_systems_positive() {
6662 let _guard = test_guard!();
6663 let cases = [
6664 Case {
6665 cmd: "make",
6666 expect_compilation: true,
6667 expected_kind: Some(CompilationKind::Make),
6668 reason_contains: "make build",
6669 min_confidence: 0.80,
6670 },
6671 Case {
6672 cmd: "make -j8",
6673 expect_compilation: true,
6674 expected_kind: Some(CompilationKind::Make),
6675 reason_contains: "make build",
6676 min_confidence: 0.80,
6677 },
6678 Case {
6679 cmd: "make all",
6680 expect_compilation: true,
6681 expected_kind: Some(CompilationKind::Make),
6682 reason_contains: "make build",
6683 min_confidence: 0.80,
6684 },
6685 Case {
6686 cmd: "cmake --build build",
6687 expect_compilation: true,
6688 expected_kind: Some(CompilationKind::CmakeBuild),
6689 reason_contains: "cmake --build",
6690 min_confidence: 0.85,
6691 },
6692 Case {
6693 cmd: "cmake --build=build",
6694 expect_compilation: true,
6695 expected_kind: Some(CompilationKind::CmakeBuild),
6696 reason_contains: "cmake --build",
6697 min_confidence: 0.85,
6698 },
6699 Case {
6700 cmd: "ninja",
6701 expect_compilation: true,
6702 expected_kind: Some(CompilationKind::Ninja),
6703 reason_contains: "ninja build",
6704 min_confidence: 0.85,
6705 },
6706 Case {
6707 cmd: "ninja -j4",
6708 expect_compilation: true,
6709 expected_kind: Some(CompilationKind::Ninja),
6710 reason_contains: "ninja build",
6711 min_confidence: 0.85,
6712 },
6713 Case {
6714 cmd: "meson compile",
6715 expect_compilation: true,
6716 expected_kind: Some(CompilationKind::Meson),
6717 reason_contains: "meson compile",
6718 min_confidence: 0.80,
6719 },
6720 ];
6721 run_cases(&cases);
6722 }
6723
6724 #[test]
6729 fn regression_build_systems_not_intercepted() {
6730 let _guard = test_guard!();
6731 let cases = [
6732 Case {
6733 cmd: "make clean",
6734 expect_compilation: false,
6735 expected_kind: None,
6736 reason_contains: "make maintenance",
6737 min_confidence: 0.0,
6738 },
6739 Case {
6740 cmd: "make install",
6741 expect_compilation: false,
6742 expected_kind: None,
6743 reason_contains: "make maintenance",
6744 min_confidence: 0.0,
6745 },
6746 Case {
6747 cmd: "make distclean",
6748 expect_compilation: false,
6749 expected_kind: None,
6750 reason_contains: "make maintenance",
6751 min_confidence: 0.0,
6752 },
6753 Case {
6754 cmd: "ninja -t clean",
6755 expect_compilation: false,
6756 expected_kind: None,
6757 reason_contains: "ninja clean",
6758 min_confidence: 0.0,
6759 },
6760 Case {
6761 cmd: "ninja clean",
6762 expect_compilation: false,
6763 expected_kind: None,
6764 reason_contains: "ninja clean",
6765 min_confidence: 0.0,
6766 },
6767 Case {
6769 cmd: "meson setup build",
6770 expect_compilation: false,
6771 expected_kind: None,
6772 reason_contains: "no matching pattern",
6773 min_confidence: 0.0,
6774 },
6775 Case {
6776 cmd: "meson configure",
6777 expect_compilation: false,
6778 expected_kind: None,
6779 reason_contains: "no matching pattern",
6780 min_confidence: 0.0,
6781 },
6782 Case {
6784 cmd: "cmake .",
6785 expect_compilation: false,
6786 expected_kind: None,
6787 reason_contains: "no matching pattern",
6788 min_confidence: 0.0,
6789 },
6790 Case {
6791 cmd: "cmake -DCMAKE_BUILD_TYPE=Release ..",
6792 expect_compilation: false,
6793 expected_kind: None,
6794 reason_contains: "no matching pattern",
6795 min_confidence: 0.0,
6796 },
6797 ];
6798 run_cases(&cases);
6799 }
6800
6801 #[test]
6806 fn regression_rustc_positive() {
6807 let _guard = test_guard!();
6808 let cases = [
6809 Case {
6810 cmd: "rustc main.rs",
6811 expect_compilation: true,
6812 expected_kind: Some(CompilationKind::Rustc),
6813 reason_contains: "rustc",
6814 min_confidence: 0.90,
6815 },
6816 Case {
6817 cmd: "rustc main.rs -o main",
6818 expect_compilation: true,
6819 expected_kind: Some(CompilationKind::Rustc),
6820 reason_contains: "rustc",
6821 min_confidence: 0.90,
6822 },
6823 Case {
6824 cmd: "rustc --edition 2021 main.rs",
6825 expect_compilation: true,
6826 expected_kind: Some(CompilationKind::Rustc),
6827 reason_contains: "rustc",
6828 min_confidence: 0.90,
6829 },
6830 ];
6831 run_cases(&cases);
6832 }
6833
6834 #[test]
6839 fn regression_shell_structure_exclusions() {
6840 let _guard = test_guard!();
6841 let cases = [
6842 Case {
6845 cmd: "cargo build 2>&1 | xargs rm",
6846 expect_compilation: false,
6847 expected_kind: None,
6848 reason_contains: "piped",
6849 min_confidence: 0.0,
6850 },
6851 Case {
6853 cmd: "cargo build < input.txt",
6854 expect_compilation: false,
6855 expected_kind: None,
6856 reason_contains: "input redirect",
6857 min_confidence: 0.0,
6858 },
6859 Case {
6861 cmd: "(cargo build)",
6862 expect_compilation: false,
6863 expected_kind: None,
6864 reason_contains: "subshell",
6865 min_confidence: 0.0,
6866 },
6867 Case {
6868 cmd: "$(cargo build)",
6869 expect_compilation: false,
6870 expected_kind: None,
6871 reason_contains: "subshell",
6872 min_confidence: 0.0,
6873 },
6874 Case {
6875 cmd: "`cargo build`",
6876 expect_compilation: false,
6877 expected_kind: None,
6878 reason_contains: "subshell capture",
6879 min_confidence: 0.0,
6880 },
6881 Case {
6883 cmd: "cargo build --config <(echo ...)",
6884 expect_compilation: false,
6885 expected_kind: None,
6886 reason_contains: "subshell",
6887 min_confidence: 0.0,
6888 },
6889 Case {
6891 cmd: "cargo build; cargo test",
6892 expect_compilation: false,
6893 expected_kind: None,
6894 reason_contains: "chained",
6895 min_confidence: 0.0,
6896 },
6897 Case {
6899 cmd: "cargo build || echo failed",
6900 expect_compilation: false,
6901 expected_kind: None,
6902 reason_contains: "chained",
6903 min_confidence: 0.0,
6904 },
6905 Case {
6907 cmd: "cargo build\nrm -rf /",
6908 expect_compilation: false,
6909 expected_kind: None,
6910 reason_contains: "newline",
6911 min_confidence: 0.0,
6912 },
6913 Case {
6914 cmd: "cargo build\r\nevil",
6915 expect_compilation: false,
6916 expected_kind: None,
6917 reason_contains: "newline",
6918 min_confidence: 0.0,
6919 },
6920 ];
6921 run_cases(&cases);
6922 }
6923
6924 #[test]
6933 fn regression_compound_chain_offloads_every_compilation_segment() {
6934 let _guard = test_guard!();
6935
6936 let result = classify_command("cargo build --release && cargo test");
6937 assert!(result.is_compilation, "got: {:?}", result.reason);
6938 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
6939 assert_eq!(
6940 result.command_prefix.as_deref(),
6941 Some("rch exec -- cargo build --release && ")
6942 );
6943 assert_eq!(result.extracted_command.as_deref(), Some("cargo test"));
6944
6945 let result = classify_command("touch marker && cargo build && cargo test --workspace");
6947 assert_eq!(
6948 result.command_prefix.as_deref(),
6949 Some("touch marker && rch exec -- cargo build && ")
6950 );
6951 assert_eq!(
6952 result.extracted_command.as_deref(),
6953 Some("cargo test --workspace")
6954 );
6955
6956 let result = classify_command("go vet ./... && cargo check");
6958 assert_eq!(
6959 result.command_prefix.as_deref(),
6960 Some("rch exec -- go vet ./... && ")
6961 );
6962
6963 let result = classify_command("cd /repo && cargo build");
6965 assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
6966
6967 let result = classify_command("go build -o app ./cmd && cargo test");
6969 assert!(result.is_compilation);
6970 assert_eq!(
6971 result.command_prefix.as_deref(),
6972 Some("go build -o app ./cmd && ")
6973 );
6974 assert_eq!(
6976 compound_local_compilation_segments("go build -o app ./cmd && cargo test"),
6977 vec!["go build -o app ./cmd".to_string()]
6978 );
6979 assert!(
6980 compound_local_compilation_segments("cargo build --release && cargo test").is_empty()
6981 );
6982 assert!(compound_local_compilation_segments("cd /repo && cargo build").is_empty());
6983 assert!(compound_local_compilation_segments("cargo test").is_empty());
6984 assert!(
6985 compound_local_compilation_segments("bash -c \"cargo build && cargo test\"").is_empty(),
6986 "quoted operators are not chain separators"
6987 );
6988
6989 assert!(!classify_command("cargo build ; cargo test").is_compilation);
6992 assert!(declined_compilation_due_to_structure("cargo build ; cargo test").is_some());
6993 assert!(declined_compilation_due_to_structure("cargo build || echo failed").is_some());
6994 assert!(declined_compilation_due_to_structure("cargo build && ./run").is_some());
6995 }
6996
6997 #[test]
7007 fn regression_benign_trailing_structure_offloaded() {
7008 let _guard = test_guard!();
7009 let expectations: &[(&str, CompilationKind, &str)] = &[
7010 (
7012 "cargo build > log.txt",
7013 CompilationKind::CargoBuild,
7014 "cargo build > log.txt",
7015 ),
7016 (
7017 "cargo build >> log.txt",
7018 CompilationKind::CargoBuild,
7019 "cargo build >> log.txt",
7020 ),
7021 (
7022 "cargo test --workspace > out.log 2>&1",
7023 CompilationKind::CargoTest,
7024 "cargo test --workspace > out.log 2>&1",
7025 ),
7026 (
7028 "cargo build 2>&1 | grep error",
7029 CompilationKind::CargoBuild,
7030 "cargo build 2>&1 | grep error",
7031 ),
7032 (
7033 "cargo test | tee output.log",
7034 CompilationKind::CargoTest,
7035 "cargo test | tee output.log",
7036 ),
7037 (
7038 "cargo check --lib 2>&1 | head",
7039 CompilationKind::CargoCheck,
7040 "cargo check --lib 2>&1 | head",
7041 ),
7042 (
7044 "cargo build &",
7045 CompilationKind::CargoBuild,
7046 "cargo build &",
7047 ),
7048 ];
7049 for (cmd, kind, extracted) in expectations {
7050 let result = classify_command(cmd);
7051 assert!(
7052 result.is_compilation,
7053 "'{cmd}' should be offloaded, got: {:?}",
7054 result.reason
7055 );
7056 assert_eq!(result.kind, Some(*kind), "kind mismatch for '{cmd}'");
7057 assert_eq!(
7058 result.extracted_command.as_deref(),
7059 Some(*extracted),
7060 "extracted command mismatch for '{cmd}'"
7061 );
7062 assert_eq!(result.command_prefix.as_deref(), Some(""));
7064 }
7065 }
7066
7067 #[test]
7068 fn regression_unsafe_trailing_structure_still_rejected() {
7069 let _guard = test_guard!();
7070 let rejected = [
7073 "cargo build < input.txt", "cargo build | xargs rm", "cargo build 2>&1 | grep e | sh", "cargo test | tee log && cargo run", "cargo build > a.log < in.txt", ];
7079 for cmd in rejected {
7080 let result = classify_command(cmd);
7081 assert!(
7082 !result.is_compilation,
7083 "'{cmd}' should NOT be offloaded, got: {:?}",
7084 result
7085 );
7086 }
7087 }
7088
7089 #[test]
7090 fn regression_shell_dash_c_wrapper_offloaded() {
7091 let _guard = test_guard!();
7092 let result = classify_command("bash -c \"cargo test --workspace\"");
7094 assert!(result.is_compilation, "got: {:?}", result.reason);
7095 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7096 assert_eq!(
7097 result.extracted_command.as_deref(),
7098 Some("cargo test --workspace")
7099 );
7100
7101 let result = classify_command("sh -c 'cargo build --release'");
7102 assert!(result.is_compilation, "got: {:?}", result.reason);
7103 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7104 assert_eq!(
7105 result.extracted_command.as_deref(),
7106 Some("cargo build --release")
7107 );
7108
7109 let result = classify_command("/bin/bash -c \"cargo check\"");
7111 assert!(result.is_compilation, "got: {:?}", result.reason);
7112
7113 let result = classify_command("bash -c \"cd /repo && cargo build\"");
7115 assert!(result.is_compilation, "got: {:?}", result.reason);
7116 assert_eq!(
7117 result.extracted_command.as_deref(),
7118 Some("cargo build"),
7119 "inner compound should keep extracted command"
7120 );
7121 assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7122
7123 let result = classify_command("bash -c \"cargo test > log 2>&1\"");
7125 assert!(result.is_compilation, "got: {:?}", result.reason);
7126 assert_eq!(
7127 result.extracted_command.as_deref(),
7128 Some("cargo test > log 2>&1")
7129 );
7130
7131 let result = classify_command("bash -c \"ls -la\"");
7133 assert!(!result.is_compilation);
7134
7135 let result = classify_command("bash -c \"cargo fmt\"");
7137 assert!(!result.is_compilation);
7138 }
7139
7140 #[test]
7145 fn regression_fd_redirect_safe() {
7146 let _guard = test_guard!();
7147 let result = classify_command("cargo build 2>&1");
7153 assert!(
7154 result.is_compilation,
7155 "cargo build 2>&1 should be classified as compilation (fd redirect is safe), got: {:?}",
7156 result.reason
7157 );
7158 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7159 }
7160
7161 #[test]
7166 fn regression_compound_commands() {
7167 let _guard = test_guard!();
7168 let result = classify_command("cd /path && cargo build");
7170 assert!(
7171 result.is_compilation,
7172 "cd && cargo build should compile, got: {:?}",
7173 result.reason
7174 );
7175 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7176 assert!(result.command_prefix.is_some(), "should have prefix");
7177 assert!(
7178 result.extracted_command.is_some(),
7179 "should have extracted command"
7180 );
7181
7182 let result = classify_command("cd /path && cargo test --workspace");
7183 assert!(
7184 result.is_compilation,
7185 "cd && cargo test should compile, got: {:?}",
7186 result.reason
7187 );
7188 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7189
7190 let result = classify_command("cd /path && cargo fmt");
7192 assert!(!result.is_compilation, "cd && cargo fmt should NOT compile");
7193
7194 let result = classify_command("cd /path && ls -la");
7195 assert!(!result.is_compilation, "cd && ls should NOT compile");
7196 }
7197
7198 #[test]
7203 fn regression_wrapper_normalization() {
7204 let _guard = test_guard!();
7205 let cases = [
7206 Case {
7207 cmd: "sudo cargo build",
7208 expect_compilation: true,
7209 expected_kind: Some(CompilationKind::CargoBuild),
7210 reason_contains: "cargo build",
7211 min_confidence: 0.90,
7212 },
7213 Case {
7214 cmd: "time cargo test",
7215 expect_compilation: true,
7216 expected_kind: Some(CompilationKind::CargoTest),
7217 reason_contains: "cargo test",
7218 min_confidence: 0.90,
7219 },
7220 Case {
7221 cmd: "env RUST_BACKTRACE=1 cargo test",
7222 expect_compilation: true,
7223 expected_kind: Some(CompilationKind::CargoTest),
7224 reason_contains: "cargo test",
7225 min_confidence: 0.90,
7226 },
7227 Case {
7228 cmd: "env -u RUST_LOG cargo test",
7229 expect_compilation: true,
7230 expected_kind: Some(CompilationKind::CargoTest),
7231 reason_contains: "cargo test",
7232 min_confidence: 0.90,
7233 },
7234 Case {
7235 cmd: "RUST_BACKTRACE=1 cargo test",
7236 expect_compilation: true,
7237 expected_kind: Some(CompilationKind::CargoTest),
7238 reason_contains: "cargo test",
7239 min_confidence: 0.90,
7240 },
7241 Case {
7242 cmd: "nice cargo build --release",
7243 expect_compilation: true,
7244 expected_kind: Some(CompilationKind::CargoBuild),
7245 reason_contains: "cargo build",
7246 min_confidence: 0.90,
7247 },
7248 Case {
7249 cmd: "/usr/bin/cargo build",
7250 expect_compilation: true,
7251 expected_kind: Some(CompilationKind::CargoBuild),
7252 reason_contains: "cargo build",
7253 min_confidence: 0.90,
7254 },
7255 Case {
7256 cmd: "sudo env RUSTFLAGS=-Dwarnings cargo clippy",
7257 expect_compilation: true,
7258 expected_kind: Some(CompilationKind::CargoClippy),
7259 reason_contains: "cargo clippy",
7260 min_confidence: 0.85,
7261 },
7262 ];
7263 run_cases(&cases);
7264 }
7265
7266 #[test]
7271 fn regression_non_compilation_passthrough() {
7272 let _guard = test_guard!();
7273 let cases = [
7274 Case {
7275 cmd: "ls -la",
7276 expect_compilation: false,
7277 expected_kind: None,
7278 reason_contains: "no compilation keyword",
7279 min_confidence: 0.0,
7280 },
7281 Case {
7282 cmd: "pwd",
7283 expect_compilation: false,
7284 expected_kind: None,
7285 reason_contains: "no compilation keyword",
7286 min_confidence: 0.0,
7287 },
7288 Case {
7289 cmd: "echo hello",
7290 expect_compilation: false,
7291 expected_kind: None,
7292 reason_contains: "no compilation keyword",
7293 min_confidence: 0.0,
7294 },
7295 Case {
7296 cmd: "cat Cargo.toml",
7297 expect_compilation: false,
7298 expected_kind: None,
7299 reason_contains: "no compilation keyword",
7300 min_confidence: 0.0,
7301 },
7302 Case {
7303 cmd: "git status",
7304 expect_compilation: false,
7305 expected_kind: None,
7306 reason_contains: "no compilation keyword",
7307 min_confidence: 0.0,
7308 },
7309 Case {
7310 cmd: "git commit -m 'fix'",
7311 expect_compilation: false,
7312 expected_kind: None,
7313 reason_contains: "no compilation keyword",
7314 min_confidence: 0.0,
7315 },
7316 Case {
7317 cmd: "npm install",
7318 expect_compilation: false,
7319 expected_kind: None,
7320 reason_contains: "no compilation keyword",
7321 min_confidence: 0.0,
7322 },
7323 Case {
7324 cmd: "yarn build",
7325 expect_compilation: false,
7326 expected_kind: None,
7327 reason_contains: "no compilation keyword",
7328 min_confidence: 0.0,
7329 },
7330 Case {
7331 cmd: "python main.py",
7332 expect_compilation: false,
7333 expected_kind: None,
7334 reason_contains: "no compilation keyword",
7335 min_confidence: 0.0,
7336 },
7337 Case {
7341 cmd: "go build .",
7342 expect_compilation: true,
7343 expected_kind: Some(CompilationKind::GoBuild),
7344 reason_contains: "go build command",
7345 min_confidence: 0.9,
7346 },
7347 Case {
7350 cmd: "go build -o bin/app ./cmd/app",
7351 expect_compilation: false,
7352 expected_kind: None,
7353 reason_contains: "emits a local binary",
7354 min_confidence: 0.0,
7355 },
7356 Case {
7357 cmd: "docker build -t myapp .",
7358 expect_compilation: false,
7359 expected_kind: None,
7360 reason_contains: "no compilation keyword",
7361 min_confidence: 0.0,
7362 },
7363 Case {
7364 cmd: "pip install -r requirements.txt",
7365 expect_compilation: false,
7366 expected_kind: None,
7367 reason_contains: "no compilation keyword",
7368 min_confidence: 0.0,
7369 },
7370 Case {
7371 cmd: "curl https://example.com",
7372 expect_compilation: false,
7373 expected_kind: None,
7374 reason_contains: "no compilation keyword",
7375 min_confidence: 0.0,
7376 },
7377 Case {
7378 cmd: "mkdir -p build",
7379 expect_compilation: false,
7380 expected_kind: None,
7381 reason_contains: "no compilation keyword",
7382 min_confidence: 0.0,
7383 },
7384 Case {
7385 cmd: "rm -rf target/",
7386 expect_compilation: false,
7387 expected_kind: None,
7388 reason_contains: "no compilation keyword",
7389 min_confidence: 0.0,
7390 },
7391 Case {
7392 cmd: "cp -r src/ backup/",
7393 expect_compilation: false,
7394 expected_kind: None,
7395 reason_contains: "no compilation keyword",
7396 min_confidence: 0.0,
7397 },
7398 Case {
7399 cmd: "",
7400 expect_compilation: false,
7401 expected_kind: None,
7402 reason_contains: "empty command",
7403 min_confidence: 0.0,
7404 },
7405 Case {
7406 cmd: " ",
7407 expect_compilation: false,
7408 expected_kind: None,
7409 reason_contains: "empty command",
7410 min_confidence: 0.0,
7411 },
7412 ];
7413 run_cases(&cases);
7414 }
7415
7416 #[test]
7421 fn regression_classification_details_fields() {
7422 let _guard = test_guard!();
7423
7424 let details = classify_command_detailed("cargo build --release");
7426 assert_eq!(details.original, "cargo build --release");
7427 assert!(details.classification.is_compilation);
7428 assert_eq!(
7429 details.classification.kind,
7430 Some(CompilationKind::CargoBuild)
7431 );
7432 assert!(details.classification.confidence >= 0.90);
7433 assert_eq!(details.tiers.len(), 5, "Should have 5 tier decisions");
7435 for tier in &details.tiers[..4] {
7436 assert_eq!(tier.decision, TierDecision::Pass);
7437 }
7438 assert_eq!(details.tiers[4].decision, TierDecision::Pass);
7440 assert_eq!(details.tiers[4].tier, 4);
7441
7442 let details = classify_command_detailed("ls -la");
7444 assert!(!details.classification.is_compilation);
7445 assert!(
7446 details.tiers.len() >= 3,
7447 "Should have at least 3 tiers for keyword rejection"
7448 );
7449 assert_eq!(details.tiers.last().unwrap().decision, TierDecision::Reject);
7450
7451 let details = classify_command_detailed("cargo fmt --check");
7453 assert!(!details.classification.is_compilation);
7454 assert!(
7455 details.tiers.len() >= 4,
7456 "Should have at least 4 tiers for never-intercept rejection"
7457 );
7458 let tier3 = details.tiers.iter().find(|t| t.tier == 3).unwrap();
7460 assert_eq!(tier3.decision, TierDecision::Reject);
7461 assert!(
7462 tier3.reason.contains("never-intercept"),
7463 "Tier 3 reason should mention never-intercept, got: {:?}",
7464 tier3.reason
7465 );
7466
7467 let details = classify_command_detailed("cargo build | xargs rm");
7471 assert!(!details.classification.is_compilation);
7472 let tier1 = details.tiers.iter().find(|t| t.tier == 1).unwrap();
7473 assert_eq!(tier1.decision, TierDecision::Reject);
7474 assert!(tier1.reason.contains("piped"));
7475
7476 let details = classify_command_detailed("");
7478 assert!(!details.classification.is_compilation);
7479 assert_eq!(details.tiers[0].tier, 0);
7480 assert_eq!(details.tiers[0].decision, TierDecision::Reject);
7481 }
7482
7483 #[test]
7488 fn regression_every_compilation_has_reason_and_confidence() {
7489 let _guard = test_guard!();
7490 let compilation_cmds = [
7491 "cargo build",
7492 "cargo test",
7493 "cargo check",
7494 "cargo clippy",
7495 "cargo doc",
7496 "cargo run",
7497 "cargo bench",
7498 "cargo nextest run",
7499 "rustc main.rs",
7500 "gcc -c main.c -o main.o",
7501 "g++ -c main.cpp -o main.o",
7502 "clang -c main.c -o main.o",
7503 "clang++ -c main.cpp -o main.o",
7504 "make",
7505 "cmake --build build",
7506 "ninja",
7507 "meson compile",
7508 "bun test",
7509 "bun typecheck",
7510 ];
7511 for cmd in compilation_cmds {
7512 let result = classify_command(cmd);
7513 assert!(result.is_compilation, "{cmd:?} should be compilation");
7514 assert!(
7515 result.kind.is_some(),
7516 "{cmd:?} should have a CompilationKind"
7517 );
7518 assert!(
7519 result.confidence > 0.0,
7520 "{cmd:?} should have non-zero confidence"
7521 );
7522 assert!(
7523 !result.reason.is_empty(),
7524 "{cmd:?} should have a non-empty reason"
7525 );
7526 }
7527 }
7528
7529 #[test]
7534 fn regression_every_rejection_has_reason() {
7535 let _guard = test_guard!();
7536 let non_compilation_cmds = [
7537 "",
7538 "ls",
7539 "pwd",
7540 "git status",
7541 "cargo fmt",
7542 "cargo install ripgrep",
7543 "cargo clean",
7544 "bun install",
7545 "bun add react",
7546 "bun run dev",
7547 "bun dev",
7548 "bun repl",
7549 "bun x vitest",
7550 "bun test --watch",
7551 "bun typecheck -w",
7552 "make clean",
7553 "ninja clean",
7554 "gcc --version",
7555 "rustc --version",
7556 "cargo build | xargs rm",
7557 "cargo build < input.txt",
7558 "cargo build || echo failed",
7559 "(cargo build)",
7560 ];
7561 for cmd in non_compilation_cmds {
7562 let result = classify_command(cmd);
7563 assert!(
7564 !result.is_compilation,
7565 "{cmd:?} should NOT be compilation, got kind={:?}",
7566 result.kind
7567 );
7568 assert!(
7569 !result.reason.is_empty(),
7570 "{cmd:?} should have a non-empty rejection reason"
7571 );
7572 assert_eq!(result.kind, None, "{cmd:?} should have no CompilationKind");
7573 assert_eq!(
7574 result.confidence, 0.0,
7575 "{cmd:?} should have zero confidence"
7576 );
7577 }
7578 }
7579
7580 #[test]
7585 fn regression_real_world_agent_workflows() {
7586 let _guard = test_guard!();
7587
7588 let cd_build = classify_command(
7590 "cd /data/projects/remote_compilation_helper && cargo build --workspace --all-targets",
7591 );
7592 assert!(
7593 cd_build.is_compilation,
7594 "agent cd+build workflow should be intercepted"
7595 );
7596
7597 let test_ws = classify_command("cargo test --workspace -- --nocapture");
7599 assert!(test_ws.is_compilation);
7600 assert_eq!(test_ws.kind, Some(CompilationKind::CargoTest));
7601
7602 let clippy = classify_command("cargo clippy --workspace --all-targets -- -D warnings");
7604 assert!(clippy.is_compilation);
7605 assert_eq!(clippy.kind, Some(CompilationKind::CargoClippy));
7606
7607 let fmt = classify_command("cargo fmt --check");
7609 assert!(!fmt.is_compilation);
7610
7611 let cat = classify_command("cat Cargo.toml");
7613 assert!(!cat.is_compilation);
7614
7615 let gs = classify_command("git status");
7617 assert!(!gs.is_compilation);
7618
7619 let install = classify_command("cargo install cargo-nextest");
7621 assert!(!install.is_compilation);
7622
7623 let pkg_test = classify_command("cargo test -p rch-common");
7625 assert!(pkg_test.is_compilation);
7626 assert_eq!(pkg_test.kind, Some(CompilationKind::CargoTest));
7627
7628 let env_test = classify_command("RUST_BACKTRACE=1 cargo test --workspace");
7630 assert!(env_test.is_compilation);
7631 assert_eq!(env_test.kind, Some(CompilationKind::CargoTest));
7632
7633 let nxt = classify_command("cargo nextest run --workspace --no-fail-fast");
7635 assert!(nxt.is_compilation);
7636 assert_eq!(nxt.kind, Some(CompilationKind::CargoNextest));
7637 }
7638
7639 #[test]
7644 fn regression_keyword_in_non_compilation_context() {
7645 let _guard = test_guard!();
7646 let result = classify_command("echo cargo build");
7648 assert!(
7652 !result.is_compilation,
7653 "echo cargo should not compile, got: {:?}",
7654 result.reason
7655 );
7656
7657 let result = classify_command("grep make Makefile");
7659 assert!(
7661 !result.is_compilation,
7662 "grep make should not compile, got: {:?}",
7663 result.reason
7664 );
7665
7666 let result = classify_command("cat gcc_output.log");
7668 assert!(
7670 !result.is_compilation,
7671 "cat gcc_output should not compile, got: {:?}",
7672 result.reason
7673 );
7674 }
7675
7676 #[test]
7677 fn regression_build_words_in_non_build_arguments_are_not_compilation() {
7678 let _guard = test_guard!();
7679 let cases = [
7680 r#"br comments add ft-4tp7g.1 "remote proof blocked: cargo test -p rchd --lib""#,
7681 r#"AGENT_NAME=Codex br comments add ft-4tp7g.1 "proof lane: cargo clippy --workspace""#,
7682 r#"printf "cargo build --release""#,
7683 r#"echo 'cargo test -p rchd -- --nocapture'"#,
7684 r#"grep "cmake --build" docs/proof-notes.md"#,
7685 ];
7686
7687 for cmd in cases {
7688 let result = classify_command(cmd);
7689 assert!(
7690 !result.is_compilation,
7691 "embedded build text should not compile for {cmd:?}: {:?}",
7692 result
7693 );
7694 assert_eq!(result.reason.as_ref(), "no compilation keyword");
7695 }
7696 }
7697
7698 #[test]
7699 fn golden_non_build_arguments_with_build_words_stay_non_compilation() {
7700 let _guard = test_guard!();
7701 const FIXTURE_PATH: &str =
7702 "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json";
7703
7704 #[derive(serde::Deserialize)]
7705 struct Golden {
7706 schema_version: String,
7707 cases: Vec<Case>,
7708 expected_results: serde_json::Value,
7709 }
7710
7711 #[derive(serde::Deserialize)]
7712 struct Case {
7713 name: String,
7714 command: String,
7715 }
7716
7717 let golden: Golden = serde_json::from_str(include_str!(
7718 "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json"
7719 ))
7720 .expect("classification golden fixture parses");
7721 assert_eq!(
7722 golden.schema_version,
7723 "rch.golden.classification_non_build_arguments.v1"
7724 );
7725
7726 let mut actual_results = Vec::with_capacity(golden.cases.len());
7727 for case in golden.cases {
7728 let result = classify_command(&case.command);
7729 actual_results.push(serde_json::json!({
7730 "name": case.name,
7731 "is_compilation": result.is_compilation,
7732 "kind": result.kind,
7733 "reason": result.reason.as_ref(),
7734 }));
7735 }
7736 assert_golden_json(
7737 serde_json::Value::Array(actual_results),
7738 &golden.expected_results,
7739 FIXTURE_PATH,
7740 );
7741 }
7742
7743 #[test]
7744 fn regression_keyword_filter_still_accepts_normalized_build_invocations() {
7745 let _guard = test_guard!();
7746 let cases = [
7747 ("RUST_BACKTRACE=1 cargo test", CompilationKind::CargoTest),
7748 (
7749 "env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build",
7750 CompilationKind::CargoBuild,
7751 ),
7752 ("sudo -E cargo check", CompilationKind::CargoCheck),
7753 ("/usr/bin/time cargo build", CompilationKind::CargoBuild),
7754 ];
7755
7756 for (cmd, expected_kind) in cases {
7757 let result = classify_command(cmd);
7758 assert!(
7759 result.is_compilation,
7760 "normalized build invocation should compile for {cmd:?}: {:?}",
7761 result
7762 );
7763 assert_eq!(result.kind, Some(expected_kind));
7764 }
7765 }
7766
7767 #[test]
7772 fn regression_compilation_kind_properties() {
7773 let _guard = test_guard!();
7774
7775 assert!(CompilationKind::CargoTest.is_test_command());
7777 assert!(CompilationKind::CargoNextest.is_test_command());
7778 assert!(CompilationKind::CargoBench.is_test_command());
7779 assert!(CompilationKind::BunTest.is_test_command());
7780
7781 assert!(!CompilationKind::CargoBuild.is_test_command());
7783 assert!(!CompilationKind::CargoCheck.is_test_command());
7784 assert!(!CompilationKind::CargoClippy.is_test_command());
7785 assert!(!CompilationKind::CargoDoc.is_test_command());
7786 assert!(!CompilationKind::BunTypecheck.is_test_command());
7787 assert!(!CompilationKind::Gcc.is_test_command());
7788 assert!(!CompilationKind::Gpp.is_test_command());
7789 assert!(!CompilationKind::Clang.is_test_command());
7790 assert!(!CompilationKind::Clangpp.is_test_command());
7791 assert!(!CompilationKind::Make.is_test_command());
7792 assert!(!CompilationKind::CmakeBuild.is_test_command());
7793 assert!(!CompilationKind::Ninja.is_test_command());
7794 assert!(!CompilationKind::Meson.is_test_command());
7795 assert!(!CompilationKind::Rustc.is_test_command());
7796
7797 assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
7799 assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
7800 assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
7801 assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
7802 assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
7803 assert_eq!(CompilationKind::Gpp.command_base(), "g++");
7804 assert_eq!(CompilationKind::Clang.command_base(), "clang");
7805 assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
7806 assert_eq!(CompilationKind::Make.command_base(), "make");
7807 assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
7808 assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
7809 assert_eq!(CompilationKind::Meson.command_base(), "meson");
7810 assert_eq!(CompilationKind::BunTest.command_base(), "bun");
7811 assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
7812 }
7813}
7814
7815#[cfg(test)]
7816mod tests_go_and_typescript {
7817 use super::*;
7818
7819 fn kind_of(cmd: &str) -> Option<CompilationKind> {
7820 classify_command(cmd).kind
7821 }
7822
7823 #[test]
7826 fn go_build_test_vet_are_classified() {
7827 assert_eq!(kind_of("go build ./..."), Some(CompilationKind::GoBuild));
7828 assert_eq!(kind_of("go test ./..."), Some(CompilationKind::GoTest));
7829 assert_eq!(kind_of("go vet ./..."), Some(CompilationKind::GoVet));
7830 assert_eq!(
7832 kind_of("go test -tags=e2e ./e2e -run ^TestE2EAtomicAssignment$"),
7833 Some(CompilationKind::GoTest)
7834 );
7835 assert_eq!(
7836 kind_of("go test ./internal/robot ./internal/cli -run TestExitCode"),
7837 Some(CompilationKind::GoTest)
7838 );
7839 }
7840
7841 #[test]
7842 fn go_commands_clear_the_confidence_threshold() {
7843 for cmd in ["go build ./...", "go test ./...", "go vet ./..."] {
7846 let c = classify_command(cmd);
7847 assert!(
7848 c.confidence > 0.85,
7849 "{cmd} scored {} which does not clear the 0.85 threshold",
7850 c.confidence
7851 );
7852 }
7853 }
7854
7855 #[test]
7856 fn go_test_is_a_test_command() {
7857 assert!(CompilationKind::GoTest.is_test_command());
7859 assert!(!CompilationKind::GoBuild.is_test_command());
7860 assert!(!CompilationKind::GoVet.is_test_command());
7861 }
7862
7863 #[test]
7866 fn go_build_with_output_flag_is_not_offloaded() {
7867 assert_eq!(kind_of("go build -o bin/app ./cmd/app"), None);
7870 assert_eq!(kind_of("go build -o=bin/app ./cmd/app"), None);
7871 }
7872
7873 #[test]
7874 fn go_test_c_and_exec_stay_local() {
7875 assert_eq!(kind_of("go test -c ./pkg"), None);
7876 assert_eq!(kind_of("go test -exec sudo ./pkg"), None);
7877 }
7878
7879 #[test]
7880 fn go_state_mutating_subcommands_are_never_intercepted() {
7881 for cmd in [
7882 "go get github.com/foo/bar",
7883 "go mod tidy",
7884 "go mod download",
7885 "go install ./cmd/tool",
7886 "go run main.go",
7887 "go generate ./...",
7888 "go fmt ./...",
7889 "go clean -cache",
7890 "go work sync",
7891 "go env GOPATH",
7892 "go version",
7893 "go list ./...",
7894 "go tool pprof",
7895 ] {
7896 assert_eq!(kind_of(cmd), None, "{cmd} must never be intercepted");
7897 }
7898 }
7899
7900 #[test]
7901 fn go_prefixed_binaries_are_not_matched() {
7902 assert_eq!(kind_of("gofmt -l ."), None);
7904 assert_eq!(kind_of("golangci-lint run"), None);
7905 }
7906
7907 #[test]
7910 fn tsc_noemit_is_classified() {
7911 assert_eq!(kind_of("tsc --noEmit"), Some(CompilationKind::Tsc));
7912 assert_eq!(kind_of("npx tsc --noEmit"), Some(CompilationKind::Tsc));
7913 assert_eq!(
7915 kind_of("npx tsc --noEmit --incremental false"),
7916 Some(CompilationKind::Tsc)
7917 );
7918 assert_eq!(kind_of("tsc --noemit"), Some(CompilationKind::Tsc));
7920 }
7921
7922 #[test]
7923 fn emitting_tsc_stays_local() {
7924 assert_eq!(kind_of("tsc"), None);
7927 assert_eq!(kind_of("tsc -p tsconfig.json"), None);
7928 assert_eq!(kind_of("npx tsc --outDir dist"), None);
7929 }
7930
7931 #[test]
7932 fn tsc_watch_is_not_intercepted() {
7933 assert_eq!(kind_of("tsc --noEmit --watch"), None);
7934 assert_eq!(kind_of("tsc --noEmit -w"), None);
7935 }
7936
7937 #[test]
7938 fn tsc_trivial_forms_are_not_intercepted() {
7939 assert_eq!(kind_of("tsc --version"), None);
7940 assert_eq!(kind_of("tsc --init"), None);
7941 }
7942
7943 #[test]
7944 fn npx_non_tsc_is_not_intercepted() {
7945 assert_eq!(kind_of("npx jest"), None);
7948 assert_eq!(kind_of("npx prettier --write ."), None);
7949 }
7950
7951 #[test]
7954 fn command_base_is_allowlisted() {
7955 use crate::types::ExecutionConfig;
7956 let exec = ExecutionConfig::default();
7957 for kind in [
7958 CompilationKind::GoBuild,
7959 CompilationKind::GoTest,
7960 CompilationKind::GoVet,
7961 CompilationKind::Tsc,
7962 ] {
7963 let base = kind.command_base();
7964 assert!(
7965 exec.is_allowed(base),
7966 "{base} missing from the execution allowlist — the hook would fail \
7967 open to LOCAL execution, silently losing offload"
7968 );
7969 }
7970 assert_eq!(CompilationKind::GoBuild.command_base(), "go");
7971 assert_eq!(CompilationKind::Tsc.command_base(), "tsc");
7972 }
7973}