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,
288 GoTest,
290 GoVet,
292
293 Tsc,
296
297 NixBuild,
306
307 Job,
319}
320
321impl CompilationKind {
322 pub fn is_test_command(&self) -> bool {
328 matches!(
329 self,
330 CompilationKind::CargoTest
331 | CompilationKind::CargoNextest
332 | CompilationKind::CargoBench
333 | CompilationKind::BunTest
334 | CompilationKind::GoTest
335 )
336 }
337
338 pub fn command_base(&self) -> &'static str {
343 match self {
344 CompilationKind::CargoBuild
346 | CompilationKind::CargoTest
347 | CompilationKind::CargoCheck
348 | CompilationKind::CargoClippy
349 | CompilationKind::CargoDoc
350 | CompilationKind::CargoBench => "cargo",
351 CompilationKind::CargoNextest => "cargo", CompilationKind::CargoZigbuild => "cargo",
356 CompilationKind::Rustc => "rustc",
357 CompilationKind::Gcc => "gcc",
359 CompilationKind::Gpp => "g++",
360 CompilationKind::Clang => "clang",
361 CompilationKind::Clangpp => "clang++",
362 CompilationKind::Make => "make",
364 CompilationKind::CmakeBuild => "cmake",
365 CompilationKind::Ninja => "ninja",
366 CompilationKind::Meson => "meson",
367 CompilationKind::BunTest | CompilationKind::BunTypecheck => "bun",
369 CompilationKind::NixBuild => "nix",
372 CompilationKind::GoBuild | CompilationKind::GoTest | CompilationKind::GoVet => "go",
374 CompilationKind::Tsc => "tsc",
377 CompilationKind::Job => "job",
383 }
384 }
385}
386
387pub fn classify_command(cmd: &str) -> Classification {
397 classify_command_inner(cmd, 0)
398}
399
400pub fn declined_compilation_due_to_structure(cmd: &str) -> Option<&'static str> {
415 let trimmed = cmd.trim();
416 if trimmed.is_empty() {
417 return None;
418 }
419 if classify_command(trimmed).is_compilation {
422 return None;
423 }
424 let reason = check_structure(trimmed)?;
427 let normalized = normalize_command(trimmed);
428 if starts_with_compilation_command(normalized.as_ref()) {
429 Some(reason)
430 } else {
431 None
432 }
433}
434
435#[allow(dead_code)] const MAX_CLASSIFY_DEPTH: u8 = 1;
439
440#[allow(dead_code)] const MAX_SPLIT_INPUT_LEN: usize = 10 * 1024;
444
445fn classify_command_inner(cmd: &str, depth: u8) -> Classification {
446 let cmd = cmd.trim();
447
448 if cmd.is_empty() {
450 return Classification::not_compilation("empty command");
451 }
452
453 if depth == 0
457 && cmd.len() < MAX_SPLIT_INPUT_LEN
458 && let Some(result) = try_classify_compound_command(cmd)
459 {
460 return result;
461 }
462
463 if depth == 0 && cmd.len() < MAX_SPLIT_INPUT_LEN {
478 if let Some(result) = try_classify_wrapped_command(cmd) {
479 return result;
480 }
481 if let Some(result) = try_classify_benign_suffix_command(cmd) {
482 return result;
483 }
484 }
485
486 if let Some(reason) = check_structure(cmd) {
488 return Classification::not_compilation(reason);
489 }
490
491 let normalized_cow = normalize_command(cmd);
493 let normalized = normalized_cow.as_ref();
494
495 if !starts_with_compilation_command(normalized) {
501 return Classification::not_compilation("no compilation keyword");
502 }
503
504 for pattern in NEVER_INTERCEPT {
507 if let Some(rest) = normalized.strip_prefix(pattern) {
508 if rest.is_empty() || rest.starts_with(' ') {
511 return Classification::not_compilation("matches never-intercept pattern");
512 }
513 }
514 }
515
516 classify_full(normalized)
518}
519
520fn try_classify_compound_command(cmd: &str) -> Option<Classification> {
533 if !cmd.contains("&&") {
535 return None;
536 }
537
538 if cmd.contains('|') {
541 return None; }
543 if has_file_redirect(cmd) {
544 return None; }
546 if cmd.contains('(') || cmd.contains('`') || cmd.contains("$(") {
547 return None; }
549 if cmd.contains(';') {
550 return None; }
552
553 let segments = split_and_chain(cmd)?;
555 if segments.is_empty() {
556 return None;
557 }
558
559 let last_segment = segments.last()?.trim();
561 if last_segment.is_empty() {
562 return None;
563 }
564
565 let last_classification = classify_command_inner(last_segment, 1);
567
568 if !last_classification.is_compilation {
569 return None;
570 }
571
572 if segments.len() == 1 {
573 return None;
575 }
576
577 let mut prefix = String::with_capacity(cmd.len() + segments.len() * EXEC_REWRITE_PREFIX.len());
587 for segment in &segments[..segments.len() - 1] {
588 if classify_command_inner(segment, 1).is_compilation {
589 prefix.push_str(EXEC_REWRITE_PREFIX);
590 }
591 prefix.push_str(segment);
592 prefix.push_str(" && ");
593 }
594
595 Some(Classification::compound_compilation(
597 last_classification.kind?,
598 last_classification.confidence,
599 "compound command with compilation suffix",
600 prefix,
601 last_segment.to_string(),
602 ))
603}
604
605const EXEC_REWRITE_PREFIX: &str = "rch exec -- ";
608
609pub fn compound_local_compilation_segments(cmd: &str) -> Vec<String> {
619 let trimmed = cmd.trim();
620 if trimmed.len() >= MAX_SPLIT_INPUT_LEN {
621 return Vec::new();
622 }
623 let Some(segments) = split_and_chain(trimmed) else {
624 return Vec::new();
625 };
626 if segments.len() < 2 {
627 return Vec::new();
628 }
629 segments[..segments.len() - 1]
630 .iter()
631 .filter(|segment| {
632 !classify_command_inner(segment, 1).is_compilation
633 && starts_with_compilation_command(normalize_command(segment).as_ref())
634 })
635 .map(|segment| (*segment).to_string())
636 .collect()
637}
638
639const BENIGN_PAGER_COMMANDS: &[&str] = &[
648 "tee", "head", "cat", "grep", "less", "more", "tail", "wc", "rg",
649];
650
651fn try_classify_wrapped_command(cmd: &str) -> Option<Classification> {
663 let cmd = cmd.trim();
664
665 let rest = strip_shell_dash_c_prefix(cmd)?;
668 let rest = rest.trim_start();
669
670 let inner = extract_single_quoted_argument(rest)?;
672 if inner.trim().is_empty() {
673 return None;
674 }
675
676 let inner_classification = classify_command_inner(&inner, 0);
679 if !inner_classification.is_compilation {
680 return None;
681 }
682
683 let (prefix, extracted) = match (
688 &inner_classification.command_prefix,
689 &inner_classification.extracted_command,
690 ) {
691 (Some(p), Some(e)) => (p.clone(), e.clone()),
692 _ => (String::new(), inner.trim().to_string()),
693 };
694
695 Some(Classification::compound_compilation(
696 inner_classification.kind?,
697 inner_classification.confidence,
698 "shell -c wrapper with compilation command",
699 prefix,
700 extracted,
701 ))
702}
703
704fn strip_shell_dash_c_prefix(cmd: &str) -> Option<&str> {
707 let trimmed = cmd.trim_start();
711 let mut idx = 0;
712 let bytes = trimmed.as_bytes();
713
714 let word1_end = next_word_end(bytes, idx)?;
716 let shell = &trimmed[idx..word1_end];
717 if !shell_basename_is(shell, "bash") && !shell_basename_is(shell, "sh") {
718 return None;
719 }
720 idx = skip_spaces(bytes, word1_end);
721
722 let word2_end = next_word_end(bytes, idx)?;
724 if &trimmed[idx..word2_end] != "-c" {
725 return None;
726 }
727 idx = skip_spaces(bytes, word2_end);
728
729 if idx >= trimmed.len() {
730 return None;
731 }
732 Some(&trimmed[idx..])
733}
734
735fn shell_basename_is(word: &str, name: &str) -> bool {
737 word.rsplit('/').next().map(|b| b == name).unwrap_or(false)
738}
739
740fn next_word_end(bytes: &[u8], start: usize) -> Option<usize> {
743 if start >= bytes.len() || bytes[start].is_ascii_whitespace() {
744 return None;
745 }
746 let mut i = start;
747 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
748 i += 1;
749 }
750 Some(i)
751}
752
753fn skip_spaces(bytes: &[u8], start: usize) -> usize {
755 let mut i = start;
756 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
757 i += 1;
758 }
759 i
760}
761
762fn extract_single_quoted_argument(s: &str) -> Option<String> {
767 let s = s.trim();
768 let bytes = s.as_bytes();
769 if bytes.len() < 2 {
770 return None;
771 }
772 let quote = bytes[0];
773 if quote != b'"' && quote != b'\'' {
774 return None;
775 }
776
777 let mut out = String::with_capacity(s.len());
778 let mut i = 1;
779 while i < bytes.len() {
780 let b = bytes[i];
781 if quote == b'"' && b == b'\\' && i + 1 < bytes.len() {
783 out.push(bytes[i + 1] as char);
784 i += 2;
785 continue;
786 }
787 if b == quote {
788 if s[i + 1..].trim().is_empty() {
790 return Some(out);
791 }
792 return None;
793 }
794 out.push(b as char);
795 i += 1;
796 }
797 None
799}
800
801fn try_classify_benign_suffix_command(cmd: &str) -> Option<Classification> {
820 let cmd = cmd.trim();
821
822 if cmd.bytes().any(|byte| matches!(byte, b'\n' | b'\r')) {
826 return None;
827 }
828
829 if cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
832 return None;
833 }
834 if cmd.contains('(') || cmd.contains('`') || cmd.contains("$(") {
835 return None;
836 }
837
838 let boundary = find_first_structural_boundary(cmd)?;
841 let (leading_raw, suffix_raw) = cmd.split_at(boundary.byte_pos);
842 let leading = leading_raw.trim();
843 let suffix = suffix_raw; if leading.is_empty() {
845 return None;
846 }
847
848 if !suffix_is_benign(boundary.kind, suffix) {
850 return None;
851 }
852
853 let leading_classification = classify_command_inner(leading, 1);
856 if !leading_classification.is_compilation {
857 return None;
858 }
859
860 let extracted = cmd.to_string();
864
865 Some(Classification::compound_compilation(
866 leading_classification.kind?,
867 leading_classification.confidence,
868 "compilation command with benign trailing redirect/pipe/background",
869 String::new(),
870 extracted,
871 ))
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876enum BoundaryKind {
877 Pipe,
879 Background,
881 OutputRedirect,
883 InputRedirect,
885}
886
887struct StructuralBoundary {
888 byte_pos: usize,
889 kind: BoundaryKind,
890}
891
892fn find_first_structural_boundary(cmd: &str) -> Option<StructuralBoundary> {
896 let bytes = cmd.as_bytes();
897 let len = bytes.len();
898 let mut in_single = false;
899 let mut in_double = false;
900 let mut escaped = false;
901 let mut i = 0;
902
903 while i < len {
904 let b = bytes[i];
905 if escaped {
906 escaped = false;
907 i += 1;
908 continue;
909 }
910 if b == b'\\' && !in_single {
911 escaped = true;
912 i += 1;
913 continue;
914 }
915 if b == b'\'' && !in_double {
916 in_single = !in_single;
917 i += 1;
918 continue;
919 }
920 if b == b'"' && !in_single {
921 in_double = !in_double;
922 i += 1;
923 continue;
924 }
925 if in_single || in_double {
926 i += 1;
927 continue;
928 }
929
930 match b {
931 b'|' => {
932 if i + 1 < len && bytes[i + 1] == b'|' {
935 return None;
936 }
937 return Some(StructuralBoundary {
938 byte_pos: i,
939 kind: BoundaryKind::Pipe,
940 });
941 }
942 b'&' => {
943 if i + 1 < len && bytes[i + 1] == b'&' {
946 return None;
947 }
948 let prev = if i > 0 { bytes[i - 1] } else { 0 };
949 if prev == b'>' {
950 i += 1;
952 continue;
953 }
954 return Some(StructuralBoundary {
955 byte_pos: i,
956 kind: BoundaryKind::Background,
957 });
958 }
959 b'>' => {
960 if i + 1 < len && bytes[i + 1] == b'&' {
962 i += 2;
963 if i < len && bytes[i].is_ascii_digit() {
964 i += 1;
965 }
966 continue;
967 }
968 if i + 1 < len && bytes[i + 1] == b'(' {
970 return None;
971 }
972 return Some(StructuralBoundary {
973 byte_pos: i,
974 kind: BoundaryKind::OutputRedirect,
975 });
976 }
977 b'<' => {
978 if i + 1 < len && bytes[i + 1] == b'(' {
979 return None;
980 }
981 return Some(StructuralBoundary {
982 byte_pos: i,
983 kind: BoundaryKind::InputRedirect,
984 });
985 }
986 _ => {}
987 }
988 i += 1;
989 }
990 None
991}
992
993fn suffix_is_benign(kind: BoundaryKind, suffix: &str) -> bool {
996 match kind {
1001 BoundaryKind::InputRedirect => false,
1002 BoundaryKind::Background => {
1003 let after = suffix.trim_start_matches('&').trim();
1006 after.is_empty()
1007 }
1008 BoundaryKind::OutputRedirect => {
1009 !suffix_has_disallowed_followup(suffix)
1012 }
1013 BoundaryKind::Pipe => {
1014 let rhs = suffix.trim_start_matches('|').trim();
1017 if rhs.is_empty() {
1018 return false;
1019 }
1020 let rhs_cmd = rhs.split_whitespace().next().unwrap_or("");
1021 let rhs_base = rhs_cmd.rsplit('/').next().unwrap_or(rhs_cmd);
1022 if !BENIGN_PAGER_COMMANDS.contains(&rhs_base) {
1023 return false;
1024 }
1025 !suffix_has_disallowed_followup(rhs)
1026 }
1027 }
1028}
1029
1030fn suffix_has_disallowed_followup(s: &str) -> bool {
1041 let mut rest = s;
1042 loop {
1043 match find_first_structural_boundary(rest) {
1044 Some(b) => {
1045 if matches!(
1046 b.kind,
1047 BoundaryKind::Pipe | BoundaryKind::Background | BoundaryKind::InputRedirect
1048 ) {
1049 return true;
1050 }
1051 let next = b.byte_pos + 1;
1054 if next >= rest.len() {
1055 return false;
1056 }
1057 rest = &rest[next..];
1058 }
1059 None => return false,
1060 }
1061 }
1062}
1063
1064fn split_and_chain(cmd: &str) -> Option<Vec<&str>> {
1067 if !cmd.contains("&&") {
1068 return None;
1069 }
1070
1071 let mut segments = Vec::new();
1072 let mut current_start = 0;
1073 let mut in_single = false;
1074 let mut in_double = false;
1075 let mut escaped = false;
1076 let bytes = cmd.as_bytes();
1077 let mut i = 0;
1078
1079 while i < bytes.len() {
1080 let b = bytes[i];
1081
1082 if escaped {
1083 escaped = false;
1084 i += 1;
1085 continue;
1086 }
1087
1088 if b == b'\\' && !in_single {
1093 escaped = true;
1094 i += 1;
1095 continue;
1096 }
1097
1098 if b == b'\'' && !in_double {
1099 in_single = !in_single;
1100 } else if b == b'"' && !in_single {
1101 in_double = !in_double;
1102 } else if !in_single
1103 && !in_double
1104 && b == b'&'
1105 && i + 1 < bytes.len()
1106 && bytes[i + 1] == b'&'
1107 {
1108 let segment = &cmd[current_start..i];
1109 let trimmed = segment.trim();
1110 if !trimmed.is_empty() {
1111 segments.push(trimmed);
1112 }
1113 current_start = i + 2;
1114 i += 1; }
1116
1117 i += 1;
1118 }
1119
1120 let final_segment = &cmd[current_start..];
1122 let trimmed = final_segment.trim();
1123 if !trimmed.is_empty() {
1124 segments.push(trimmed);
1125 }
1126
1127 if segments.len() > 1 {
1128 Some(segments)
1129 } else {
1130 None
1131 }
1132}
1133
1134#[allow(dead_code)] fn split_multi_command(cmd: &str) -> Option<Vec<&str>> {
1140 if !cmd.contains("&&") && !cmd.contains("||") && !cmd.contains(';') {
1142 return None;
1143 }
1144
1145 let mut segments = Vec::new();
1146 let mut current_start = 0;
1147 let mut in_single = false;
1148 let mut in_double = false;
1149 let mut escaped = false;
1150 let chars: Vec<char> = cmd.chars().collect();
1151 let mut i = 0;
1152
1153 while i < chars.len() {
1154 let c = chars[i];
1155
1156 if escaped {
1157 escaped = false;
1158 i += 1;
1159 continue;
1160 }
1161
1162 if c == '\\' && !in_single {
1165 escaped = true;
1166 i += 1;
1167 continue;
1168 }
1169
1170 if c == '\'' && !in_double {
1171 in_single = !in_single;
1172 } else if c == '"' && !in_single {
1173 in_double = !in_double;
1174 } else if !in_single && !in_double {
1175 if c == ';' {
1177 let segment = &cmd[current_start..byte_index(&chars, i)];
1178 let trimmed = segment.trim();
1179 if !trimmed.is_empty() {
1180 segments.push(trimmed);
1181 }
1182 current_start = byte_index(&chars, i + 1);
1183 } else if c == '&' && i + 1 < chars.len() && chars[i + 1] == '&' {
1184 let segment = &cmd[current_start..byte_index(&chars, i)];
1185 let trimmed = segment.trim();
1186 if !trimmed.is_empty() {
1187 segments.push(trimmed);
1188 }
1189 current_start = byte_index(&chars, i + 2);
1190 i += 1; } else if c == '|' && i + 1 < chars.len() && chars[i + 1] == '|' {
1192 let segment = &cmd[current_start..byte_index(&chars, i)];
1193 let trimmed = segment.trim();
1194 if !trimmed.is_empty() {
1195 segments.push(trimmed);
1196 }
1197 current_start = byte_index(&chars, i + 2);
1198 i += 1; }
1200 }
1201
1202 i += 1;
1203 }
1204
1205 let final_segment = &cmd[current_start..];
1207 let trimmed = final_segment.trim();
1208 if !trimmed.is_empty() {
1209 segments.push(trimmed);
1210 }
1211
1212 if segments.len() > 1 {
1214 Some(segments)
1215 } else {
1216 None
1217 }
1218}
1219
1220#[allow(dead_code)] fn byte_index(chars: &[char], char_idx: usize) -> usize {
1223 chars.iter().take(char_idx).map(|c| c.len_utf8()).sum()
1224}
1225
1226#[allow(dead_code)] fn classify_multi_command(segments: &[&str], depth: u8) -> Classification {
1232 let mut best_compilation: Option<Classification> = None;
1233
1234 for &segment in segments {
1235 let result = classify_command_inner(segment, depth + 1);
1236 if result.is_compilation {
1237 let dominated = match &best_compilation {
1238 Some(prev) => result.confidence > prev.confidence,
1239 None => true,
1240 };
1241 if dominated {
1242 best_compilation = Some(result);
1243 }
1244 }
1245 }
1246
1247 if let Some(compilation) = best_compilation {
1248 return compilation;
1249 }
1250
1251 Classification::not_compilation("no sub-command is compilation")
1252}
1253
1254pub fn classify_command_detailed(cmd: &str) -> ClassificationDetails {
1256 let original = cmd.to_string();
1257 let cmd = cmd.trim();
1258 let mut tiers = Vec::new();
1259
1260 if cmd.is_empty() {
1262 let classification = Classification::not_compilation("empty command");
1263 tiers.push(ClassificationTier {
1264 tier: 0,
1265 name: Cow::Borrowed(TIER_INSTANT_REJECT),
1266 decision: TierDecision::Reject,
1267 reason: Cow::Borrowed("empty command"),
1268 });
1269 return ClassificationDetails {
1270 original,
1271 normalized: cmd.to_string(),
1272 tiers,
1273 classification,
1274 };
1275 }
1276
1277 tiers.push(ClassificationTier {
1278 tier: 0,
1279 name: Cow::Borrowed(TIER_INSTANT_REJECT),
1280 decision: TierDecision::Pass,
1281 reason: Cow::Borrowed("command present"),
1282 });
1283
1284 if cmd.len() < MAX_SPLIT_INPUT_LEN
1290 && let Some((classification, structure_reason)) = try_classify_compound_command(cmd)
1291 .map(|c| (c, "safe compound command with compilation suffix"))
1292 .or_else(|| {
1293 try_classify_wrapped_command(cmd)
1294 .map(|c| (c, "shell -c wrapper with compilation command"))
1295 })
1296 .or_else(|| {
1297 try_classify_benign_suffix_command(cmd).map(|c| {
1298 (
1299 c,
1300 "compilation command with benign trailing redirect/pipe/background",
1301 )
1302 })
1303 })
1304 {
1305 let normalized = classification
1306 .extracted_command
1307 .clone()
1308 .unwrap_or_else(|| cmd.to_string());
1309
1310 tiers.push(ClassificationTier {
1311 tier: 1,
1312 name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1313 decision: TierDecision::Pass,
1314 reason: Cow::Borrowed(structure_reason),
1315 });
1316 tiers.push(ClassificationTier {
1317 tier: 2,
1318 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1319 decision: TierDecision::Pass,
1320 reason: Cow::Borrowed("keyword present in extracted command"),
1321 });
1322 tiers.push(ClassificationTier {
1323 tier: 3,
1324 name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1325 decision: TierDecision::Pass,
1326 reason: Cow::Borrowed("extracted command passed never-intercept checks"),
1327 });
1328 tiers.push(ClassificationTier {
1329 tier: 4,
1330 name: Cow::Borrowed(TIER_FULL_CLASSIFICATION),
1331 decision: TierDecision::Pass,
1332 reason: classification.reason.clone(),
1333 });
1334
1335 return ClassificationDetails {
1336 original,
1337 normalized,
1338 tiers,
1339 classification,
1340 };
1341 }
1342
1343 if let Some(reason) = check_structure(cmd) {
1345 let classification = Classification::not_compilation(reason);
1346 tiers.push(ClassificationTier {
1347 tier: 1,
1348 name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1349 decision: TierDecision::Reject,
1350 reason: Cow::Borrowed(reason),
1351 });
1352 return ClassificationDetails {
1353 original,
1354 normalized: cmd.to_string(),
1355 tiers,
1356 classification,
1357 };
1358 }
1359
1360 tiers.push(ClassificationTier {
1361 tier: 1,
1362 name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1363 decision: TierDecision::Pass,
1364 reason: Cow::Borrowed("no pipes/redirects/backgrounding"),
1365 });
1366
1367 let normalized_cow = normalize_command(cmd);
1369 let normalized = normalized_cow.as_ref();
1370
1371 if !starts_with_compilation_command(normalized) {
1373 let classification = Classification::not_compilation("no compilation keyword");
1374 tiers.push(ClassificationTier {
1375 tier: 2,
1376 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1377 decision: TierDecision::Reject,
1378 reason: Cow::Borrowed("no compilation keyword"),
1379 });
1380 return ClassificationDetails {
1381 original,
1382 normalized: normalized.to_string(),
1383 tiers,
1384 classification,
1385 };
1386 }
1387
1388 tiers.push(ClassificationTier {
1389 tier: 2,
1390 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1391 decision: TierDecision::Pass,
1392 reason: Cow::Borrowed("keyword present"),
1393 });
1394
1395 for pattern in NEVER_INTERCEPT {
1397 if let Some(rest) = normalized.strip_prefix(pattern)
1398 && (rest.is_empty() || rest.starts_with(' '))
1399 {
1400 let reason: Cow<'static, str> =
1401 Cow::Owned(format!("matches never-intercept: {pattern}"));
1402 let classification = Classification::not_compilation(reason.clone());
1403 tiers.push(ClassificationTier {
1404 tier: 3,
1405 name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1406 decision: TierDecision::Reject,
1407 reason,
1408 });
1409 return ClassificationDetails {
1410 original,
1411 normalized: normalized.to_string(),
1412 tiers,
1413 classification,
1414 };
1415 }
1416 }
1417
1418 tiers.push(ClassificationTier {
1419 tier: 3,
1420 name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1421 decision: TierDecision::Pass,
1422 reason: Cow::Borrowed("no never-intercept match"),
1423 });
1424
1425 let classification = classify_full(normalized);
1427 let decision = if classification.is_compilation {
1428 TierDecision::Pass
1429 } else {
1430 TierDecision::Reject
1431 };
1432 tiers.push(ClassificationTier {
1433 tier: 4,
1434 name: Cow::Borrowed(TIER_FULL_CLASSIFICATION),
1435 decision,
1436 reason: classification.reason.clone(),
1437 });
1438
1439 ClassificationDetails {
1440 original,
1441 normalized: normalized.to_string(),
1442 tiers,
1443 classification,
1444 }
1445}
1446
1447fn wrapper_flag_takes_value(wrapper: &str, flag: &str) -> bool {
1452 match wrapper {
1453 "env" => matches!(flag, "-u" | "--unset"),
1454 "nice" => matches!(flag, "-n" | "--adjustment"),
1455 "ionice" => matches!(
1456 flag,
1457 "-c" | "--class" | "-n" | "--classdata" | "-p" | "--pid"
1458 ),
1459 "taskset" => matches!(flag, "-c" | "--cpu-list" | "-p" | "--pid"),
1460 "chrt" => matches!(flag, "-p" | "--pid"),
1461 "timeout" => matches!(flag, "-s" | "--signal" | "-k" | "--kill-after"),
1462 _ => false,
1463 }
1464}
1465
1466fn looks_like_duration_or_priority(token: &str) -> bool {
1470 token.chars().next().is_some_and(|c| c.is_ascii_digit())
1471}
1472
1473pub(crate) fn split_wrapper_word(input: &str) -> Option<(&str, &str)> {
1478 let input = input.trim_start();
1479 let first = input.chars().next()?;
1480 if matches!(first, '\'' | '"') {
1481 let end = input[1..].find(first)? + 1;
1482 let word = &input[1..end];
1483 let rest = &input[end + 1..];
1484 if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
1485 return None;
1486 }
1487 if first == '"' && word.contains(['\\', '$', '`']) {
1490 return None;
1491 }
1492 Some((word, rest.trim_start()))
1493 } else {
1494 let (word, rest) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
1495 if word.contains(['\'', '"', '\\']) {
1496 return None;
1497 }
1498 Some((word, rest.trim_start()))
1499 }
1500}
1501
1502pub fn normalize_command(cmd: &str) -> Cow<'_, str> {
1504 let mut result = cmd.trim();
1505
1506 let wrappers = [
1509 "sudo", "env", "time", "timeout", "nice", "ionice", "chrt", "strace", "ltrace", "perf",
1510 "taskset", "numactl",
1511 ];
1512
1513 loop {
1514 let mut changed = false;
1515 for wrapper in wrappers {
1516 if let Some(rest) = result.strip_prefix(wrapper) {
1517 if rest.is_empty() || rest.starts_with(char::is_whitespace) {
1521 result = rest.trim_start();
1522 changed = true;
1523
1524 while let Some((flag, rest_after_flag)) = split_wrapper_word(result) {
1532 if !flag.starts_with('-') {
1533 break;
1534 }
1535 result = rest_after_flag;
1536
1537 if wrapper_flag_takes_value(wrapper, flag) {
1538 let Some((_value, rest_after_value)) = split_wrapper_word(result)
1539 else {
1540 break;
1541 };
1542 result = rest_after_value;
1543 }
1544 }
1545
1546 if matches!(wrapper, "timeout" | "chrt")
1552 && let Some((first, after_first)) = result.split_once(char::is_whitespace)
1553 && !first.starts_with('-')
1554 && looks_like_duration_or_priority(first)
1555 {
1556 result = after_first.trim_start();
1557 }
1558 }
1559 }
1560 }
1561
1562 let chars = result.chars();
1566 let mut token_len = 0;
1567 let mut in_quote = None; let mut escaped = false;
1569 let mut has_equals = false;
1570 let mut has_space = false;
1571
1572 for c in chars {
1573 if escaped {
1574 escaped = false;
1575 token_len += c.len_utf8();
1576 continue;
1577 }
1578
1579 if c == '\\' {
1580 escaped = true;
1581 token_len += c.len_utf8();
1582 continue;
1583 }
1584
1585 if let Some(q) = in_quote {
1586 if c == q {
1587 in_quote = None;
1588 } else if c == '=' {
1589 has_equals = true;
1590 }
1591 token_len += c.len_utf8();
1592 } else if c == '"' || c == '\'' {
1593 in_quote = Some(c);
1594 token_len += c.len_utf8();
1595 } else if c.is_whitespace() {
1596 has_space = true;
1597 break;
1598 } else {
1599 if c == '=' {
1600 has_equals = true;
1601 }
1602 token_len += c.len_utf8();
1603 }
1604 }
1605
1606 if has_equals && in_quote.is_none() && has_space {
1607 result = result[token_len..].trim_start();
1610 changed = true;
1611 }
1612
1613 if result.starts_with('/') {
1616 if let Some(space_idx) = result.find(' ') {
1617 let cmd_part = &result[..space_idx];
1618 if let Some(last_slash) = cmd_part.rfind('/') {
1619 result = &result[last_slash + 1..];
1621 changed = true;
1622 }
1623 } else {
1624 if let Some(last_slash) = result.rfind('/') {
1626 result = &result[last_slash + 1..];
1627 changed = true;
1628 }
1629 }
1630 }
1631
1632 if !changed {
1633 break;
1634 }
1635 }
1636
1637 if result.eq(cmd) {
1638 Cow::Borrowed(cmd)
1639 } else {
1640 Cow::Owned(result.to_string())
1641 }
1642}
1643
1644fn has_file_redirect(cmd: &str) -> bool {
1653 let bytes = cmd.as_bytes();
1654 let len = bytes.len();
1655 let mut in_single = false;
1656 let mut in_double = false;
1657 let mut escaped = false;
1658 let mut i = 0;
1659
1660 while i < len {
1661 let b = bytes[i];
1662
1663 if escaped {
1664 escaped = false;
1665 i += 1;
1666 continue;
1667 }
1668 if b == b'\\' && !in_single {
1673 escaped = true;
1674 i += 1;
1675 continue;
1676 }
1677 match b {
1678 b'\'' if !in_double => {
1679 in_single = !in_single;
1680 }
1681 b'"' if !in_single => {
1682 in_double = !in_double;
1683 }
1684 b'>' if !in_single && !in_double => {
1685 if i + 1 < len && bytes[i + 1] == b'&' {
1687 i += 2; if i < len && bytes[i].is_ascii_digit() {
1691 i += 1;
1692 }
1693 continue;
1694 }
1695 if i + 1 < len && bytes[i + 1] == b'(' {
1698 i += 1;
1699 continue;
1700 }
1701 return true; }
1703 b'<' if !in_single && !in_double => {
1704 if i + 1 < len && bytes[i + 1] == b'(' {
1706 i += 1;
1707 continue;
1708 }
1709 return true; }
1711 _ => {}
1712 }
1713 i += 1;
1714 }
1715 false
1716}
1717
1718fn check_structure(cmd: &str) -> Option<&'static str> {
1735 let bytes = cmd.as_bytes();
1736 let len = bytes.len();
1737
1738 let mut in_single = false;
1739 let mut in_double = false;
1740 let mut escaped = false;
1741
1742 let mut found_backgrounded = false;
1745 let mut found_piped = false;
1746 let mut found_subshell = false;
1747 let mut found_output_redirect = false;
1748 let mut found_input_redirect = false;
1749 let mut found_semicolon = false;
1750 let mut found_and_chain = false;
1751 let mut found_or_chain = false;
1752 let mut found_subshell_capture = false;
1753
1754 let mut i = 0;
1755 while i < len {
1756 let b = bytes[i];
1757
1758 if escaped {
1760 escaped = false;
1761 i += 1;
1762 continue;
1763 }
1764 if b == b'\\' && !in_single {
1769 escaped = true;
1770 i += 1;
1771 continue;
1772 }
1773
1774 if b == b'\'' && !in_double {
1776 in_single = !in_single;
1777 i += 1;
1778 continue;
1779 }
1780 if b == b'"' && !in_single {
1781 in_double = !in_double;
1782 i += 1;
1783 continue;
1784 }
1785
1786 if in_single || in_double {
1788 i += 1;
1789 continue;
1790 }
1791
1792 match b {
1794 b'\n' | b'\r' => return Some("contains embedded newline"),
1796
1797 b'&' => {
1799 if i + 1 < len && bytes[i + 1] == b'&' {
1800 found_and_chain = true;
1801 i += 1; } else {
1803 let prev = if i > 0 { bytes[i - 1] } else { 0 };
1805 let next = if i + 1 < len { bytes[i + 1] } else { 0 };
1806 if prev != b'>' && next != b'>' {
1807 found_backgrounded = true;
1808 }
1809 }
1810 }
1811
1812 b'|' => {
1814 if i + 1 < len && bytes[i + 1] == b'|' {
1815 found_or_chain = true;
1816 i += 1; } else {
1818 found_piped = true;
1819 }
1820 }
1821
1822 b'(' => found_subshell = true,
1824
1825 b'>' => {
1828 if i + 1 < len && bytes[i + 1] == b'(' {
1829 found_subshell = true;
1830 } else if i + 1 < len && bytes[i + 1] == b'&' {
1831 i += 1; if i + 1 < len && bytes[i + 1].is_ascii_digit() {
1836 i += 1; }
1838 } else if i + 1 < len && bytes[i + 1] == b'>' {
1839 found_output_redirect = true;
1841 i += 1; } else {
1843 found_output_redirect = true;
1844 }
1845 }
1846
1847 b'<' => {
1849 if i + 1 < len && bytes[i + 1] == b'(' {
1850 found_subshell = true;
1851 } else {
1852 found_input_redirect = true;
1853 }
1854 }
1855
1856 b';' => found_semicolon = true,
1858
1859 b'`' => found_subshell_capture = true,
1861
1862 b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
1864 found_subshell_capture = true;
1865 }
1866
1867 _ => {}
1868 }
1869
1870 i += 1;
1871 }
1872
1873 if found_backgrounded {
1875 return Some("backgrounded command");
1876 }
1877 if found_piped && !found_or_chain {
1879 return Some("piped command");
1880 }
1881 if found_subshell {
1882 return Some("subshell execution");
1883 }
1884 if found_output_redirect {
1885 return Some("output redirected");
1886 }
1887 if found_input_redirect {
1888 return Some("input redirected");
1889 }
1890 if found_semicolon {
1891 return Some("chained command (;)");
1892 }
1893 if found_and_chain {
1894 return Some("chained command (&&)");
1895 }
1896 if found_or_chain {
1897 return Some("chained command (||)");
1898 }
1899 if found_subshell_capture {
1900 return Some("subshell capture");
1901 }
1902
1903 None
1904}
1905
1906fn starts_with_compilation_command(cmd: &str) -> bool {
1912 let cmd = cmd.trim_start();
1913 COMPILATION_KEYWORDS
1914 .iter()
1915 .any(|keyword| command_word_matches(cmd, keyword))
1916}
1917
1918fn command_word_matches(cmd: &str, keyword: &str) -> bool {
1919 let Some(rest) = cmd.strip_prefix(keyword) else {
1920 return false;
1921 };
1922 rest.is_empty() || rest.starts_with(char::is_whitespace)
1923}
1924
1925fn positional_build_targets<'a>(cmd: &'a str, value_flags: &[&str]) -> Vec<&'a str> {
1936 let mut targets = Vec::new();
1937 let toks: Vec<&str> = cmd.split_whitespace().skip(1).collect();
1938 let mut i = 0;
1939 while i < toks.len() {
1940 let tok = toks[i];
1941 if value_flags.contains(&tok) {
1942 i += 2; } else if tok.starts_with('-') || tok.contains('=') {
1944 i += 1; } else {
1946 targets.push(tok);
1947 i += 1;
1948 }
1949 }
1950 targets
1951}
1952
1953fn classify_full(cmd: &str) -> Classification {
1955 if cmd.starts_with("cargo ") || cmd.eq("cargo") {
1957 return classify_cargo(cmd);
1958 }
1959
1960 if cmd.starts_with("cargo-zigbuild ") || cmd.eq("cargo-zigbuild") {
1963 return classify_cargo_zigbuild(cmd);
1964 }
1965
1966 if cmd.starts_with("cargo-xwin ") || cmd.eq("cargo-xwin") {
1967 return if let Some(kind) = cargo_xwin_compilation_kind(cmd) {
1968 Classification::compilation(kind, 0.95, "cargo-xwin compilation")
1969 } else {
1970 Classification::not_compilation("cargo-xwin subcommand not interceptable")
1971 };
1972 }
1973
1974 if cmd.starts_with("rustc ") || cmd.eq("rustc") {
1976 return Classification::compilation(CompilationKind::Rustc, 0.95, "rustc invocation");
1977 }
1978
1979 if cmd.starts_with("gcc ")
1981 && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
1982 {
1983 return Classification::compilation(CompilationKind::Gcc, 0.90, "gcc compilation");
1984 }
1985
1986 if cmd.starts_with("g++ ")
1988 && (cmd.contains(" -c ")
1989 || cmd.contains(" -o ")
1990 || cmd.contains(".cpp")
1991 || cmd.contains(".cc"))
1992 {
1993 return Classification::compilation(CompilationKind::Gpp, 0.90, "g++ compilation");
1994 }
1995
1996 if cmd.starts_with("clang ")
1998 && !cmd.starts_with("clang++ ")
1999 && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
2000 {
2001 return Classification::compilation(CompilationKind::Clang, 0.90, "clang compilation");
2002 }
2003
2004 if cmd.starts_with("clang++ ")
2006 && (cmd.contains(" -c ")
2007 || cmd.contains(" -o ")
2008 || cmd.contains(".cpp")
2009 || cmd.contains(".cc"))
2010 {
2011 return Classification::compilation(CompilationKind::Clangpp, 0.90, "clang++ compilation");
2012 }
2013
2014 if cmd.starts_with("cc ")
2016 && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
2017 {
2018 return Classification::compilation(CompilationKind::Gcc, 0.85, "cc compilation");
2019 }
2020
2021 if cmd.starts_with("c++ ")
2023 && (cmd.contains(" -c ")
2024 || cmd.contains(" -o ")
2025 || cmd.contains(".cpp")
2026 || cmd.contains(".cc"))
2027 {
2028 return Classification::compilation(CompilationKind::Gpp, 0.85, "c++ compilation");
2029 }
2030
2031 if cmd.starts_with("make") && (cmd.eq("make") || cmd.starts_with("make ")) {
2033 const MAKE_VALUE_FLAGS: [&str; 8] = [
2038 "-f",
2039 "-C",
2040 "-I",
2041 "-o",
2042 "-W",
2043 "--file",
2044 "--directory",
2045 "--include-dir",
2046 ];
2047 const MAKE_MAINTENANCE_TARGETS: [&str; 4] = ["clean", "install", "distclean", "uninstall"];
2048 let is_maintenance = positional_build_targets(cmd, &MAKE_VALUE_FLAGS)
2049 .iter()
2050 .any(|target| MAKE_MAINTENANCE_TARGETS.contains(target));
2051 if is_maintenance {
2052 return Classification::not_compilation("make maintenance command");
2053 }
2054 return Classification::compilation(CompilationKind::Make, 0.85, "make build");
2055 }
2056
2057 if cmd.starts_with("cmake ") || cmd.eq("cmake") {
2059 let mut tokens = cmd.split_whitespace();
2060 let _ = tokens.next(); if tokens.any(|token| token.eq("--build") || token.starts_with("--build=")) {
2062 return Classification::compilation(CompilationKind::CmakeBuild, 0.90, "cmake --build");
2063 }
2064 }
2065
2066 if cmd.starts_with("ninja") && (cmd.eq("ninja") || cmd.starts_with("ninja ")) {
2068 const NINJA_VALUE_FLAGS: [&str; 8] = ["-C", "-f", "-j", "-k", "-l", "-d", "-t", "-w"];
2072 let raw: Vec<&str> = cmd.split_whitespace().collect();
2073 let is_clean_tool = raw.windows(2).any(|w| w[0] == "-t" && w[1] == "clean");
2074 let is_clean_target = positional_build_targets(cmd, &NINJA_VALUE_FLAGS).contains(&"clean");
2075 if is_clean_tool || is_clean_target {
2076 return Classification::not_compilation("ninja clean");
2077 }
2078 return Classification::compilation(CompilationKind::Ninja, 0.90, "ninja build");
2079 }
2080
2081 if cmd.starts_with("meson ") || cmd.eq("meson") {
2083 let mut tokens = cmd.split_whitespace();
2084 let _ = tokens.next(); let mut subcommand = None;
2086 for token in tokens {
2087 if token.starts_with('-') {
2088 continue;
2089 }
2090 subcommand = Some(token);
2091 break;
2092 }
2093 if matches!(subcommand, Some("compile")) {
2094 return Classification::compilation(CompilationKind::Meson, 0.85, "meson compile");
2095 }
2096 }
2097
2098 if cmd.eq("nix-build")
2102 || cmd.starts_with("nix-build ")
2103 || cmd.eq("nix")
2104 || cmd.starts_with("nix ")
2105 {
2106 return classify_nix(cmd);
2107 }
2108
2109 let mut tokens = cmd.split_whitespace();
2111 if tokens.next().is_some_and(|token| token.eq("bun")) {
2112 match tokens.next() {
2113 Some("test") => {
2114 if tokens.any(|a| a.eq("-w") || a.eq("--watch")) {
2117 return Classification::not_compilation(
2118 "bun test --watch is interactive (not intercepted)",
2119 );
2120 }
2121 return Classification::compilation(
2122 CompilationKind::BunTest,
2123 0.95,
2124 "bun test command",
2125 );
2126 }
2127 Some("typecheck") => {
2128 if tokens.any(|a| a.eq("-w") || a.eq("--watch")) {
2130 return Classification::not_compilation(
2131 "bun typecheck --watch is interactive (not intercepted)",
2132 );
2133 }
2134 return Classification::compilation(
2135 CompilationKind::BunTypecheck,
2136 0.95,
2137 "bun typecheck command",
2138 );
2139 }
2140 Some("x") => {
2141 return Classification::not_compilation("bun x runs arbitrary packages");
2144 }
2145 _ => {}
2146 }
2147 }
2148
2149 if cmd.starts_with("go ") {
2151 return classify_go(cmd);
2152 }
2153
2154 if let Some(rest) = cmd
2159 .strip_prefix("tsc ")
2160 .or_else(|| cmd.strip_prefix("npx tsc "))
2161 {
2162 return classify_tsc(rest);
2163 }
2164
2165 Classification::not_compilation("no matching pattern")
2166}
2167
2168fn classify_tsc(args: &str) -> Classification {
2178 let tokens = args.split_whitespace();
2179 let mut no_emit = false;
2180 for token in tokens {
2181 if token.eq("-w") || token.eq("--watch") || token.eq("--watchFile") {
2183 return Classification::not_compilation("tsc --watch is interactive (not intercepted)");
2184 }
2185 if token.eq_ignore_ascii_case("--noemit") {
2187 no_emit = true;
2188 }
2189 }
2190
2191 if !no_emit {
2192 return Classification::not_compilation(
2193 "tsc without --noEmit emits output files locally (not intercepted)",
2194 );
2195 }
2196
2197 Classification::compilation(CompilationKind::Tsc, 0.95, "tsc --noEmit typecheck")
2198}
2199
2200fn literal_flag_words(mut command: &str) -> Option<Vec<&str>> {
2204 if command.len() > 65_536 || command.contains(['\0', '\r', '\n']) {
2205 return None;
2206 }
2207 let mut words = Vec::new();
2208 while !command.trim().is_empty() {
2209 command = command.trim_start();
2210 let quoted = command.starts_with(['\'', '"']);
2211 let (word, rest) = split_wrapper_word(command)?;
2212 if !quoted
2213 && word.contains([
2214 '$', '`', '*', '?', '[', '{', '}', '~', '|', '&', ';', '<', '>', '(', ')', '#',
2215 ])
2216 {
2217 return None;
2218 }
2219 words.push(word);
2220 if words.len() > 4096 {
2221 return None;
2222 }
2223 command = rest;
2224 }
2225 Some(words)
2226}
2227
2228fn go_linker_flags_have_one_output(value: &str) -> bool {
2233 let mut flags = value.split_whitespace();
2234 let mut present = false;
2235 while let Some(flag) = flags.next() {
2236 present = true;
2237 match flag {
2238 "-s" | "-w" | "-s=true" | "-s=false" | "-w=true" | "-w=false" => {}
2239 "-X" => {
2240 if !flags.next().is_some_and(|value| {
2241 value
2242 .split_once('=')
2243 .is_some_and(|(name, _)| !name.is_empty())
2244 }) {
2245 return false;
2246 }
2247 }
2248 _ => return false,
2249 }
2250 }
2251 present
2252}
2253
2254#[derive(Debug, Clone, PartialEq, Eq)]
2256pub struct GoBuildOutput {
2257 pub path: String,
2259 pub option_index: usize,
2263}
2264
2265#[must_use]
2272pub fn go_build_output(command: &str) -> Option<GoBuildOutput> {
2273 let normalized = normalize_command(command);
2274 let words = literal_flag_words(&normalized)?;
2275 let mut args = words.into_iter().enumerate();
2276 if args.next()?.1 != "go" || args.next()?.1 != "build" {
2277 return None;
2278 }
2279 let mut output = None;
2280 let mut packages_started = false;
2281 while let Some((option_index, arg)) = args.next() {
2282 if !arg.starts_with('-') {
2283 packages_started = true;
2284 continue;
2285 }
2286 if packages_started {
2287 return None;
2288 }
2289 let (flag, inline) = arg
2290 .split_once('=')
2291 .map_or((arg, None), |(flag, value)| (flag, Some(value)));
2292 match flag {
2293 "-o" => {
2294 let path = inline.or_else(|| args.next().map(|(_, value)| value))?;
2295 if output
2296 .replace(GoBuildOutput {
2297 path: path.to_owned(),
2298 option_index,
2299 })
2300 .is_some()
2301 || path.is_empty()
2302 || path == "-"
2303 || path.ends_with('/')
2304 || path.chars().any(char::is_control)
2305 || !std::path::Path::new(path).components().all(|part| {
2306 matches!(
2307 part,
2308 std::path::Component::Normal(_) | std::path::Component::CurDir
2309 )
2310 })
2311 || !std::path::Path::new(path)
2312 .components()
2313 .any(|part| matches!(part, std::path::Component::Normal(_)))
2314 {
2315 return None;
2316 }
2317 }
2318 "-buildmode" => {
2319 if !matches!(
2320 inline.or_else(|| args.next().map(|(_, value)| value))?,
2321 "default" | "exe" | "pie"
2322 ) {
2323 return None;
2324 }
2325 }
2326 "-compiler" => {
2327 if inline.or_else(|| args.next().map(|(_, value)| value))? != "gc" {
2328 return None;
2329 }
2330 }
2331 "-p" | "-tags" | "-covermode" | "-coverpkg" => {
2332 if inline
2333 .or_else(|| args.next().map(|(_, value)| value))?
2334 .is_empty()
2335 {
2336 return None;
2337 }
2338 }
2339 "-ldflags" => {
2340 if !go_linker_flags_have_one_output(
2341 inline.or_else(|| args.next().map(|(_, value)| value))?,
2342 ) {
2343 return None;
2344 }
2345 }
2346 "-mod" => {
2347 if !matches!(
2348 inline.or_else(|| args.next().map(|(_, value)| value))?,
2349 "readonly" | "vendor"
2350 ) {
2351 return None;
2352 }
2353 }
2354 "-buildvcs" => {
2355 if inline.is_some_and(|value| !matches!(value, "auto" | "true" | "false")) {
2356 return None;
2357 }
2358 }
2359 "-a" | "-race" | "-msan" | "-asan" | "-v" | "-x" | "-trimpath" | "-modcacherw"
2360 | "-cover" => {
2361 if inline.is_some_and(|value| !matches!(value, "true" | "false")) {
2362 return None;
2363 }
2364 }
2365 _ => return None,
2366 }
2367 }
2368 output
2369}
2370
2371fn classify_go(cmd: &str) -> Classification {
2375 let mut tokens = cmd.split_whitespace();
2376 tokens.next();
2378
2379 let subcommand = match tokens.next() {
2380 Some(sub) => sub,
2381 None => return Classification::not_compilation("bare `go` is not a compilation command"),
2382 };
2383
2384 let args: Vec<&str> = tokens.collect();
2386
2387 match subcommand {
2388 "build" => {
2389 if go_build_output(cmd).is_none() {
2390 return Classification::not_compilation(
2391 "go build requires a supported explicit -o file for artifact delivery",
2392 );
2393 }
2394 if !cfg!(unix) {
2395 return Classification::not_compilation(
2396 "Go output delivery requires a Unix dispatcher and worker",
2397 );
2398 }
2399 Classification::compilation(CompilationKind::GoBuild, 0.95, "go build command")
2400 }
2401 "test" => {
2402 if args.iter().any(|arg| {
2405 let arg = arg.trim_matches(['\'', '"']);
2408 if !arg.starts_with('-') {
2409 return false;
2410 }
2411 let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag);
2412 let flag = flag.trim_start_matches('-');
2413 let flag = flag.strip_prefix("test.").unwrap_or(flag);
2414 matches!(
2415 flag,
2416 "c" | "o"
2417 | "exec"
2418 | "coverprofile"
2419 | "cpuprofile"
2420 | "memprofile"
2421 | "blockprofile"
2422 | "mutexprofile"
2423 | "trace"
2424 | "outputdir"
2425 | "fuzz"
2426 | "fuzzcachedir"
2427 | "gocoverdir"
2428 | "testlogfile"
2429 )
2430 }) {
2431 return Classification::not_compilation(
2432 "go test emits files or uses a local execution wrapper (not intercepted)",
2433 );
2434 }
2435 Classification::compilation(CompilationKind::GoTest, 0.95, "go test command")
2436 }
2437 "vet" => Classification::compilation(CompilationKind::GoVet, 0.95, "go vet command"),
2438 _ => Classification::not_compilation("go subcommand is not a compilation command"),
2439 }
2440}
2441
2442pub fn is_cargo_package_verification(command: &str) -> bool {
2450 let normalized = normalize_command(command);
2451 let cmd = normalized.as_ref();
2452 if check_structure(cmd).is_some() || cmd.contains(['\'', '"', '\\', '$', '`']) {
2453 return false;
2454 }
2455
2456 let mut tokens = cmd.split_whitespace();
2457 if tokens.next() != Some("cargo") {
2458 return false;
2459 }
2460
2461 let mut subcommand = None;
2462 let mut dry_run = false;
2463 const VALUE_FLAGS: &[&str] = &[
2464 "--index",
2465 "--registry",
2466 "--token",
2467 "--color",
2468 "--config",
2469 "-Z",
2470 "-C",
2471 "-p",
2472 "--package",
2473 "--exclude",
2474 "-F",
2475 "--features",
2476 "-j",
2477 "--jobs",
2478 "--target",
2479 "--target-dir",
2480 "-m",
2481 "--manifest-path",
2482 "--lockfile-path",
2483 "--message-format",
2484 ];
2485
2486 while let Some(token) = tokens.next() {
2487 if subcommand.is_none() && token.starts_with('+') {
2488 continue;
2489 }
2490 if matches!(token, "package" | "publish") && subcommand.is_none() {
2491 subcommand = Some(token);
2492 continue;
2493 }
2494 if VALUE_FLAGS.contains(&token) {
2495 if tokens.next().is_none_or(|value| value.starts_with('-')) {
2496 return false;
2497 }
2498 continue;
2499 }
2500 if let Some((flag, value)) = token.split_once('=')
2501 && VALUE_FLAGS.contains(&flag)
2502 && !value.is_empty()
2503 {
2504 continue;
2505 }
2506 if ["-j", "-p", "-F", "-m", "-Z"]
2507 .iter()
2508 .any(|flag| token.starts_with(flag) && token.len() > flag.len())
2509 {
2510 continue;
2511 }
2512 match token {
2513 "--dry-run" | "-n" => dry_run = true,
2514 "--workspace"
2515 | "--allow-dirty"
2516 | "--no-metadata"
2517 | "--exclude-lockfile"
2518 | "--all-features"
2519 | "--no-default-features"
2520 | "--keep-going"
2521 | "--locked"
2522 | "--offline"
2523 | "--frozen"
2524 | "--verbose"
2525 | "-v"
2526 | "-vv"
2527 | "-vvv"
2528 | "--quiet"
2529 | "-q" => {}
2530 _ => return false,
2533 }
2534 }
2535
2536 matches!(subcommand, Some("package")) || (subcommand == Some("publish") && dry_run)
2537}
2538
2539#[must_use]
2545pub fn cargo_xwin_compilation_kind(command: &str) -> Option<CompilationKind> {
2546 let normalized = normalize_command(command);
2547 let cmd = normalized.as_ref();
2548 let mut tokens = cmd.split_whitespace().map(unquote_literal_option_word);
2549 if check_structure(cmd).is_some()
2550 || tokens
2551 .clone()
2552 .any(|token| token.contains(['\'', '"', '\\', '$', '`']))
2553 {
2554 return None;
2555 }
2556 let subcommand = match tokens.next() {
2557 Some("cargo-xwin") => {
2558 let subcommand = tokens.next();
2559 if subcommand == Some("xwin") {
2560 tokens.next()
2561 } else {
2562 subcommand
2563 }
2564 }
2565 Some("cargo") => loop {
2566 match tokens.next() {
2567 Some("xwin") => break tokens.next(),
2568 Some("--color" | "--config" | "-Z" | "-C") => {
2569 if tokens.next().is_none_or(|value| value.starts_with('-')) {
2570 return None;
2571 }
2572 }
2573 Some("--locked" | "--offline" | "--frozen" | "-v" | "-vv" | "-q") => {}
2574 Some(token)
2575 if token.starts_with('+')
2576 || token.starts_with("--color=")
2577 || token.starts_with("--config=") => {}
2578 _ => return None,
2579 }
2580 },
2581 _ => return None,
2582 };
2583 let kind = match subcommand? {
2584 "build" => CompilationKind::CargoBuild,
2585 "check" => CompilationKind::CargoCheck,
2586 "clippy" => CompilationKind::CargoClippy,
2587 _ => return None,
2588 };
2589 if tokens.any(|token| matches!(token, "--help" | "-h" | "--version" | "-V")) {
2590 return None;
2591 }
2592 Some(kind)
2593}
2594
2595#[must_use]
2597pub fn is_cargo_xwin_build(command: &str) -> bool {
2598 matches!(
2599 cargo_xwin_compilation_kind(command),
2600 Some(CompilationKind::CargoBuild)
2601 )
2602}
2603
2604fn unquote_whole_word(token: &str) -> &str {
2609 token
2610 .strip_prefix('\'')
2611 .and_then(|rest| rest.strip_suffix('\''))
2612 .filter(|inner| !inner.contains('\''))
2613 .unwrap_or(token)
2614}
2615
2616pub(crate) fn unquote_literal_option_word(token: &str) -> &str {
2620 let word = unquote_whole_word(token);
2621 if word.starts_with('-')
2622 && word
2623 .bytes()
2624 .all(|byte| byte.is_ascii_alphanumeric() || b"-_./=:+,".contains(&byte))
2625 {
2626 word
2627 } else {
2628 token
2629 }
2630}
2631
2632fn classify_cargo(cmd: &str) -> Classification {
2637 let mut tokens = cmd.split_whitespace();
2638
2639 let _cargo = tokens.next();
2641
2642 const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2649 let subcommand = loop {
2650 let Some(tok) = tokens.next().map(unquote_whole_word) else {
2651 return Classification::not_compilation("bare cargo command");
2652 };
2653 if tok.starts_with('+') {
2654 continue;
2656 }
2657 if tok.starts_with('-') {
2658 if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2659 let _ = tokens.next();
2661 }
2662 continue;
2663 }
2664 break tok;
2665 };
2666
2667 match subcommand {
2668 "build" | "b" => {
2669 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build")
2670 }
2671 "package" | "publish" => {
2672 if is_cargo_package_verification(cmd) {
2673 Classification::compilation(
2674 CompilationKind::CargoBuild,
2675 0.95,
2676 "cargo package verification (includes build)",
2677 )
2678 } else {
2679 Classification::not_compilation(
2680 "cargo package/publish without supported verification (never-intercept)",
2681 )
2682 }
2683 }
2684 "test" | "t" => Classification::compilation(CompilationKind::CargoTest, 0.95, "cargo test"),
2685 "check" | "c" => {
2686 Classification::compilation(CompilationKind::CargoCheck, 0.90, "cargo check")
2687 }
2688 "clippy" => Classification::compilation(CompilationKind::CargoClippy, 0.90, "cargo clippy"),
2689 "doc" => Classification::compilation(CompilationKind::CargoDoc, 0.85, "cargo doc"),
2690 "run" | "r" => {
2691 Classification::compilation(
2693 CompilationKind::CargoBuild,
2694 0.85,
2695 "cargo run (includes build)",
2696 )
2697 }
2698 "bench" => Classification::compilation(CompilationKind::CargoBench, 0.90, "cargo bench"),
2699 "zigbuild" => {
2704 Classification::compilation(CompilationKind::CargoZigbuild, 0.95, "cargo zigbuild")
2705 }
2706 "xwin" => {
2707 if let Some(kind) = cargo_xwin_compilation_kind(cmd) {
2708 Classification::compilation(kind, 0.95, "cargo xwin compilation")
2709 } else {
2710 Classification::not_compilation("cargo xwin subcommand not interceptable")
2711 }
2712 }
2713 "nextest" => {
2714 let Some(nextest_sub) = tokens.next() else {
2718 return Classification::not_compilation("bare cargo nextest without subcommand");
2719 };
2720
2721 match nextest_sub {
2722 "run" | "r" => Classification::compilation(
2723 CompilationKind::CargoNextest,
2724 0.95,
2725 "cargo nextest run",
2726 ),
2727 _ => Classification::not_compilation("cargo nextest subcommand not interceptable"),
2728 }
2729 }
2730 _ => Classification::not_compilation("cargo subcommand not interceptable"),
2731 }
2732}
2733
2734fn classify_cargo_zigbuild(cmd: &str) -> Classification {
2744 let mut tokens = cmd.split_whitespace();
2745 let _bin = tokens.next();
2747
2748 const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2751 let subcommand = loop {
2752 let Some(tok) = tokens.next().map(unquote_whole_word) else {
2753 return Classification::not_compilation("bare cargo-zigbuild command");
2754 };
2755 if tok.starts_with('+') {
2756 continue;
2757 }
2758 if tok.starts_with('-') {
2759 if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2760 let _ = tokens.next();
2761 }
2762 continue;
2763 }
2764 break tok;
2765 };
2766
2767 match subcommand {
2768 "zigbuild" | "build" | "b" => Classification::compilation(
2769 CompilationKind::CargoZigbuild,
2770 0.95,
2771 "cargo-zigbuild build",
2772 ),
2773 _ => Classification::not_compilation("cargo-zigbuild subcommand not interceptable"),
2774 }
2775}
2776
2777const NIX_GLOBAL_VALUE_FLAGS: &[&str] = &[
2786 "--extra-experimental-features",
2787 "--experimental-features",
2788 "--log-format",
2789 "--max-jobs",
2790 "-j",
2791 "--cores",
2792 "--builders",
2793 "--store",
2794 "--file",
2795 "-f",
2796 "-I",
2797 "--include",
2798];
2799
2800fn classify_nix(cmd: &str) -> Classification {
2821 if cmd.eq("nix-build") || cmd.starts_with("nix-build ") {
2823 return Classification::compilation(
2824 CompilationKind::NixBuild,
2825 0.9,
2826 "nix-build (legacy) derivation build",
2827 );
2828 }
2829
2830 let mut tokens = cmd.split_whitespace();
2831 let _nix = tokens.next(); let subcommand = loop {
2835 let Some(tok) = tokens.next() else {
2836 return Classification::not_compilation("bare nix command (no subcommand)");
2837 };
2838 if tok.starts_with('-') {
2839 if tok.eq("--option") {
2840 let _ = tokens.next();
2842 let _ = tokens.next();
2843 } else if !tok.contains('=') && NIX_GLOBAL_VALUE_FLAGS.contains(&tok) {
2844 let _ = tokens.next();
2845 }
2846 continue;
2847 }
2848 break tok;
2849 };
2850
2851 let rest: Vec<&str> = tokens.collect();
2853 let wants_help = rest.iter().any(|t| t.eq(&"--help") || t.eq(&"-h"));
2854 if wants_help {
2855 return Classification::not_compilation("nix --help (not a build)");
2856 }
2857 let has_command_flag = rest.iter().any(|t| t.eq(&"-c") || t.eq(&"--command"));
2858
2859 match subcommand {
2860 "build" => {
2862 Classification::compilation(CompilationKind::NixBuild, 0.9, "nix build derivation")
2863 }
2864 "flake" => {
2868 let flake_sub = rest.iter().find(|t| !t.starts_with('-'));
2869 if flake_sub == Some(&"check") {
2870 Classification::compilation(
2871 CompilationKind::NixBuild,
2872 0.9,
2873 "nix flake check (evaluates + builds all outputs)",
2874 )
2875 } else {
2876 Classification::not_compilation("nix flake subcommand is not a build")
2877 }
2878 }
2879 "develop" | "shell" => {
2883 if has_command_flag {
2884 let reason = if subcommand == "develop" {
2885 "nix develop -c (non-interactive command in dev shell)"
2886 } else {
2887 "nix shell -c (non-interactive command with packages)"
2888 };
2889 Classification::compilation(CompilationKind::NixBuild, 0.9, reason)
2890 } else {
2891 Classification::not_compilation(
2892 "interactive nix shell (no -c/--command; not intercepted)",
2893 )
2894 }
2895 }
2896 _ => Classification::not_compilation("nix subcommand is not an offloadable build"),
2900 }
2901}
2902
2903pub fn split_shell_commands(cmd: &str) -> Vec<&str> {
2914 let bytes = cmd.as_bytes();
2915 let len = bytes.len();
2916 let mut segments: Vec<&str> = Vec::new();
2917 let mut start = 0;
2918 let mut i = 0;
2919
2920 let mut in_single = false;
2922 let mut in_double = false;
2923 let mut in_backtick = false;
2924 let mut escaped = false;
2925
2926 while i < len {
2927 let b = bytes[i];
2928
2929 if escaped {
2930 escaped = false;
2931 i += 1;
2932 continue;
2933 }
2934
2935 if b == b'\\' && !in_single {
2940 escaped = true;
2941 i += 1;
2942 continue;
2943 }
2944
2945 if !in_double && !in_backtick && b == b'\'' {
2947 in_single = !in_single;
2948 i += 1;
2949 continue;
2950 }
2951 if !in_single && !in_backtick && b == b'"' {
2952 in_double = !in_double;
2953 i += 1;
2954 continue;
2955 }
2956 if !in_single && !in_double && b == b'`' {
2957 in_backtick = !in_backtick;
2958 i += 1;
2959 continue;
2960 }
2961
2962 if in_single || in_double || in_backtick {
2964 i += 1;
2965 continue;
2966 }
2967
2968 if b == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
2970 let seg = cmd[start..i].trim();
2971 if !seg.is_empty() {
2972 segments.push(seg);
2973 }
2974 i += 2;
2975 start = i;
2976 continue;
2977 }
2978
2979 if b == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
2981 let seg = cmd[start..i].trim();
2982 if !seg.is_empty() {
2983 segments.push(seg);
2984 }
2985 i += 2;
2986 start = i;
2987 continue;
2988 }
2989
2990 if b == b';' {
2992 let seg = cmd[start..i].trim();
2993 if !seg.is_empty() {
2994 segments.push(seg);
2995 }
2996 i += 1;
2997 start = i;
2998 continue;
2999 }
3000
3001 i += 1;
3002 }
3003
3004 let seg = cmd[start..].trim();
3006 if !seg.is_empty() {
3007 segments.push(seg);
3008 }
3009
3010 segments
3011}
3012
3013#[cfg(test)]
3014mod tests {
3015 use super::*;
3016
3017 #[test]
3018 fn cargo_xwin_compilations_use_their_cargo_contracts() {
3019 for (subcommand, kind, arguments) in [
3020 (
3021 "build",
3022 CompilationKind::CargoBuild,
3023 "--release --locked --target x86_64-pc-windows-msvc",
3024 ),
3025 (
3026 "check",
3027 CompilationKind::CargoCheck,
3028 "--locked --all-targets --target aarch64-pc-windows-msvc",
3029 ),
3030 (
3031 "clippy",
3032 CompilationKind::CargoClippy,
3033 "--locked --all-targets --target=x86_64-pc-windows-msvc -- -D warnings",
3034 ),
3035 ] {
3036 for prefix in [
3037 "cargo xwin",
3038 "cargo +nightly-2026-08-31 xwin",
3039 "cargo --color never --locked xwin",
3040 "cargo --config build.jobs=2 xwin",
3041 "env CARGO_HOME=/root/.cargo cargo xwin",
3042 "cargo-xwin",
3043 "cargo-xwin xwin",
3044 "/home/ubuntu/.local/bin/cargo-xwin xwin",
3045 ] {
3046 let command = format!("{prefix} {subcommand} {arguments}");
3047 assert_eq!(
3048 cargo_xwin_compilation_kind(&command),
3049 Some(kind),
3050 "{command}"
3051 );
3052 let classification = classify_command(&command);
3053 assert!(classification.is_compilation, "{command}");
3054 assert_eq!(classification.kind, Some(kind), "{command}");
3055 }
3056 }
3057 }
3058
3059 #[test]
3060 fn cargo_xwin_recognition_declines_unsupported_commands_and_argument_mentions() {
3061 for command in [
3062 "cargo xwin",
3063 "cargo xwin env",
3064 "cargo xwin cache clear",
3065 "cargo xwin publish",
3066 "cargo xwin test",
3067 "cargo xwin run",
3068 "cargo xwin unknown",
3069 "cargo xwin build --help",
3070 "cargo xwin check --help",
3071 "cargo xwin clippy --version",
3072 "cargo-xwin --version",
3073 "cargo-xwin cache clear",
3074 "cargo-xwin xwin build --help",
3075 "cargo-xwin xwin check -h",
3076 "cargo-xwin clippy -V",
3077 "cargo-xwin xwin env",
3078 "cargo-xwin check-extra",
3079 "cargo xwin --help check",
3080 "cargo xwin 'check'",
3081 "cargo xwin $SUBCOMMAND",
3082 "cargo --config xwin build --target x86_64-pc-windows-msvc",
3083 "cargo --config xwin check --target x86_64-pc-windows-msvc",
3084 "cargo --config xwin clippy --target x86_64-pc-windows-msvc",
3085 "cargo build --manifest-path cargo-xwin --target x86_64-pc-windows-msvc",
3086 "cargo check --manifest-path 'cargo xwin check' --target x86_64-pc-windows-msvc",
3087 "cargo clippy --config 'alias.cross=xwin' --target x86_64-pc-windows-msvc",
3088 "echo cargo xwin check",
3089 "cargo xwin build && cargo build",
3090 "cargo xwin check && cargo check",
3091 "cargo xwin clippy | cat",
3092 ] {
3093 assert_eq!(cargo_xwin_compilation_kind(command), None, "{command}");
3094 }
3095 for command in [
3096 "cargo xwin env",
3097 "cargo xwin cache clear",
3098 "cargo xwin publish",
3099 "cargo xwin test",
3100 "cargo xwin run",
3101 "cargo xwin unknown",
3102 "cargo xwin check --help",
3103 "cargo xwin clippy --version",
3104 "cargo-xwin --version",
3105 "cargo-xwin xwin check -h",
3106 "cargo-xwin clippy -V",
3107 "cargo-xwin xwin env",
3108 "cargo-xwin check-extra",
3109 "cargo xwin 'check'",
3110 ] {
3111 assert!(!classify_command(command).is_compilation, "{command}");
3112 }
3113 }
3114 use crate::test_guard;
3115
3116 #[test]
3117 fn test_cargo_build_with_toolchain() {
3118 let _guard = test_guard!();
3119 let result = classify_command("cargo +nightly build");
3120 assert!(result.is_compilation);
3121 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3122 }
3123
3124 #[test]
3127 fn test_nix_build_offloadable() {
3128 let _guard = test_guard!();
3129 for cmd in [
3130 "nix build",
3131 "nix build .#foo",
3132 "nix build .#packages.x86_64-linux.default",
3133 "nix build /path/to/flake#pkg",
3134 "nix build --extra-experimental-features 'nix-command flakes' .#foo",
3135 "nix --option sandbox true build .#foo",
3136 "nix -j4 build .#foo",
3137 "nix build nixpkgs#hello -o result-hello",
3138 ] {
3139 let result = classify_command(cmd);
3140 assert!(result.is_compilation, "expected offload for `{cmd}`");
3141 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3142 assert!(result.confidence >= 0.85, "`{cmd}` confidence too low");
3143 }
3144 }
3145
3146 #[test]
3147 fn test_nix_build_legacy_offloadable() {
3148 let _guard = test_guard!();
3149 for cmd in ["nix-build", "nix-build .", "nix-build -A hello"] {
3150 let result = classify_command(cmd);
3151 assert!(result.is_compilation, "expected offload for `{cmd}`");
3152 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3153 }
3154 }
3155
3156 #[test]
3157 fn test_nix_flake_check_offloadable_but_metadata_not() {
3158 let _guard = test_guard!();
3159 let check = classify_command("nix flake check");
3160 assert!(check.is_compilation, "nix flake check should offload");
3161 assert_eq!(check.kind, Some(CompilationKind::NixBuild));
3162
3163 for cmd in [
3164 "nix flake metadata",
3165 "nix flake show",
3166 "nix flake update",
3167 "nix flake lock",
3168 "nix flake info",
3169 ] {
3170 let result = classify_command(cmd);
3171 assert!(!result.is_compilation, "`{cmd}` must NOT offload");
3172 }
3173 }
3174
3175 #[test]
3176 fn test_nix_develop_and_shell_only_with_command() {
3177 let _guard = test_guard!();
3178 for cmd in [
3180 "nix develop -c make",
3181 "nix develop --command cargo build",
3182 "nix develop .#dev -c bun test",
3183 "nix shell nixpkgs#gcc -c make",
3184 "nix shell nixpkgs#hello --command hello",
3185 ] {
3186 let result = classify_command(cmd);
3187 assert!(result.is_compilation, "expected offload for `{cmd}`");
3188 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3189 }
3190 for cmd in ["nix develop", "nix develop .#dev", "nix shell nixpkgs#gcc"] {
3192 let result = classify_command(cmd);
3193 assert!(
3194 !result.is_compilation,
3195 "`{cmd}` is interactive; must run local"
3196 );
3197 }
3198 }
3199
3200 #[test]
3201 fn test_nix_execution_and_mutation_not_offloaded() {
3202 let _guard = test_guard!();
3203 for cmd in [
3204 "nix run .#app",
3205 "nix run nixpkgs#hello",
3206 "nix repl",
3207 "nix eval .#foo",
3208 "nix search nixpkgs hello",
3209 "nix profile install nixpkgs#hello",
3210 "nix registry list",
3211 "nix store gc",
3212 "nix copy --to ssh://host .#foo",
3213 "nix path-info .#foo",
3214 "nix --version",
3215 "nix build --help",
3216 "nix",
3217 ] {
3218 let result = classify_command(cmd);
3219 assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
3220 }
3221 }
3222
3223 #[test]
3224 fn test_nix_legacy_mutating_tools_have_no_keyword() {
3225 let _guard = test_guard!();
3226 for cmd in [
3230 "nix-env -iA nixpkgs.hello",
3231 "nix-shell",
3232 "nix-shell -p gcc",
3233 "nix-store --gc",
3234 "nix-collect-garbage -d",
3235 "nix-channel --update",
3236 ] {
3237 assert!(
3238 !starts_with_compilation_command(cmd),
3239 "`{cmd}` should not match any compilation keyword"
3240 );
3241 let result = classify_command(cmd);
3242 assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
3243 }
3244 }
3245
3246 #[test]
3247 fn test_nix_build_maps_to_nix_runtime_base() {
3248 let _guard = test_guard!();
3249 assert_eq!(CompilationKind::NixBuild.command_base(), "nix");
3250 assert!(!CompilationKind::NixBuild.is_test_command());
3251 }
3252
3253 #[test]
3254 fn test_nix_build_compound_and_wrapped() {
3255 let _guard = test_guard!();
3256 let compound = classify_command("cd /repo && nix build .#foo");
3258 assert!(compound.is_compilation, "compound nix build should offload");
3259 assert_eq!(compound.kind, Some(CompilationKind::NixBuild));
3260 let wrapped = classify_command("bash -c \"nix build .#foo\"");
3262 assert!(wrapped.is_compilation, "wrapped nix build should offload");
3263 assert_eq!(wrapped.kind, Some(CompilationKind::NixBuild));
3264 }
3265
3266 #[test]
3267 fn test_job_kind_serde_and_base_token() {
3268 let _guard = test_guard!();
3269 let json = serde_json::to_string(&CompilationKind::Job).unwrap();
3271 assert_eq!(json, "\"job\"");
3272 let round: CompilationKind = serde_json::from_str(&json).unwrap();
3273 assert_eq!(round, CompilationKind::Job);
3274 assert_eq!(CompilationKind::Job.command_base(), "job");
3277 assert!(!CompilationKind::Job.is_test_command());
3278 }
3279
3280 #[test]
3281 fn test_cargo_test_with_toolchain() {
3282 let _guard = test_guard!();
3283 let result = classify_command("cargo +1.80.0 test");
3284 assert!(result.is_compilation);
3285 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3286 }
3287
3288 #[test]
3289 fn test_cargo_nextest_with_toolchain() {
3290 let _guard = test_guard!();
3291 let result = classify_command("cargo +nightly nextest run");
3292 assert!(result.is_compilation);
3293 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
3294 }
3295
3296 #[test]
3297 fn test_cargo_build() {
3298 let _guard = test_guard!();
3299 let result = classify_command("cargo build");
3300 assert!(result.is_compilation);
3301 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3302 assert!(result.confidence >= 0.90);
3303 }
3304
3305 #[test]
3306 fn test_cargo_build_release() {
3307 let _guard = test_guard!();
3308 let result = classify_command("cargo build --release");
3309 assert!(result.is_compilation);
3310 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3311 }
3312
3313 #[test]
3314 fn test_cargo_test() {
3315 let _guard = test_guard!();
3316 let result = classify_command("cargo test");
3317 assert!(result.is_compilation);
3318 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3319 }
3320
3321 #[test]
3322 fn test_cargo_fmt_not_intercepted() {
3323 let _guard = test_guard!();
3324 let result = classify_command("cargo fmt");
3325 assert!(!result.is_compilation);
3326 assert!(result.reason.contains("never-intercept"));
3327 }
3328
3329 #[test]
3330 fn test_cargo_install_not_intercepted() {
3331 let _guard = test_guard!();
3332 let result = classify_command("cargo install ripgrep");
3333 assert!(!result.is_compilation);
3334 assert!(result.reason.contains("never-intercept"));
3335 }
3336
3337 #[test]
3338 fn test_piped_to_benign_pager_intercepted() {
3339 let _guard = test_guard!();
3340 let result = classify_command("cargo build 2>&1 | grep error");
3344 assert!(result.is_compilation, "got: {:?}", result.reason);
3345 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3346 assert_eq!(
3347 result.extracted_command.as_deref(),
3348 Some("cargo build 2>&1 | grep error")
3349 );
3350 }
3351
3352 #[test]
3353 fn test_piped_to_nonbenign_command_not_intercepted() {
3354 let _guard = test_guard!();
3355 let result = classify_command("cargo build | xargs rm");
3357 assert!(!result.is_compilation);
3358 assert!(result.reason.contains("piped"));
3359 }
3360
3361 #[test]
3362 fn test_backgrounded_intercepted() {
3363 let _guard = test_guard!();
3364 let result = classify_command("cargo build &");
3366 assert!(result.is_compilation, "got: {:?}", result.reason);
3367 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3368 assert_eq!(result.extracted_command.as_deref(), Some("cargo build &"));
3369 }
3370
3371 #[test]
3372 fn test_newline_injection_not_intercepted() {
3373 let _guard = test_guard!();
3374 let result = classify_command("cargo build\nrm -rf /");
3376 assert!(!result.is_compilation);
3377 assert!(result.reason.contains("newline"));
3378
3379 let result = classify_command("cargo build\r\nrm -rf /");
3380 assert!(!result.is_compilation);
3381 assert!(result.reason.contains("newline"));
3382
3383 for command in [
3384 "cargo build > out.log\nrm -rf /",
3385 "cargo build | tee out.log\r\nrm -rf /",
3386 "cargo build &\nrm -rf /",
3387 ] {
3388 let result = classify_command(command);
3389 assert!(
3390 !result.is_compilation,
3391 "newline-bearing benign suffix must fail closed: {command:?}"
3392 );
3393 assert!(result.reason.contains("newline"), "{result:?}");
3394 }
3395 }
3396
3397 #[test]
3398 fn test_redirected_intercepted() {
3399 let _guard = test_guard!();
3400 let result = classify_command("cargo build > log.txt");
3403 assert!(result.is_compilation, "got: {:?}", result.reason);
3404 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3405 assert_eq!(
3406 result.extracted_command.as_deref(),
3407 Some("cargo build > log.txt")
3408 );
3409
3410 let result = classify_command("cargo test --workspace > out.log 2>&1");
3411 assert!(result.is_compilation, "got: {:?}", result.reason);
3412 assert_eq!(
3413 result.extracted_command.as_deref(),
3414 Some("cargo test --workspace > out.log 2>&1")
3415 );
3416
3417 for command in [
3420 "cargo build 2>errors.txt",
3421 "cargo build 2>>errors.txt",
3422 "cargo build >out.log 2>&1",
3423 ] {
3424 let result = classify_command(command);
3425 assert!(result.is_compilation, "got: {:?}", result.reason);
3426 assert_eq!(result.extracted_command.as_deref(), Some(command));
3427 }
3428 }
3429
3430 #[test]
3431 fn test_input_redirected_not_intercepted() {
3432 let _guard = test_guard!();
3433 let result = classify_command("cargo build < input.txt");
3435 assert!(!result.is_compilation);
3436 assert!(result.reason.contains("input redirected"));
3437 }
3438
3439 #[test]
3440 fn test_process_substitution_not_intercepted() {
3441 let _guard = test_guard!();
3442 let result = classify_command("cargo build --config <(echo ...)");
3443 assert!(!result.is_compilation);
3444 assert!(result.reason.contains("subshell execution"));
3445 }
3446
3447 #[test]
3448 fn test_subshell_not_intercepted() {
3449 let _guard = test_guard!();
3450 let result = classify_command("(cargo build)");
3451 assert!(!result.is_compilation);
3452 assert!(result.reason.contains("subshell execution"));
3453 }
3454
3455 #[test]
3456 fn test_make_maintenance_matches_whole_target_tokens() {
3457 let _guard = test_guard!();
3460
3461 for cmd in [
3463 "make clean",
3464 "make install",
3465 "make distclean",
3466 "make uninstall",
3467 ] {
3468 let r = classify_command(cmd);
3469 assert!(!r.is_compilation, "{cmd} should not be intercepted");
3470 assert!(r.reason.contains("make maintenance command"), "{cmd}");
3471 }
3472 let r = classify_command("make -j8 clean");
3474 assert!(!r.is_compilation);
3475
3476 for cmd in [
3478 "make CFLAGS=-Iinstall/include",
3479 "make build INSTALL_DIR=/x",
3480 "make TARGET=cleanup",
3481 "make clean-all", "make -C install", ] {
3484 let r = classify_command(cmd);
3485 assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3486 assert_eq!(r.kind, Some(CompilationKind::Make), "{cmd}");
3487 }
3488 }
3489
3490 #[test]
3491 fn test_ninja_clean_matches_tool_and_whole_target() {
3492 let _guard = test_guard!();
3495
3496 for cmd in ["ninja clean", "ninja -t clean", "ninja -j8 clean"] {
3497 let r = classify_command(cmd);
3498 assert!(!r.is_compilation, "{cmd} should not be intercepted");
3499 assert!(r.reason.contains("ninja clean"), "{cmd}");
3500 }
3501
3502 for cmd in [
3503 "ninja build/clean_obj.o", "ninja -C clean", "ninja -j4",
3506 ] {
3507 let r = classify_command(cmd);
3508 assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3509 assert_eq!(r.kind, Some(CompilationKind::Ninja), "{cmd}");
3510 }
3511 }
3512
3513 #[test]
3514 fn test_gcc_compile() {
3515 let _guard = test_guard!();
3516 let result = classify_command("gcc -c main.c -o main.o");
3517 assert!(result.is_compilation);
3518 assert_eq!(result.kind, Some(CompilationKind::Gcc));
3519 }
3520
3521 #[test]
3522 fn test_make() {
3523 let _guard = test_guard!();
3524 let result = classify_command("make -j8");
3525 assert!(result.is_compilation);
3526 assert_eq!(result.kind, Some(CompilationKind::Make));
3527 }
3528
3529 #[test]
3530 fn test_make_clean_not_intercepted() {
3531 let _guard = test_guard!();
3532 let result = classify_command("make clean");
3533 assert!(!result.is_compilation);
3534 assert!(result.reason.contains("make maintenance command"));
3535 }
3536
3537 #[test]
3538 fn test_cmake_build_requires_cmake_command() {
3539 let _guard = test_guard!();
3540 let result = classify_command("echo cmake --build .");
3541 assert!(!result.is_compilation);
3542 }
3543
3544 #[test]
3545 fn test_cmake_build_with_equals_flag() {
3546 let _guard = test_guard!();
3547 let result = classify_command("cmake --build=build");
3548 assert!(result.is_compilation);
3549 assert_eq!(result.kind, Some(CompilationKind::CmakeBuild));
3550 }
3551
3552 #[test]
3553 fn test_meson_compile_requires_meson_command() {
3554 let _guard = test_guard!();
3555 let result = classify_command("echo meson compile");
3556 assert!(!result.is_compilation);
3557 }
3558
3559 #[test]
3560 fn test_non_compilation() {
3561 let _guard = test_guard!();
3562 let result = classify_command("ls -la");
3563 assert!(!result.is_compilation);
3564 assert!(result.reason.contains("no compilation keyword"));
3565 }
3566
3567 #[test]
3568 fn test_empty_command() {
3569 let _guard = test_guard!();
3570 let result = classify_command("");
3571 assert!(!result.is_compilation);
3572 assert!(result.reason.contains("empty command"));
3573 }
3574
3575 #[test]
3578 fn test_bun_keyword_detected() {
3579 let _guard = test_guard!();
3580 assert!(starts_with_compilation_command("bun test"));
3583 assert!(starts_with_compilation_command("bun typecheck"));
3584 assert!(starts_with_compilation_command("bun install"));
3585 }
3586
3587 #[test]
3588 fn test_bun_install_not_intercepted() {
3589 let _guard = test_guard!();
3590 let result = classify_command("bun install");
3592 assert!(!result.is_compilation);
3593 assert!(result.reason.contains("never-intercept"));
3594 }
3595
3596 #[test]
3597 fn test_bun_add_not_intercepted() {
3598 let _guard = test_guard!();
3599 let result = classify_command("bun add lodash");
3601 assert!(!result.is_compilation);
3602 assert!(result.reason.contains("never-intercept"));
3603 }
3604
3605 #[test]
3606 fn test_bun_remove_not_intercepted() {
3607 let _guard = test_guard!();
3608 let result = classify_command("bun remove lodash");
3609 assert!(!result.is_compilation);
3610 assert!(result.reason.contains("never-intercept"));
3611 }
3612
3613 #[test]
3614 fn test_bun_run_not_intercepted() {
3615 let _guard = test_guard!();
3616 let result = classify_command("bun run build");
3618 assert!(!result.is_compilation);
3619 assert!(result.reason.contains("never-intercept"));
3620 }
3621
3622 #[test]
3623 fn test_bun_build_not_intercepted() {
3624 let _guard = test_guard!();
3625 let result = classify_command("bun build ./src/index.ts");
3627 assert!(!result.is_compilation);
3628 assert!(result.reason.contains("never-intercept"));
3629 }
3630
3631 #[test]
3632 fn test_bun_version_not_intercepted() {
3633 let _guard = test_guard!();
3634 let result = classify_command("bun --version");
3635 assert!(!result.is_compilation);
3636 assert!(result.reason.contains("never-intercept"));
3637
3638 let result = classify_command("bun -v");
3639 assert!(!result.is_compilation);
3640 assert!(result.reason.contains("never-intercept"));
3641 }
3642
3643 #[test]
3644 fn test_bun_dev_not_intercepted() {
3645 let _guard = test_guard!();
3646 let result = classify_command("bun dev");
3648 assert!(!result.is_compilation);
3649 assert!(result.reason.contains("never-intercept"));
3650 }
3651
3652 #[test]
3653 fn test_bun_repl_not_intercepted() {
3654 let _guard = test_guard!();
3655 let result = classify_command("bun repl");
3657 assert!(!result.is_compilation);
3658 assert!(result.reason.contains("never-intercept"));
3659 }
3660
3661 #[test]
3662 fn test_bun_link_not_intercepted() {
3663 let _guard = test_guard!();
3664 let result = classify_command("bun link");
3665 assert!(!result.is_compilation);
3666 assert!(result.reason.contains("never-intercept"));
3667 }
3668
3669 #[test]
3670 fn test_bun_init_not_intercepted() {
3671 let _guard = test_guard!();
3672 let result = classify_command("bun init");
3673 assert!(!result.is_compilation);
3674 assert!(result.reason.contains("never-intercept"));
3675 }
3676
3677 #[test]
3678 fn test_bun_create_not_intercepted() {
3679 let _guard = test_guard!();
3680 let result = classify_command("bun create next-app");
3681 assert!(!result.is_compilation);
3682 assert!(result.reason.contains("never-intercept"));
3683 }
3684
3685 #[test]
3686 fn test_bun_pm_not_intercepted() {
3687 let _guard = test_guard!();
3688 let result = classify_command("bun pm cache");
3689 assert!(!result.is_compilation);
3690 assert!(result.reason.contains("never-intercept"));
3691 }
3692
3693 #[test]
3694 fn test_bun_help_not_intercepted() {
3695 let _guard = test_guard!();
3696 let result = classify_command("bun --help");
3697 assert!(!result.is_compilation);
3698 assert!(result.reason.contains("never-intercept"));
3699
3700 let result = classify_command("bun -h");
3701 assert!(!result.is_compilation);
3702 assert!(result.reason.contains("never-intercept"));
3703 }
3704
3705 #[test]
3708 fn test_bun_test_classification() {
3709 let _guard = test_guard!();
3710 let result = classify_command("bun test");
3712 assert!(result.is_compilation);
3713 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3714 assert!((result.confidence - 0.95).abs() < 0.001);
3715
3716 let result = classify_command("bun test src/");
3718 assert!(result.is_compilation);
3719 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3720
3721 let result = classify_command("bun test --coverage");
3723 assert!(result.is_compilation);
3724 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3725
3726 let result = classify_command("bun test auth.test.ts");
3728 assert!(result.is_compilation);
3729 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3730
3731 let result = classify_command("bun test --bail --timeout 5000");
3733 assert!(result.is_compilation);
3734 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3735
3736 let result = classify_command("bun test --reporter json");
3738 assert!(result.is_compilation);
3739 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3740 }
3741
3742 #[test]
3743 fn test_bun_test_watch_not_intercepted() {
3744 let _guard = test_guard!();
3745 let result = classify_command("bun test --watch");
3747 assert!(!result.is_compilation);
3748 assert!(result.reason.contains("interactive"));
3749
3750 let result = classify_command("bun test --watch --coverage");
3752 assert!(!result.is_compilation);
3753 assert!(result.reason.contains("interactive"));
3754
3755 let result = classify_command("bun test src/ --watch");
3757 assert!(!result.is_compilation);
3758
3759 let result = classify_command("bun test -w");
3761 assert!(!result.is_compilation);
3762
3763 let result = classify_command("bun test -w src/");
3765 assert!(!result.is_compilation);
3766 }
3767
3768 #[test]
3769 fn test_bun_typecheck_watch_not_intercepted() {
3770 let _guard = test_guard!();
3771 let result = classify_command("bun typecheck --watch");
3773 assert!(!result.is_compilation);
3774 assert!(result.reason.contains("interactive"));
3775
3776 let result = classify_command("bun typecheck -w");
3778 assert!(!result.is_compilation);
3779 }
3780
3781 #[test]
3782 fn test_bun_typecheck_classification() {
3783 let _guard = test_guard!();
3784 let result = classify_command("bun typecheck");
3786 assert!(result.is_compilation);
3787 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3788 assert!((result.confidence - 0.95).abs() < 0.001);
3789
3790 let result = classify_command("bun typecheck src/");
3792 assert!(result.is_compilation);
3793 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3794
3795 }
3798
3799 #[test]
3800 fn test_bun_edge_cases_not_matched() {
3801 let _guard = test_guard!();
3802 let result = classify_command("bun testing");
3804 assert!(!result.is_compilation);
3805 assert!(result.reason.contains("no matching pattern"));
3806
3807 let result = classify_command("bun typechecker");
3808 assert!(!result.is_compilation);
3809 assert!(result.reason.contains("no matching pattern"));
3810
3811 let result = classify_command("bun type");
3812 assert!(!result.is_compilation);
3813 assert!(result.reason.contains("no matching pattern"));
3814
3815 let result = classify_command("bun x eslint");
3817 assert!(!result.is_compilation);
3818 assert!(result.reason.contains("bun x runs arbitrary packages"));
3819
3820 let result = classify_command("bun x prettier --write .");
3821 assert!(!result.is_compilation);
3822 assert!(result.reason.contains("bun x runs arbitrary packages"));
3823
3824 let result = classify_command("bun x vitest run");
3825 assert!(!result.is_compilation);
3826 assert!(result.reason.contains("bun x runs arbitrary packages"));
3827 }
3828
3829 #[test]
3830 fn test_bun_test_vs_never_intercept() {
3831 let _guard = test_guard!();
3832 let result = classify_command("bun test");
3834 assert!(!result.reason.contains("never-intercept"));
3835 assert!(result.is_compilation);
3836 }
3837
3838 #[test]
3839 fn test_bun_typecheck_vs_never_intercept() {
3840 let _guard = test_guard!();
3841 let result = classify_command("bun typecheck");
3843 assert!(!result.reason.contains("never-intercept"));
3844 assert!(result.is_compilation);
3845 }
3846
3847 #[test]
3850 fn test_bun_compilation_kind_serde() {
3851 let _guard = test_guard!();
3852 let kind = CompilationKind::BunTest;
3854 let json = serde_json::to_string(&kind).unwrap();
3855 assert_eq!(json, "\"bun_test\"");
3856 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3857 assert_eq!(parsed, CompilationKind::BunTest);
3858
3859 let kind = CompilationKind::BunTypecheck;
3861 let json = serde_json::to_string(&kind).unwrap();
3862 assert_eq!(json, "\"bun_typecheck\"");
3863 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3864 assert_eq!(parsed, CompilationKind::BunTypecheck);
3865 }
3866
3867 #[test]
3868 fn test_bun_kinds_are_distinct() {
3869 let _guard = test_guard!();
3870 assert_ne!(CompilationKind::BunTest, CompilationKind::BunTypecheck);
3872 assert_ne!(CompilationKind::BunTest, CompilationKind::CargoTest);
3874 }
3875
3876 #[test]
3877 fn test_wrapped_command_classification_repro() {
3878 let _guard = test_guard!();
3879 let result = classify_command("time cargo build");
3882 assert!(
3883 result.is_compilation,
3884 "Should classify 'time cargo build' as compilation"
3885 );
3886 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3887
3888 let result = classify_command("sudo cargo check");
3889 assert!(
3890 result.is_compilation,
3891 "Should classify 'sudo cargo check' as compilation"
3892 );
3893
3894 let result = classify_command("env RUST_BACKTRACE=1 cargo test");
3895 assert!(
3896 result.is_compilation,
3897 "Should classify env-wrapped cargo test as compilation"
3898 );
3899
3900 let result = classify_command("env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build");
3901 assert!(
3902 result.is_compilation,
3903 "Should classify shell-quoted env assignment before cargo build as compilation"
3904 );
3905 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3906 }
3907
3908 #[test]
3913 fn test_bun_piped_to_benign_pager_intercepted() {
3914 let _guard = test_guard!();
3915 let result = classify_command("bun test | grep error");
3917 assert!(result.is_compilation, "got: {:?}", result.reason);
3918 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3919 assert_eq!(
3920 result.extracted_command.as_deref(),
3921 Some("bun test | grep error")
3922 );
3923
3924 let result = classify_command("bun test 2>&1 | tee output.log");
3926 assert!(result.is_compilation, "got: {:?}", result.reason);
3927 assert_eq!(
3928 result.extracted_command.as_deref(),
3929 Some("bun test 2>&1 | tee output.log")
3930 );
3931
3932 let result = classify_command("bun typecheck | head -20");
3934 assert!(result.is_compilation, "got: {:?}", result.reason);
3935 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3936 }
3937
3938 #[test]
3939 fn test_bunx_tsc_not_intercepted() {
3940 let _guard = test_guard!();
3941 let result = classify_command("bun x tsc --noEmit");
3944 assert!(!result.is_compilation);
3945 assert!(result.reason.contains("bun x"));
3946
3947 let result = classify_command("bun x eslint .");
3949 assert!(!result.is_compilation);
3950
3951 let result = classify_command("bun x prettier --write .");
3952 assert!(!result.is_compilation);
3953
3954 let result = classify_command("bun x vitest run");
3955 assert!(!result.is_compilation);
3956 }
3957
3958 #[test]
3959 fn test_bun_redirected_intercepted() {
3960 let _guard = test_guard!();
3961 let result = classify_command("bun test > results.txt");
3963 assert!(result.is_compilation, "got: {:?}", result.reason);
3964 assert_eq!(
3965 result.extracted_command.as_deref(),
3966 Some("bun test > results.txt")
3967 );
3968
3969 let result = classify_command("bun typecheck > errors.log");
3970 assert!(result.is_compilation, "got: {:?}", result.reason);
3971 }
3972
3973 #[test]
3974 fn test_bun_backgrounded_intercepted() {
3975 let _guard = test_guard!();
3976 let result = classify_command("bun test &");
3978 assert!(result.is_compilation, "got: {:?}", result.reason);
3979 assert_eq!(result.extracted_command.as_deref(), Some("bun test &"));
3980 }
3981
3982 #[test]
3983 fn test_bun_chained_commands_classified() {
3984 let _guard = test_guard!();
3985 let result = classify_command("bun test && echo done");
3987 assert!(
3988 !result.is_compilation,
3989 "chained commands should be rejected"
3990 );
3991 assert!(result.reason.contains("chained"));
3992
3993 let result = classify_command("bun typecheck; bun test");
3994 assert!(
3995 !result.is_compilation,
3996 "chained commands should be rejected"
3997 );
3998 assert!(result.reason.contains("chained"));
3999 }
4000
4001 #[test]
4002 fn test_bun_subshell_capture_not_intercepted() {
4003 let _guard = test_guard!();
4004 let result = classify_command("bun test $(echo src/)");
4006 assert!(!result.is_compilation);
4007 assert!(result.reason.contains("subshell"));
4008 }
4009
4010 #[test]
4011 fn test_bun_wrapped_commands() {
4012 let _guard = test_guard!();
4013 let result = classify_command("time bun test");
4016 if result.is_compilation {
4019 assert_eq!(result.kind, Some(CompilationKind::BunTest));
4020 }
4021
4022 let result = classify_command("env DEBUG=1 bun test");
4023 if result.is_compilation {
4024 assert_eq!(result.kind, Some(CompilationKind::BunTest));
4025 }
4026 }
4027
4028 #[test]
4029 fn test_classify_command_detailed_matches_basic() {
4030 let _guard = test_guard!();
4031 let commands = [
4032 "cargo build",
4033 "cargo test --release",
4034 "bun typecheck",
4035 "gcc -c main.c -o main.o",
4036 "cd /tmp && cargo build --release",
4037 "ls -la",
4038 ];
4039
4040 for cmd in commands {
4041 let basic = classify_command(cmd);
4042 let detailed = classify_command_detailed(cmd);
4043 assert_eq!(basic, detailed.classification);
4044 }
4045 }
4046
4047 #[test]
4048 fn test_classify_command_detailed_rejects_piped() {
4049 let _guard = test_guard!();
4050 let detailed = classify_command_detailed("cargo build | xargs rm");
4053 assert!(!detailed.classification.is_compilation);
4054 let tier1 = detailed.tiers.iter().find(|t| t.tier.eq(&1)).unwrap();
4055 assert_eq!(tier1.decision, TierDecision::Reject);
4056 assert!(tier1.reason.contains("piped"));
4057
4058 let detailed = classify_command_detailed("cargo build | tee log.txt");
4060 assert!(detailed.classification.is_compilation);
4061 assert_eq!(
4062 detailed.classification.extracted_command.as_deref(),
4063 Some("cargo build | tee log.txt")
4064 );
4065 }
4066
4067 #[test]
4068 fn test_classify_command_detailed_normalizes_wrappers() {
4069 let _guard = test_guard!();
4070 for (cmd, normalized) in [
4071 ("sudo cargo check", "cargo check"),
4072 ("env -u RUST_LOG cargo test", "cargo test"),
4073 ] {
4074 let detailed = classify_command_detailed(cmd);
4075 assert_eq!(
4076 (
4077 detailed.normalized.as_str(),
4078 detailed.classification.is_compilation
4079 ),
4080 (normalized, true),
4081 "{cmd}"
4082 );
4083 }
4084 }
4085
4086 #[test]
4087 fn test_classify_command_detailed_matches_compound_command() {
4088 let _guard = test_guard!();
4089 let detailed = classify_command_detailed("cd /tmp && cargo build --release");
4090
4091 assert!(detailed.classification.is_compilation);
4092 assert_eq!(
4093 detailed.classification.kind,
4094 Some(CompilationKind::CargoBuild)
4095 );
4096 assert_eq!(
4097 detailed.classification.command_prefix.as_deref(),
4098 Some("cd /tmp && ")
4099 );
4100 assert_eq!(
4101 detailed.classification.extracted_command.as_deref(),
4102 Some("cargo build --release")
4103 );
4104 assert_eq!(detailed.normalized, "cargo build --release");
4105 assert_eq!(detailed.tiers.len(), 5);
4106 assert!(
4107 detailed
4108 .tiers
4109 .iter()
4110 .all(|tier| tier.decision == TierDecision::Pass)
4111 );
4112 }
4113
4114 #[test]
4119 fn test_cargo_test_release() {
4120 let _guard = test_guard!();
4121 let result = classify_command("cargo test --release");
4122 assert!(
4123 result.is_compilation,
4124 "cargo test --release should be classified as compilation"
4125 );
4126 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4127 assert!(result.confidence >= 0.90);
4128 }
4129
4130 #[test]
4131 fn test_cargo_test_specific_test_name() {
4132 let _guard = test_guard!();
4133 let result = classify_command("cargo test my_test_function");
4134 assert!(
4135 result.is_compilation,
4136 "cargo test with specific test name should be classified"
4137 );
4138 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4139 }
4140
4141 #[test]
4142 fn test_cargo_test_with_nocapture() {
4143 let _guard = test_guard!();
4144 let result = classify_command("cargo test -- --nocapture");
4145 assert!(
4146 result.is_compilation,
4147 "cargo test -- --nocapture should be classified"
4148 );
4149 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4150 }
4151
4152 #[test]
4153 fn test_cargo_test_workspace() {
4154 let _guard = test_guard!();
4155 let result = classify_command("cargo test --workspace");
4156 assert!(
4157 result.is_compilation,
4158 "cargo test --workspace should be classified"
4159 );
4160 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4161 }
4162
4163 #[test]
4164 fn test_cargo_test_package() {
4165 let _guard = test_guard!();
4166 let result = classify_command("cargo test -p rch-common");
4167 assert!(result.is_compilation, "cargo test -p should be classified");
4168 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4169 }
4170
4171 #[test]
4172 fn test_cargo_test_short_alias() {
4173 let _guard = test_guard!();
4174 let result = classify_command("cargo t");
4176 assert!(
4177 result.is_compilation,
4178 "cargo t (short alias) should be classified"
4179 );
4180 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4181 }
4182
4183 #[test]
4184 fn test_cargo_test_all_features() {
4185 let _guard = test_guard!();
4186 let result = classify_command("cargo test --all-features");
4187 assert!(
4188 result.is_compilation,
4189 "cargo test --all-features should be classified"
4190 );
4191 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4192 }
4193
4194 #[test]
4195 fn test_cargo_test_no_default_features() {
4196 let _guard = test_guard!();
4197 let result = classify_command("cargo test --no-default-features");
4198 assert!(
4199 result.is_compilation,
4200 "cargo test --no-default-features should be classified"
4201 );
4202 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4203 }
4204
4205 #[test]
4206 fn test_cargo_test_with_env_var() {
4207 let _guard = test_guard!();
4208 let result = classify_command("RUST_BACKTRACE=1 cargo test");
4209 assert!(
4210 result.is_compilation,
4211 "cargo test with env var should be classified"
4212 );
4213 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4214 }
4215
4216 #[test]
4217 fn test_cargo_test_with_multiple_flags() {
4218 let _guard = test_guard!();
4219 let result = classify_command("cargo test --release --workspace -p rch -- --nocapture");
4220 assert!(
4221 result.is_compilation,
4222 "cargo test with multiple flags should be classified"
4223 );
4224 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4225 }
4226
4227 #[test]
4228 fn test_cargo_test_with_jobs() {
4229 let _guard = test_guard!();
4230 let result = classify_command("cargo test -j 8");
4231 assert!(result.is_compilation, "cargo test -j should be classified");
4232 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4233 }
4234
4235 #[test]
4236 fn test_cargo_test_target() {
4237 let _guard = test_guard!();
4238 let result = classify_command("cargo test --target x86_64-unknown-linux-gnu");
4239 assert!(
4240 result.is_compilation,
4241 "cargo test --target should be classified"
4242 );
4243 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4244 }
4245
4246 #[test]
4247 fn test_cargo_test_lib() {
4248 let _guard = test_guard!();
4249 let result = classify_command("cargo test --lib");
4250 assert!(
4251 result.is_compilation,
4252 "cargo test --lib should be classified"
4253 );
4254 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4255 }
4256
4257 #[test]
4258 fn test_cargo_test_bins() {
4259 let _guard = test_guard!();
4260 let result = classify_command("cargo test --bins");
4261 assert!(
4262 result.is_compilation,
4263 "cargo test --bins should be classified"
4264 );
4265 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4266 }
4267
4268 #[test]
4269 fn test_cargo_test_doc() {
4270 let _guard = test_guard!();
4271 let result = classify_command("cargo test --doc");
4272 assert!(
4273 result.is_compilation,
4274 "cargo test --doc should be classified"
4275 );
4276 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4277 }
4278
4279 #[test]
4280 fn test_cargo_test_filter_pattern() {
4281 let _guard = test_guard!();
4282 let result = classify_command("cargo test test_classification");
4283 assert!(
4284 result.is_compilation,
4285 "cargo test with filter pattern should be classified"
4286 );
4287 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4288 }
4289
4290 #[test]
4291 fn test_cargo_test_exact() {
4292 let _guard = test_guard!();
4293 let result = classify_command("cargo test --exact my_test");
4294 assert!(
4295 result.is_compilation,
4296 "cargo test --exact should be classified"
4297 );
4298 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4299 }
4300
4301 #[test]
4306 fn test_nextest_keyword_detected() {
4307 let _guard = test_guard!();
4308 assert!(starts_with_compilation_command("cargo nextest run"));
4310 assert!(starts_with_compilation_command("cargo nextest list"));
4311 }
4312
4313 #[test]
4314 fn test_cargo_nextest_run_classification() {
4315 let _guard = test_guard!();
4316 let result = classify_command("cargo nextest run");
4318 assert!(
4319 result.is_compilation,
4320 "cargo nextest run should be classified"
4321 );
4322 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4323 assert!((result.confidence - 0.95).abs() < 0.001);
4324 }
4325
4326 #[test]
4327 fn test_cargo_nextest_run_with_flags() {
4328 let _guard = test_guard!();
4329 let result = classify_command("cargo nextest run --release");
4331 assert!(result.is_compilation);
4332 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4333
4334 let result = classify_command("cargo nextest run --cargo-profile ci");
4336 assert!(result.is_compilation);
4337 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4338
4339 let result = classify_command("cargo nextest run --workspace");
4341 assert!(result.is_compilation);
4342 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4343
4344 let result = classify_command("cargo nextest run -p rch-common");
4346 assert!(result.is_compilation);
4347 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4348
4349 let result = classify_command("cargo nextest run test_classification");
4351 assert!(result.is_compilation);
4352 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4353
4354 let result = classify_command("cargo nextest run --release --no-fail-fast -j 8");
4356 assert!(result.is_compilation);
4357 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4358 }
4359
4360 #[test]
4361 fn test_cargo_nextest_run_short_alias() {
4362 let _guard = test_guard!();
4363 let result = classify_command("cargo nextest r");
4365 assert!(
4366 result.is_compilation,
4367 "cargo nextest r should be classified"
4368 );
4369 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4370 }
4371
4372 #[test]
4373 fn test_cargo_nextest_list_not_intercepted() {
4374 let _guard = test_guard!();
4375 let result = classify_command("cargo nextest list");
4377 assert!(!result.is_compilation);
4378 assert!(result.reason.contains("never-intercept"));
4379 }
4380
4381 #[test]
4382 fn test_cargo_nextest_archive_not_intercepted() {
4383 let _guard = test_guard!();
4384 let result = classify_command("cargo nextest archive");
4386 assert!(!result.is_compilation);
4387 assert!(result.reason.contains("never-intercept"));
4388 }
4389
4390 #[test]
4391 fn test_cargo_nextest_show_not_intercepted() {
4392 let _guard = test_guard!();
4393 let result = classify_command("cargo nextest show");
4395 assert!(!result.is_compilation);
4396 assert!(result.reason.contains("never-intercept"));
4397 }
4398
4399 #[test]
4400 fn test_bare_cargo_nextest_not_intercepted() {
4401 let _guard = test_guard!();
4402 let result = classify_command("cargo nextest");
4404 assert!(!result.is_compilation);
4405 assert!(result.reason.contains("without subcommand"));
4406 }
4407
4408 #[test]
4409 fn test_cargo_nextest_wrapped_commands() {
4410 let _guard = test_guard!();
4411 let result = classify_command("time cargo nextest run");
4413 assert!(
4414 result.is_compilation,
4415 "time cargo nextest run should be classified"
4416 );
4417 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4418
4419 let result = classify_command("RUST_BACKTRACE=1 cargo nextest run");
4420 assert!(
4421 result.is_compilation,
4422 "env-wrapped cargo nextest run should be classified"
4423 );
4424 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4425 }
4426
4427 #[test]
4428 fn test_cargo_nextest_compilation_kind_serde() {
4429 let _guard = test_guard!();
4430 let kind = CompilationKind::CargoNextest;
4432 let json = serde_json::to_string(&kind).unwrap();
4433 assert_eq!(json, "\"cargo_nextest\"");
4434 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4435 assert_eq!(parsed, CompilationKind::CargoNextest);
4436 }
4437
4438 #[test]
4439 fn test_cargo_nextest_vs_cargo_test_distinct() {
4440 let _guard = test_guard!();
4441 assert_ne!(CompilationKind::CargoNextest, CompilationKind::CargoTest);
4443
4444 let nextest_result = classify_command("cargo nextest run");
4446 let test_result = classify_command("cargo test");
4447 assert!(nextest_result.is_compilation);
4448 assert!(test_result.is_compilation);
4449 assert_eq!(nextest_result.kind, Some(CompilationKind::CargoNextest));
4450 assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4451 }
4452
4453 #[test]
4454 fn test_cargo_nextest_piped_to_pager_intercepted() {
4455 let _guard = test_guard!();
4456 let result = classify_command("cargo nextest run | grep FAIL");
4458 assert!(result.is_compilation, "got: {:?}", result.reason);
4459 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4460 assert_eq!(
4461 result.extracted_command.as_deref(),
4462 Some("cargo nextest run | grep FAIL")
4463 );
4464 }
4465
4466 #[test]
4467 fn test_cargo_nextest_redirected_intercepted() {
4468 let _guard = test_guard!();
4469 let result = classify_command("cargo nextest run > results.txt");
4471 assert!(result.is_compilation, "got: {:?}", result.reason);
4472 assert_eq!(
4473 result.extracted_command.as_deref(),
4474 Some("cargo nextest run > results.txt")
4475 );
4476 }
4477
4478 #[test]
4479 fn test_cargo_nextest_backgrounded_intercepted() {
4480 let _guard = test_guard!();
4481 let result = classify_command("cargo nextest run &");
4483 assert!(result.is_compilation, "got: {:?}", result.reason);
4484 assert_eq!(
4485 result.extracted_command.as_deref(),
4486 Some("cargo nextest run &")
4487 );
4488 }
4489
4490 #[test]
4495 fn test_cargo_bench_classification() {
4496 let _guard = test_guard!();
4497 let result = classify_command("cargo bench");
4498 assert!(result.is_compilation, "cargo bench should be classified");
4499 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4500 assert!((result.confidence - 0.90).abs() < 0.001);
4501 }
4502
4503 #[test]
4504 fn test_cargo_bench_with_filter() {
4505 let _guard = test_guard!();
4506 let result = classify_command("cargo bench my_bench");
4508 assert!(result.is_compilation);
4509 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4510 }
4511
4512 #[test]
4513 fn test_cargo_bench_with_flags() {
4514 let _guard = test_guard!();
4515 let result = classify_command("cargo bench --release");
4517 assert!(result.is_compilation);
4518 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4519
4520 let result = classify_command("cargo bench -p rch-common");
4522 assert!(result.is_compilation);
4523 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4524
4525 let result = classify_command("cargo bench --features benchmarks");
4527 assert!(result.is_compilation);
4528 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4529
4530 let result = classify_command("cargo bench --bench criterion_bench");
4532 assert!(result.is_compilation);
4533 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4534 }
4535
4536 #[test]
4537 fn test_cargo_bench_wrapped() {
4538 let _guard = test_guard!();
4539 let result = classify_command("time cargo bench");
4541 assert!(
4542 result.is_compilation,
4543 "time cargo bench should be classified"
4544 );
4545 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4546
4547 let result = classify_command("CARGO_INCREMENTAL=0 cargo bench");
4549 assert!(result.is_compilation);
4550 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4551 }
4552
4553 #[test]
4554 fn test_cargo_bench_serde() {
4555 let _guard = test_guard!();
4556 let kind = CompilationKind::CargoBench;
4558 let json = serde_json::to_string(&kind).unwrap();
4559 assert_eq!(json, "\"cargo_bench\"");
4560 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4561 assert_eq!(parsed, CompilationKind::CargoBench);
4562 }
4563
4564 #[test]
4565 fn test_cargo_bench_distinct_from_test() {
4566 let _guard = test_guard!();
4567 assert_ne!(CompilationKind::CargoBench, CompilationKind::CargoTest);
4569
4570 let bench_result = classify_command("cargo bench");
4572 let test_result = classify_command("cargo test");
4573 assert!(bench_result.is_compilation);
4574 assert!(test_result.is_compilation);
4575 assert_eq!(bench_result.kind, Some(CompilationKind::CargoBench));
4576 assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4577 }
4578
4579 #[test]
4580 fn test_cargo_bench_piped_to_pager_intercepted() {
4581 let _guard = test_guard!();
4582 let result = classify_command("cargo bench | tee output.txt");
4584 assert!(result.is_compilation, "got: {:?}", result.reason);
4585 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4586 assert_eq!(
4587 result.extracted_command.as_deref(),
4588 Some("cargo bench | tee output.txt")
4589 );
4590 }
4591
4592 #[test]
4593
4594 fn test_normalize_path_and_wrapper_bug_repro() {
4595 let _guard = test_guard!();
4596 let normalized = normalize_command("/usr/bin/time cargo build");
4605
4606 assert_eq!(normalized, "cargo build");
4607
4608 let result = classify_command("/usr/bin/time cargo build");
4609
4610 assert!(result.is_compilation);
4611 }
4612
4613 #[test]
4618 fn test_command_base_rust() {
4619 let _guard = test_guard!();
4620 assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
4621 assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
4622 assert_eq!(CompilationKind::CargoCheck.command_base(), "cargo");
4623 assert_eq!(CompilationKind::CargoClippy.command_base(), "cargo");
4624 assert_eq!(CompilationKind::CargoDoc.command_base(), "cargo");
4625 assert_eq!(CompilationKind::CargoBench.command_base(), "cargo");
4626 assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
4627 assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
4628 }
4629
4630 #[test]
4631 fn test_command_base_c_cpp() {
4632 let _guard = test_guard!();
4633 assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
4634 assert_eq!(CompilationKind::Gpp.command_base(), "g++");
4635 assert_eq!(CompilationKind::Clang.command_base(), "clang");
4636 assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
4637 }
4638
4639 #[test]
4640 fn test_command_base_build_systems() {
4641 let _guard = test_guard!();
4642 assert_eq!(CompilationKind::Make.command_base(), "make");
4643 assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
4644 assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
4645 assert_eq!(CompilationKind::Meson.command_base(), "meson");
4646 }
4647
4648 #[test]
4649 fn test_command_base_bun() {
4650 let _guard = test_guard!();
4651 assert_eq!(CompilationKind::BunTest.command_base(), "bun");
4652 assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
4653 }
4654
4655 mod proptest_classification {
4660 use super::*;
4661 use proptest::prelude::*;
4662
4663 fn arbitrary_string() -> impl Strategy<Value = String> {
4665 prop::string::string_regex(".{0,500}").unwrap()
4666 }
4667
4668 fn command_like_string() -> impl Strategy<Value = String> {
4670 let prefixes = prop::sample::select(vec![
4671 "cargo", "rustc", "gcc", "g++", "clang", "make", "cmake", "ninja", "bun", "ls",
4672 "cd", "echo", "cat", "grep", "find", "rm", "mv", "cp", "mkdir",
4673 ]);
4674 (prefixes, "[ a-zA-Z0-9_.-]{0,200}")
4675 .prop_map(|(prefix, suffix)| format!("{}{}", prefix, suffix))
4676 }
4677
4678 fn known_command_with_flags() -> impl Strategy<Value = String> {
4680 let base_commands = prop::sample::select(vec![
4681 "cargo build",
4682 "cargo test",
4683 "cargo check",
4684 "cargo clippy",
4685 "cargo run",
4686 "rustc",
4687 "gcc",
4688 "g++",
4689 "make",
4690 "bun test",
4691 "bun typecheck",
4692 ]);
4693 let flags = prop::collection::vec(
4694 prop::sample::select(vec![
4695 "--release",
4696 "--verbose",
4697 "-j8",
4698 "-p",
4699 "--all",
4700 "--workspace",
4701 "--lib",
4702 "--bin",
4703 "-o",
4704 "-c",
4705 ]),
4706 0..5,
4707 );
4708 (base_commands, flags).prop_map(|(cmd, flags)| {
4709 if flags.is_empty() {
4710 cmd.to_string()
4711 } else {
4712 format!("{} {}", cmd, flags.join(" "))
4713 }
4714 })
4715 }
4716
4717 fn unicode_and_control_chars() -> impl Strategy<Value = String> {
4719 prop::string::string_regex(
4720 r"[\x00-\x1f\x80-\xff\u{100}-\u{10FFFF}a-zA-Z0-9 |>&;$()]{0,100}",
4721 )
4722 .unwrap()
4723 }
4724
4725 fn shell_special_commands() -> impl Strategy<Value = String> {
4727 let components = prop::sample::select(vec![
4728 "cargo build",
4729 "ls -la",
4730 "echo test",
4731 "cat file.txt",
4732 "grep pattern",
4733 ]);
4734 let operators = prop::sample::select(vec![
4735 " | ", " && ", " || ", " ; ", " > ", " < ", " & ", " 2>&1 ", " $(", " `",
4736 ]);
4737 prop::collection::vec((components, operators), 1..4).prop_map(|pairs| {
4738 let mut result = String::new();
4739 for (i, (comp, op)) in pairs.iter().enumerate() {
4740 if i > 0 {
4741 result.push_str(op);
4742 }
4743 result.push_str(comp);
4744 }
4745 result
4746 })
4747 }
4748
4749 fn wrapped_commands() -> impl Strategy<Value = String> {
4751 let wrappers = prop::sample::select(vec![
4752 "sudo ",
4753 "env ",
4754 "time ",
4755 "nice ",
4756 "/usr/bin/time ",
4757 "RUST_BACKTRACE=1 ",
4758 "CC=clang ",
4759 "",
4760 ]);
4761 let commands = prop::sample::select(vec![
4762 "cargo build",
4763 "cargo test",
4764 "make",
4765 "gcc -c main.c",
4766 "ls -la",
4767 ]);
4768 (wrappers, commands).prop_map(|(wrapper, cmd)| format!("{}{}", wrapper, cmd))
4769 }
4770
4771 proptest! {
4772 #![proptest_config(ProptestConfig::with_cases(1000))]
4774
4775 #[test]
4777 fn test_classify_arbitrary_no_panic(s in arbitrary_string()) {
4778 let _ = classify_command(&s);
4780 }
4781
4782 #[test]
4784 fn test_classify_command_like_no_panic(s in command_like_string()) {
4785 let _ = classify_command(&s);
4786 }
4787
4788 #[test]
4790 fn test_classify_known_commands_valid(s in known_command_with_flags()) {
4791 let result = classify_command(&s);
4792 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0,
4794 "Confidence {} out of range for command: {}", result.confidence, s);
4795 if result.is_compilation {
4797 prop_assert!(result.kind.is_some(),
4798 "is_compilation=true but kind=None for: {}", s);
4799 }
4800 }
4801
4802 #[test]
4804 fn test_classify_unicode_no_panic(s in unicode_and_control_chars()) {
4805 let _ = classify_command(&s);
4806 }
4807
4808 #[test]
4810 fn test_classify_shell_special(s in shell_special_commands()) {
4811 let result = classify_command(&s);
4812 if s.contains(" | ") || s.contains(" > ") || s.contains(" < ") || s.contains(" & ") {
4815 let _ = result; }
4818 }
4819
4820 #[test]
4822 fn test_classify_wrapped_commands(s in wrapped_commands()) {
4823 let result = classify_command(&s);
4824 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4825 }
4826
4827 #[test]
4829 fn test_classify_deterministic(s in arbitrary_string()) {
4830 let result1 = classify_command(&s);
4831 let result2 = classify_command(&s);
4832 prop_assert_eq!(result1, result2,
4833 "Non-deterministic classification for: {}", s);
4834 }
4835
4836 #[test]
4838 fn test_classify_whitespace_variants(s in "[ \t\n\r]{0,20}") {
4839 let _guard = test_guard!();
4840 let result = classify_command(&s);
4841 prop_assert!(!result.is_compilation,
4842 "Whitespace-only command should not be classified as compilation: {:?}", s);
4843 prop_assert!(result.reason.contains("empty") || result.reason.contains("keyword"),
4844 "Unexpected reason for whitespace: {}", result.reason);
4845 }
4846
4847 #[test]
4849 fn test_classify_long_commands(
4850 prefix in "cargo (build|test|check)",
4851 suffix in "[a-zA-Z0-9_ -]{0,10000}"
4852 ) {
4853 let long_cmd = format!("{} {}", prefix, suffix);
4854 let result = classify_command(&long_cmd);
4855 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4856 }
4857
4858 #[test]
4860 fn test_classify_special_bytes(s in prop::collection::vec(any::<u8>(), 0..200)) {
4861 if let Ok(valid_str) = String::from_utf8(s.clone()) {
4862 let _ = classify_command(&valid_str);
4863 }
4864 }
4866 }
4867
4868 proptest! {
4871 #![proptest_config(ProptestConfig::with_cases(500))]
4872
4873 #[test]
4875 fn test_cargo_subcommand_robustness(
4876 subcommand in "(build|test|check|clippy|fmt|clean|run|doc|bench|nextest)",
4877 suffix in "[a-zA-Z0-9_ -]{0,50}"
4878 ) {
4879 let cmd = format!("cargo {} {}", subcommand, suffix);
4880 let result = classify_command(&cmd);
4881 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4883 if subcommand.eq("fmt") || subcommand.eq("clean") {
4885 prop_assert!(!result.is_compilation,
4886 "cargo {} should not be compilation: {}", subcommand, cmd);
4887 }
4888 }
4889
4890 #[test]
4892 fn test_bun_command_robustness(
4893 subcommand in "(test|typecheck|install|add|remove|run|build|dev)",
4894 suffix in "[a-zA-Z0-9_ -]{0,30}"
4895 ) {
4896 let cmd = format!("bun {} {}", subcommand, suffix);
4897 let result = classify_command(&cmd);
4898 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4899 if matches!(subcommand.as_str(), "install" | "add" | "remove" | "run" | "build" | "dev") {
4901 prop_assert!(!result.is_compilation,
4902 "bun {} should not be compilation", subcommand);
4903 }
4904 }
4905
4906 #[test]
4908 fn test_c_compiler_robustness(
4909 compiler in "(gcc|g\\+\\+|clang|clang\\+\\+)",
4910 flags in "(-[cCoOgW][a-z0-9]* )*",
4911 file in "[a-zA-Z_][a-zA-Z0-9_]*\\.(c|cpp|cc|h|hpp)"
4912 ) {
4913 let cmd = format!("{} {}{}", compiler, flags, file);
4914 let result = classify_command(&cmd);
4915 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4916 }
4917
4918 #[test]
4920 fn test_quoted_args_handling(
4921 base in "(cargo build|cargo test|gcc|make)",
4922 quoted_content in "[a-zA-Z0-9 _-]{0,30}"
4923 ) {
4924 let cmd_single = format!("{} '{}'", base, quoted_content);
4926 let _ = classify_command(&cmd_single);
4927
4928 let cmd_double = format!("{} \"{}\"", base, quoted_content);
4930 let _ = classify_command(&cmd_double);
4931 }
4932 }
4933
4934 #[test]
4936 fn test_known_edge_cases() {
4937 let _guard = test_guard!();
4938 let edge_cases = [
4940 "",
4941 " ",
4942 "\t",
4943 "\n",
4944 "cargo",
4945 "cargo ",
4946 "cargo ",
4947 "cargo\tbuild",
4948 "cargo\nbuild",
4949 "cargo\rbuild",
4950 "cargo+nightly",
4951 "cargo +nightly",
4952 "cargo +nightly build",
4953 " cargo build ",
4954 "CARGO_TARGET_DIR=/tmp cargo build",
4955 "/usr/local/bin/cargo build",
4956 "~/.cargo/bin/cargo build",
4957 "./cargo build",
4958 "../cargo build",
4959 "cargo build 'test file.rs'",
4960 "cargo build \"test file.rs\"",
4961 "cargo build test\\ file.rs",
4962 "cargo build -- --test-threads=1",
4963 "cargo build --features \"feat1 feat2\"",
4964 "cargo build 2>&1",
4965 "cargo build 2>/dev/null",
4966 "gcc -DFOO=\"bar baz\" main.c",
4967 "make -j$(nproc)",
4968 "ninja -C build",
4969 "cmake --build . --target all",
4970 "bun test --timeout 5000 src/",
4971 "env -i PATH=/usr/bin cargo build",
4972 ];
4973
4974 for cmd in edge_cases {
4975 let result = classify_command(cmd);
4976 assert!(
4977 result.confidence >= 0.0 && result.confidence <= 1.0,
4978 "Invalid confidence for: {:?}",
4979 cmd
4980 );
4981 }
4982 }
4983
4984 #[test]
4986 fn test_detailed_matches_simple_proptest() {
4987 let _guard = test_guard!();
4988 let test_cases = [
4989 "cargo build --release",
4990 "cargo test",
4991 "make -j8",
4992 "ls -la",
4993 "echo hello",
4994 ];
4995
4996 for cmd in test_cases {
4997 let simple = classify_command(cmd);
4998 let detailed = classify_command_detailed(cmd);
4999 assert_eq!(simple, detailed.classification, "Mismatch for: {}", cmd);
5000 }
5001 }
5002
5003 #[test]
5006 fn test_split_single_command() {
5007 let _guard = test_guard!();
5008 assert_eq!(split_shell_commands("cargo build"), vec!["cargo build"]);
5009 }
5010
5011 #[test]
5012 fn test_split_and_operator() {
5013 let _guard = test_guard!();
5014 assert_eq!(
5015 split_shell_commands("cargo fmt && cargo build"),
5016 vec!["cargo fmt", "cargo build"]
5017 );
5018 }
5019
5020 #[test]
5021 fn test_split_quoted_operator() {
5022 let _guard = test_guard!();
5023 assert_eq!(
5024 split_shell_commands("echo '&&' && cargo build"),
5025 vec!["echo '&&'", "cargo build"]
5026 );
5027 }
5028
5029 #[test]
5030 fn test_split_mixed_operators() {
5031 let _guard = test_guard!();
5032 assert_eq!(
5033 split_shell_commands("cd /tmp && make -j4 || echo fail"),
5034 vec!["cd /tmp", "make -j4", "echo fail"]
5035 );
5036 }
5037
5038 #[test]
5039 fn test_split_semicolons() {
5040 let _guard = test_guard!();
5041 assert_eq!(split_shell_commands("a ; b ; c"), vec!["a", "b", "c"]);
5042 }
5043
5044 #[test]
5045 fn test_split_quoted_semicolon() {
5046 let _guard = test_guard!();
5047 assert_eq!(
5048 split_shell_commands("echo 'hello;world' && make"),
5049 vec!["echo 'hello;world'", "make"]
5050 );
5051 }
5052
5053 #[test]
5054 fn test_split_pipe_preserved() {
5055 let _guard = test_guard!();
5056 assert_eq!(
5058 split_shell_commands("cargo build 2>&1 | tee log"),
5059 vec!["cargo build 2>&1 | tee log"]
5060 );
5061 }
5062
5063 #[test]
5064 fn test_split_double_quoted_operator() {
5065 let _guard = test_guard!();
5066 assert_eq!(
5067 split_shell_commands(r#"echo "&&" && cargo build"#),
5068 vec![r#"echo "&&""#, "cargo build"]
5069 );
5070 }
5071
5072 #[test]
5073 fn test_split_nested_quotes() {
5074 let _guard = test_guard!();
5075 assert_eq!(
5076 split_shell_commands("echo \"he said 'hello && bye'\" && cargo test"),
5077 vec!["echo \"he said 'hello && bye'\"", "cargo test"]
5078 );
5079 }
5080
5081 #[test]
5082 fn test_split_escaped_quote() {
5083 let _guard = test_guard!();
5084 assert_eq!(
5086 split_shell_commands(r"echo it\'s && cargo build"),
5087 vec![r"echo it\'s", "cargo build"]
5088 );
5089 }
5090
5091 #[test]
5092 fn test_split_backslash_literal_in_single_quotes() {
5093 let _guard = test_guard!();
5094 assert_eq!(
5099 split_shell_commands(r"cargo build 'a\' && rm -rf x"),
5100 vec![r"cargo build 'a\'", "rm -rf x"]
5101 );
5102 }
5103
5104 #[test]
5105 fn test_split_empty_string() {
5106 let _guard = test_guard!();
5107 assert!(split_shell_commands("").is_empty());
5108 }
5109
5110 #[test]
5111 fn test_split_only_whitespace() {
5112 let _guard = test_guard!();
5113 assert!(split_shell_commands(" ").is_empty());
5114 }
5115
5116 #[test]
5117 fn test_split_backtick_quoting() {
5118 let _guard = test_guard!();
5119 assert_eq!(
5120 split_shell_commands("echo `echo && fail` && cargo build"),
5121 vec!["echo `echo && fail`", "cargo build"]
5122 );
5123 }
5124
5125 #[test]
5126 fn test_split_trailing_operator() {
5127 let _guard = test_guard!();
5128 assert_eq!(split_shell_commands("cargo build &&"), vec!["cargo build"]);
5130 }
5131
5132 #[test]
5135 fn test_split_unclosed_single_quote() {
5136 let _guard = test_guard!();
5137 let result = split_shell_commands("echo 'hello && cargo build");
5139 assert_eq!(result, vec!["echo 'hello && cargo build"]);
5140 }
5141
5142 #[test]
5143 fn test_split_unclosed_double_quote() {
5144 let _guard = test_guard!();
5145 let result = split_shell_commands("echo \"hello && cargo build");
5146 assert_eq!(result, vec!["echo \"hello && cargo build"]);
5147 }
5148
5149 #[test]
5150 fn test_split_unclosed_backtick() {
5151 let _guard = test_guard!();
5152 let result = split_shell_commands("echo `hello && cargo build");
5153 assert_eq!(result, vec!["echo `hello && cargo build"]);
5154 }
5155
5156 #[test]
5157 fn test_split_embedded_nulls() {
5158 let _guard = test_guard!();
5159 let input = "cargo build\0 && echo done";
5161 let result = split_shell_commands(input);
5162 assert_eq!(result.len(), 2);
5163 }
5164
5165 #[test]
5166 fn test_split_unicode_input() {
5167 let _guard = test_guard!();
5168 let result = split_shell_commands("echo 'こんにちは' && cargo build");
5169 assert_eq!(result, vec!["echo 'こんにちは'", "cargo build"]);
5170 }
5171
5172 #[test]
5173 fn test_split_extremely_long_input() {
5174 let _guard = test_guard!();
5175 let long_cmd = format!("echo {} && cargo build", "x".repeat(20_000));
5177 let result = split_shell_commands(&long_cmd);
5178 assert_eq!(result.len(), 2);
5179 }
5180
5181 #[test]
5182 fn test_classify_long_input_skips_splitting() {
5183 let _guard = test_guard!();
5184 let long_cmd = format!("cargo build && echo {}", "x".repeat(11_000));
5186 let result = classify_command(&long_cmd);
5187 assert!(!result.is_compilation);
5189 assert!(
5190 result.reason.to_string().contains("chained"),
5191 "long input should be rejected by check_structure, not split"
5192 );
5193 }
5194
5195 #[test]
5196 fn test_split_only_operators() {
5197 let _guard = test_guard!();
5198 let result = split_shell_commands("&& || ;");
5200 assert!(
5201 result.is_empty(),
5202 "only operators should yield empty result"
5203 );
5204 }
5205
5206 #[test]
5207 fn test_split_consecutive_operators() {
5208 let _guard = test_guard!();
5209 let result = split_shell_commands("cargo build && && echo done");
5210 assert_eq!(result, vec!["cargo build", "echo done"]);
5212 }
5213
5214 #[test]
5215 fn test_classify_empty_after_split() {
5216 let _guard = test_guard!();
5217 let result = classify_command("echo hello && ls -la || pwd");
5219 assert!(!result.is_compilation);
5220 }
5221
5222 #[test]
5227 fn test_split_three_segment_chain() {
5228 let _guard = test_guard!();
5229 assert_eq!(
5230 split_shell_commands("cd /proj && cmake .. && make"),
5231 vec!["cd /proj", "cmake ..", "make"]
5232 );
5233 }
5234
5235 #[test]
5236 fn test_split_single_with_flags() {
5237 let _guard = test_guard!();
5238 assert_eq!(
5239 split_shell_commands("cargo build --release"),
5240 vec!["cargo build --release"]
5241 );
5242 }
5243
5244 #[test]
5245 fn test_split_nested_quotes_semicolon() {
5246 let _guard = test_guard!();
5247 assert_eq!(
5248 split_shell_commands("echo \"it's && done\" && make"),
5249 vec!["echo \"it's && done\"", "make"]
5250 );
5251 }
5252
5253 #[test]
5254 fn test_split_pipe_then_and() {
5255 let _guard = test_guard!();
5256 assert_eq!(
5258 split_shell_commands("make 2>&1 | grep error && echo done"),
5259 vec!["make 2>&1 | grep error", "echo done"]
5260 );
5261 }
5262
5263 #[test]
5264 fn test_split_leading_operator() {
5265 let _guard = test_guard!();
5266 assert_eq!(split_shell_commands("&& cargo build"), vec!["cargo build"]);
5268 }
5269
5270 #[test]
5275 fn test_classify_cargo_fmt_and_build() {
5276 let _guard = test_guard!();
5277 let result = classify_command("cargo fmt && cargo build");
5279 assert!(
5280 result.is_compilation,
5281 "compound command with compilation suffix should be accepted"
5282 );
5283 assert!(result.reason.contains("compound"));
5284 }
5285
5286 #[test]
5287 fn test_classify_cd_and_make() {
5288 let _guard = test_guard!();
5289 let result = classify_command("cd /project && make -j8");
5291 assert!(
5292 result.is_compilation,
5293 "compound command with compilation suffix should be accepted"
5294 );
5295 assert!(result.reason.contains("compound"));
5296 }
5297 #[test]
5298 fn test_classify_export_and_cargo_build() {
5299 let _guard = test_guard!();
5300 let result =
5302 classify_command("export RUSTFLAGS='-C opt-level=3' && cargo build --release");
5303 assert!(
5304 result.is_compilation,
5305 "compound command with compilation suffix should be accepted"
5306 );
5307 assert!(result.reason.contains("compound"));
5308 }
5309 #[test]
5310 fn test_classify_mkdir_cmake_chain() {
5311 let _guard = test_guard!();
5312 let result =
5314 classify_command("mkdir -p build && cmake -B build && cmake --build build");
5315 assert!(
5316 result.is_compilation,
5317 "compound command with compilation suffix should be accepted"
5318 );
5319 assert!(result.reason.contains("compound"));
5320 }
5321 #[test]
5322 fn test_classify_echo_and_cargo_test() {
5323 let _guard = test_guard!();
5324 let result = classify_command("echo 'Starting...' && cargo test");
5326 assert!(
5327 result.is_compilation,
5328 "compound command with compilation suffix should be accepted"
5329 );
5330 assert!(result.reason.contains("compound"));
5331 }
5332 #[test]
5333 fn test_classify_semicolon_chain_with_compilation() {
5334 let _guard = test_guard!();
5335 let result = classify_command("cargo fmt; cargo build; cargo test");
5337 assert!(
5338 !result.is_compilation,
5339 "semicolon chained commands should be rejected"
5340 );
5341 assert!(result.reason.contains("chained"));
5342 }
5343 #[test]
5346 fn test_classify_echo_chain_non_compilation() {
5347 let _guard = test_guard!();
5348 let result = classify_command("echo hello && echo world");
5349 assert!(!result.is_compilation);
5350 }
5351
5352 #[test]
5353 fn test_classify_ls_cat_non_compilation() {
5354 let _guard = test_guard!();
5355 let result = classify_command("ls -la && cat file.txt");
5356 assert!(!result.is_compilation);
5357 }
5358
5359 #[test]
5360 fn test_classify_git_chain_non_compilation() {
5361 let _guard = test_guard!();
5362 let result = classify_command("git status && git log");
5363 assert!(!result.is_compilation);
5364 }
5365
5366 #[test]
5369 fn test_classify_multi_command_performance() {
5370 let _guard = test_guard!();
5371 let start = std::time::Instant::now();
5372 for _ in 0..100 {
5373 let _ = classify_command("cargo fmt && cargo build && cargo test");
5374 }
5375 let elapsed = start.elapsed();
5376 let per_call_us = elapsed.as_micros() / 100;
5377 assert!(
5379 per_call_us < 5_000,
5380 "classify_command took {}us per call, should be <5000us",
5381 per_call_us
5382 );
5383 eprintln!(
5384 "[perf] classify_command multi-command: {}us avg per call",
5385 per_call_us
5386 );
5387 }
5388
5389 #[test]
5392 fn test_classify_pipe_to_pager_preserved() {
5393 let _guard = test_guard!();
5394 let result = classify_command("cargo build 2>&1 | tee log");
5397 assert!(result.is_compilation, "got: {:?}", result.reason);
5398 assert_eq!(
5399 result.extracted_command.as_deref(),
5400 Some("cargo build 2>&1 | tee log")
5401 );
5402 }
5403
5404 #[test]
5405 fn test_classify_pipe_segment_and_operator() {
5406 let _guard = test_guard!();
5407 let result = classify_command("make 2>&1 | grep error && cargo build");
5409 assert!(
5411 !result.is_compilation,
5412 "chained commands should be rejected"
5413 );
5414 assert!(result.reason.contains("piped") || result.reason.contains("chained"));
5419 }
5420 }
5421
5422 #[allow(clippy::ptr_arg)]
5433 fn assert_cow_borrowed(cow: &Cow<'static, str>, context: &str) {
5434 assert!(
5435 matches!(cow, Cow::Borrowed(_)),
5436 "{context}: expected Cow::Borrowed, got Cow::Owned({:?})",
5437 cow,
5438 );
5439 }
5440
5441 #[test]
5444 fn test_cow_reject_empty_command_correctness() {
5445 let _guard = test_guard!();
5446 let result = classify_command("");
5447 assert!(!result.is_compilation);
5448 assert_eq!(result.confidence, 0.0);
5449 assert_eq!(result.kind, None);
5450 assert!(result.reason.contains("empty"), "reason: {}", result.reason);
5451 }
5452
5453 #[test]
5454 fn test_cow_reject_non_compilation_commands() {
5455 let _guard = test_guard!();
5456 let non_compilation = [
5457 "ls -la",
5458 "cat file.txt",
5459 "echo hello",
5460 "git status",
5461 "pwd",
5462 "cd /tmp",
5463 ];
5464 for cmd in non_compilation {
5465 let result = classify_command(cmd);
5466 assert!(
5467 !result.is_compilation,
5468 "'{cmd}' should NOT be classified as compilation"
5469 );
5470 assert_eq!(result.confidence, 0.0, "'{cmd}' should have 0.0 confidence");
5471 assert_eq!(result.kind, None, "'{cmd}' should have no CompilationKind");
5472 assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5473 }
5474 }
5475
5476 #[test]
5477 fn test_cow_accept_compilation_commands() {
5478 let _guard = test_guard!();
5479 let cases: &[(&str, CompilationKind)] = &[
5480 ("cargo build", CompilationKind::CargoBuild),
5481 ("cargo test", CompilationKind::CargoTest),
5482 ("cargo check", CompilationKind::CargoCheck),
5483 ("cargo clippy", CompilationKind::CargoClippy),
5484 ("gcc -o hello hello.c", CompilationKind::Gcc),
5485 ("make", CompilationKind::Make),
5486 ("bun test", CompilationKind::BunTest),
5487 ];
5488 for &(cmd, expected_kind) in cases {
5489 let result = classify_command(cmd);
5490 assert!(
5491 result.is_compilation,
5492 "'{cmd}' should be classified as compilation"
5493 );
5494 assert_eq!(
5495 result.kind,
5496 Some(expected_kind),
5497 "'{cmd}' should have kind {expected_kind:?}"
5498 );
5499 assert!(
5500 result.confidence > 0.0,
5501 "'{cmd}' should have positive confidence"
5502 );
5503 assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5504 }
5505 }
5506
5507 #[test]
5510 fn test_cow_borrowed_on_tier0_reject() {
5511 let _guard = test_guard!();
5512 let result = classify_command("");
5514 assert_cow_borrowed(&result.reason, "Tier 0 empty command reason");
5515 }
5516
5517 #[test]
5518 fn test_cow_borrowed_on_tier1_reject() {
5519 let _guard = test_guard!();
5520 let result = classify_command("cargo build | xargs rm");
5523 assert!(!result.is_compilation);
5524 assert_cow_borrowed(&result.reason, "Tier 1 piped command reason");
5525
5526 let result = classify_command("cargo build < input.txt");
5528 assert!(!result.is_compilation);
5529 assert_cow_borrowed(&result.reason, "Tier 1 input redirect reason");
5530
5531 let result = classify_command("cargo build $(echo args)");
5533 assert!(!result.is_compilation);
5534 assert_cow_borrowed(&result.reason, "Tier 1 subshell reason");
5535 }
5536
5537 #[test]
5538 fn test_cow_borrowed_on_tier2_reject() {
5539 let _guard = test_guard!();
5540 let non_keyword_commands = [
5542 "ls -la",
5543 "cat file.txt",
5544 "echo hello world",
5545 "git status",
5546 "pwd",
5547 "cd /tmp",
5548 "grep pattern file",
5549 "find . -name '*.rs'",
5550 "cp src dst",
5551 "rm file.txt",
5552 ];
5553 for cmd in non_keyword_commands {
5554 let result = classify_command(cmd);
5555 assert!(!result.is_compilation, "'{cmd}' should be rejected");
5556 assert_cow_borrowed(&result.reason, &format!("Tier 2 reject for '{cmd}'"));
5557 }
5558 }
5559
5560 #[test]
5561 fn test_cow_borrowed_on_tier3_reject() {
5562 let _guard = test_guard!();
5563 let never_intercept = ["cargo fmt", "cargo install ripgrep", "cargo clean"];
5565 for cmd in never_intercept {
5566 let result = classify_command(cmd);
5567 assert!(!result.is_compilation, "'{cmd}' should be rejected");
5568 assert!(
5569 result.reason.contains("never-intercept"),
5570 "'{cmd}' reason should mention never-intercept: {}",
5571 result.reason
5572 );
5573 assert_cow_borrowed(&result.reason, &format!("Tier 3 reject for '{cmd}'"));
5575 }
5576 }
5577
5578 #[test]
5581 fn test_detailed_tiers_use_borrowed_names() {
5582 let _guard = test_guard!();
5583 let details = classify_command_detailed("ls -la");
5586 for tier in &details.tiers {
5587 assert_cow_borrowed(&tier.name, &format!("Tier {} name", tier.tier));
5588 }
5589 }
5590
5591 #[test]
5592 fn test_detailed_tier_reasons_borrowed_on_reject() {
5593 let _guard = test_guard!();
5594 let details = classify_command_detailed("ls -la");
5596 assert!(!details.classification.is_compilation);
5597 for tier in &details.tiers {
5598 assert_cow_borrowed(
5599 &tier.reason,
5600 &format!("Tier {} reason for 'ls -la'", tier.tier),
5601 );
5602 }
5603 }
5604
5605 #[test]
5606 fn test_detailed_compilation_tier_names_borrowed() {
5607 let _guard = test_guard!();
5608 let details = classify_command_detailed("cargo build");
5610 assert!(details.classification.is_compilation);
5611 for tier in &details.tiers {
5612 assert_cow_borrowed(
5613 &tier.name,
5614 &format!("Tier {} name for 'cargo build'", tier.tier),
5615 );
5616 }
5617 }
5618
5619 #[test]
5622 fn test_classification_serde_roundtrip_borrowed() {
5623 let _guard = test_guard!();
5624 let original = Classification::not_compilation("no compilation keyword");
5625 assert_cow_borrowed(&original.reason, "before serialize");
5626
5627 let json = serde_json::to_string(&original).expect("serialize");
5628 let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5629
5630 assert_eq!(deserialized.is_compilation, original.is_compilation);
5631 assert_eq!(deserialized.confidence, original.confidence);
5632 assert_eq!(deserialized.kind, original.kind);
5633 assert_eq!(deserialized.reason, original.reason);
5634 }
5637
5638 #[test]
5639 fn test_classification_serde_roundtrip_compilation() {
5640 let _guard = test_guard!();
5641 let original =
5642 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build detected");
5643
5644 let json = serde_json::to_string(&original).expect("serialize");
5645 let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5646
5647 assert_eq!(deserialized.is_compilation, original.is_compilation);
5648 assert_eq!(deserialized.confidence, original.confidence);
5649 assert_eq!(deserialized.kind, original.kind);
5650 assert_eq!(deserialized.reason.as_ref(), original.reason.as_ref());
5651 }
5652
5653 #[test]
5654 fn test_classification_tier_serde_roundtrip() {
5655 let _guard = test_guard!();
5656 let tier = ClassificationTier {
5657 tier: 2,
5658 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
5659 decision: TierDecision::Reject,
5660 reason: Cow::Borrowed("no compilation keyword"),
5661 };
5662 assert_cow_borrowed(&tier.name, "tier name before serialize");
5663 assert_cow_borrowed(&tier.reason, "tier reason before serialize");
5664
5665 let json = serde_json::to_string(&tier).expect("serialize");
5666 let deserialized: ClassificationTier = serde_json::from_str(&json).expect("deserialize");
5667
5668 assert_eq!(deserialized.tier, tier.tier);
5669 assert_eq!(deserialized.name.as_ref(), tier.name.as_ref());
5670 assert_eq!(deserialized.decision, tier.decision);
5671 assert_eq!(deserialized.reason.as_ref(), tier.reason.as_ref());
5672 }
5673
5674 #[test]
5675 fn test_classification_details_serde_roundtrip() {
5676 let _guard = test_guard!();
5677 let details = classify_command_detailed("cargo build");
5678 assert!(details.classification.is_compilation);
5679
5680 let json = serde_json::to_string(&details).expect("serialize");
5681 let deserialized: ClassificationDetails = serde_json::from_str(&json).expect("deserialize");
5682
5683 assert_eq!(deserialized.original, details.original);
5684 assert_eq!(deserialized.normalized, details.normalized);
5685 assert_eq!(deserialized.tiers.len(), details.tiers.len());
5686 assert_eq!(
5687 deserialized.classification.is_compilation,
5688 details.classification.is_compilation
5689 );
5690 assert_eq!(
5691 deserialized.classification.kind,
5692 details.classification.kind
5693 );
5694 }
5695
5696 #[test]
5699 fn test_cow_reason_display() {
5700 let _guard = test_guard!();
5701 let result = classify_command("ls");
5702 let displayed = format!("{}", result.reason);
5704 assert!(!displayed.is_empty());
5705 let debugged = format!("{:?}", result.reason);
5706 assert!(!debugged.is_empty());
5707 }
5708
5709 #[test]
5710 fn test_cow_reason_comparison() {
5711 let _guard = test_guard!();
5712 let borrowed: Cow<'static, str> = Cow::Borrowed("no compilation keyword");
5714 let owned: Cow<'static, str> = Cow::Owned("no compilation keyword".to_string());
5715 assert_eq!(borrowed, owned);
5716
5717 let r1 = classify_command("ls");
5719 let r2 = classify_command("pwd");
5720 assert_eq!(r1.reason, r2.reason);
5722 }
5723}
5724
5725#[cfg(test)]
5726mod tests_bun_whitespace {
5727 use super::*;
5728 use crate::test_guard;
5729
5730 #[test]
5731 fn test_bun_whitespace_resilience() {
5732 let _guard = test_guard!();
5733
5734 let result = classify_command("bun test");
5736 assert!(result.is_compilation, "bun test failed");
5737 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5738
5739 let result = classify_command("bun test");
5741 assert!(result.is_compilation, "bun test failed");
5742 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5743
5744 let result = classify_command("bun\ttest");
5746 assert!(result.is_compilation, "bun\\ttest failed");
5747 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5748
5749 let result = classify_command("bun typecheck src/");
5751 assert!(result.is_compilation, "bun typecheck with spaces failed");
5752 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
5753 }
5754
5755 #[test]
5756 fn test_bun_watch_with_whitespace() {
5757 let _guard = test_guard!();
5758
5759 let result = classify_command("bun test --watch");
5761 assert!(
5762 !result.is_compilation,
5763 "bun test --watch should be rejected"
5764 );
5765 assert!(result.reason.contains("interactive"));
5766
5767 let result = classify_command("bun\ttypecheck\t-w");
5769 assert!(
5770 !result.is_compilation,
5771 "bun typecheck -w should be rejected"
5772 );
5773 assert!(result.reason.contains("interactive"));
5774 }
5775
5776 #[test]
5777 fn test_bun_x_whitespace() {
5778 let _guard = test_guard!();
5779
5780 let result = classify_command("bun x vitest");
5782 assert!(!result.is_compilation, "bun x should be rejected");
5783 assert!(result.reason.contains("bun x"));
5784 }
5785}
5786
5787#[cfg(test)]
5788mod tests_normalize_whitespace {
5789 use super::*;
5790 use crate::test_guard;
5791
5792 #[test]
5793 fn test_wrapper_whitespace_resilience() {
5794 let _guard = test_guard!();
5795
5796 assert_eq!(normalize_command("sudo cargo"), "cargo");
5798
5799 assert_eq!(normalize_command("sudo\tcargo"), "cargo");
5801
5802 assert_eq!(normalize_command("time\tsudo cargo"), "cargo");
5804
5805 assert_eq!(normalize_command("sudocargo"), "sudocargo");
5807
5808 assert_eq!(normalize_command("sudo"), "");
5810 }
5811
5812 #[test]
5813 fn wrapper_value_flags_consume_their_value_token() {
5814 let _guard = test_guard!();
5818 assert_eq!(normalize_command("nice -n 10 cargo build"), "cargo build");
5819 assert_eq!(
5820 normalize_command("taskset -c 0-3 cargo build"),
5821 "cargo build"
5822 );
5823 assert_eq!(normalize_command("ionice -c 2 cargo test"), "cargo test");
5824 assert_eq!(
5825 normalize_command("ionice -c 2 -n 4 cargo build"),
5826 "cargo build"
5827 );
5828 assert_eq!(normalize_command("chrt -r 10 cargo build"), "cargo build");
5829 assert_eq!(normalize_command("timeout 60 cargo build"), "cargo build");
5830 assert_eq!(
5831 normalize_command("timeout -s KILL 90 cargo build"),
5832 "cargo build"
5833 );
5834 assert_eq!(
5835 normalize_command("sudo nice -n 5 cargo build"),
5836 "cargo build"
5837 );
5838 assert_eq!(normalize_command("nice -n10 cargo build"), "cargo build");
5840 assert_eq!(normalize_command("ionice -c2 cargo build"), "cargo build");
5841 assert_eq!(
5843 normalize_command("env -u RUSTFLAGS cargo build"),
5844 "cargo build"
5845 );
5846 }
5847
5848 #[test]
5849 fn positional_wrapper_does_not_eat_a_non_numeric_command() {
5850 let _guard = test_guard!();
5853 assert_eq!(normalize_command("timeout cargo build"), "cargo build");
5854 }
5855
5856 #[test]
5857 fn wrapped_cargo_build_is_still_classified_as_compilation() {
5858 let _guard = test_guard!();
5859 for cmd in [
5860 "nice -n 10 cargo build",
5861 "taskset -c 0-3 cargo build",
5862 "ionice -c 2 cargo build",
5863 "chrt -r 10 cargo build",
5864 "timeout 60 cargo build",
5865 "timeout -s KILL 90 cargo test",
5866 "time '-f%U' cargo build",
5867 "time \"-f%U\" cargo build",
5868 "nice '--adjustment=+5' cargo build",
5869 "nice '-n' '5' cargo build",
5870 "env '--unset' 'RUSTFLAGS' cargo build",
5871 ] {
5872 assert!(
5873 classify_command(cmd).is_compilation,
5874 "wrapper must not defeat offload: {cmd}"
5875 );
5876 }
5877 assert!(!classify_command("nice -n 10 ls -la").is_compilation);
5879 assert!(!classify_command("timeout 60 echo hi").is_compilation);
5880 assert!(!classify_command("time '-f%U' echo cargo build").is_compilation);
5881 assert!(!classify_command("time '-f%U cargo build").is_compilation);
5882 assert!(!classify_command("time '-f%U'echo cargo build").is_compilation);
5883 }
5884
5885 #[test]
5886 fn test_env_var_whitespace() {
5887 let _guard = test_guard!();
5888
5889 for (cmd, normalized) in [
5890 ("env RUST_BACKTRACE=1 cargo", "cargo"),
5892 ("env\tRUST_BACKTRACE=1\tcargo", "cargo"),
5894 ("env -u RUST_LOG cargo test", "cargo test"),
5895 ("env --unset RUST_LOG cargo test", "cargo test"),
5896 ("env --unset=RUST_LOG cargo test", "cargo test"),
5897 ] {
5898 assert_eq!(normalize_command(cmd), normalized, "{cmd}");
5899 }
5900 }
5901}
5902
5903#[cfg(test)]
5912mod regression_classification {
5913 use super::*;
5914 use crate::test_guard;
5915
5916 fn assert_golden_json(
5917 actual: serde_json::Value,
5918 expected: &serde_json::Value,
5919 fixture_path: &str,
5920 ) {
5921 if &actual == expected {
5922 return;
5923 }
5924
5925 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
5926 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
5927 panic!(
5928 "golden mismatch in {fixture_path}\n\
5929 expected_blake3={}\n\
5930 actual_blake3={}\n\
5931 bless path: review the semantic diff, update the fixture expected_* field, \
5932 and record both hashes in the Beads closeout.\n\
5933 expected:\n{expected_json}\n\
5934 actual:\n{actual_json}",
5935 blake3::hash(expected_json.as_bytes()),
5936 blake3::hash(actual_json.as_bytes())
5937 );
5938 }
5939
5940 struct Case {
5942 cmd: &'static str,
5943 expect_compilation: bool,
5944 expected_kind: Option<CompilationKind>,
5945 reason_contains: &'static str,
5947 min_confidence: f64,
5949 }
5950
5951 fn run_cases(cases: &[Case]) {
5952 for (i, c) in cases.iter().enumerate() {
5953 let result = classify_command(c.cmd);
5954 assert_eq!(
5955 result.is_compilation, c.expect_compilation,
5956 "Case {i} ({:?}): expected is_compilation={}, got={}. reason={:?}",
5957 c.cmd, c.expect_compilation, result.is_compilation, result.reason
5958 );
5959 if c.expect_compilation {
5960 assert_eq!(
5961 result.kind, c.expected_kind,
5962 "Case {i} ({:?}): expected kind={:?}, got={:?}",
5963 c.cmd, c.expected_kind, result.kind
5964 );
5965 assert!(
5966 result.confidence >= c.min_confidence,
5967 "Case {i} ({:?}): expected confidence >= {}, got={}",
5968 c.cmd,
5969 c.min_confidence,
5970 result.confidence
5971 );
5972 }
5973 if !c.reason_contains.is_empty() {
5974 assert!(
5975 result.reason.contains(c.reason_contains),
5976 "Case {i} ({:?}): expected reason containing {:?}, got {:?}",
5977 c.cmd,
5978 c.reason_contains,
5979 result.reason
5980 );
5981 }
5982 }
5983 }
5984
5985 #[test]
5990 fn regression_cargo_compilation_positive() {
5991 let _guard = test_guard!();
5992 let cases = [
5993 Case {
5994 cmd: "cargo build",
5995 expect_compilation: true,
5996 expected_kind: Some(CompilationKind::CargoBuild),
5997 reason_contains: "cargo build",
5998 min_confidence: 0.90,
5999 },
6000 Case {
6001 cmd: "cargo build --release",
6002 expect_compilation: true,
6003 expected_kind: Some(CompilationKind::CargoBuild),
6004 reason_contains: "cargo build",
6005 min_confidence: 0.90,
6006 },
6007 Case {
6008 cmd: "cargo build --workspace",
6009 expect_compilation: true,
6010 expected_kind: Some(CompilationKind::CargoBuild),
6011 reason_contains: "cargo build",
6012 min_confidence: 0.90,
6013 },
6014 Case {
6015 cmd: "cargo build -p my-crate",
6016 expect_compilation: true,
6017 expected_kind: Some(CompilationKind::CargoBuild),
6018 reason_contains: "cargo build",
6019 min_confidence: 0.90,
6020 },
6021 Case {
6022 cmd: "cargo b",
6023 expect_compilation: true,
6024 expected_kind: Some(CompilationKind::CargoBuild),
6025 reason_contains: "cargo build",
6026 min_confidence: 0.90,
6027 },
6028 Case {
6029 cmd: "cargo test",
6030 expect_compilation: true,
6031 expected_kind: Some(CompilationKind::CargoTest),
6032 reason_contains: "cargo test",
6033 min_confidence: 0.90,
6034 },
6035 Case {
6036 cmd: "cargo test --release",
6037 expect_compilation: true,
6038 expected_kind: Some(CompilationKind::CargoTest),
6039 reason_contains: "cargo test",
6040 min_confidence: 0.90,
6041 },
6042 Case {
6043 cmd: "cargo test my_test",
6044 expect_compilation: true,
6045 expected_kind: Some(CompilationKind::CargoTest),
6046 reason_contains: "cargo test",
6047 min_confidence: 0.90,
6048 },
6049 Case {
6050 cmd: "cargo test -- --nocapture",
6051 expect_compilation: true,
6052 expected_kind: Some(CompilationKind::CargoTest),
6053 reason_contains: "cargo test",
6054 min_confidence: 0.90,
6055 },
6056 Case {
6057 cmd: "cargo test --workspace",
6058 expect_compilation: true,
6059 expected_kind: Some(CompilationKind::CargoTest),
6060 reason_contains: "cargo test",
6061 min_confidence: 0.90,
6062 },
6063 Case {
6064 cmd: "cargo test -p rch-common",
6065 expect_compilation: true,
6066 expected_kind: Some(CompilationKind::CargoTest),
6067 reason_contains: "cargo test",
6068 min_confidence: 0.90,
6069 },
6070 Case {
6071 cmd: "cargo t",
6072 expect_compilation: true,
6073 expected_kind: Some(CompilationKind::CargoTest),
6074 reason_contains: "cargo test",
6075 min_confidence: 0.90,
6076 },
6077 Case {
6078 cmd: "cargo test --lib",
6079 expect_compilation: true,
6080 expected_kind: Some(CompilationKind::CargoTest),
6081 reason_contains: "cargo test",
6082 min_confidence: 0.90,
6083 },
6084 Case {
6085 cmd: "cargo test --bins",
6086 expect_compilation: true,
6087 expected_kind: Some(CompilationKind::CargoTest),
6088 reason_contains: "cargo test",
6089 min_confidence: 0.90,
6090 },
6091 Case {
6092 cmd: "cargo test --doc",
6093 expect_compilation: true,
6094 expected_kind: Some(CompilationKind::CargoTest),
6095 reason_contains: "cargo test",
6096 min_confidence: 0.90,
6097 },
6098 Case {
6099 cmd: "cargo test -- --test-threads=4",
6100 expect_compilation: true,
6101 expected_kind: Some(CompilationKind::CargoTest),
6102 reason_contains: "cargo test",
6103 min_confidence: 0.90,
6104 },
6105 Case {
6106 cmd: "cargo check",
6107 expect_compilation: true,
6108 expected_kind: Some(CompilationKind::CargoCheck),
6109 reason_contains: "cargo check",
6110 min_confidence: 0.85,
6111 },
6112 Case {
6113 cmd: "cargo check --workspace --all-targets",
6114 expect_compilation: true,
6115 expected_kind: Some(CompilationKind::CargoCheck),
6116 reason_contains: "cargo check",
6117 min_confidence: 0.85,
6118 },
6119 Case {
6120 cmd: "cargo c",
6121 expect_compilation: true,
6122 expected_kind: Some(CompilationKind::CargoCheck),
6123 reason_contains: "cargo check",
6124 min_confidence: 0.85,
6125 },
6126 Case {
6127 cmd: "cargo clippy",
6128 expect_compilation: true,
6129 expected_kind: Some(CompilationKind::CargoClippy),
6130 reason_contains: "cargo clippy",
6131 min_confidence: 0.85,
6132 },
6133 Case {
6134 cmd: "cargo clippy --workspace --all-targets -- -D warnings",
6135 expect_compilation: true,
6136 expected_kind: Some(CompilationKind::CargoClippy),
6137 reason_contains: "cargo clippy",
6138 min_confidence: 0.85,
6139 },
6140 Case {
6141 cmd: "cargo doc",
6142 expect_compilation: true,
6143 expected_kind: Some(CompilationKind::CargoDoc),
6144 reason_contains: "cargo doc",
6145 min_confidence: 0.80,
6146 },
6147 Case {
6148 cmd: "cargo doc --no-deps",
6149 expect_compilation: true,
6150 expected_kind: Some(CompilationKind::CargoDoc),
6151 reason_contains: "cargo doc",
6152 min_confidence: 0.80,
6153 },
6154 Case {
6155 cmd: "cargo run",
6156 expect_compilation: true,
6157 expected_kind: Some(CompilationKind::CargoBuild),
6158 reason_contains: "cargo run",
6159 min_confidence: 0.80,
6160 },
6161 Case {
6162 cmd: "cargo run --release",
6163 expect_compilation: true,
6164 expected_kind: Some(CompilationKind::CargoBuild),
6165 reason_contains: "cargo run",
6166 min_confidence: 0.80,
6167 },
6168 Case {
6169 cmd: "cargo r",
6170 expect_compilation: true,
6171 expected_kind: Some(CompilationKind::CargoBuild),
6172 reason_contains: "cargo run",
6173 min_confidence: 0.80,
6174 },
6175 Case {
6176 cmd: "cargo bench",
6177 expect_compilation: true,
6178 expected_kind: Some(CompilationKind::CargoBench),
6179 reason_contains: "cargo bench",
6180 min_confidence: 0.85,
6181 },
6182 Case {
6183 cmd: "cargo bench --bench my_bench",
6184 expect_compilation: true,
6185 expected_kind: Some(CompilationKind::CargoBench),
6186 reason_contains: "cargo bench",
6187 min_confidence: 0.85,
6188 },
6189 Case {
6190 cmd: "cargo nextest run",
6191 expect_compilation: true,
6192 expected_kind: Some(CompilationKind::CargoNextest),
6193 reason_contains: "cargo nextest run",
6194 min_confidence: 0.90,
6195 },
6196 Case {
6197 cmd: "cargo nextest run --workspace",
6198 expect_compilation: true,
6199 expected_kind: Some(CompilationKind::CargoNextest),
6200 reason_contains: "cargo nextest run",
6201 min_confidence: 0.90,
6202 },
6203 Case {
6204 cmd: "cargo nextest r",
6205 expect_compilation: true,
6206 expected_kind: Some(CompilationKind::CargoNextest),
6207 reason_contains: "cargo nextest run",
6208 min_confidence: 0.90,
6209 },
6210 Case {
6212 cmd: "cargo +nightly build",
6213 expect_compilation: true,
6214 expected_kind: Some(CompilationKind::CargoBuild),
6215 reason_contains: "cargo build",
6216 min_confidence: 0.90,
6217 },
6218 Case {
6219 cmd: "cargo +1.80.0 test",
6220 expect_compilation: true,
6221 expected_kind: Some(CompilationKind::CargoTest),
6222 reason_contains: "cargo test",
6223 min_confidence: 0.90,
6224 },
6225 Case {
6226 cmd: "cargo +nightly nextest run",
6227 expect_compilation: true,
6228 expected_kind: Some(CompilationKind::CargoNextest),
6229 reason_contains: "cargo nextest run",
6230 min_confidence: 0.90,
6231 },
6232 Case {
6235 cmd: "cargo --offline build",
6236 expect_compilation: true,
6237 expected_kind: Some(CompilationKind::CargoBuild),
6238 reason_contains: "cargo build",
6239 min_confidence: 0.90,
6240 },
6241 Case {
6242 cmd: "cargo -q test",
6243 expect_compilation: true,
6244 expected_kind: Some(CompilationKind::CargoTest),
6245 reason_contains: "cargo test",
6246 min_confidence: 0.90,
6247 },
6248 Case {
6249 cmd: "cargo --locked --frozen check",
6250 expect_compilation: true,
6251 expected_kind: Some(CompilationKind::CargoCheck),
6252 reason_contains: "cargo check",
6253 min_confidence: 0.85,
6254 },
6255 Case {
6256 cmd: "cargo --color never build",
6257 expect_compilation: true,
6258 expected_kind: Some(CompilationKind::CargoBuild),
6259 reason_contains: "cargo build",
6260 min_confidence: 0.90,
6261 },
6262 Case {
6263 cmd: "cargo +nightly --offline -Z unstable-options build",
6264 expect_compilation: true,
6265 expected_kind: Some(CompilationKind::CargoBuild),
6266 reason_contains: "cargo build",
6267 min_confidence: 0.90,
6268 },
6269 Case {
6271 cmd: "cargo '--config=build.jobs=2' -vvv clippy",
6272 expect_compilation: true,
6273 expected_kind: Some(CompilationKind::CargoClippy),
6274 reason_contains: "cargo clippy",
6275 min_confidence: 0.85,
6276 },
6277 Case {
6278 cmd: "cargo-zigbuild '--color=never' zigbuild",
6279 expect_compilation: true,
6280 expected_kind: Some(CompilationKind::CargoZigbuild),
6281 reason_contains: "cargo-zigbuild build",
6282 min_confidence: 0.90,
6283 },
6284 Case {
6286 cmd: "cargo 'fetch'",
6287 expect_compilation: false,
6288 expected_kind: None,
6289 reason_contains: "",
6290 min_confidence: 0.0,
6291 },
6292 ];
6293 run_cases(&cases);
6294 }
6295
6296 #[test]
6297 fn regression_cargo_package_verification_is_compilation() {
6298 let _guard = test_guard!();
6299 for command in [
6300 "cargo package",
6301 "cargo package --workspace --locked -j2",
6302 "cargo package -p asupersync --features tls --target x86_64-unknown-linux-gnu",
6303 "cargo +nightly --offline package --allow-dirty --no-metadata",
6304 "cargo --color never package --target-dir=/data/tmp/package-check",
6305 "cargo publish --dry-run",
6306 "cargo publish -n --workspace --locked --keep-going -j 2",
6307 "cargo publish --registry crates-io -p asupersync --dry-run",
6308 "cargo +nightly --locked publish --dry-run --features=tls",
6309 "env CARGO_INCREMENTAL=0 cargo publish --dry-run --workspace",
6310 ] {
6311 let classification = classify_command(command);
6312 assert!(
6313 classification.is_compilation,
6314 "{command}: {classification:?}"
6315 );
6316 assert_eq!(classification.kind, Some(CompilationKind::CargoBuild));
6317 assert_eq!(
6318 classify_command_detailed(command).classification,
6319 classification,
6320 "diagnosis must agree with execution for {command}"
6321 );
6322 }
6323 }
6324
6325 #[test]
6326 fn regression_cargo_package_verification_declines_publication_and_metadata() {
6327 let _guard = test_guard!();
6328 for command in [
6329 "cargo publish",
6330 "cargo publish --workspace --locked",
6331 "cargo +nightly --offline publish",
6332 "cargo publish --no-verify",
6333 "cargo publish --dry-run --no-verify",
6334 "cargo publish --no-verify -n",
6335 "cargo publish -qn --no-verify",
6336 "cargo package --no-verify",
6337 "cargo package --list",
6338 "cargo package -l",
6339 "cargo package -vl",
6340 "cargo package --help",
6341 "cargo --help package",
6342 "cargo publish --dry-run -h",
6343 "cargo publish -- --dry-run",
6344 "cargo publish --dry-run -- --no-verify",
6345 "cargo publish --dry-run=false",
6346 "cargo publish --registry=--dry-run",
6347 "cargo publish -p--dry-run",
6348 "cargo publish --token=--dry-run",
6349 "cargo publish --config --dry-run",
6350 "cargo publish --config alias.note=--dry-run",
6351 "cargo publish --unknown-option --dry-run",
6352 "cargo package --target --no-verify",
6353 "cargo package --package --list",
6354 "cargo package --config",
6355 "cargo publish --config 'alias.note=\"--dry-run\"'",
6356 "cargo publish --registry 'example --dry-run registry'",
6357 "cargo publish --dry-run --config 'alias.note=\"--no-verify\"'",
6358 "cargo publish --dry-run $EXTRA_CARGO_OPTIONS",
6359 ] {
6360 let classification = classify_command(command);
6361 assert!(
6362 !classification.is_compilation,
6363 "{command}: {classification:?}"
6364 );
6365 let detailed = classify_command_detailed(command).classification;
6366 assert!(!detailed.is_compilation, "{command}: {detailed:?}");
6367 assert!(!is_cargo_package_verification(command), "{command}");
6368 }
6369 }
6370
6371 #[test]
6376 fn regression_cargo_never_intercept() {
6377 let _guard = test_guard!();
6378 let cases = [
6379 Case {
6380 cmd: "cargo install ripgrep",
6381 expect_compilation: false,
6382 expected_kind: None,
6383 reason_contains: "never-intercept",
6384 min_confidence: 0.0,
6385 },
6386 Case {
6387 cmd: "cargo publish",
6388 expect_compilation: false,
6389 expected_kind: None,
6390 reason_contains: "never-intercept",
6391 min_confidence: 0.0,
6392 },
6393 Case {
6394 cmd: "cargo login",
6395 expect_compilation: false,
6396 expected_kind: None,
6397 reason_contains: "never-intercept",
6398 min_confidence: 0.0,
6399 },
6400 Case {
6401 cmd: "cargo fmt",
6402 expect_compilation: false,
6403 expected_kind: None,
6404 reason_contains: "never-intercept",
6405 min_confidence: 0.0,
6406 },
6407 Case {
6408 cmd: "cargo fmt --check",
6409 expect_compilation: false,
6410 expected_kind: None,
6411 reason_contains: "never-intercept",
6412 min_confidence: 0.0,
6413 },
6414 Case {
6415 cmd: "cargo fix",
6416 expect_compilation: false,
6417 expected_kind: None,
6418 reason_contains: "never-intercept",
6419 min_confidence: 0.0,
6420 },
6421 Case {
6422 cmd: "cargo clean",
6423 expect_compilation: false,
6424 expected_kind: None,
6425 reason_contains: "never-intercept",
6426 min_confidence: 0.0,
6427 },
6428 Case {
6429 cmd: "cargo new my_project",
6430 expect_compilation: false,
6431 expected_kind: None,
6432 reason_contains: "never-intercept",
6433 min_confidence: 0.0,
6434 },
6435 Case {
6436 cmd: "cargo init",
6437 expect_compilation: false,
6438 expected_kind: None,
6439 reason_contains: "never-intercept",
6440 min_confidence: 0.0,
6441 },
6442 Case {
6443 cmd: "cargo add serde",
6444 expect_compilation: false,
6445 expected_kind: None,
6446 reason_contains: "never-intercept",
6447 min_confidence: 0.0,
6448 },
6449 Case {
6450 cmd: "cargo remove serde",
6451 expect_compilation: false,
6452 expected_kind: None,
6453 reason_contains: "never-intercept",
6454 min_confidence: 0.0,
6455 },
6456 Case {
6457 cmd: "cargo update",
6458 expect_compilation: false,
6459 expected_kind: None,
6460 reason_contains: "never-intercept",
6461 min_confidence: 0.0,
6462 },
6463 Case {
6464 cmd: "cargo generate-lockfile",
6465 expect_compilation: false,
6466 expected_kind: None,
6467 reason_contains: "never-intercept",
6468 min_confidence: 0.0,
6469 },
6470 Case {
6471 cmd: "cargo watch -x test",
6472 expect_compilation: false,
6473 expected_kind: None,
6474 reason_contains: "never-intercept",
6475 min_confidence: 0.0,
6476 },
6477 Case {
6478 cmd: "cargo --version",
6479 expect_compilation: false,
6480 expected_kind: None,
6481 reason_contains: "never-intercept",
6482 min_confidence: 0.0,
6483 },
6484 Case {
6485 cmd: "cargo -V",
6486 expect_compilation: false,
6487 expected_kind: None,
6488 reason_contains: "never-intercept",
6489 min_confidence: 0.0,
6490 },
6491 Case {
6493 cmd: "cargo nextest list",
6494 expect_compilation: false,
6495 expected_kind: None,
6496 reason_contains: "never-intercept",
6497 min_confidence: 0.0,
6498 },
6499 Case {
6500 cmd: "cargo nextest archive",
6501 expect_compilation: false,
6502 expected_kind: None,
6503 reason_contains: "never-intercept",
6504 min_confidence: 0.0,
6505 },
6506 Case {
6507 cmd: "cargo nextest show",
6508 expect_compilation: false,
6509 expected_kind: None,
6510 reason_contains: "never-intercept",
6511 min_confidence: 0.0,
6512 },
6513 Case {
6515 cmd: "cargo",
6516 expect_compilation: false,
6517 expected_kind: None,
6518 reason_contains: "bare cargo",
6519 min_confidence: 0.0,
6520 },
6521 Case {
6522 cmd: "cargo nextest",
6523 expect_compilation: false,
6524 expected_kind: None,
6525 reason_contains: "bare cargo nextest",
6526 min_confidence: 0.0,
6527 },
6528 Case {
6530 cmd: "cargo tree",
6531 expect_compilation: false,
6532 expected_kind: None,
6533 reason_contains: "not interceptable",
6534 min_confidence: 0.0,
6535 },
6536 Case {
6537 cmd: "cargo metadata",
6538 expect_compilation: false,
6539 expected_kind: None,
6540 reason_contains: "not interceptable",
6541 min_confidence: 0.0,
6542 },
6543 Case {
6544 cmd: "cargo search serde",
6545 expect_compilation: false,
6546 expected_kind: None,
6547 reason_contains: "not interceptable",
6548 min_confidence: 0.0,
6549 },
6550 Case {
6551 cmd: "cargo vendor",
6552 expect_compilation: false,
6553 expected_kind: None,
6554 reason_contains: "not interceptable",
6555 min_confidence: 0.0,
6556 },
6557 ];
6558 run_cases(&cases);
6559 }
6560
6561 #[test]
6566 fn regression_bun_compilation_positive() {
6567 let _guard = test_guard!();
6568 let cases = [
6569 Case {
6570 cmd: "bun test",
6571 expect_compilation: true,
6572 expected_kind: Some(CompilationKind::BunTest),
6573 reason_contains: "bun test",
6574 min_confidence: 0.90,
6575 },
6576 Case {
6577 cmd: "bun test src/",
6578 expect_compilation: true,
6579 expected_kind: Some(CompilationKind::BunTest),
6580 reason_contains: "bun test",
6581 min_confidence: 0.90,
6582 },
6583 Case {
6584 cmd: "bun test --bail",
6585 expect_compilation: true,
6586 expected_kind: Some(CompilationKind::BunTest),
6587 reason_contains: "bun test",
6588 min_confidence: 0.90,
6589 },
6590 Case {
6591 cmd: "bun test --timeout 5000",
6592 expect_compilation: true,
6593 expected_kind: Some(CompilationKind::BunTest),
6594 reason_contains: "bun test",
6595 min_confidence: 0.90,
6596 },
6597 Case {
6598 cmd: "bun typecheck",
6599 expect_compilation: true,
6600 expected_kind: Some(CompilationKind::BunTypecheck),
6601 reason_contains: "bun typecheck",
6602 min_confidence: 0.90,
6603 },
6604 Case {
6605 cmd: "bun typecheck src/",
6606 expect_compilation: true,
6607 expected_kind: Some(CompilationKind::BunTypecheck),
6608 reason_contains: "bun typecheck",
6609 min_confidence: 0.90,
6610 },
6611 ];
6612 run_cases(&cases);
6613 }
6614
6615 #[test]
6620 fn regression_bun_never_intercept() {
6621 let _guard = test_guard!();
6622 let cases = [
6623 Case {
6625 cmd: "bun install",
6626 expect_compilation: false,
6627 expected_kind: None,
6628 reason_contains: "never-intercept",
6629 min_confidence: 0.0,
6630 },
6631 Case {
6632 cmd: "bun add react",
6633 expect_compilation: false,
6634 expected_kind: None,
6635 reason_contains: "never-intercept",
6636 min_confidence: 0.0,
6637 },
6638 Case {
6639 cmd: "bun remove react",
6640 expect_compilation: false,
6641 expected_kind: None,
6642 reason_contains: "never-intercept",
6643 min_confidence: 0.0,
6644 },
6645 Case {
6646 cmd: "bun link",
6647 expect_compilation: false,
6648 expected_kind: None,
6649 reason_contains: "never-intercept",
6650 min_confidence: 0.0,
6651 },
6652 Case {
6653 cmd: "bun unlink",
6654 expect_compilation: false,
6655 expected_kind: None,
6656 reason_contains: "never-intercept",
6657 min_confidence: 0.0,
6658 },
6659 Case {
6660 cmd: "bun pm ls",
6661 expect_compilation: false,
6662 expected_kind: None,
6663 reason_contains: "never-intercept",
6664 min_confidence: 0.0,
6665 },
6666 Case {
6667 cmd: "bun init",
6668 expect_compilation: false,
6669 expected_kind: None,
6670 reason_contains: "never-intercept",
6671 min_confidence: 0.0,
6672 },
6673 Case {
6674 cmd: "bun create my-app",
6675 expect_compilation: false,
6676 expected_kind: None,
6677 reason_contains: "never-intercept",
6678 min_confidence: 0.0,
6679 },
6680 Case {
6681 cmd: "bun upgrade",
6682 expect_compilation: false,
6683 expected_kind: None,
6684 reason_contains: "never-intercept",
6685 min_confidence: 0.0,
6686 },
6687 Case {
6689 cmd: "bun run dev",
6690 expect_compilation: false,
6691 expected_kind: None,
6692 reason_contains: "never-intercept",
6693 min_confidence: 0.0,
6694 },
6695 Case {
6696 cmd: "bun build src/index.ts",
6697 expect_compilation: false,
6698 expected_kind: None,
6699 reason_contains: "never-intercept",
6700 min_confidence: 0.0,
6701 },
6702 Case {
6703 cmd: "bun dev",
6704 expect_compilation: false,
6705 expected_kind: None,
6706 reason_contains: "never-intercept",
6707 min_confidence: 0.0,
6708 },
6709 Case {
6710 cmd: "bun repl",
6711 expect_compilation: false,
6712 expected_kind: None,
6713 reason_contains: "never-intercept",
6714 min_confidence: 0.0,
6715 },
6716 Case {
6718 cmd: "bun x vitest",
6719 expect_compilation: false,
6720 expected_kind: None,
6721 reason_contains: "bun x",
6722 min_confidence: 0.0,
6723 },
6724 Case {
6725 cmd: "bun x tsc --noEmit",
6726 expect_compilation: false,
6727 expected_kind: None,
6728 reason_contains: "bun x",
6729 min_confidence: 0.0,
6730 },
6731 Case {
6733 cmd: "bun test --watch",
6734 expect_compilation: false,
6735 expected_kind: None,
6736 reason_contains: "interactive",
6737 min_confidence: 0.0,
6738 },
6739 Case {
6740 cmd: "bun test -w",
6741 expect_compilation: false,
6742 expected_kind: None,
6743 reason_contains: "interactive",
6744 min_confidence: 0.0,
6745 },
6746 Case {
6747 cmd: "bun typecheck --watch",
6748 expect_compilation: false,
6749 expected_kind: None,
6750 reason_contains: "interactive",
6751 min_confidence: 0.0,
6752 },
6753 Case {
6754 cmd: "bun typecheck -w",
6755 expect_compilation: false,
6756 expected_kind: None,
6757 reason_contains: "interactive",
6758 min_confidence: 0.0,
6759 },
6760 Case {
6762 cmd: "bun --version",
6763 expect_compilation: false,
6764 expected_kind: None,
6765 reason_contains: "never-intercept",
6766 min_confidence: 0.0,
6767 },
6768 Case {
6769 cmd: "bun -v",
6770 expect_compilation: false,
6771 expected_kind: None,
6772 reason_contains: "never-intercept",
6773 min_confidence: 0.0,
6774 },
6775 Case {
6776 cmd: "bun --help",
6777 expect_compilation: false,
6778 expected_kind: None,
6779 reason_contains: "never-intercept",
6780 min_confidence: 0.0,
6781 },
6782 Case {
6783 cmd: "bun -h",
6784 expect_compilation: false,
6785 expected_kind: None,
6786 reason_contains: "never-intercept",
6787 min_confidence: 0.0,
6788 },
6789 Case {
6790 cmd: "bun completions",
6791 expect_compilation: false,
6792 expected_kind: None,
6793 reason_contains: "never-intercept",
6794 min_confidence: 0.0,
6795 },
6796 ];
6797 run_cases(&cases);
6798 }
6799
6800 #[test]
6805 fn regression_c_cpp_compilation_positive() {
6806 let _guard = test_guard!();
6807 let cases = [
6808 Case {
6809 cmd: "gcc -c main.c -o main.o",
6810 expect_compilation: true,
6811 expected_kind: Some(CompilationKind::Gcc),
6812 reason_contains: "gcc",
6813 min_confidence: 0.85,
6814 },
6815 Case {
6816 cmd: "gcc main.c -o main",
6817 expect_compilation: true,
6818 expected_kind: Some(CompilationKind::Gcc),
6819 reason_contains: "gcc",
6820 min_confidence: 0.85,
6821 },
6822 Case {
6823 cmd: "g++ -c main.cpp -o main.o",
6824 expect_compilation: true,
6825 expected_kind: Some(CompilationKind::Gpp),
6826 reason_contains: "g++",
6827 min_confidence: 0.85,
6828 },
6829 Case {
6830 cmd: "g++ main.cc -o main",
6831 expect_compilation: true,
6832 expected_kind: Some(CompilationKind::Gpp),
6833 reason_contains: "g++",
6834 min_confidence: 0.85,
6835 },
6836 Case {
6837 cmd: "clang -c main.c -o main.o",
6838 expect_compilation: true,
6839 expected_kind: Some(CompilationKind::Clang),
6840 reason_contains: "clang",
6841 min_confidence: 0.85,
6842 },
6843 Case {
6844 cmd: "clang++ -c main.cpp -o main.o",
6845 expect_compilation: true,
6846 expected_kind: Some(CompilationKind::Clangpp),
6847 reason_contains: "clang++",
6848 min_confidence: 0.85,
6849 },
6850 Case {
6851 cmd: "clang++ main.cc -o main",
6852 expect_compilation: true,
6853 expected_kind: Some(CompilationKind::Clangpp),
6854 reason_contains: "clang++",
6855 min_confidence: 0.85,
6856 },
6857 Case {
6858 cmd: "cc -c main.c -o main.o",
6859 expect_compilation: true,
6860 expected_kind: Some(CompilationKind::Gcc),
6861 reason_contains: "cc",
6862 min_confidence: 0.80,
6863 },
6864 Case {
6865 cmd: "c++ -c main.cpp -o main.o",
6866 expect_compilation: true,
6867 expected_kind: Some(CompilationKind::Gpp),
6868 reason_contains: "c++",
6869 min_confidence: 0.80,
6870 },
6871 ];
6872 run_cases(&cases);
6873 }
6874
6875 #[test]
6880 fn regression_c_cpp_version_checks_not_intercepted() {
6881 let _guard = test_guard!();
6882 let cases = [
6883 Case {
6884 cmd: "rustc --version",
6885 expect_compilation: false,
6886 expected_kind: None,
6887 reason_contains: "never-intercept",
6888 min_confidence: 0.0,
6889 },
6890 Case {
6891 cmd: "rustc -V",
6892 expect_compilation: false,
6893 expected_kind: None,
6894 reason_contains: "never-intercept",
6895 min_confidence: 0.0,
6896 },
6897 Case {
6898 cmd: "gcc --version",
6899 expect_compilation: false,
6900 expected_kind: None,
6901 reason_contains: "never-intercept",
6902 min_confidence: 0.0,
6903 },
6904 Case {
6905 cmd: "gcc -v",
6906 expect_compilation: false,
6907 expected_kind: None,
6908 reason_contains: "never-intercept",
6909 min_confidence: 0.0,
6910 },
6911 Case {
6912 cmd: "clang --version",
6913 expect_compilation: false,
6914 expected_kind: None,
6915 reason_contains: "never-intercept",
6916 min_confidence: 0.0,
6917 },
6918 Case {
6919 cmd: "clang -v",
6920 expect_compilation: false,
6921 expected_kind: None,
6922 reason_contains: "never-intercept",
6923 min_confidence: 0.0,
6924 },
6925 Case {
6926 cmd: "make --version",
6927 expect_compilation: false,
6928 expected_kind: None,
6929 reason_contains: "never-intercept",
6930 min_confidence: 0.0,
6931 },
6932 Case {
6933 cmd: "make -v",
6934 expect_compilation: false,
6935 expected_kind: None,
6936 reason_contains: "never-intercept",
6937 min_confidence: 0.0,
6938 },
6939 Case {
6940 cmd: "cmake --version",
6941 expect_compilation: false,
6942 expected_kind: None,
6943 reason_contains: "never-intercept",
6944 min_confidence: 0.0,
6945 },
6946 ];
6947 run_cases(&cases);
6948 }
6949
6950 #[test]
6955 fn regression_build_systems_positive() {
6956 let _guard = test_guard!();
6957 let cases = [
6958 Case {
6959 cmd: "make",
6960 expect_compilation: true,
6961 expected_kind: Some(CompilationKind::Make),
6962 reason_contains: "make build",
6963 min_confidence: 0.80,
6964 },
6965 Case {
6966 cmd: "make -j8",
6967 expect_compilation: true,
6968 expected_kind: Some(CompilationKind::Make),
6969 reason_contains: "make build",
6970 min_confidence: 0.80,
6971 },
6972 Case {
6973 cmd: "make all",
6974 expect_compilation: true,
6975 expected_kind: Some(CompilationKind::Make),
6976 reason_contains: "make build",
6977 min_confidence: 0.80,
6978 },
6979 Case {
6980 cmd: "cmake --build build",
6981 expect_compilation: true,
6982 expected_kind: Some(CompilationKind::CmakeBuild),
6983 reason_contains: "cmake --build",
6984 min_confidence: 0.85,
6985 },
6986 Case {
6987 cmd: "cmake --build=build",
6988 expect_compilation: true,
6989 expected_kind: Some(CompilationKind::CmakeBuild),
6990 reason_contains: "cmake --build",
6991 min_confidence: 0.85,
6992 },
6993 Case {
6994 cmd: "ninja",
6995 expect_compilation: true,
6996 expected_kind: Some(CompilationKind::Ninja),
6997 reason_contains: "ninja build",
6998 min_confidence: 0.85,
6999 },
7000 Case {
7001 cmd: "ninja -j4",
7002 expect_compilation: true,
7003 expected_kind: Some(CompilationKind::Ninja),
7004 reason_contains: "ninja build",
7005 min_confidence: 0.85,
7006 },
7007 Case {
7008 cmd: "meson compile",
7009 expect_compilation: true,
7010 expected_kind: Some(CompilationKind::Meson),
7011 reason_contains: "meson compile",
7012 min_confidence: 0.80,
7013 },
7014 ];
7015 run_cases(&cases);
7016 }
7017
7018 #[test]
7023 fn regression_build_systems_not_intercepted() {
7024 let _guard = test_guard!();
7025 let cases = [
7026 Case {
7027 cmd: "make clean",
7028 expect_compilation: false,
7029 expected_kind: None,
7030 reason_contains: "make maintenance",
7031 min_confidence: 0.0,
7032 },
7033 Case {
7034 cmd: "make install",
7035 expect_compilation: false,
7036 expected_kind: None,
7037 reason_contains: "make maintenance",
7038 min_confidence: 0.0,
7039 },
7040 Case {
7041 cmd: "make distclean",
7042 expect_compilation: false,
7043 expected_kind: None,
7044 reason_contains: "make maintenance",
7045 min_confidence: 0.0,
7046 },
7047 Case {
7048 cmd: "ninja -t clean",
7049 expect_compilation: false,
7050 expected_kind: None,
7051 reason_contains: "ninja clean",
7052 min_confidence: 0.0,
7053 },
7054 Case {
7055 cmd: "ninja clean",
7056 expect_compilation: false,
7057 expected_kind: None,
7058 reason_contains: "ninja clean",
7059 min_confidence: 0.0,
7060 },
7061 Case {
7063 cmd: "meson setup build",
7064 expect_compilation: false,
7065 expected_kind: None,
7066 reason_contains: "no matching pattern",
7067 min_confidence: 0.0,
7068 },
7069 Case {
7070 cmd: "meson configure",
7071 expect_compilation: false,
7072 expected_kind: None,
7073 reason_contains: "no matching pattern",
7074 min_confidence: 0.0,
7075 },
7076 Case {
7078 cmd: "cmake .",
7079 expect_compilation: false,
7080 expected_kind: None,
7081 reason_contains: "no matching pattern",
7082 min_confidence: 0.0,
7083 },
7084 Case {
7085 cmd: "cmake -DCMAKE_BUILD_TYPE=Release ..",
7086 expect_compilation: false,
7087 expected_kind: None,
7088 reason_contains: "no matching pattern",
7089 min_confidence: 0.0,
7090 },
7091 ];
7092 run_cases(&cases);
7093 }
7094
7095 #[test]
7100 fn regression_rustc_positive() {
7101 let _guard = test_guard!();
7102 let cases = [
7103 Case {
7104 cmd: "rustc main.rs",
7105 expect_compilation: true,
7106 expected_kind: Some(CompilationKind::Rustc),
7107 reason_contains: "rustc",
7108 min_confidence: 0.90,
7109 },
7110 Case {
7111 cmd: "rustc main.rs -o main",
7112 expect_compilation: true,
7113 expected_kind: Some(CompilationKind::Rustc),
7114 reason_contains: "rustc",
7115 min_confidence: 0.90,
7116 },
7117 Case {
7118 cmd: "rustc --edition 2021 main.rs",
7119 expect_compilation: true,
7120 expected_kind: Some(CompilationKind::Rustc),
7121 reason_contains: "rustc",
7122 min_confidence: 0.90,
7123 },
7124 ];
7125 run_cases(&cases);
7126 }
7127
7128 #[test]
7133 fn regression_shell_structure_exclusions() {
7134 let _guard = test_guard!();
7135 let cases = [
7136 Case {
7139 cmd: "cargo build 2>&1 | xargs rm",
7140 expect_compilation: false,
7141 expected_kind: None,
7142 reason_contains: "piped",
7143 min_confidence: 0.0,
7144 },
7145 Case {
7147 cmd: "cargo build < input.txt",
7148 expect_compilation: false,
7149 expected_kind: None,
7150 reason_contains: "input redirect",
7151 min_confidence: 0.0,
7152 },
7153 Case {
7155 cmd: "(cargo build)",
7156 expect_compilation: false,
7157 expected_kind: None,
7158 reason_contains: "subshell",
7159 min_confidence: 0.0,
7160 },
7161 Case {
7162 cmd: "$(cargo build)",
7163 expect_compilation: false,
7164 expected_kind: None,
7165 reason_contains: "subshell",
7166 min_confidence: 0.0,
7167 },
7168 Case {
7169 cmd: "`cargo build`",
7170 expect_compilation: false,
7171 expected_kind: None,
7172 reason_contains: "subshell capture",
7173 min_confidence: 0.0,
7174 },
7175 Case {
7177 cmd: "cargo build --config <(echo ...)",
7178 expect_compilation: false,
7179 expected_kind: None,
7180 reason_contains: "subshell",
7181 min_confidence: 0.0,
7182 },
7183 Case {
7185 cmd: "cargo build; cargo test",
7186 expect_compilation: false,
7187 expected_kind: None,
7188 reason_contains: "chained",
7189 min_confidence: 0.0,
7190 },
7191 Case {
7193 cmd: "cargo build || echo failed",
7194 expect_compilation: false,
7195 expected_kind: None,
7196 reason_contains: "chained",
7197 min_confidence: 0.0,
7198 },
7199 Case {
7201 cmd: "cargo build\nrm -rf /",
7202 expect_compilation: false,
7203 expected_kind: None,
7204 reason_contains: "newline",
7205 min_confidence: 0.0,
7206 },
7207 Case {
7208 cmd: "cargo build\r\nevil",
7209 expect_compilation: false,
7210 expected_kind: None,
7211 reason_contains: "newline",
7212 min_confidence: 0.0,
7213 },
7214 ];
7215 run_cases(&cases);
7216 }
7217
7218 #[test]
7227 fn regression_compound_chain_offloads_every_compilation_segment() {
7228 let _guard = test_guard!();
7229
7230 let result = classify_command("cargo build --release && cargo test");
7231 assert!(result.is_compilation, "got: {:?}", result.reason);
7232 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7233 assert_eq!(
7234 result.command_prefix.as_deref(),
7235 Some("rch exec -- cargo build --release && ")
7236 );
7237 assert_eq!(result.extracted_command.as_deref(), Some("cargo test"));
7238
7239 let result = classify_command("touch marker && cargo build && cargo test --workspace");
7241 assert_eq!(
7242 result.command_prefix.as_deref(),
7243 Some("touch marker && rch exec -- cargo build && ")
7244 );
7245 assert_eq!(
7246 result.extracted_command.as_deref(),
7247 Some("cargo test --workspace")
7248 );
7249
7250 let result = classify_command("go vet ./... && cargo check");
7252 assert_eq!(
7253 result.command_prefix.as_deref(),
7254 Some("rch exec -- go vet ./... && ")
7255 );
7256
7257 let result = classify_command("cd /repo && cargo build");
7259 assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7260
7261 let result = classify_command("go build ./cmd && cargo test");
7263 assert!(result.is_compilation);
7264 assert_eq!(result.command_prefix.as_deref(), Some("go build ./cmd && "));
7265 assert_eq!(
7267 compound_local_compilation_segments("go build ./cmd && cargo test"),
7268 vec!["go build ./cmd".to_string()]
7269 );
7270 assert!(
7271 compound_local_compilation_segments("cargo build --release && cargo test").is_empty()
7272 );
7273 assert!(compound_local_compilation_segments("cd /repo && cargo build").is_empty());
7274 assert!(compound_local_compilation_segments("cargo test").is_empty());
7275 assert!(
7276 compound_local_compilation_segments("bash -c \"cargo build && cargo test\"").is_empty(),
7277 "quoted operators are not chain separators"
7278 );
7279
7280 assert!(!classify_command("cargo build ; cargo test").is_compilation);
7283 assert!(declined_compilation_due_to_structure("cargo build ; cargo test").is_some());
7284 assert!(declined_compilation_due_to_structure("cargo build || echo failed").is_some());
7285 assert!(declined_compilation_due_to_structure("cargo build && ./run").is_some());
7286 }
7287
7288 #[test]
7298 fn regression_benign_trailing_structure_offloaded() {
7299 let _guard = test_guard!();
7300 let expectations: &[(&str, CompilationKind, &str)] = &[
7301 (
7303 "cargo build > log.txt",
7304 CompilationKind::CargoBuild,
7305 "cargo build > log.txt",
7306 ),
7307 (
7308 "cargo build >> log.txt",
7309 CompilationKind::CargoBuild,
7310 "cargo build >> log.txt",
7311 ),
7312 (
7313 "cargo test --workspace > out.log 2>&1",
7314 CompilationKind::CargoTest,
7315 "cargo test --workspace > out.log 2>&1",
7316 ),
7317 (
7319 "cargo build 2>&1 | grep error",
7320 CompilationKind::CargoBuild,
7321 "cargo build 2>&1 | grep error",
7322 ),
7323 (
7324 "cargo test | tee output.log",
7325 CompilationKind::CargoTest,
7326 "cargo test | tee output.log",
7327 ),
7328 (
7329 "cargo check --lib 2>&1 | head",
7330 CompilationKind::CargoCheck,
7331 "cargo check --lib 2>&1 | head",
7332 ),
7333 (
7335 "cargo build &",
7336 CompilationKind::CargoBuild,
7337 "cargo build &",
7338 ),
7339 ];
7340 for (cmd, kind, extracted) in expectations {
7341 let result = classify_command(cmd);
7342 assert!(
7343 result.is_compilation,
7344 "'{cmd}' should be offloaded, got: {:?}",
7345 result.reason
7346 );
7347 assert_eq!(result.kind, Some(*kind), "kind mismatch for '{cmd}'");
7348 assert_eq!(
7349 result.extracted_command.as_deref(),
7350 Some(*extracted),
7351 "extracted command mismatch for '{cmd}'"
7352 );
7353 assert_eq!(result.command_prefix.as_deref(), Some(""));
7355 }
7356 }
7357
7358 #[test]
7359 fn regression_unsafe_trailing_structure_still_rejected() {
7360 let _guard = test_guard!();
7361 let rejected = [
7364 "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", ];
7370 for cmd in rejected {
7371 let result = classify_command(cmd);
7372 assert!(
7373 !result.is_compilation,
7374 "'{cmd}' should NOT be offloaded, got: {:?}",
7375 result
7376 );
7377 }
7378 }
7379
7380 #[test]
7381 fn regression_shell_dash_c_wrapper_offloaded() {
7382 let _guard = test_guard!();
7383 let result = classify_command("bash -c \"cargo test --workspace\"");
7385 assert!(result.is_compilation, "got: {:?}", result.reason);
7386 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7387 assert_eq!(
7388 result.extracted_command.as_deref(),
7389 Some("cargo test --workspace")
7390 );
7391
7392 let result = classify_command("sh -c 'cargo build --release'");
7393 assert!(result.is_compilation, "got: {:?}", result.reason);
7394 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7395 assert_eq!(
7396 result.extracted_command.as_deref(),
7397 Some("cargo build --release")
7398 );
7399
7400 let result = classify_command("/bin/bash -c \"cargo check\"");
7402 assert!(result.is_compilation, "got: {:?}", result.reason);
7403
7404 let result = classify_command("bash -c \"cd /repo && cargo build\"");
7406 assert!(result.is_compilation, "got: {:?}", result.reason);
7407 assert_eq!(
7408 result.extracted_command.as_deref(),
7409 Some("cargo build"),
7410 "inner compound should keep extracted command"
7411 );
7412 assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7413
7414 let result = classify_command("bash -c \"cargo test > log 2>&1\"");
7416 assert!(result.is_compilation, "got: {:?}", result.reason);
7417 assert_eq!(
7418 result.extracted_command.as_deref(),
7419 Some("cargo test > log 2>&1")
7420 );
7421
7422 let result = classify_command("bash -c \"ls -la\"");
7424 assert!(!result.is_compilation);
7425
7426 let result = classify_command("bash -c \"cargo fmt\"");
7428 assert!(!result.is_compilation);
7429 }
7430
7431 #[test]
7436 fn regression_fd_redirect_safe() {
7437 let _guard = test_guard!();
7438 let result = classify_command("cargo build 2>&1");
7444 assert!(
7445 result.is_compilation,
7446 "cargo build 2>&1 should be classified as compilation (fd redirect is safe), got: {:?}",
7447 result.reason
7448 );
7449 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7450 }
7451
7452 #[test]
7457 fn regression_compound_commands() {
7458 let _guard = test_guard!();
7459 let result = classify_command("cd /path && cargo build");
7461 assert!(
7462 result.is_compilation,
7463 "cd && cargo build should compile, got: {:?}",
7464 result.reason
7465 );
7466 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7467 assert!(result.command_prefix.is_some(), "should have prefix");
7468 assert!(
7469 result.extracted_command.is_some(),
7470 "should have extracted command"
7471 );
7472
7473 let result = classify_command("cd /path && cargo test --workspace");
7474 assert!(
7475 result.is_compilation,
7476 "cd && cargo test should compile, got: {:?}",
7477 result.reason
7478 );
7479 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7480
7481 let result = classify_command("cd /path && cargo fmt");
7483 assert!(!result.is_compilation, "cd && cargo fmt should NOT compile");
7484
7485 let result = classify_command("cd /path && ls -la");
7486 assert!(!result.is_compilation, "cd && ls should NOT compile");
7487 }
7488
7489 #[test]
7494 fn regression_wrapper_normalization() {
7495 let _guard = test_guard!();
7496 let cases = [
7497 Case {
7498 cmd: "sudo cargo build",
7499 expect_compilation: true,
7500 expected_kind: Some(CompilationKind::CargoBuild),
7501 reason_contains: "cargo build",
7502 min_confidence: 0.90,
7503 },
7504 Case {
7505 cmd: "time cargo test",
7506 expect_compilation: true,
7507 expected_kind: Some(CompilationKind::CargoTest),
7508 reason_contains: "cargo test",
7509 min_confidence: 0.90,
7510 },
7511 Case {
7512 cmd: "env RUST_BACKTRACE=1 cargo test",
7513 expect_compilation: true,
7514 expected_kind: Some(CompilationKind::CargoTest),
7515 reason_contains: "cargo test",
7516 min_confidence: 0.90,
7517 },
7518 Case {
7519 cmd: "env -u RUST_LOG cargo test",
7520 expect_compilation: true,
7521 expected_kind: Some(CompilationKind::CargoTest),
7522 reason_contains: "cargo test",
7523 min_confidence: 0.90,
7524 },
7525 Case {
7526 cmd: "RUST_BACKTRACE=1 cargo test",
7527 expect_compilation: true,
7528 expected_kind: Some(CompilationKind::CargoTest),
7529 reason_contains: "cargo test",
7530 min_confidence: 0.90,
7531 },
7532 Case {
7533 cmd: "nice cargo build --release",
7534 expect_compilation: true,
7535 expected_kind: Some(CompilationKind::CargoBuild),
7536 reason_contains: "cargo build",
7537 min_confidence: 0.90,
7538 },
7539 Case {
7540 cmd: "/usr/bin/cargo build",
7541 expect_compilation: true,
7542 expected_kind: Some(CompilationKind::CargoBuild),
7543 reason_contains: "cargo build",
7544 min_confidence: 0.90,
7545 },
7546 Case {
7547 cmd: "sudo env RUSTFLAGS=-Dwarnings cargo clippy",
7548 expect_compilation: true,
7549 expected_kind: Some(CompilationKind::CargoClippy),
7550 reason_contains: "cargo clippy",
7551 min_confidence: 0.85,
7552 },
7553 ];
7554 run_cases(&cases);
7555 }
7556
7557 #[test]
7562 fn regression_non_compilation_passthrough() {
7563 let _guard = test_guard!();
7564 let cases = [
7565 Case {
7566 cmd: "ls -la",
7567 expect_compilation: false,
7568 expected_kind: None,
7569 reason_contains: "no compilation keyword",
7570 min_confidence: 0.0,
7571 },
7572 Case {
7573 cmd: "pwd",
7574 expect_compilation: false,
7575 expected_kind: None,
7576 reason_contains: "no compilation keyword",
7577 min_confidence: 0.0,
7578 },
7579 Case {
7580 cmd: "echo hello",
7581 expect_compilation: false,
7582 expected_kind: None,
7583 reason_contains: "no compilation keyword",
7584 min_confidence: 0.0,
7585 },
7586 Case {
7587 cmd: "cat Cargo.toml",
7588 expect_compilation: false,
7589 expected_kind: None,
7590 reason_contains: "no compilation keyword",
7591 min_confidence: 0.0,
7592 },
7593 Case {
7594 cmd: "git status",
7595 expect_compilation: false,
7596 expected_kind: None,
7597 reason_contains: "no compilation keyword",
7598 min_confidence: 0.0,
7599 },
7600 Case {
7601 cmd: "git commit -m 'fix'",
7602 expect_compilation: false,
7603 expected_kind: None,
7604 reason_contains: "no compilation keyword",
7605 min_confidence: 0.0,
7606 },
7607 Case {
7608 cmd: "npm install",
7609 expect_compilation: false,
7610 expected_kind: None,
7611 reason_contains: "no compilation keyword",
7612 min_confidence: 0.0,
7613 },
7614 Case {
7615 cmd: "yarn build",
7616 expect_compilation: false,
7617 expected_kind: None,
7618 reason_contains: "no compilation keyword",
7619 min_confidence: 0.0,
7620 },
7621 Case {
7622 cmd: "python main.py",
7623 expect_compilation: false,
7624 expected_kind: None,
7625 reason_contains: "no compilation keyword",
7626 min_confidence: 0.0,
7627 },
7628 #[cfg(unix)]
7630 Case {
7631 cmd: "go build -o bin/app .",
7632 expect_compilation: true,
7633 expected_kind: Some(CompilationKind::GoBuild),
7634 reason_contains: "go build command",
7635 min_confidence: 0.9,
7636 },
7637 Case {
7639 cmd: "go build ./cmd/app",
7640 expect_compilation: false,
7641 expected_kind: None,
7642 reason_contains: "requires a supported explicit -o file",
7643 min_confidence: 0.0,
7644 },
7645 Case {
7646 cmd: "docker build -t myapp .",
7647 expect_compilation: false,
7648 expected_kind: None,
7649 reason_contains: "no compilation keyword",
7650 min_confidence: 0.0,
7651 },
7652 Case {
7653 cmd: "pip install -r requirements.txt",
7654 expect_compilation: false,
7655 expected_kind: None,
7656 reason_contains: "no compilation keyword",
7657 min_confidence: 0.0,
7658 },
7659 Case {
7660 cmd: "curl https://example.com",
7661 expect_compilation: false,
7662 expected_kind: None,
7663 reason_contains: "no compilation keyword",
7664 min_confidence: 0.0,
7665 },
7666 Case {
7667 cmd: "mkdir -p build",
7668 expect_compilation: false,
7669 expected_kind: None,
7670 reason_contains: "no compilation keyword",
7671 min_confidence: 0.0,
7672 },
7673 Case {
7674 cmd: "rm -rf target/",
7675 expect_compilation: false,
7676 expected_kind: None,
7677 reason_contains: "no compilation keyword",
7678 min_confidence: 0.0,
7679 },
7680 Case {
7681 cmd: "cp -r src/ backup/",
7682 expect_compilation: false,
7683 expected_kind: None,
7684 reason_contains: "no compilation keyword",
7685 min_confidence: 0.0,
7686 },
7687 Case {
7688 cmd: "",
7689 expect_compilation: false,
7690 expected_kind: None,
7691 reason_contains: "empty command",
7692 min_confidence: 0.0,
7693 },
7694 Case {
7695 cmd: " ",
7696 expect_compilation: false,
7697 expected_kind: None,
7698 reason_contains: "empty command",
7699 min_confidence: 0.0,
7700 },
7701 ];
7702 run_cases(&cases);
7703 }
7704
7705 #[test]
7710 fn regression_classification_details_fields() {
7711 let _guard = test_guard!();
7712
7713 let details = classify_command_detailed("cargo build --release");
7715 assert_eq!(details.original, "cargo build --release");
7716 assert!(details.classification.is_compilation);
7717 assert_eq!(
7718 details.classification.kind,
7719 Some(CompilationKind::CargoBuild)
7720 );
7721 assert!(details.classification.confidence >= 0.90);
7722 assert_eq!(details.tiers.len(), 5, "Should have 5 tier decisions");
7724 for tier in &details.tiers[..4] {
7725 assert_eq!(tier.decision, TierDecision::Pass);
7726 }
7727 assert_eq!(details.tiers[4].decision, TierDecision::Pass);
7729 assert_eq!(details.tiers[4].tier, 4);
7730
7731 let details = classify_command_detailed("ls -la");
7733 assert!(!details.classification.is_compilation);
7734 assert!(
7735 details.tiers.len() >= 3,
7736 "Should have at least 3 tiers for keyword rejection"
7737 );
7738 assert_eq!(details.tiers.last().unwrap().decision, TierDecision::Reject);
7739
7740 let details = classify_command_detailed("cargo fmt --check");
7742 assert!(!details.classification.is_compilation);
7743 assert!(
7744 details.tiers.len() >= 4,
7745 "Should have at least 4 tiers for never-intercept rejection"
7746 );
7747 let tier3 = details.tiers.iter().find(|t| t.tier == 3).unwrap();
7749 assert_eq!(tier3.decision, TierDecision::Reject);
7750 assert!(
7751 tier3.reason.contains("never-intercept"),
7752 "Tier 3 reason should mention never-intercept, got: {:?}",
7753 tier3.reason
7754 );
7755
7756 let details = classify_command_detailed("cargo build | xargs rm");
7760 assert!(!details.classification.is_compilation);
7761 let tier1 = details.tiers.iter().find(|t| t.tier == 1).unwrap();
7762 assert_eq!(tier1.decision, TierDecision::Reject);
7763 assert!(tier1.reason.contains("piped"));
7764
7765 let details = classify_command_detailed("");
7767 assert!(!details.classification.is_compilation);
7768 assert_eq!(details.tiers[0].tier, 0);
7769 assert_eq!(details.tiers[0].decision, TierDecision::Reject);
7770 }
7771
7772 #[test]
7777 fn regression_every_compilation_has_reason_and_confidence() {
7778 let _guard = test_guard!();
7779 let compilation_cmds = [
7780 "cargo build",
7781 "cargo test",
7782 "cargo check",
7783 "cargo clippy",
7784 "cargo doc",
7785 "cargo run",
7786 "cargo bench",
7787 "cargo nextest run",
7788 "rustc main.rs",
7789 "gcc -c main.c -o main.o",
7790 "g++ -c main.cpp -o main.o",
7791 "clang -c main.c -o main.o",
7792 "clang++ -c main.cpp -o main.o",
7793 "make",
7794 "cmake --build build",
7795 "ninja",
7796 "meson compile",
7797 "bun test",
7798 "bun typecheck",
7799 ];
7800 for cmd in compilation_cmds {
7801 let result = classify_command(cmd);
7802 assert!(result.is_compilation, "{cmd:?} should be compilation");
7803 assert!(
7804 result.kind.is_some(),
7805 "{cmd:?} should have a CompilationKind"
7806 );
7807 assert!(
7808 result.confidence > 0.0,
7809 "{cmd:?} should have non-zero confidence"
7810 );
7811 assert!(
7812 !result.reason.is_empty(),
7813 "{cmd:?} should have a non-empty reason"
7814 );
7815 }
7816 }
7817
7818 #[test]
7823 fn regression_every_rejection_has_reason() {
7824 let _guard = test_guard!();
7825 let non_compilation_cmds = [
7826 "",
7827 "ls",
7828 "pwd",
7829 "git status",
7830 "cargo fmt",
7831 "cargo install ripgrep",
7832 "cargo clean",
7833 "bun install",
7834 "bun add react",
7835 "bun run dev",
7836 "bun dev",
7837 "bun repl",
7838 "bun x vitest",
7839 "bun test --watch",
7840 "bun typecheck -w",
7841 "make clean",
7842 "ninja clean",
7843 "gcc --version",
7844 "rustc --version",
7845 "cargo build | xargs rm",
7846 "cargo build < input.txt",
7847 "cargo build || echo failed",
7848 "(cargo build)",
7849 ];
7850 for cmd in non_compilation_cmds {
7851 let result = classify_command(cmd);
7852 assert!(
7853 !result.is_compilation,
7854 "{cmd:?} should NOT be compilation, got kind={:?}",
7855 result.kind
7856 );
7857 assert!(
7858 !result.reason.is_empty(),
7859 "{cmd:?} should have a non-empty rejection reason"
7860 );
7861 assert_eq!(result.kind, None, "{cmd:?} should have no CompilationKind");
7862 assert_eq!(
7863 result.confidence, 0.0,
7864 "{cmd:?} should have zero confidence"
7865 );
7866 }
7867 }
7868
7869 #[test]
7874 fn regression_real_world_agent_workflows() {
7875 let _guard = test_guard!();
7876
7877 let cd_build = classify_command(
7879 "cd /data/projects/remote_compilation_helper && cargo build --workspace --all-targets",
7880 );
7881 assert!(
7882 cd_build.is_compilation,
7883 "agent cd+build workflow should be intercepted"
7884 );
7885
7886 let test_ws = classify_command("cargo test --workspace -- --nocapture");
7888 assert!(test_ws.is_compilation);
7889 assert_eq!(test_ws.kind, Some(CompilationKind::CargoTest));
7890
7891 let clippy = classify_command("cargo clippy --workspace --all-targets -- -D warnings");
7893 assert!(clippy.is_compilation);
7894 assert_eq!(clippy.kind, Some(CompilationKind::CargoClippy));
7895
7896 let fmt = classify_command("cargo fmt --check");
7898 assert!(!fmt.is_compilation);
7899
7900 let cat = classify_command("cat Cargo.toml");
7902 assert!(!cat.is_compilation);
7903
7904 let gs = classify_command("git status");
7906 assert!(!gs.is_compilation);
7907
7908 let install = classify_command("cargo install cargo-nextest");
7910 assert!(!install.is_compilation);
7911
7912 let pkg_test = classify_command("cargo test -p rch-common");
7914 assert!(pkg_test.is_compilation);
7915 assert_eq!(pkg_test.kind, Some(CompilationKind::CargoTest));
7916
7917 let env_test = classify_command("RUST_BACKTRACE=1 cargo test --workspace");
7919 assert!(env_test.is_compilation);
7920 assert_eq!(env_test.kind, Some(CompilationKind::CargoTest));
7921
7922 let nxt = classify_command("cargo nextest run --workspace --no-fail-fast");
7924 assert!(nxt.is_compilation);
7925 assert_eq!(nxt.kind, Some(CompilationKind::CargoNextest));
7926 }
7927
7928 #[test]
7933 fn regression_keyword_in_non_compilation_context() {
7934 let _guard = test_guard!();
7935 let result = classify_command("echo cargo build");
7937 assert!(
7941 !result.is_compilation,
7942 "echo cargo should not compile, got: {:?}",
7943 result.reason
7944 );
7945
7946 let result = classify_command("grep make Makefile");
7948 assert!(
7950 !result.is_compilation,
7951 "grep make should not compile, got: {:?}",
7952 result.reason
7953 );
7954
7955 let result = classify_command("cat gcc_output.log");
7957 assert!(
7959 !result.is_compilation,
7960 "cat gcc_output should not compile, got: {:?}",
7961 result.reason
7962 );
7963 }
7964
7965 #[test]
7966 fn regression_build_words_in_non_build_arguments_are_not_compilation() {
7967 let _guard = test_guard!();
7968 let cases = [
7969 r#"br comments add ft-4tp7g.1 "remote proof blocked: cargo test -p rchd --lib""#,
7970 r#"AGENT_NAME=Codex br comments add ft-4tp7g.1 "proof lane: cargo clippy --workspace""#,
7971 r#"printf "cargo build --release""#,
7972 r#"echo 'cargo test -p rchd -- --nocapture'"#,
7973 r#"grep "cmake --build" docs/proof-notes.md"#,
7974 ];
7975
7976 for cmd in cases {
7977 let result = classify_command(cmd);
7978 assert!(
7979 !result.is_compilation,
7980 "embedded build text should not compile for {cmd:?}: {:?}",
7981 result
7982 );
7983 assert_eq!(result.reason.as_ref(), "no compilation keyword");
7984 }
7985 }
7986
7987 #[test]
7988 fn golden_non_build_arguments_with_build_words_stay_non_compilation() {
7989 let _guard = test_guard!();
7990 const FIXTURE_PATH: &str =
7991 "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json";
7992
7993 #[derive(serde::Deserialize)]
7994 struct Golden {
7995 schema_version: String,
7996 cases: Vec<Case>,
7997 expected_results: serde_json::Value,
7998 }
7999
8000 #[derive(serde::Deserialize)]
8001 struct Case {
8002 name: String,
8003 command: String,
8004 }
8005
8006 let golden: Golden = serde_json::from_str(include_str!(
8007 "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json"
8008 ))
8009 .expect("classification golden fixture parses");
8010 assert_eq!(
8011 golden.schema_version,
8012 "rch.golden.classification_non_build_arguments.v1"
8013 );
8014
8015 let mut actual_results = Vec::with_capacity(golden.cases.len());
8016 for case in golden.cases {
8017 let result = classify_command(&case.command);
8018 actual_results.push(serde_json::json!({
8019 "name": case.name,
8020 "is_compilation": result.is_compilation,
8021 "kind": result.kind,
8022 "reason": result.reason.as_ref(),
8023 }));
8024 }
8025 assert_golden_json(
8026 serde_json::Value::Array(actual_results),
8027 &golden.expected_results,
8028 FIXTURE_PATH,
8029 );
8030 }
8031
8032 #[test]
8033 fn regression_keyword_filter_still_accepts_normalized_build_invocations() {
8034 let _guard = test_guard!();
8035 let cases = [
8036 ("RUST_BACKTRACE=1 cargo test", CompilationKind::CargoTest),
8037 (
8038 "env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build",
8039 CompilationKind::CargoBuild,
8040 ),
8041 ("sudo -E cargo check", CompilationKind::CargoCheck),
8042 ("/usr/bin/time cargo build", CompilationKind::CargoBuild),
8043 ];
8044
8045 for (cmd, expected_kind) in cases {
8046 let result = classify_command(cmd);
8047 assert!(
8048 result.is_compilation,
8049 "normalized build invocation should compile for {cmd:?}: {:?}",
8050 result
8051 );
8052 assert_eq!(result.kind, Some(expected_kind));
8053 }
8054 }
8055
8056 #[test]
8061 fn regression_compilation_kind_properties() {
8062 let _guard = test_guard!();
8063
8064 assert!(CompilationKind::CargoTest.is_test_command());
8066 assert!(CompilationKind::CargoNextest.is_test_command());
8067 assert!(CompilationKind::CargoBench.is_test_command());
8068 assert!(CompilationKind::BunTest.is_test_command());
8069
8070 assert!(!CompilationKind::CargoBuild.is_test_command());
8072 assert!(!CompilationKind::CargoCheck.is_test_command());
8073 assert!(!CompilationKind::CargoClippy.is_test_command());
8074 assert!(!CompilationKind::CargoDoc.is_test_command());
8075 assert!(!CompilationKind::BunTypecheck.is_test_command());
8076 assert!(!CompilationKind::Gcc.is_test_command());
8077 assert!(!CompilationKind::Gpp.is_test_command());
8078 assert!(!CompilationKind::Clang.is_test_command());
8079 assert!(!CompilationKind::Clangpp.is_test_command());
8080 assert!(!CompilationKind::Make.is_test_command());
8081 assert!(!CompilationKind::CmakeBuild.is_test_command());
8082 assert!(!CompilationKind::Ninja.is_test_command());
8083 assert!(!CompilationKind::Meson.is_test_command());
8084 assert!(!CompilationKind::Rustc.is_test_command());
8085
8086 assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
8088 assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
8089 assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
8090 assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
8091 assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
8092 assert_eq!(CompilationKind::Gpp.command_base(), "g++");
8093 assert_eq!(CompilationKind::Clang.command_base(), "clang");
8094 assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
8095 assert_eq!(CompilationKind::Make.command_base(), "make");
8096 assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
8097 assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
8098 assert_eq!(CompilationKind::Meson.command_base(), "meson");
8099 assert_eq!(CompilationKind::BunTest.command_base(), "bun");
8100 assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
8101 }
8102}
8103
8104#[cfg(test)]
8105mod tests_go_and_typescript {
8106 use super::*;
8107
8108 fn kind_of(cmd: &str) -> Option<CompilationKind> {
8109 classify_command(cmd).kind
8110 }
8111
8112 #[test]
8115 fn go_build_test_vet_are_classified() {
8116 assert_eq!(
8117 kind_of("go build -o bin/app ."),
8118 cfg!(unix).then_some(CompilationKind::GoBuild)
8119 );
8120 assert_eq!(kind_of("go test ./..."), Some(CompilationKind::GoTest));
8121 assert_eq!(kind_of("go vet ./..."), Some(CompilationKind::GoVet));
8122 assert_eq!(
8124 kind_of("go test -tags=e2e ./e2e -run ^TestE2EAtomicAssignment$"),
8125 Some(CompilationKind::GoTest)
8126 );
8127 assert_eq!(
8128 kind_of("go test ./internal/robot ./internal/cli -run TestExitCode"),
8129 Some(CompilationKind::GoTest)
8130 );
8131 }
8132
8133 #[test]
8134 fn go_commands_clear_the_confidence_threshold() {
8135 for cmd in ["go build -o app .", "go test ./...", "go vet ./..."] {
8138 if !cfg!(unix) && cmd.starts_with("go build") {
8139 continue;
8140 }
8141 let c = classify_command(cmd);
8142 assert!(
8143 c.confidence > 0.85,
8144 "{cmd} scored {} which does not clear the 0.85 threshold",
8145 c.confidence
8146 );
8147 }
8148 }
8149
8150 #[test]
8151 fn go_test_is_a_test_command() {
8152 assert!(CompilationKind::GoTest.is_test_command());
8154 assert!(!CompilationKind::GoBuild.is_test_command());
8155 assert!(!CompilationKind::GoVet.is_test_command());
8156 }
8157
8158 #[test]
8161 fn go_build_explicit_outputs_share_one_literal_contract() {
8162 for (command, path, option_index) in [
8163 ("go build -o bin/app ./cmd/app", "bin/app", 2),
8164 ("go build -o=bin/app ./cmd/app", "bin/app", 2),
8165 ("go build '-o=bin/app' main.go", "bin/app", 2),
8166 (
8167 "go build -trimpath -p=2 -o './products/app [dev]*?' .",
8168 "./products/app [dev]*?",
8169 4,
8170 ),
8171 ("go build -ldflags '-s -w' -o app", "app", 4),
8172 ("go build -ldflags '-s -X main.version=v2' -o app", "app", 4),
8173 ("go build '-buildmode=pie' -o app .", "app", 3),
8174 ("go build -tags -o -o app .", "app", 4),
8175 ("env GOFLAGS= go build -o app .", "app", 2),
8176 ] {
8177 let selection = go_build_output(command).unwrap_or_else(|| panic!("{command}"));
8178 assert_eq!(selection.path, path, "{command}");
8179 assert_eq!(selection.option_index, option_index, "{command}");
8180 assert_eq!(
8181 kind_of(command),
8182 cfg!(unix).then_some(CompilationKind::GoBuild),
8183 "{command}"
8184 );
8185 }
8186 }
8187
8188 #[test]
8189 fn go_build_implicit_ambiguous_and_extra_outputs_stay_local() {
8190 for command in [
8191 "go build",
8192 "go build .",
8193 "go build ./...",
8194 "go build main.go",
8195 "go build -o",
8196 "go build -o /tmp/app .",
8197 "go build -o ../app .",
8198 "go build -o . .",
8199 "go build -o bin/ .",
8200 "go build -o - .",
8201 "go build -o one -o two .",
8202 "go build -n -o app .",
8203 "go build -n=true -o app .",
8204 "go build -buildmode=c-shared -o app .",
8205 "go build -buildmode=c-archive -o app .",
8206 "go build -buildmode=plugin -o app .",
8207 "go build -toolexec wrapper -o app .",
8208 "go build -o $OUTPUT .",
8209 "go build -o app* .",
8210 "go build . -o app",
8211 "go build -tags -o app .",
8212 "go build -overlay overlay.json -o app .",
8213 "go build -mod=mod -o app .",
8214 "go build -ldflags '-cpuprofile=cpu.out' -o app .",
8215 "go build -ldflags '-s -w -tmpdir=products' -o app .",
8216 "go build -gcflags '-cpuprofile=cpu.out' -o app .",
8217 "go build -asmflags '-debug' -o app .",
8218 ] {
8219 assert!(go_build_output(command).is_none(), "{command}");
8220 assert_eq!(kind_of(command), None, "{command}");
8221 }
8222 assert!(go_build_output(&format!("go build -o {} .", "x".repeat(65_536))).is_none());
8223 assert!(go_build_output("go build -o 'app\nnext' .").is_none());
8224 }
8225
8226 #[test]
8227 fn go_test_binary_profile_and_exec_outputs_stay_local() {
8228 for command in [
8229 "go test -c ./pkg",
8230 "go test -c=true ./pkg",
8231 "go test '-c=true' ./pkg",
8232 "go test -exec sudo ./pkg",
8233 "go test '-exec=sudo' ./pkg",
8234 "go test -o test.bin ./pkg",
8235 "go test -o=test.bin ./pkg",
8236 "go test '-o=test binary' ./pkg",
8237 "go test -coverprofile=coverage.out ./pkg",
8238 "go test -cpuprofile cpu.out ./pkg",
8239 "go test -memprofile=mem.out ./pkg",
8240 "go test -blockprofile=block.out ./pkg",
8241 "go test -mutexprofile=mutex.out ./pkg",
8242 "go test -trace trace.out ./pkg",
8243 "go test ./pkg -args -test.cpuprofile=cpu.out",
8244 "go test ./pkg '-test.coverprofile=coverage.out'",
8245 "go test -outputdir profiles ./pkg",
8246 "go test -fuzz=FuzzParser ./pkg",
8247 "go test ./pkg -args -test.fuzz FuzzParser",
8248 "go test ./pkg -args -test.fuzzcachedir=fuzz-corpus",
8249 "go test ./pkg -args -test.gocoverdir=coverage",
8250 "go test ./pkg -args '-test.testlogfile=test log'",
8251 ] {
8252 assert_eq!(kind_of(command), None, "{command}");
8253 }
8254 assert_eq!(kind_of("go test c o exec"), Some(CompilationKind::GoTest));
8255 }
8256
8257 #[test]
8258 fn go_state_mutating_subcommands_are_never_intercepted() {
8259 for cmd in [
8260 "go get github.com/foo/bar",
8261 "go mod tidy",
8262 "go mod download",
8263 "go install ./cmd/tool",
8264 "go run main.go",
8265 "go generate ./...",
8266 "go fmt ./...",
8267 "go clean -cache",
8268 "go work sync",
8269 "go env GOPATH",
8270 "go version",
8271 "go list ./...",
8272 "go tool pprof",
8273 ] {
8274 assert_eq!(kind_of(cmd), None, "{cmd} must never be intercepted");
8275 }
8276 }
8277
8278 #[test]
8279 fn go_prefixed_binaries_are_not_matched() {
8280 assert_eq!(kind_of("gofmt -l ."), None);
8282 assert_eq!(kind_of("golangci-lint run"), None);
8283 }
8284
8285 #[test]
8288 fn tsc_noemit_is_classified() {
8289 assert_eq!(kind_of("tsc --noEmit"), Some(CompilationKind::Tsc));
8290 assert_eq!(kind_of("npx tsc --noEmit"), Some(CompilationKind::Tsc));
8291 assert_eq!(
8293 kind_of("npx tsc --noEmit --incremental false"),
8294 Some(CompilationKind::Tsc)
8295 );
8296 assert_eq!(kind_of("tsc --noemit"), Some(CompilationKind::Tsc));
8298 }
8299
8300 #[test]
8301 fn emitting_tsc_stays_local() {
8302 assert_eq!(kind_of("tsc"), None);
8305 assert_eq!(kind_of("tsc -p tsconfig.json"), None);
8306 assert_eq!(kind_of("npx tsc --outDir dist"), None);
8307 }
8308
8309 #[test]
8310 fn tsc_watch_is_not_intercepted() {
8311 assert_eq!(kind_of("tsc --noEmit --watch"), None);
8312 assert_eq!(kind_of("tsc --noEmit -w"), None);
8313 }
8314
8315 #[test]
8316 fn tsc_trivial_forms_are_not_intercepted() {
8317 assert_eq!(kind_of("tsc --version"), None);
8318 assert_eq!(kind_of("tsc --init"), None);
8319 }
8320
8321 #[test]
8322 fn npx_non_tsc_is_not_intercepted() {
8323 assert_eq!(kind_of("npx jest"), None);
8326 assert_eq!(kind_of("npx prettier --write ."), None);
8327 }
8328
8329 #[test]
8332 fn command_base_is_allowlisted() {
8333 use crate::types::ExecutionConfig;
8334 let exec = ExecutionConfig::default();
8335 for kind in [
8336 CompilationKind::GoBuild,
8337 CompilationKind::GoTest,
8338 CompilationKind::GoVet,
8339 CompilationKind::Tsc,
8340 ] {
8341 let base = kind.command_base();
8342 assert!(
8343 exec.is_allowed(base),
8344 "{base} missing from the execution allowlist — the hook would fail \
8345 open to LOCAL execution, silently losing offload"
8346 );
8347 }
8348 assert_eq!(CompilationKind::GoBuild.command_base(), "go");
8349 assert_eq!(CompilationKind::Tsc.command_base(), "tsc");
8350 }
8351}