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 is_cargo_xwin_build(cmd) {
1968 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo-xwin build")
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 is_cargo_xwin_build(command: &str) -> bool {
2546 let normalized = normalize_command(command);
2547 let cmd = normalized.as_ref();
2548 if check_structure(cmd).is_some() || cmd.contains(['\'', '"', '\\', '$', '`']) {
2549 return false;
2550 }
2551 let mut tokens = cmd.split_whitespace();
2552 match tokens.next() {
2553 Some("cargo-xwin") => {
2554 let mut subcommand = tokens.next();
2555 if subcommand == Some("xwin") {
2556 subcommand = tokens.next();
2557 }
2558 if subcommand != Some("build") {
2559 return false;
2560 }
2561 }
2562 Some("cargo") => loop {
2563 match tokens.next() {
2564 Some("xwin") => {
2565 if tokens.next() != Some("build") {
2566 return false;
2567 }
2568 break;
2569 }
2570 Some("--color" | "--config" | "-Z" | "-C") => {
2571 if tokens.next().is_none_or(|value| value.starts_with('-')) {
2572 return false;
2573 }
2574 }
2575 Some("--locked" | "--offline" | "--frozen" | "-v" | "-vv" | "-q") => {}
2576 Some(token)
2577 if token.starts_with('+')
2578 || token.starts_with("--color=")
2579 || token.starts_with("--config=") => {}
2580 _ => return false,
2581 }
2582 },
2583 _ => return false,
2584 }
2585 !tokens.any(|token| matches!(token, "--help" | "-h" | "--version" | "-V"))
2586}
2587
2588fn unquote_whole_word(token: &str) -> &str {
2593 token
2594 .strip_prefix('\'')
2595 .and_then(|rest| rest.strip_suffix('\''))
2596 .filter(|inner| !inner.contains('\''))
2597 .unwrap_or(token)
2598}
2599
2600fn classify_cargo(cmd: &str) -> Classification {
2605 let mut tokens = cmd.split_whitespace();
2606
2607 let _cargo = tokens.next();
2609
2610 const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2617 let subcommand = loop {
2618 let Some(tok) = tokens.next().map(unquote_whole_word) else {
2619 return Classification::not_compilation("bare cargo command");
2620 };
2621 if tok.starts_with('+') {
2622 continue;
2624 }
2625 if tok.starts_with('-') {
2626 if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2627 let _ = tokens.next();
2629 }
2630 continue;
2631 }
2632 break tok;
2633 };
2634
2635 match subcommand {
2636 "build" | "b" => {
2637 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build")
2638 }
2639 "package" | "publish" => {
2640 if is_cargo_package_verification(cmd) {
2641 Classification::compilation(
2642 CompilationKind::CargoBuild,
2643 0.95,
2644 "cargo package verification (includes build)",
2645 )
2646 } else {
2647 Classification::not_compilation(
2648 "cargo package/publish without supported verification (never-intercept)",
2649 )
2650 }
2651 }
2652 "test" | "t" => Classification::compilation(CompilationKind::CargoTest, 0.95, "cargo test"),
2653 "check" | "c" => {
2654 Classification::compilation(CompilationKind::CargoCheck, 0.90, "cargo check")
2655 }
2656 "clippy" => Classification::compilation(CompilationKind::CargoClippy, 0.90, "cargo clippy"),
2657 "doc" => Classification::compilation(CompilationKind::CargoDoc, 0.85, "cargo doc"),
2658 "run" | "r" => {
2659 Classification::compilation(
2661 CompilationKind::CargoBuild,
2662 0.85,
2663 "cargo run (includes build)",
2664 )
2665 }
2666 "bench" => Classification::compilation(CompilationKind::CargoBench, 0.90, "cargo bench"),
2667 "zigbuild" => {
2672 Classification::compilation(CompilationKind::CargoZigbuild, 0.95, "cargo zigbuild")
2673 }
2674 "xwin" => {
2675 if is_cargo_xwin_build(cmd) {
2676 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo xwin build")
2677 } else {
2678 Classification::not_compilation("cargo xwin subcommand not interceptable")
2679 }
2680 }
2681 "nextest" => {
2682 let Some(nextest_sub) = tokens.next() else {
2686 return Classification::not_compilation("bare cargo nextest without subcommand");
2687 };
2688
2689 match nextest_sub {
2690 "run" | "r" => Classification::compilation(
2691 CompilationKind::CargoNextest,
2692 0.95,
2693 "cargo nextest run",
2694 ),
2695 _ => Classification::not_compilation("cargo nextest subcommand not interceptable"),
2696 }
2697 }
2698 _ => Classification::not_compilation("cargo subcommand not interceptable"),
2699 }
2700}
2701
2702fn classify_cargo_zigbuild(cmd: &str) -> Classification {
2712 let mut tokens = cmd.split_whitespace();
2713 let _bin = tokens.next();
2715
2716 const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2719 let subcommand = loop {
2720 let Some(tok) = tokens.next().map(unquote_whole_word) else {
2721 return Classification::not_compilation("bare cargo-zigbuild command");
2722 };
2723 if tok.starts_with('+') {
2724 continue;
2725 }
2726 if tok.starts_with('-') {
2727 if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2728 let _ = tokens.next();
2729 }
2730 continue;
2731 }
2732 break tok;
2733 };
2734
2735 match subcommand {
2736 "zigbuild" | "build" | "b" => Classification::compilation(
2737 CompilationKind::CargoZigbuild,
2738 0.95,
2739 "cargo-zigbuild build",
2740 ),
2741 _ => Classification::not_compilation("cargo-zigbuild subcommand not interceptable"),
2742 }
2743}
2744
2745const NIX_GLOBAL_VALUE_FLAGS: &[&str] = &[
2754 "--extra-experimental-features",
2755 "--experimental-features",
2756 "--log-format",
2757 "--max-jobs",
2758 "-j",
2759 "--cores",
2760 "--builders",
2761 "--store",
2762 "--file",
2763 "-f",
2764 "-I",
2765 "--include",
2766];
2767
2768fn classify_nix(cmd: &str) -> Classification {
2789 if cmd.eq("nix-build") || cmd.starts_with("nix-build ") {
2791 return Classification::compilation(
2792 CompilationKind::NixBuild,
2793 0.9,
2794 "nix-build (legacy) derivation build",
2795 );
2796 }
2797
2798 let mut tokens = cmd.split_whitespace();
2799 let _nix = tokens.next(); let subcommand = loop {
2803 let Some(tok) = tokens.next() else {
2804 return Classification::not_compilation("bare nix command (no subcommand)");
2805 };
2806 if tok.starts_with('-') {
2807 if tok.eq("--option") {
2808 let _ = tokens.next();
2810 let _ = tokens.next();
2811 } else if !tok.contains('=') && NIX_GLOBAL_VALUE_FLAGS.contains(&tok) {
2812 let _ = tokens.next();
2813 }
2814 continue;
2815 }
2816 break tok;
2817 };
2818
2819 let rest: Vec<&str> = tokens.collect();
2821 let wants_help = rest.iter().any(|t| t.eq(&"--help") || t.eq(&"-h"));
2822 if wants_help {
2823 return Classification::not_compilation("nix --help (not a build)");
2824 }
2825 let has_command_flag = rest.iter().any(|t| t.eq(&"-c") || t.eq(&"--command"));
2826
2827 match subcommand {
2828 "build" => {
2830 Classification::compilation(CompilationKind::NixBuild, 0.9, "nix build derivation")
2831 }
2832 "flake" => {
2836 let flake_sub = rest.iter().find(|t| !t.starts_with('-'));
2837 if flake_sub == Some(&"check") {
2838 Classification::compilation(
2839 CompilationKind::NixBuild,
2840 0.9,
2841 "nix flake check (evaluates + builds all outputs)",
2842 )
2843 } else {
2844 Classification::not_compilation("nix flake subcommand is not a build")
2845 }
2846 }
2847 "develop" | "shell" => {
2851 if has_command_flag {
2852 let reason = if subcommand == "develop" {
2853 "nix develop -c (non-interactive command in dev shell)"
2854 } else {
2855 "nix shell -c (non-interactive command with packages)"
2856 };
2857 Classification::compilation(CompilationKind::NixBuild, 0.9, reason)
2858 } else {
2859 Classification::not_compilation(
2860 "interactive nix shell (no -c/--command; not intercepted)",
2861 )
2862 }
2863 }
2864 _ => Classification::not_compilation("nix subcommand is not an offloadable build"),
2868 }
2869}
2870
2871pub fn split_shell_commands(cmd: &str) -> Vec<&str> {
2882 let bytes = cmd.as_bytes();
2883 let len = bytes.len();
2884 let mut segments: Vec<&str> = Vec::new();
2885 let mut start = 0;
2886 let mut i = 0;
2887
2888 let mut in_single = false;
2890 let mut in_double = false;
2891 let mut in_backtick = false;
2892 let mut escaped = false;
2893
2894 while i < len {
2895 let b = bytes[i];
2896
2897 if escaped {
2898 escaped = false;
2899 i += 1;
2900 continue;
2901 }
2902
2903 if b == b'\\' && !in_single {
2908 escaped = true;
2909 i += 1;
2910 continue;
2911 }
2912
2913 if !in_double && !in_backtick && b == b'\'' {
2915 in_single = !in_single;
2916 i += 1;
2917 continue;
2918 }
2919 if !in_single && !in_backtick && b == b'"' {
2920 in_double = !in_double;
2921 i += 1;
2922 continue;
2923 }
2924 if !in_single && !in_double && b == b'`' {
2925 in_backtick = !in_backtick;
2926 i += 1;
2927 continue;
2928 }
2929
2930 if in_single || in_double || in_backtick {
2932 i += 1;
2933 continue;
2934 }
2935
2936 if b == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
2938 let seg = cmd[start..i].trim();
2939 if !seg.is_empty() {
2940 segments.push(seg);
2941 }
2942 i += 2;
2943 start = i;
2944 continue;
2945 }
2946
2947 if b == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
2949 let seg = cmd[start..i].trim();
2950 if !seg.is_empty() {
2951 segments.push(seg);
2952 }
2953 i += 2;
2954 start = i;
2955 continue;
2956 }
2957
2958 if b == b';' {
2960 let seg = cmd[start..i].trim();
2961 if !seg.is_empty() {
2962 segments.push(seg);
2963 }
2964 i += 1;
2965 start = i;
2966 continue;
2967 }
2968
2969 i += 1;
2970 }
2971
2972 let seg = cmd[start..].trim();
2974 if !seg.is_empty() {
2975 segments.push(seg);
2976 }
2977
2978 segments
2979}
2980
2981#[cfg(test)]
2982mod tests {
2983 use super::*;
2984
2985 #[test]
2986 fn cargo_xwin_builds_use_the_cargo_build_contract() {
2987 for command in [
2988 "cargo xwin build --release --locked --target x86_64-pc-windows-msvc",
2989 "cargo +nightly-2026-08-31 xwin build --target=aarch64-pc-windows-msvc",
2990 "cargo --color never --locked xwin build",
2991 "env CARGO_HOME=/root/.cargo cargo xwin build --release",
2992 "cargo-xwin build --release",
2993 "cargo-xwin xwin build --release",
2994 "/home/ubuntu/.local/bin/cargo-xwin xwin build --release",
2995 ] {
2996 assert!(is_cargo_xwin_build(command), "{command}");
2997 let classification = classify_command(command);
2998 assert!(classification.is_compilation, "{command}");
2999 assert_eq!(
3000 classification.kind,
3001 Some(CompilationKind::CargoBuild),
3002 "{command}"
3003 );
3004 }
3005 }
3006
3007 #[test]
3008 fn cargo_xwin_recognition_declines_non_builds_and_argument_mentions() {
3009 for command in [
3010 "cargo xwin",
3011 "cargo xwin env",
3012 "cargo xwin cache clear",
3013 "cargo xwin check",
3014 "cargo xwin publish",
3015 "cargo xwin build --help",
3016 "cargo-xwin --version",
3017 "cargo-xwin cache clear",
3018 "cargo-xwin xwin build --help",
3019 "cargo --config xwin build --target x86_64-pc-windows-msvc",
3020 "cargo build --manifest-path cargo-xwin --target x86_64-pc-windows-msvc",
3021 "echo cargo xwin build",
3022 "cargo xwin build && cargo build",
3023 ] {
3024 assert!(!is_cargo_xwin_build(command), "{command}");
3025 }
3026 for command in [
3027 "cargo xwin env",
3028 "cargo xwin cache clear",
3029 "cargo-xwin --version",
3030 ] {
3031 assert!(!classify_command(command).is_compilation, "{command}");
3032 }
3033 }
3034 use crate::test_guard;
3035
3036 #[test]
3037 fn test_cargo_build_with_toolchain() {
3038 let _guard = test_guard!();
3039 let result = classify_command("cargo +nightly build");
3040 assert!(result.is_compilation);
3041 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3042 }
3043
3044 #[test]
3047 fn test_nix_build_offloadable() {
3048 let _guard = test_guard!();
3049 for cmd in [
3050 "nix build",
3051 "nix build .#foo",
3052 "nix build .#packages.x86_64-linux.default",
3053 "nix build /path/to/flake#pkg",
3054 "nix build --extra-experimental-features 'nix-command flakes' .#foo",
3055 "nix --option sandbox true build .#foo",
3056 "nix -j4 build .#foo",
3057 "nix build nixpkgs#hello -o result-hello",
3058 ] {
3059 let result = classify_command(cmd);
3060 assert!(result.is_compilation, "expected offload for `{cmd}`");
3061 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3062 assert!(result.confidence >= 0.85, "`{cmd}` confidence too low");
3063 }
3064 }
3065
3066 #[test]
3067 fn test_nix_build_legacy_offloadable() {
3068 let _guard = test_guard!();
3069 for cmd in ["nix-build", "nix-build .", "nix-build -A hello"] {
3070 let result = classify_command(cmd);
3071 assert!(result.is_compilation, "expected offload for `{cmd}`");
3072 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3073 }
3074 }
3075
3076 #[test]
3077 fn test_nix_flake_check_offloadable_but_metadata_not() {
3078 let _guard = test_guard!();
3079 let check = classify_command("nix flake check");
3080 assert!(check.is_compilation, "nix flake check should offload");
3081 assert_eq!(check.kind, Some(CompilationKind::NixBuild));
3082
3083 for cmd in [
3084 "nix flake metadata",
3085 "nix flake show",
3086 "nix flake update",
3087 "nix flake lock",
3088 "nix flake info",
3089 ] {
3090 let result = classify_command(cmd);
3091 assert!(!result.is_compilation, "`{cmd}` must NOT offload");
3092 }
3093 }
3094
3095 #[test]
3096 fn test_nix_develop_and_shell_only_with_command() {
3097 let _guard = test_guard!();
3098 for cmd in [
3100 "nix develop -c make",
3101 "nix develop --command cargo build",
3102 "nix develop .#dev -c bun test",
3103 "nix shell nixpkgs#gcc -c make",
3104 "nix shell nixpkgs#hello --command hello",
3105 ] {
3106 let result = classify_command(cmd);
3107 assert!(result.is_compilation, "expected offload for `{cmd}`");
3108 assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3109 }
3110 for cmd in ["nix develop", "nix develop .#dev", "nix shell nixpkgs#gcc"] {
3112 let result = classify_command(cmd);
3113 assert!(
3114 !result.is_compilation,
3115 "`{cmd}` is interactive; must run local"
3116 );
3117 }
3118 }
3119
3120 #[test]
3121 fn test_nix_execution_and_mutation_not_offloaded() {
3122 let _guard = test_guard!();
3123 for cmd in [
3124 "nix run .#app",
3125 "nix run nixpkgs#hello",
3126 "nix repl",
3127 "nix eval .#foo",
3128 "nix search nixpkgs hello",
3129 "nix profile install nixpkgs#hello",
3130 "nix registry list",
3131 "nix store gc",
3132 "nix copy --to ssh://host .#foo",
3133 "nix path-info .#foo",
3134 "nix --version",
3135 "nix build --help",
3136 "nix",
3137 ] {
3138 let result = classify_command(cmd);
3139 assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
3140 }
3141 }
3142
3143 #[test]
3144 fn test_nix_legacy_mutating_tools_have_no_keyword() {
3145 let _guard = test_guard!();
3146 for cmd in [
3150 "nix-env -iA nixpkgs.hello",
3151 "nix-shell",
3152 "nix-shell -p gcc",
3153 "nix-store --gc",
3154 "nix-collect-garbage -d",
3155 "nix-channel --update",
3156 ] {
3157 assert!(
3158 !starts_with_compilation_command(cmd),
3159 "`{cmd}` should not match any compilation keyword"
3160 );
3161 let result = classify_command(cmd);
3162 assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
3163 }
3164 }
3165
3166 #[test]
3167 fn test_nix_build_maps_to_nix_runtime_base() {
3168 let _guard = test_guard!();
3169 assert_eq!(CompilationKind::NixBuild.command_base(), "nix");
3170 assert!(!CompilationKind::NixBuild.is_test_command());
3171 }
3172
3173 #[test]
3174 fn test_nix_build_compound_and_wrapped() {
3175 let _guard = test_guard!();
3176 let compound = classify_command("cd /repo && nix build .#foo");
3178 assert!(compound.is_compilation, "compound nix build should offload");
3179 assert_eq!(compound.kind, Some(CompilationKind::NixBuild));
3180 let wrapped = classify_command("bash -c \"nix build .#foo\"");
3182 assert!(wrapped.is_compilation, "wrapped nix build should offload");
3183 assert_eq!(wrapped.kind, Some(CompilationKind::NixBuild));
3184 }
3185
3186 #[test]
3187 fn test_job_kind_serde_and_base_token() {
3188 let _guard = test_guard!();
3189 let json = serde_json::to_string(&CompilationKind::Job).unwrap();
3191 assert_eq!(json, "\"job\"");
3192 let round: CompilationKind = serde_json::from_str(&json).unwrap();
3193 assert_eq!(round, CompilationKind::Job);
3194 assert_eq!(CompilationKind::Job.command_base(), "job");
3197 assert!(!CompilationKind::Job.is_test_command());
3198 }
3199
3200 #[test]
3201 fn test_cargo_test_with_toolchain() {
3202 let _guard = test_guard!();
3203 let result = classify_command("cargo +1.80.0 test");
3204 assert!(result.is_compilation);
3205 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3206 }
3207
3208 #[test]
3209 fn test_cargo_nextest_with_toolchain() {
3210 let _guard = test_guard!();
3211 let result = classify_command("cargo +nightly nextest run");
3212 assert!(result.is_compilation);
3213 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
3214 }
3215
3216 #[test]
3217 fn test_cargo_build() {
3218 let _guard = test_guard!();
3219 let result = classify_command("cargo build");
3220 assert!(result.is_compilation);
3221 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3222 assert!(result.confidence >= 0.90);
3223 }
3224
3225 #[test]
3226 fn test_cargo_build_release() {
3227 let _guard = test_guard!();
3228 let result = classify_command("cargo build --release");
3229 assert!(result.is_compilation);
3230 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3231 }
3232
3233 #[test]
3234 fn test_cargo_test() {
3235 let _guard = test_guard!();
3236 let result = classify_command("cargo test");
3237 assert!(result.is_compilation);
3238 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3239 }
3240
3241 #[test]
3242 fn test_cargo_fmt_not_intercepted() {
3243 let _guard = test_guard!();
3244 let result = classify_command("cargo fmt");
3245 assert!(!result.is_compilation);
3246 assert!(result.reason.contains("never-intercept"));
3247 }
3248
3249 #[test]
3250 fn test_cargo_install_not_intercepted() {
3251 let _guard = test_guard!();
3252 let result = classify_command("cargo install ripgrep");
3253 assert!(!result.is_compilation);
3254 assert!(result.reason.contains("never-intercept"));
3255 }
3256
3257 #[test]
3258 fn test_piped_to_benign_pager_intercepted() {
3259 let _guard = test_guard!();
3260 let result = classify_command("cargo build 2>&1 | grep error");
3264 assert!(result.is_compilation, "got: {:?}", result.reason);
3265 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3266 assert_eq!(
3267 result.extracted_command.as_deref(),
3268 Some("cargo build 2>&1 | grep error")
3269 );
3270 }
3271
3272 #[test]
3273 fn test_piped_to_nonbenign_command_not_intercepted() {
3274 let _guard = test_guard!();
3275 let result = classify_command("cargo build | xargs rm");
3277 assert!(!result.is_compilation);
3278 assert!(result.reason.contains("piped"));
3279 }
3280
3281 #[test]
3282 fn test_backgrounded_intercepted() {
3283 let _guard = test_guard!();
3284 let result = classify_command("cargo build &");
3286 assert!(result.is_compilation, "got: {:?}", result.reason);
3287 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3288 assert_eq!(result.extracted_command.as_deref(), Some("cargo build &"));
3289 }
3290
3291 #[test]
3292 fn test_newline_injection_not_intercepted() {
3293 let _guard = test_guard!();
3294 let result = classify_command("cargo build\nrm -rf /");
3296 assert!(!result.is_compilation);
3297 assert!(result.reason.contains("newline"));
3298
3299 let result = classify_command("cargo build\r\nrm -rf /");
3300 assert!(!result.is_compilation);
3301 assert!(result.reason.contains("newline"));
3302
3303 for command in [
3304 "cargo build > out.log\nrm -rf /",
3305 "cargo build | tee out.log\r\nrm -rf /",
3306 "cargo build &\nrm -rf /",
3307 ] {
3308 let result = classify_command(command);
3309 assert!(
3310 !result.is_compilation,
3311 "newline-bearing benign suffix must fail closed: {command:?}"
3312 );
3313 assert!(result.reason.contains("newline"), "{result:?}");
3314 }
3315 }
3316
3317 #[test]
3318 fn test_redirected_intercepted() {
3319 let _guard = test_guard!();
3320 let result = classify_command("cargo build > log.txt");
3323 assert!(result.is_compilation, "got: {:?}", result.reason);
3324 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3325 assert_eq!(
3326 result.extracted_command.as_deref(),
3327 Some("cargo build > log.txt")
3328 );
3329
3330 let result = classify_command("cargo test --workspace > out.log 2>&1");
3331 assert!(result.is_compilation, "got: {:?}", result.reason);
3332 assert_eq!(
3333 result.extracted_command.as_deref(),
3334 Some("cargo test --workspace > out.log 2>&1")
3335 );
3336
3337 for command in [
3340 "cargo build 2>errors.txt",
3341 "cargo build 2>>errors.txt",
3342 "cargo build >out.log 2>&1",
3343 ] {
3344 let result = classify_command(command);
3345 assert!(result.is_compilation, "got: {:?}", result.reason);
3346 assert_eq!(result.extracted_command.as_deref(), Some(command));
3347 }
3348 }
3349
3350 #[test]
3351 fn test_input_redirected_not_intercepted() {
3352 let _guard = test_guard!();
3353 let result = classify_command("cargo build < input.txt");
3355 assert!(!result.is_compilation);
3356 assert!(result.reason.contains("input redirected"));
3357 }
3358
3359 #[test]
3360 fn test_process_substitution_not_intercepted() {
3361 let _guard = test_guard!();
3362 let result = classify_command("cargo build --config <(echo ...)");
3363 assert!(!result.is_compilation);
3364 assert!(result.reason.contains("subshell execution"));
3365 }
3366
3367 #[test]
3368 fn test_subshell_not_intercepted() {
3369 let _guard = test_guard!();
3370 let result = classify_command("(cargo build)");
3371 assert!(!result.is_compilation);
3372 assert!(result.reason.contains("subshell execution"));
3373 }
3374
3375 #[test]
3376 fn test_make_maintenance_matches_whole_target_tokens() {
3377 let _guard = test_guard!();
3380
3381 for cmd in [
3383 "make clean",
3384 "make install",
3385 "make distclean",
3386 "make uninstall",
3387 ] {
3388 let r = classify_command(cmd);
3389 assert!(!r.is_compilation, "{cmd} should not be intercepted");
3390 assert!(r.reason.contains("make maintenance command"), "{cmd}");
3391 }
3392 let r = classify_command("make -j8 clean");
3394 assert!(!r.is_compilation);
3395
3396 for cmd in [
3398 "make CFLAGS=-Iinstall/include",
3399 "make build INSTALL_DIR=/x",
3400 "make TARGET=cleanup",
3401 "make clean-all", "make -C install", ] {
3404 let r = classify_command(cmd);
3405 assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3406 assert_eq!(r.kind, Some(CompilationKind::Make), "{cmd}");
3407 }
3408 }
3409
3410 #[test]
3411 fn test_ninja_clean_matches_tool_and_whole_target() {
3412 let _guard = test_guard!();
3415
3416 for cmd in ["ninja clean", "ninja -t clean", "ninja -j8 clean"] {
3417 let r = classify_command(cmd);
3418 assert!(!r.is_compilation, "{cmd} should not be intercepted");
3419 assert!(r.reason.contains("ninja clean"), "{cmd}");
3420 }
3421
3422 for cmd in [
3423 "ninja build/clean_obj.o", "ninja -C clean", "ninja -j4",
3426 ] {
3427 let r = classify_command(cmd);
3428 assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3429 assert_eq!(r.kind, Some(CompilationKind::Ninja), "{cmd}");
3430 }
3431 }
3432
3433 #[test]
3434 fn test_gcc_compile() {
3435 let _guard = test_guard!();
3436 let result = classify_command("gcc -c main.c -o main.o");
3437 assert!(result.is_compilation);
3438 assert_eq!(result.kind, Some(CompilationKind::Gcc));
3439 }
3440
3441 #[test]
3442 fn test_make() {
3443 let _guard = test_guard!();
3444 let result = classify_command("make -j8");
3445 assert!(result.is_compilation);
3446 assert_eq!(result.kind, Some(CompilationKind::Make));
3447 }
3448
3449 #[test]
3450 fn test_make_clean_not_intercepted() {
3451 let _guard = test_guard!();
3452 let result = classify_command("make clean");
3453 assert!(!result.is_compilation);
3454 assert!(result.reason.contains("make maintenance command"));
3455 }
3456
3457 #[test]
3458 fn test_cmake_build_requires_cmake_command() {
3459 let _guard = test_guard!();
3460 let result = classify_command("echo cmake --build .");
3461 assert!(!result.is_compilation);
3462 }
3463
3464 #[test]
3465 fn test_cmake_build_with_equals_flag() {
3466 let _guard = test_guard!();
3467 let result = classify_command("cmake --build=build");
3468 assert!(result.is_compilation);
3469 assert_eq!(result.kind, Some(CompilationKind::CmakeBuild));
3470 }
3471
3472 #[test]
3473 fn test_meson_compile_requires_meson_command() {
3474 let _guard = test_guard!();
3475 let result = classify_command("echo meson compile");
3476 assert!(!result.is_compilation);
3477 }
3478
3479 #[test]
3480 fn test_non_compilation() {
3481 let _guard = test_guard!();
3482 let result = classify_command("ls -la");
3483 assert!(!result.is_compilation);
3484 assert!(result.reason.contains("no compilation keyword"));
3485 }
3486
3487 #[test]
3488 fn test_empty_command() {
3489 let _guard = test_guard!();
3490 let result = classify_command("");
3491 assert!(!result.is_compilation);
3492 assert!(result.reason.contains("empty command"));
3493 }
3494
3495 #[test]
3498 fn test_bun_keyword_detected() {
3499 let _guard = test_guard!();
3500 assert!(starts_with_compilation_command("bun test"));
3503 assert!(starts_with_compilation_command("bun typecheck"));
3504 assert!(starts_with_compilation_command("bun install"));
3505 }
3506
3507 #[test]
3508 fn test_bun_install_not_intercepted() {
3509 let _guard = test_guard!();
3510 let result = classify_command("bun install");
3512 assert!(!result.is_compilation);
3513 assert!(result.reason.contains("never-intercept"));
3514 }
3515
3516 #[test]
3517 fn test_bun_add_not_intercepted() {
3518 let _guard = test_guard!();
3519 let result = classify_command("bun add lodash");
3521 assert!(!result.is_compilation);
3522 assert!(result.reason.contains("never-intercept"));
3523 }
3524
3525 #[test]
3526 fn test_bun_remove_not_intercepted() {
3527 let _guard = test_guard!();
3528 let result = classify_command("bun remove lodash");
3529 assert!(!result.is_compilation);
3530 assert!(result.reason.contains("never-intercept"));
3531 }
3532
3533 #[test]
3534 fn test_bun_run_not_intercepted() {
3535 let _guard = test_guard!();
3536 let result = classify_command("bun run build");
3538 assert!(!result.is_compilation);
3539 assert!(result.reason.contains("never-intercept"));
3540 }
3541
3542 #[test]
3543 fn test_bun_build_not_intercepted() {
3544 let _guard = test_guard!();
3545 let result = classify_command("bun build ./src/index.ts");
3547 assert!(!result.is_compilation);
3548 assert!(result.reason.contains("never-intercept"));
3549 }
3550
3551 #[test]
3552 fn test_bun_version_not_intercepted() {
3553 let _guard = test_guard!();
3554 let result = classify_command("bun --version");
3555 assert!(!result.is_compilation);
3556 assert!(result.reason.contains("never-intercept"));
3557
3558 let result = classify_command("bun -v");
3559 assert!(!result.is_compilation);
3560 assert!(result.reason.contains("never-intercept"));
3561 }
3562
3563 #[test]
3564 fn test_bun_dev_not_intercepted() {
3565 let _guard = test_guard!();
3566 let result = classify_command("bun dev");
3568 assert!(!result.is_compilation);
3569 assert!(result.reason.contains("never-intercept"));
3570 }
3571
3572 #[test]
3573 fn test_bun_repl_not_intercepted() {
3574 let _guard = test_guard!();
3575 let result = classify_command("bun repl");
3577 assert!(!result.is_compilation);
3578 assert!(result.reason.contains("never-intercept"));
3579 }
3580
3581 #[test]
3582 fn test_bun_link_not_intercepted() {
3583 let _guard = test_guard!();
3584 let result = classify_command("bun link");
3585 assert!(!result.is_compilation);
3586 assert!(result.reason.contains("never-intercept"));
3587 }
3588
3589 #[test]
3590 fn test_bun_init_not_intercepted() {
3591 let _guard = test_guard!();
3592 let result = classify_command("bun init");
3593 assert!(!result.is_compilation);
3594 assert!(result.reason.contains("never-intercept"));
3595 }
3596
3597 #[test]
3598 fn test_bun_create_not_intercepted() {
3599 let _guard = test_guard!();
3600 let result = classify_command("bun create next-app");
3601 assert!(!result.is_compilation);
3602 assert!(result.reason.contains("never-intercept"));
3603 }
3604
3605 #[test]
3606 fn test_bun_pm_not_intercepted() {
3607 let _guard = test_guard!();
3608 let result = classify_command("bun pm cache");
3609 assert!(!result.is_compilation);
3610 assert!(result.reason.contains("never-intercept"));
3611 }
3612
3613 #[test]
3614 fn test_bun_help_not_intercepted() {
3615 let _guard = test_guard!();
3616 let result = classify_command("bun --help");
3617 assert!(!result.is_compilation);
3618 assert!(result.reason.contains("never-intercept"));
3619
3620 let result = classify_command("bun -h");
3621 assert!(!result.is_compilation);
3622 assert!(result.reason.contains("never-intercept"));
3623 }
3624
3625 #[test]
3628 fn test_bun_test_classification() {
3629 let _guard = test_guard!();
3630 let result = classify_command("bun test");
3632 assert!(result.is_compilation);
3633 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3634 assert!((result.confidence - 0.95).abs() < 0.001);
3635
3636 let result = classify_command("bun test src/");
3638 assert!(result.is_compilation);
3639 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3640
3641 let result = classify_command("bun test --coverage");
3643 assert!(result.is_compilation);
3644 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3645
3646 let result = classify_command("bun test auth.test.ts");
3648 assert!(result.is_compilation);
3649 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3650
3651 let result = classify_command("bun test --bail --timeout 5000");
3653 assert!(result.is_compilation);
3654 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3655
3656 let result = classify_command("bun test --reporter json");
3658 assert!(result.is_compilation);
3659 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3660 }
3661
3662 #[test]
3663 fn test_bun_test_watch_not_intercepted() {
3664 let _guard = test_guard!();
3665 let result = classify_command("bun test --watch");
3667 assert!(!result.is_compilation);
3668 assert!(result.reason.contains("interactive"));
3669
3670 let result = classify_command("bun test --watch --coverage");
3672 assert!(!result.is_compilation);
3673 assert!(result.reason.contains("interactive"));
3674
3675 let result = classify_command("bun test src/ --watch");
3677 assert!(!result.is_compilation);
3678
3679 let result = classify_command("bun test -w");
3681 assert!(!result.is_compilation);
3682
3683 let result = classify_command("bun test -w src/");
3685 assert!(!result.is_compilation);
3686 }
3687
3688 #[test]
3689 fn test_bun_typecheck_watch_not_intercepted() {
3690 let _guard = test_guard!();
3691 let result = classify_command("bun typecheck --watch");
3693 assert!(!result.is_compilation);
3694 assert!(result.reason.contains("interactive"));
3695
3696 let result = classify_command("bun typecheck -w");
3698 assert!(!result.is_compilation);
3699 }
3700
3701 #[test]
3702 fn test_bun_typecheck_classification() {
3703 let _guard = test_guard!();
3704 let result = classify_command("bun typecheck");
3706 assert!(result.is_compilation);
3707 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3708 assert!((result.confidence - 0.95).abs() < 0.001);
3709
3710 let result = classify_command("bun typecheck src/");
3712 assert!(result.is_compilation);
3713 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3714
3715 }
3718
3719 #[test]
3720 fn test_bun_edge_cases_not_matched() {
3721 let _guard = test_guard!();
3722 let result = classify_command("bun testing");
3724 assert!(!result.is_compilation);
3725 assert!(result.reason.contains("no matching pattern"));
3726
3727 let result = classify_command("bun typechecker");
3728 assert!(!result.is_compilation);
3729 assert!(result.reason.contains("no matching pattern"));
3730
3731 let result = classify_command("bun type");
3732 assert!(!result.is_compilation);
3733 assert!(result.reason.contains("no matching pattern"));
3734
3735 let result = classify_command("bun x eslint");
3737 assert!(!result.is_compilation);
3738 assert!(result.reason.contains("bun x runs arbitrary packages"));
3739
3740 let result = classify_command("bun x prettier --write .");
3741 assert!(!result.is_compilation);
3742 assert!(result.reason.contains("bun x runs arbitrary packages"));
3743
3744 let result = classify_command("bun x vitest run");
3745 assert!(!result.is_compilation);
3746 assert!(result.reason.contains("bun x runs arbitrary packages"));
3747 }
3748
3749 #[test]
3750 fn test_bun_test_vs_never_intercept() {
3751 let _guard = test_guard!();
3752 let result = classify_command("bun test");
3754 assert!(!result.reason.contains("never-intercept"));
3755 assert!(result.is_compilation);
3756 }
3757
3758 #[test]
3759 fn test_bun_typecheck_vs_never_intercept() {
3760 let _guard = test_guard!();
3761 let result = classify_command("bun typecheck");
3763 assert!(!result.reason.contains("never-intercept"));
3764 assert!(result.is_compilation);
3765 }
3766
3767 #[test]
3770 fn test_bun_compilation_kind_serde() {
3771 let _guard = test_guard!();
3772 let kind = CompilationKind::BunTest;
3774 let json = serde_json::to_string(&kind).unwrap();
3775 assert_eq!(json, "\"bun_test\"");
3776 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3777 assert_eq!(parsed, CompilationKind::BunTest);
3778
3779 let kind = CompilationKind::BunTypecheck;
3781 let json = serde_json::to_string(&kind).unwrap();
3782 assert_eq!(json, "\"bun_typecheck\"");
3783 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3784 assert_eq!(parsed, CompilationKind::BunTypecheck);
3785 }
3786
3787 #[test]
3788 fn test_bun_kinds_are_distinct() {
3789 let _guard = test_guard!();
3790 assert_ne!(CompilationKind::BunTest, CompilationKind::BunTypecheck);
3792 assert_ne!(CompilationKind::BunTest, CompilationKind::CargoTest);
3794 }
3795
3796 #[test]
3797 fn test_wrapped_command_classification_repro() {
3798 let _guard = test_guard!();
3799 let result = classify_command("time cargo build");
3802 assert!(
3803 result.is_compilation,
3804 "Should classify 'time cargo build' as compilation"
3805 );
3806 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3807
3808 let result = classify_command("sudo cargo check");
3809 assert!(
3810 result.is_compilation,
3811 "Should classify 'sudo cargo check' as compilation"
3812 );
3813
3814 let result = classify_command("env RUST_BACKTRACE=1 cargo test");
3815 assert!(
3816 result.is_compilation,
3817 "Should classify env-wrapped cargo test as compilation"
3818 );
3819
3820 let result = classify_command("env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build");
3821 assert!(
3822 result.is_compilation,
3823 "Should classify shell-quoted env assignment before cargo build as compilation"
3824 );
3825 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3826 }
3827
3828 #[test]
3833 fn test_bun_piped_to_benign_pager_intercepted() {
3834 let _guard = test_guard!();
3835 let result = classify_command("bun test | grep error");
3837 assert!(result.is_compilation, "got: {:?}", result.reason);
3838 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3839 assert_eq!(
3840 result.extracted_command.as_deref(),
3841 Some("bun test | grep error")
3842 );
3843
3844 let result = classify_command("bun test 2>&1 | tee output.log");
3846 assert!(result.is_compilation, "got: {:?}", result.reason);
3847 assert_eq!(
3848 result.extracted_command.as_deref(),
3849 Some("bun test 2>&1 | tee output.log")
3850 );
3851
3852 let result = classify_command("bun typecheck | head -20");
3854 assert!(result.is_compilation, "got: {:?}", result.reason);
3855 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3856 }
3857
3858 #[test]
3859 fn test_bunx_tsc_not_intercepted() {
3860 let _guard = test_guard!();
3861 let result = classify_command("bun x tsc --noEmit");
3864 assert!(!result.is_compilation);
3865 assert!(result.reason.contains("bun x"));
3866
3867 let result = classify_command("bun x eslint .");
3869 assert!(!result.is_compilation);
3870
3871 let result = classify_command("bun x prettier --write .");
3872 assert!(!result.is_compilation);
3873
3874 let result = classify_command("bun x vitest run");
3875 assert!(!result.is_compilation);
3876 }
3877
3878 #[test]
3879 fn test_bun_redirected_intercepted() {
3880 let _guard = test_guard!();
3881 let result = classify_command("bun test > results.txt");
3883 assert!(result.is_compilation, "got: {:?}", result.reason);
3884 assert_eq!(
3885 result.extracted_command.as_deref(),
3886 Some("bun test > results.txt")
3887 );
3888
3889 let result = classify_command("bun typecheck > errors.log");
3890 assert!(result.is_compilation, "got: {:?}", result.reason);
3891 }
3892
3893 #[test]
3894 fn test_bun_backgrounded_intercepted() {
3895 let _guard = test_guard!();
3896 let result = classify_command("bun test &");
3898 assert!(result.is_compilation, "got: {:?}", result.reason);
3899 assert_eq!(result.extracted_command.as_deref(), Some("bun test &"));
3900 }
3901
3902 #[test]
3903 fn test_bun_chained_commands_classified() {
3904 let _guard = test_guard!();
3905 let result = classify_command("bun test && echo done");
3907 assert!(
3908 !result.is_compilation,
3909 "chained commands should be rejected"
3910 );
3911 assert!(result.reason.contains("chained"));
3912
3913 let result = classify_command("bun typecheck; bun test");
3914 assert!(
3915 !result.is_compilation,
3916 "chained commands should be rejected"
3917 );
3918 assert!(result.reason.contains("chained"));
3919 }
3920
3921 #[test]
3922 fn test_bun_subshell_capture_not_intercepted() {
3923 let _guard = test_guard!();
3924 let result = classify_command("bun test $(echo src/)");
3926 assert!(!result.is_compilation);
3927 assert!(result.reason.contains("subshell"));
3928 }
3929
3930 #[test]
3931 fn test_bun_wrapped_commands() {
3932 let _guard = test_guard!();
3933 let result = classify_command("time bun test");
3936 if result.is_compilation {
3939 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3940 }
3941
3942 let result = classify_command("env DEBUG=1 bun test");
3943 if result.is_compilation {
3944 assert_eq!(result.kind, Some(CompilationKind::BunTest));
3945 }
3946 }
3947
3948 #[test]
3949 fn test_classify_command_detailed_matches_basic() {
3950 let _guard = test_guard!();
3951 let commands = [
3952 "cargo build",
3953 "cargo test --release",
3954 "bun typecheck",
3955 "gcc -c main.c -o main.o",
3956 "cd /tmp && cargo build --release",
3957 "ls -la",
3958 ];
3959
3960 for cmd in commands {
3961 let basic = classify_command(cmd);
3962 let detailed = classify_command_detailed(cmd);
3963 assert_eq!(basic, detailed.classification);
3964 }
3965 }
3966
3967 #[test]
3968 fn test_classify_command_detailed_rejects_piped() {
3969 let _guard = test_guard!();
3970 let detailed = classify_command_detailed("cargo build | xargs rm");
3973 assert!(!detailed.classification.is_compilation);
3974 let tier1 = detailed.tiers.iter().find(|t| t.tier.eq(&1)).unwrap();
3975 assert_eq!(tier1.decision, TierDecision::Reject);
3976 assert!(tier1.reason.contains("piped"));
3977
3978 let detailed = classify_command_detailed("cargo build | tee log.txt");
3980 assert!(detailed.classification.is_compilation);
3981 assert_eq!(
3982 detailed.classification.extracted_command.as_deref(),
3983 Some("cargo build | tee log.txt")
3984 );
3985 }
3986
3987 #[test]
3988 fn test_classify_command_detailed_normalizes_wrappers() {
3989 let _guard = test_guard!();
3990 for (cmd, normalized) in [
3991 ("sudo cargo check", "cargo check"),
3992 ("env -u RUST_LOG cargo test", "cargo test"),
3993 ] {
3994 let detailed = classify_command_detailed(cmd);
3995 assert_eq!(
3996 (
3997 detailed.normalized.as_str(),
3998 detailed.classification.is_compilation
3999 ),
4000 (normalized, true),
4001 "{cmd}"
4002 );
4003 }
4004 }
4005
4006 #[test]
4007 fn test_classify_command_detailed_matches_compound_command() {
4008 let _guard = test_guard!();
4009 let detailed = classify_command_detailed("cd /tmp && cargo build --release");
4010
4011 assert!(detailed.classification.is_compilation);
4012 assert_eq!(
4013 detailed.classification.kind,
4014 Some(CompilationKind::CargoBuild)
4015 );
4016 assert_eq!(
4017 detailed.classification.command_prefix.as_deref(),
4018 Some("cd /tmp && ")
4019 );
4020 assert_eq!(
4021 detailed.classification.extracted_command.as_deref(),
4022 Some("cargo build --release")
4023 );
4024 assert_eq!(detailed.normalized, "cargo build --release");
4025 assert_eq!(detailed.tiers.len(), 5);
4026 assert!(
4027 detailed
4028 .tiers
4029 .iter()
4030 .all(|tier| tier.decision == TierDecision::Pass)
4031 );
4032 }
4033
4034 #[test]
4039 fn test_cargo_test_release() {
4040 let _guard = test_guard!();
4041 let result = classify_command("cargo test --release");
4042 assert!(
4043 result.is_compilation,
4044 "cargo test --release should be classified as compilation"
4045 );
4046 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4047 assert!(result.confidence >= 0.90);
4048 }
4049
4050 #[test]
4051 fn test_cargo_test_specific_test_name() {
4052 let _guard = test_guard!();
4053 let result = classify_command("cargo test my_test_function");
4054 assert!(
4055 result.is_compilation,
4056 "cargo test with specific test name should be classified"
4057 );
4058 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4059 }
4060
4061 #[test]
4062 fn test_cargo_test_with_nocapture() {
4063 let _guard = test_guard!();
4064 let result = classify_command("cargo test -- --nocapture");
4065 assert!(
4066 result.is_compilation,
4067 "cargo test -- --nocapture should be classified"
4068 );
4069 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4070 }
4071
4072 #[test]
4073 fn test_cargo_test_workspace() {
4074 let _guard = test_guard!();
4075 let result = classify_command("cargo test --workspace");
4076 assert!(
4077 result.is_compilation,
4078 "cargo test --workspace should be classified"
4079 );
4080 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4081 }
4082
4083 #[test]
4084 fn test_cargo_test_package() {
4085 let _guard = test_guard!();
4086 let result = classify_command("cargo test -p rch-common");
4087 assert!(result.is_compilation, "cargo test -p should be classified");
4088 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4089 }
4090
4091 #[test]
4092 fn test_cargo_test_short_alias() {
4093 let _guard = test_guard!();
4094 let result = classify_command("cargo t");
4096 assert!(
4097 result.is_compilation,
4098 "cargo t (short alias) should be classified"
4099 );
4100 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4101 }
4102
4103 #[test]
4104 fn test_cargo_test_all_features() {
4105 let _guard = test_guard!();
4106 let result = classify_command("cargo test --all-features");
4107 assert!(
4108 result.is_compilation,
4109 "cargo test --all-features should be classified"
4110 );
4111 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4112 }
4113
4114 #[test]
4115 fn test_cargo_test_no_default_features() {
4116 let _guard = test_guard!();
4117 let result = classify_command("cargo test --no-default-features");
4118 assert!(
4119 result.is_compilation,
4120 "cargo test --no-default-features should be classified"
4121 );
4122 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4123 }
4124
4125 #[test]
4126 fn test_cargo_test_with_env_var() {
4127 let _guard = test_guard!();
4128 let result = classify_command("RUST_BACKTRACE=1 cargo test");
4129 assert!(
4130 result.is_compilation,
4131 "cargo test with env var should be classified"
4132 );
4133 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4134 }
4135
4136 #[test]
4137 fn test_cargo_test_with_multiple_flags() {
4138 let _guard = test_guard!();
4139 let result = classify_command("cargo test --release --workspace -p rch -- --nocapture");
4140 assert!(
4141 result.is_compilation,
4142 "cargo test with multiple flags should be classified"
4143 );
4144 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4145 }
4146
4147 #[test]
4148 fn test_cargo_test_with_jobs() {
4149 let _guard = test_guard!();
4150 let result = classify_command("cargo test -j 8");
4151 assert!(result.is_compilation, "cargo test -j should be classified");
4152 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4153 }
4154
4155 #[test]
4156 fn test_cargo_test_target() {
4157 let _guard = test_guard!();
4158 let result = classify_command("cargo test --target x86_64-unknown-linux-gnu");
4159 assert!(
4160 result.is_compilation,
4161 "cargo test --target should be classified"
4162 );
4163 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4164 }
4165
4166 #[test]
4167 fn test_cargo_test_lib() {
4168 let _guard = test_guard!();
4169 let result = classify_command("cargo test --lib");
4170 assert!(
4171 result.is_compilation,
4172 "cargo test --lib should be classified"
4173 );
4174 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4175 }
4176
4177 #[test]
4178 fn test_cargo_test_bins() {
4179 let _guard = test_guard!();
4180 let result = classify_command("cargo test --bins");
4181 assert!(
4182 result.is_compilation,
4183 "cargo test --bins should be classified"
4184 );
4185 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4186 }
4187
4188 #[test]
4189 fn test_cargo_test_doc() {
4190 let _guard = test_guard!();
4191 let result = classify_command("cargo test --doc");
4192 assert!(
4193 result.is_compilation,
4194 "cargo test --doc should be classified"
4195 );
4196 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4197 }
4198
4199 #[test]
4200 fn test_cargo_test_filter_pattern() {
4201 let _guard = test_guard!();
4202 let result = classify_command("cargo test test_classification");
4203 assert!(
4204 result.is_compilation,
4205 "cargo test with filter pattern should be classified"
4206 );
4207 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4208 }
4209
4210 #[test]
4211 fn test_cargo_test_exact() {
4212 let _guard = test_guard!();
4213 let result = classify_command("cargo test --exact my_test");
4214 assert!(
4215 result.is_compilation,
4216 "cargo test --exact should be classified"
4217 );
4218 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4219 }
4220
4221 #[test]
4226 fn test_nextest_keyword_detected() {
4227 let _guard = test_guard!();
4228 assert!(starts_with_compilation_command("cargo nextest run"));
4230 assert!(starts_with_compilation_command("cargo nextest list"));
4231 }
4232
4233 #[test]
4234 fn test_cargo_nextest_run_classification() {
4235 let _guard = test_guard!();
4236 let result = classify_command("cargo nextest run");
4238 assert!(
4239 result.is_compilation,
4240 "cargo nextest run should be classified"
4241 );
4242 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4243 assert!((result.confidence - 0.95).abs() < 0.001);
4244 }
4245
4246 #[test]
4247 fn test_cargo_nextest_run_with_flags() {
4248 let _guard = test_guard!();
4249 let result = classify_command("cargo nextest run --release");
4251 assert!(result.is_compilation);
4252 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4253
4254 let result = classify_command("cargo nextest run --cargo-profile ci");
4256 assert!(result.is_compilation);
4257 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4258
4259 let result = classify_command("cargo nextest run --workspace");
4261 assert!(result.is_compilation);
4262 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4263
4264 let result = classify_command("cargo nextest run -p rch-common");
4266 assert!(result.is_compilation);
4267 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4268
4269 let result = classify_command("cargo nextest run test_classification");
4271 assert!(result.is_compilation);
4272 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4273
4274 let result = classify_command("cargo nextest run --release --no-fail-fast -j 8");
4276 assert!(result.is_compilation);
4277 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4278 }
4279
4280 #[test]
4281 fn test_cargo_nextest_run_short_alias() {
4282 let _guard = test_guard!();
4283 let result = classify_command("cargo nextest r");
4285 assert!(
4286 result.is_compilation,
4287 "cargo nextest r should be classified"
4288 );
4289 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4290 }
4291
4292 #[test]
4293 fn test_cargo_nextest_list_not_intercepted() {
4294 let _guard = test_guard!();
4295 let result = classify_command("cargo nextest list");
4297 assert!(!result.is_compilation);
4298 assert!(result.reason.contains("never-intercept"));
4299 }
4300
4301 #[test]
4302 fn test_cargo_nextest_archive_not_intercepted() {
4303 let _guard = test_guard!();
4304 let result = classify_command("cargo nextest archive");
4306 assert!(!result.is_compilation);
4307 assert!(result.reason.contains("never-intercept"));
4308 }
4309
4310 #[test]
4311 fn test_cargo_nextest_show_not_intercepted() {
4312 let _guard = test_guard!();
4313 let result = classify_command("cargo nextest show");
4315 assert!(!result.is_compilation);
4316 assert!(result.reason.contains("never-intercept"));
4317 }
4318
4319 #[test]
4320 fn test_bare_cargo_nextest_not_intercepted() {
4321 let _guard = test_guard!();
4322 let result = classify_command("cargo nextest");
4324 assert!(!result.is_compilation);
4325 assert!(result.reason.contains("without subcommand"));
4326 }
4327
4328 #[test]
4329 fn test_cargo_nextest_wrapped_commands() {
4330 let _guard = test_guard!();
4331 let result = classify_command("time cargo nextest run");
4333 assert!(
4334 result.is_compilation,
4335 "time cargo nextest run should be classified"
4336 );
4337 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4338
4339 let result = classify_command("RUST_BACKTRACE=1 cargo nextest run");
4340 assert!(
4341 result.is_compilation,
4342 "env-wrapped cargo nextest run should be classified"
4343 );
4344 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4345 }
4346
4347 #[test]
4348 fn test_cargo_nextest_compilation_kind_serde() {
4349 let _guard = test_guard!();
4350 let kind = CompilationKind::CargoNextest;
4352 let json = serde_json::to_string(&kind).unwrap();
4353 assert_eq!(json, "\"cargo_nextest\"");
4354 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4355 assert_eq!(parsed, CompilationKind::CargoNextest);
4356 }
4357
4358 #[test]
4359 fn test_cargo_nextest_vs_cargo_test_distinct() {
4360 let _guard = test_guard!();
4361 assert_ne!(CompilationKind::CargoNextest, CompilationKind::CargoTest);
4363
4364 let nextest_result = classify_command("cargo nextest run");
4366 let test_result = classify_command("cargo test");
4367 assert!(nextest_result.is_compilation);
4368 assert!(test_result.is_compilation);
4369 assert_eq!(nextest_result.kind, Some(CompilationKind::CargoNextest));
4370 assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4371 }
4372
4373 #[test]
4374 fn test_cargo_nextest_piped_to_pager_intercepted() {
4375 let _guard = test_guard!();
4376 let result = classify_command("cargo nextest run | grep FAIL");
4378 assert!(result.is_compilation, "got: {:?}", result.reason);
4379 assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4380 assert_eq!(
4381 result.extracted_command.as_deref(),
4382 Some("cargo nextest run | grep FAIL")
4383 );
4384 }
4385
4386 #[test]
4387 fn test_cargo_nextest_redirected_intercepted() {
4388 let _guard = test_guard!();
4389 let result = classify_command("cargo nextest run > results.txt");
4391 assert!(result.is_compilation, "got: {:?}", result.reason);
4392 assert_eq!(
4393 result.extracted_command.as_deref(),
4394 Some("cargo nextest run > results.txt")
4395 );
4396 }
4397
4398 #[test]
4399 fn test_cargo_nextest_backgrounded_intercepted() {
4400 let _guard = test_guard!();
4401 let result = classify_command("cargo nextest run &");
4403 assert!(result.is_compilation, "got: {:?}", result.reason);
4404 assert_eq!(
4405 result.extracted_command.as_deref(),
4406 Some("cargo nextest run &")
4407 );
4408 }
4409
4410 #[test]
4415 fn test_cargo_bench_classification() {
4416 let _guard = test_guard!();
4417 let result = classify_command("cargo bench");
4418 assert!(result.is_compilation, "cargo bench should be classified");
4419 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4420 assert!((result.confidence - 0.90).abs() < 0.001);
4421 }
4422
4423 #[test]
4424 fn test_cargo_bench_with_filter() {
4425 let _guard = test_guard!();
4426 let result = classify_command("cargo bench my_bench");
4428 assert!(result.is_compilation);
4429 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4430 }
4431
4432 #[test]
4433 fn test_cargo_bench_with_flags() {
4434 let _guard = test_guard!();
4435 let result = classify_command("cargo bench --release");
4437 assert!(result.is_compilation);
4438 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4439
4440 let result = classify_command("cargo bench -p rch-common");
4442 assert!(result.is_compilation);
4443 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4444
4445 let result = classify_command("cargo bench --features benchmarks");
4447 assert!(result.is_compilation);
4448 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4449
4450 let result = classify_command("cargo bench --bench criterion_bench");
4452 assert!(result.is_compilation);
4453 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4454 }
4455
4456 #[test]
4457 fn test_cargo_bench_wrapped() {
4458 let _guard = test_guard!();
4459 let result = classify_command("time cargo bench");
4461 assert!(
4462 result.is_compilation,
4463 "time cargo bench should be classified"
4464 );
4465 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4466
4467 let result = classify_command("CARGO_INCREMENTAL=0 cargo bench");
4469 assert!(result.is_compilation);
4470 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4471 }
4472
4473 #[test]
4474 fn test_cargo_bench_serde() {
4475 let _guard = test_guard!();
4476 let kind = CompilationKind::CargoBench;
4478 let json = serde_json::to_string(&kind).unwrap();
4479 assert_eq!(json, "\"cargo_bench\"");
4480 let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4481 assert_eq!(parsed, CompilationKind::CargoBench);
4482 }
4483
4484 #[test]
4485 fn test_cargo_bench_distinct_from_test() {
4486 let _guard = test_guard!();
4487 assert_ne!(CompilationKind::CargoBench, CompilationKind::CargoTest);
4489
4490 let bench_result = classify_command("cargo bench");
4492 let test_result = classify_command("cargo test");
4493 assert!(bench_result.is_compilation);
4494 assert!(test_result.is_compilation);
4495 assert_eq!(bench_result.kind, Some(CompilationKind::CargoBench));
4496 assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4497 }
4498
4499 #[test]
4500 fn test_cargo_bench_piped_to_pager_intercepted() {
4501 let _guard = test_guard!();
4502 let result = classify_command("cargo bench | tee output.txt");
4504 assert!(result.is_compilation, "got: {:?}", result.reason);
4505 assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4506 assert_eq!(
4507 result.extracted_command.as_deref(),
4508 Some("cargo bench | tee output.txt")
4509 );
4510 }
4511
4512 #[test]
4513
4514 fn test_normalize_path_and_wrapper_bug_repro() {
4515 let _guard = test_guard!();
4516 let normalized = normalize_command("/usr/bin/time cargo build");
4525
4526 assert_eq!(normalized, "cargo build");
4527
4528 let result = classify_command("/usr/bin/time cargo build");
4529
4530 assert!(result.is_compilation);
4531 }
4532
4533 #[test]
4538 fn test_command_base_rust() {
4539 let _guard = test_guard!();
4540 assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
4541 assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
4542 assert_eq!(CompilationKind::CargoCheck.command_base(), "cargo");
4543 assert_eq!(CompilationKind::CargoClippy.command_base(), "cargo");
4544 assert_eq!(CompilationKind::CargoDoc.command_base(), "cargo");
4545 assert_eq!(CompilationKind::CargoBench.command_base(), "cargo");
4546 assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
4547 assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
4548 }
4549
4550 #[test]
4551 fn test_command_base_c_cpp() {
4552 let _guard = test_guard!();
4553 assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
4554 assert_eq!(CompilationKind::Gpp.command_base(), "g++");
4555 assert_eq!(CompilationKind::Clang.command_base(), "clang");
4556 assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
4557 }
4558
4559 #[test]
4560 fn test_command_base_build_systems() {
4561 let _guard = test_guard!();
4562 assert_eq!(CompilationKind::Make.command_base(), "make");
4563 assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
4564 assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
4565 assert_eq!(CompilationKind::Meson.command_base(), "meson");
4566 }
4567
4568 #[test]
4569 fn test_command_base_bun() {
4570 let _guard = test_guard!();
4571 assert_eq!(CompilationKind::BunTest.command_base(), "bun");
4572 assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
4573 }
4574
4575 mod proptest_classification {
4580 use super::*;
4581 use proptest::prelude::*;
4582
4583 fn arbitrary_string() -> impl Strategy<Value = String> {
4585 prop::string::string_regex(".{0,500}").unwrap()
4586 }
4587
4588 fn command_like_string() -> impl Strategy<Value = String> {
4590 let prefixes = prop::sample::select(vec![
4591 "cargo", "rustc", "gcc", "g++", "clang", "make", "cmake", "ninja", "bun", "ls",
4592 "cd", "echo", "cat", "grep", "find", "rm", "mv", "cp", "mkdir",
4593 ]);
4594 (prefixes, "[ a-zA-Z0-9_.-]{0,200}")
4595 .prop_map(|(prefix, suffix)| format!("{}{}", prefix, suffix))
4596 }
4597
4598 fn known_command_with_flags() -> impl Strategy<Value = String> {
4600 let base_commands = prop::sample::select(vec![
4601 "cargo build",
4602 "cargo test",
4603 "cargo check",
4604 "cargo clippy",
4605 "cargo run",
4606 "rustc",
4607 "gcc",
4608 "g++",
4609 "make",
4610 "bun test",
4611 "bun typecheck",
4612 ]);
4613 let flags = prop::collection::vec(
4614 prop::sample::select(vec![
4615 "--release",
4616 "--verbose",
4617 "-j8",
4618 "-p",
4619 "--all",
4620 "--workspace",
4621 "--lib",
4622 "--bin",
4623 "-o",
4624 "-c",
4625 ]),
4626 0..5,
4627 );
4628 (base_commands, flags).prop_map(|(cmd, flags)| {
4629 if flags.is_empty() {
4630 cmd.to_string()
4631 } else {
4632 format!("{} {}", cmd, flags.join(" "))
4633 }
4634 })
4635 }
4636
4637 fn unicode_and_control_chars() -> impl Strategy<Value = String> {
4639 prop::string::string_regex(
4640 r"[\x00-\x1f\x80-\xff\u{100}-\u{10FFFF}a-zA-Z0-9 |>&;$()]{0,100}",
4641 )
4642 .unwrap()
4643 }
4644
4645 fn shell_special_commands() -> impl Strategy<Value = String> {
4647 let components = prop::sample::select(vec![
4648 "cargo build",
4649 "ls -la",
4650 "echo test",
4651 "cat file.txt",
4652 "grep pattern",
4653 ]);
4654 let operators = prop::sample::select(vec![
4655 " | ", " && ", " || ", " ; ", " > ", " < ", " & ", " 2>&1 ", " $(", " `",
4656 ]);
4657 prop::collection::vec((components, operators), 1..4).prop_map(|pairs| {
4658 let mut result = String::new();
4659 for (i, (comp, op)) in pairs.iter().enumerate() {
4660 if i > 0 {
4661 result.push_str(op);
4662 }
4663 result.push_str(comp);
4664 }
4665 result
4666 })
4667 }
4668
4669 fn wrapped_commands() -> impl Strategy<Value = String> {
4671 let wrappers = prop::sample::select(vec![
4672 "sudo ",
4673 "env ",
4674 "time ",
4675 "nice ",
4676 "/usr/bin/time ",
4677 "RUST_BACKTRACE=1 ",
4678 "CC=clang ",
4679 "",
4680 ]);
4681 let commands = prop::sample::select(vec![
4682 "cargo build",
4683 "cargo test",
4684 "make",
4685 "gcc -c main.c",
4686 "ls -la",
4687 ]);
4688 (wrappers, commands).prop_map(|(wrapper, cmd)| format!("{}{}", wrapper, cmd))
4689 }
4690
4691 proptest! {
4692 #![proptest_config(ProptestConfig::with_cases(1000))]
4694
4695 #[test]
4697 fn test_classify_arbitrary_no_panic(s in arbitrary_string()) {
4698 let _ = classify_command(&s);
4700 }
4701
4702 #[test]
4704 fn test_classify_command_like_no_panic(s in command_like_string()) {
4705 let _ = classify_command(&s);
4706 }
4707
4708 #[test]
4710 fn test_classify_known_commands_valid(s in known_command_with_flags()) {
4711 let result = classify_command(&s);
4712 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0,
4714 "Confidence {} out of range for command: {}", result.confidence, s);
4715 if result.is_compilation {
4717 prop_assert!(result.kind.is_some(),
4718 "is_compilation=true but kind=None for: {}", s);
4719 }
4720 }
4721
4722 #[test]
4724 fn test_classify_unicode_no_panic(s in unicode_and_control_chars()) {
4725 let _ = classify_command(&s);
4726 }
4727
4728 #[test]
4730 fn test_classify_shell_special(s in shell_special_commands()) {
4731 let result = classify_command(&s);
4732 if s.contains(" | ") || s.contains(" > ") || s.contains(" < ") || s.contains(" & ") {
4735 let _ = result; }
4738 }
4739
4740 #[test]
4742 fn test_classify_wrapped_commands(s in wrapped_commands()) {
4743 let result = classify_command(&s);
4744 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4745 }
4746
4747 #[test]
4749 fn test_classify_deterministic(s in arbitrary_string()) {
4750 let result1 = classify_command(&s);
4751 let result2 = classify_command(&s);
4752 prop_assert_eq!(result1, result2,
4753 "Non-deterministic classification for: {}", s);
4754 }
4755
4756 #[test]
4758 fn test_classify_whitespace_variants(s in "[ \t\n\r]{0,20}") {
4759 let _guard = test_guard!();
4760 let result = classify_command(&s);
4761 prop_assert!(!result.is_compilation,
4762 "Whitespace-only command should not be classified as compilation: {:?}", s);
4763 prop_assert!(result.reason.contains("empty") || result.reason.contains("keyword"),
4764 "Unexpected reason for whitespace: {}", result.reason);
4765 }
4766
4767 #[test]
4769 fn test_classify_long_commands(
4770 prefix in "cargo (build|test|check)",
4771 suffix in "[a-zA-Z0-9_ -]{0,10000}"
4772 ) {
4773 let long_cmd = format!("{} {}", prefix, suffix);
4774 let result = classify_command(&long_cmd);
4775 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4776 }
4777
4778 #[test]
4780 fn test_classify_special_bytes(s in prop::collection::vec(any::<u8>(), 0..200)) {
4781 if let Ok(valid_str) = String::from_utf8(s.clone()) {
4782 let _ = classify_command(&valid_str);
4783 }
4784 }
4786 }
4787
4788 proptest! {
4791 #![proptest_config(ProptestConfig::with_cases(500))]
4792
4793 #[test]
4795 fn test_cargo_subcommand_robustness(
4796 subcommand in "(build|test|check|clippy|fmt|clean|run|doc|bench|nextest)",
4797 suffix in "[a-zA-Z0-9_ -]{0,50}"
4798 ) {
4799 let cmd = format!("cargo {} {}", subcommand, suffix);
4800 let result = classify_command(&cmd);
4801 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4803 if subcommand.eq("fmt") || subcommand.eq("clean") {
4805 prop_assert!(!result.is_compilation,
4806 "cargo {} should not be compilation: {}", subcommand, cmd);
4807 }
4808 }
4809
4810 #[test]
4812 fn test_bun_command_robustness(
4813 subcommand in "(test|typecheck|install|add|remove|run|build|dev)",
4814 suffix in "[a-zA-Z0-9_ -]{0,30}"
4815 ) {
4816 let cmd = format!("bun {} {}", subcommand, suffix);
4817 let result = classify_command(&cmd);
4818 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4819 if matches!(subcommand.as_str(), "install" | "add" | "remove" | "run" | "build" | "dev") {
4821 prop_assert!(!result.is_compilation,
4822 "bun {} should not be compilation", subcommand);
4823 }
4824 }
4825
4826 #[test]
4828 fn test_c_compiler_robustness(
4829 compiler in "(gcc|g\\+\\+|clang|clang\\+\\+)",
4830 flags in "(-[cCoOgW][a-z0-9]* )*",
4831 file in "[a-zA-Z_][a-zA-Z0-9_]*\\.(c|cpp|cc|h|hpp)"
4832 ) {
4833 let cmd = format!("{} {}{}", compiler, flags, file);
4834 let result = classify_command(&cmd);
4835 prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4836 }
4837
4838 #[test]
4840 fn test_quoted_args_handling(
4841 base in "(cargo build|cargo test|gcc|make)",
4842 quoted_content in "[a-zA-Z0-9 _-]{0,30}"
4843 ) {
4844 let cmd_single = format!("{} '{}'", base, quoted_content);
4846 let _ = classify_command(&cmd_single);
4847
4848 let cmd_double = format!("{} \"{}\"", base, quoted_content);
4850 let _ = classify_command(&cmd_double);
4851 }
4852 }
4853
4854 #[test]
4856 fn test_known_edge_cases() {
4857 let _guard = test_guard!();
4858 let edge_cases = [
4860 "",
4861 " ",
4862 "\t",
4863 "\n",
4864 "cargo",
4865 "cargo ",
4866 "cargo ",
4867 "cargo\tbuild",
4868 "cargo\nbuild",
4869 "cargo\rbuild",
4870 "cargo+nightly",
4871 "cargo +nightly",
4872 "cargo +nightly build",
4873 " cargo build ",
4874 "CARGO_TARGET_DIR=/tmp cargo build",
4875 "/usr/local/bin/cargo build",
4876 "~/.cargo/bin/cargo build",
4877 "./cargo build",
4878 "../cargo build",
4879 "cargo build 'test file.rs'",
4880 "cargo build \"test file.rs\"",
4881 "cargo build test\\ file.rs",
4882 "cargo build -- --test-threads=1",
4883 "cargo build --features \"feat1 feat2\"",
4884 "cargo build 2>&1",
4885 "cargo build 2>/dev/null",
4886 "gcc -DFOO=\"bar baz\" main.c",
4887 "make -j$(nproc)",
4888 "ninja -C build",
4889 "cmake --build . --target all",
4890 "bun test --timeout 5000 src/",
4891 "env -i PATH=/usr/bin cargo build",
4892 ];
4893
4894 for cmd in edge_cases {
4895 let result = classify_command(cmd);
4896 assert!(
4897 result.confidence >= 0.0 && result.confidence <= 1.0,
4898 "Invalid confidence for: {:?}",
4899 cmd
4900 );
4901 }
4902 }
4903
4904 #[test]
4906 fn test_detailed_matches_simple_proptest() {
4907 let _guard = test_guard!();
4908 let test_cases = [
4909 "cargo build --release",
4910 "cargo test",
4911 "make -j8",
4912 "ls -la",
4913 "echo hello",
4914 ];
4915
4916 for cmd in test_cases {
4917 let simple = classify_command(cmd);
4918 let detailed = classify_command_detailed(cmd);
4919 assert_eq!(simple, detailed.classification, "Mismatch for: {}", cmd);
4920 }
4921 }
4922
4923 #[test]
4926 fn test_split_single_command() {
4927 let _guard = test_guard!();
4928 assert_eq!(split_shell_commands("cargo build"), vec!["cargo build"]);
4929 }
4930
4931 #[test]
4932 fn test_split_and_operator() {
4933 let _guard = test_guard!();
4934 assert_eq!(
4935 split_shell_commands("cargo fmt && cargo build"),
4936 vec!["cargo fmt", "cargo build"]
4937 );
4938 }
4939
4940 #[test]
4941 fn test_split_quoted_operator() {
4942 let _guard = test_guard!();
4943 assert_eq!(
4944 split_shell_commands("echo '&&' && cargo build"),
4945 vec!["echo '&&'", "cargo build"]
4946 );
4947 }
4948
4949 #[test]
4950 fn test_split_mixed_operators() {
4951 let _guard = test_guard!();
4952 assert_eq!(
4953 split_shell_commands("cd /tmp && make -j4 || echo fail"),
4954 vec!["cd /tmp", "make -j4", "echo fail"]
4955 );
4956 }
4957
4958 #[test]
4959 fn test_split_semicolons() {
4960 let _guard = test_guard!();
4961 assert_eq!(split_shell_commands("a ; b ; c"), vec!["a", "b", "c"]);
4962 }
4963
4964 #[test]
4965 fn test_split_quoted_semicolon() {
4966 let _guard = test_guard!();
4967 assert_eq!(
4968 split_shell_commands("echo 'hello;world' && make"),
4969 vec!["echo 'hello;world'", "make"]
4970 );
4971 }
4972
4973 #[test]
4974 fn test_split_pipe_preserved() {
4975 let _guard = test_guard!();
4976 assert_eq!(
4978 split_shell_commands("cargo build 2>&1 | tee log"),
4979 vec!["cargo build 2>&1 | tee log"]
4980 );
4981 }
4982
4983 #[test]
4984 fn test_split_double_quoted_operator() {
4985 let _guard = test_guard!();
4986 assert_eq!(
4987 split_shell_commands(r#"echo "&&" && cargo build"#),
4988 vec![r#"echo "&&""#, "cargo build"]
4989 );
4990 }
4991
4992 #[test]
4993 fn test_split_nested_quotes() {
4994 let _guard = test_guard!();
4995 assert_eq!(
4996 split_shell_commands("echo \"he said 'hello && bye'\" && cargo test"),
4997 vec!["echo \"he said 'hello && bye'\"", "cargo test"]
4998 );
4999 }
5000
5001 #[test]
5002 fn test_split_escaped_quote() {
5003 let _guard = test_guard!();
5004 assert_eq!(
5006 split_shell_commands(r"echo it\'s && cargo build"),
5007 vec![r"echo it\'s", "cargo build"]
5008 );
5009 }
5010
5011 #[test]
5012 fn test_split_backslash_literal_in_single_quotes() {
5013 let _guard = test_guard!();
5014 assert_eq!(
5019 split_shell_commands(r"cargo build 'a\' && rm -rf x"),
5020 vec![r"cargo build 'a\'", "rm -rf x"]
5021 );
5022 }
5023
5024 #[test]
5025 fn test_split_empty_string() {
5026 let _guard = test_guard!();
5027 assert!(split_shell_commands("").is_empty());
5028 }
5029
5030 #[test]
5031 fn test_split_only_whitespace() {
5032 let _guard = test_guard!();
5033 assert!(split_shell_commands(" ").is_empty());
5034 }
5035
5036 #[test]
5037 fn test_split_backtick_quoting() {
5038 let _guard = test_guard!();
5039 assert_eq!(
5040 split_shell_commands("echo `echo && fail` && cargo build"),
5041 vec!["echo `echo && fail`", "cargo build"]
5042 );
5043 }
5044
5045 #[test]
5046 fn test_split_trailing_operator() {
5047 let _guard = test_guard!();
5048 assert_eq!(split_shell_commands("cargo build &&"), vec!["cargo build"]);
5050 }
5051
5052 #[test]
5055 fn test_split_unclosed_single_quote() {
5056 let _guard = test_guard!();
5057 let result = split_shell_commands("echo 'hello && cargo build");
5059 assert_eq!(result, vec!["echo 'hello && cargo build"]);
5060 }
5061
5062 #[test]
5063 fn test_split_unclosed_double_quote() {
5064 let _guard = test_guard!();
5065 let result = split_shell_commands("echo \"hello && cargo build");
5066 assert_eq!(result, vec!["echo \"hello && cargo build"]);
5067 }
5068
5069 #[test]
5070 fn test_split_unclosed_backtick() {
5071 let _guard = test_guard!();
5072 let result = split_shell_commands("echo `hello && cargo build");
5073 assert_eq!(result, vec!["echo `hello && cargo build"]);
5074 }
5075
5076 #[test]
5077 fn test_split_embedded_nulls() {
5078 let _guard = test_guard!();
5079 let input = "cargo build\0 && echo done";
5081 let result = split_shell_commands(input);
5082 assert_eq!(result.len(), 2);
5083 }
5084
5085 #[test]
5086 fn test_split_unicode_input() {
5087 let _guard = test_guard!();
5088 let result = split_shell_commands("echo 'こんにちは' && cargo build");
5089 assert_eq!(result, vec!["echo 'こんにちは'", "cargo build"]);
5090 }
5091
5092 #[test]
5093 fn test_split_extremely_long_input() {
5094 let _guard = test_guard!();
5095 let long_cmd = format!("echo {} && cargo build", "x".repeat(20_000));
5097 let result = split_shell_commands(&long_cmd);
5098 assert_eq!(result.len(), 2);
5099 }
5100
5101 #[test]
5102 fn test_classify_long_input_skips_splitting() {
5103 let _guard = test_guard!();
5104 let long_cmd = format!("cargo build && echo {}", "x".repeat(11_000));
5106 let result = classify_command(&long_cmd);
5107 assert!(!result.is_compilation);
5109 assert!(
5110 result.reason.to_string().contains("chained"),
5111 "long input should be rejected by check_structure, not split"
5112 );
5113 }
5114
5115 #[test]
5116 fn test_split_only_operators() {
5117 let _guard = test_guard!();
5118 let result = split_shell_commands("&& || ;");
5120 assert!(
5121 result.is_empty(),
5122 "only operators should yield empty result"
5123 );
5124 }
5125
5126 #[test]
5127 fn test_split_consecutive_operators() {
5128 let _guard = test_guard!();
5129 let result = split_shell_commands("cargo build && && echo done");
5130 assert_eq!(result, vec!["cargo build", "echo done"]);
5132 }
5133
5134 #[test]
5135 fn test_classify_empty_after_split() {
5136 let _guard = test_guard!();
5137 let result = classify_command("echo hello && ls -la || pwd");
5139 assert!(!result.is_compilation);
5140 }
5141
5142 #[test]
5147 fn test_split_three_segment_chain() {
5148 let _guard = test_guard!();
5149 assert_eq!(
5150 split_shell_commands("cd /proj && cmake .. && make"),
5151 vec!["cd /proj", "cmake ..", "make"]
5152 );
5153 }
5154
5155 #[test]
5156 fn test_split_single_with_flags() {
5157 let _guard = test_guard!();
5158 assert_eq!(
5159 split_shell_commands("cargo build --release"),
5160 vec!["cargo build --release"]
5161 );
5162 }
5163
5164 #[test]
5165 fn test_split_nested_quotes_semicolon() {
5166 let _guard = test_guard!();
5167 assert_eq!(
5168 split_shell_commands("echo \"it's && done\" && make"),
5169 vec!["echo \"it's && done\"", "make"]
5170 );
5171 }
5172
5173 #[test]
5174 fn test_split_pipe_then_and() {
5175 let _guard = test_guard!();
5176 assert_eq!(
5178 split_shell_commands("make 2>&1 | grep error && echo done"),
5179 vec!["make 2>&1 | grep error", "echo done"]
5180 );
5181 }
5182
5183 #[test]
5184 fn test_split_leading_operator() {
5185 let _guard = test_guard!();
5186 assert_eq!(split_shell_commands("&& cargo build"), vec!["cargo build"]);
5188 }
5189
5190 #[test]
5195 fn test_classify_cargo_fmt_and_build() {
5196 let _guard = test_guard!();
5197 let result = classify_command("cargo fmt && cargo build");
5199 assert!(
5200 result.is_compilation,
5201 "compound command with compilation suffix should be accepted"
5202 );
5203 assert!(result.reason.contains("compound"));
5204 }
5205
5206 #[test]
5207 fn test_classify_cd_and_make() {
5208 let _guard = test_guard!();
5209 let result = classify_command("cd /project && make -j8");
5211 assert!(
5212 result.is_compilation,
5213 "compound command with compilation suffix should be accepted"
5214 );
5215 assert!(result.reason.contains("compound"));
5216 }
5217 #[test]
5218 fn test_classify_export_and_cargo_build() {
5219 let _guard = test_guard!();
5220 let result =
5222 classify_command("export RUSTFLAGS='-C opt-level=3' && cargo build --release");
5223 assert!(
5224 result.is_compilation,
5225 "compound command with compilation suffix should be accepted"
5226 );
5227 assert!(result.reason.contains("compound"));
5228 }
5229 #[test]
5230 fn test_classify_mkdir_cmake_chain() {
5231 let _guard = test_guard!();
5232 let result =
5234 classify_command("mkdir -p build && cmake -B build && cmake --build build");
5235 assert!(
5236 result.is_compilation,
5237 "compound command with compilation suffix should be accepted"
5238 );
5239 assert!(result.reason.contains("compound"));
5240 }
5241 #[test]
5242 fn test_classify_echo_and_cargo_test() {
5243 let _guard = test_guard!();
5244 let result = classify_command("echo 'Starting...' && cargo test");
5246 assert!(
5247 result.is_compilation,
5248 "compound command with compilation suffix should be accepted"
5249 );
5250 assert!(result.reason.contains("compound"));
5251 }
5252 #[test]
5253 fn test_classify_semicolon_chain_with_compilation() {
5254 let _guard = test_guard!();
5255 let result = classify_command("cargo fmt; cargo build; cargo test");
5257 assert!(
5258 !result.is_compilation,
5259 "semicolon chained commands should be rejected"
5260 );
5261 assert!(result.reason.contains("chained"));
5262 }
5263 #[test]
5266 fn test_classify_echo_chain_non_compilation() {
5267 let _guard = test_guard!();
5268 let result = classify_command("echo hello && echo world");
5269 assert!(!result.is_compilation);
5270 }
5271
5272 #[test]
5273 fn test_classify_ls_cat_non_compilation() {
5274 let _guard = test_guard!();
5275 let result = classify_command("ls -la && cat file.txt");
5276 assert!(!result.is_compilation);
5277 }
5278
5279 #[test]
5280 fn test_classify_git_chain_non_compilation() {
5281 let _guard = test_guard!();
5282 let result = classify_command("git status && git log");
5283 assert!(!result.is_compilation);
5284 }
5285
5286 #[test]
5289 fn test_classify_multi_command_performance() {
5290 let _guard = test_guard!();
5291 let start = std::time::Instant::now();
5292 for _ in 0..100 {
5293 let _ = classify_command("cargo fmt && cargo build && cargo test");
5294 }
5295 let elapsed = start.elapsed();
5296 let per_call_us = elapsed.as_micros() / 100;
5297 assert!(
5299 per_call_us < 5_000,
5300 "classify_command took {}us per call, should be <5000us",
5301 per_call_us
5302 );
5303 eprintln!(
5304 "[perf] classify_command multi-command: {}us avg per call",
5305 per_call_us
5306 );
5307 }
5308
5309 #[test]
5312 fn test_classify_pipe_to_pager_preserved() {
5313 let _guard = test_guard!();
5314 let result = classify_command("cargo build 2>&1 | tee log");
5317 assert!(result.is_compilation, "got: {:?}", result.reason);
5318 assert_eq!(
5319 result.extracted_command.as_deref(),
5320 Some("cargo build 2>&1 | tee log")
5321 );
5322 }
5323
5324 #[test]
5325 fn test_classify_pipe_segment_and_operator() {
5326 let _guard = test_guard!();
5327 let result = classify_command("make 2>&1 | grep error && cargo build");
5329 assert!(
5331 !result.is_compilation,
5332 "chained commands should be rejected"
5333 );
5334 assert!(result.reason.contains("piped") || result.reason.contains("chained"));
5339 }
5340 }
5341
5342 #[allow(clippy::ptr_arg)]
5353 fn assert_cow_borrowed(cow: &Cow<'static, str>, context: &str) {
5354 assert!(
5355 matches!(cow, Cow::Borrowed(_)),
5356 "{context}: expected Cow::Borrowed, got Cow::Owned({:?})",
5357 cow,
5358 );
5359 }
5360
5361 #[test]
5364 fn test_cow_reject_empty_command_correctness() {
5365 let _guard = test_guard!();
5366 let result = classify_command("");
5367 assert!(!result.is_compilation);
5368 assert_eq!(result.confidence, 0.0);
5369 assert_eq!(result.kind, None);
5370 assert!(result.reason.contains("empty"), "reason: {}", result.reason);
5371 }
5372
5373 #[test]
5374 fn test_cow_reject_non_compilation_commands() {
5375 let _guard = test_guard!();
5376 let non_compilation = [
5377 "ls -la",
5378 "cat file.txt",
5379 "echo hello",
5380 "git status",
5381 "pwd",
5382 "cd /tmp",
5383 ];
5384 for cmd in non_compilation {
5385 let result = classify_command(cmd);
5386 assert!(
5387 !result.is_compilation,
5388 "'{cmd}' should NOT be classified as compilation"
5389 );
5390 assert_eq!(result.confidence, 0.0, "'{cmd}' should have 0.0 confidence");
5391 assert_eq!(result.kind, None, "'{cmd}' should have no CompilationKind");
5392 assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5393 }
5394 }
5395
5396 #[test]
5397 fn test_cow_accept_compilation_commands() {
5398 let _guard = test_guard!();
5399 let cases: &[(&str, CompilationKind)] = &[
5400 ("cargo build", CompilationKind::CargoBuild),
5401 ("cargo test", CompilationKind::CargoTest),
5402 ("cargo check", CompilationKind::CargoCheck),
5403 ("cargo clippy", CompilationKind::CargoClippy),
5404 ("gcc -o hello hello.c", CompilationKind::Gcc),
5405 ("make", CompilationKind::Make),
5406 ("bun test", CompilationKind::BunTest),
5407 ];
5408 for &(cmd, expected_kind) in cases {
5409 let result = classify_command(cmd);
5410 assert!(
5411 result.is_compilation,
5412 "'{cmd}' should be classified as compilation"
5413 );
5414 assert_eq!(
5415 result.kind,
5416 Some(expected_kind),
5417 "'{cmd}' should have kind {expected_kind:?}"
5418 );
5419 assert!(
5420 result.confidence > 0.0,
5421 "'{cmd}' should have positive confidence"
5422 );
5423 assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5424 }
5425 }
5426
5427 #[test]
5430 fn test_cow_borrowed_on_tier0_reject() {
5431 let _guard = test_guard!();
5432 let result = classify_command("");
5434 assert_cow_borrowed(&result.reason, "Tier 0 empty command reason");
5435 }
5436
5437 #[test]
5438 fn test_cow_borrowed_on_tier1_reject() {
5439 let _guard = test_guard!();
5440 let result = classify_command("cargo build | xargs rm");
5443 assert!(!result.is_compilation);
5444 assert_cow_borrowed(&result.reason, "Tier 1 piped command reason");
5445
5446 let result = classify_command("cargo build < input.txt");
5448 assert!(!result.is_compilation);
5449 assert_cow_borrowed(&result.reason, "Tier 1 input redirect reason");
5450
5451 let result = classify_command("cargo build $(echo args)");
5453 assert!(!result.is_compilation);
5454 assert_cow_borrowed(&result.reason, "Tier 1 subshell reason");
5455 }
5456
5457 #[test]
5458 fn test_cow_borrowed_on_tier2_reject() {
5459 let _guard = test_guard!();
5460 let non_keyword_commands = [
5462 "ls -la",
5463 "cat file.txt",
5464 "echo hello world",
5465 "git status",
5466 "pwd",
5467 "cd /tmp",
5468 "grep pattern file",
5469 "find . -name '*.rs'",
5470 "cp src dst",
5471 "rm file.txt",
5472 ];
5473 for cmd in non_keyword_commands {
5474 let result = classify_command(cmd);
5475 assert!(!result.is_compilation, "'{cmd}' should be rejected");
5476 assert_cow_borrowed(&result.reason, &format!("Tier 2 reject for '{cmd}'"));
5477 }
5478 }
5479
5480 #[test]
5481 fn test_cow_borrowed_on_tier3_reject() {
5482 let _guard = test_guard!();
5483 let never_intercept = ["cargo fmt", "cargo install ripgrep", "cargo clean"];
5485 for cmd in never_intercept {
5486 let result = classify_command(cmd);
5487 assert!(!result.is_compilation, "'{cmd}' should be rejected");
5488 assert!(
5489 result.reason.contains("never-intercept"),
5490 "'{cmd}' reason should mention never-intercept: {}",
5491 result.reason
5492 );
5493 assert_cow_borrowed(&result.reason, &format!("Tier 3 reject for '{cmd}'"));
5495 }
5496 }
5497
5498 #[test]
5501 fn test_detailed_tiers_use_borrowed_names() {
5502 let _guard = test_guard!();
5503 let details = classify_command_detailed("ls -la");
5506 for tier in &details.tiers {
5507 assert_cow_borrowed(&tier.name, &format!("Tier {} name", tier.tier));
5508 }
5509 }
5510
5511 #[test]
5512 fn test_detailed_tier_reasons_borrowed_on_reject() {
5513 let _guard = test_guard!();
5514 let details = classify_command_detailed("ls -la");
5516 assert!(!details.classification.is_compilation);
5517 for tier in &details.tiers {
5518 assert_cow_borrowed(
5519 &tier.reason,
5520 &format!("Tier {} reason for 'ls -la'", tier.tier),
5521 );
5522 }
5523 }
5524
5525 #[test]
5526 fn test_detailed_compilation_tier_names_borrowed() {
5527 let _guard = test_guard!();
5528 let details = classify_command_detailed("cargo build");
5530 assert!(details.classification.is_compilation);
5531 for tier in &details.tiers {
5532 assert_cow_borrowed(
5533 &tier.name,
5534 &format!("Tier {} name for 'cargo build'", tier.tier),
5535 );
5536 }
5537 }
5538
5539 #[test]
5542 fn test_classification_serde_roundtrip_borrowed() {
5543 let _guard = test_guard!();
5544 let original = Classification::not_compilation("no compilation keyword");
5545 assert_cow_borrowed(&original.reason, "before serialize");
5546
5547 let json = serde_json::to_string(&original).expect("serialize");
5548 let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5549
5550 assert_eq!(deserialized.is_compilation, original.is_compilation);
5551 assert_eq!(deserialized.confidence, original.confidence);
5552 assert_eq!(deserialized.kind, original.kind);
5553 assert_eq!(deserialized.reason, original.reason);
5554 }
5557
5558 #[test]
5559 fn test_classification_serde_roundtrip_compilation() {
5560 let _guard = test_guard!();
5561 let original =
5562 Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build detected");
5563
5564 let json = serde_json::to_string(&original).expect("serialize");
5565 let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5566
5567 assert_eq!(deserialized.is_compilation, original.is_compilation);
5568 assert_eq!(deserialized.confidence, original.confidence);
5569 assert_eq!(deserialized.kind, original.kind);
5570 assert_eq!(deserialized.reason.as_ref(), original.reason.as_ref());
5571 }
5572
5573 #[test]
5574 fn test_classification_tier_serde_roundtrip() {
5575 let _guard = test_guard!();
5576 let tier = ClassificationTier {
5577 tier: 2,
5578 name: Cow::Borrowed(TIER_KEYWORD_FILTER),
5579 decision: TierDecision::Reject,
5580 reason: Cow::Borrowed("no compilation keyword"),
5581 };
5582 assert_cow_borrowed(&tier.name, "tier name before serialize");
5583 assert_cow_borrowed(&tier.reason, "tier reason before serialize");
5584
5585 let json = serde_json::to_string(&tier).expect("serialize");
5586 let deserialized: ClassificationTier = serde_json::from_str(&json).expect("deserialize");
5587
5588 assert_eq!(deserialized.tier, tier.tier);
5589 assert_eq!(deserialized.name.as_ref(), tier.name.as_ref());
5590 assert_eq!(deserialized.decision, tier.decision);
5591 assert_eq!(deserialized.reason.as_ref(), tier.reason.as_ref());
5592 }
5593
5594 #[test]
5595 fn test_classification_details_serde_roundtrip() {
5596 let _guard = test_guard!();
5597 let details = classify_command_detailed("cargo build");
5598 assert!(details.classification.is_compilation);
5599
5600 let json = serde_json::to_string(&details).expect("serialize");
5601 let deserialized: ClassificationDetails = serde_json::from_str(&json).expect("deserialize");
5602
5603 assert_eq!(deserialized.original, details.original);
5604 assert_eq!(deserialized.normalized, details.normalized);
5605 assert_eq!(deserialized.tiers.len(), details.tiers.len());
5606 assert_eq!(
5607 deserialized.classification.is_compilation,
5608 details.classification.is_compilation
5609 );
5610 assert_eq!(
5611 deserialized.classification.kind,
5612 details.classification.kind
5613 );
5614 }
5615
5616 #[test]
5619 fn test_cow_reason_display() {
5620 let _guard = test_guard!();
5621 let result = classify_command("ls");
5622 let displayed = format!("{}", result.reason);
5624 assert!(!displayed.is_empty());
5625 let debugged = format!("{:?}", result.reason);
5626 assert!(!debugged.is_empty());
5627 }
5628
5629 #[test]
5630 fn test_cow_reason_comparison() {
5631 let _guard = test_guard!();
5632 let borrowed: Cow<'static, str> = Cow::Borrowed("no compilation keyword");
5634 let owned: Cow<'static, str> = Cow::Owned("no compilation keyword".to_string());
5635 assert_eq!(borrowed, owned);
5636
5637 let r1 = classify_command("ls");
5639 let r2 = classify_command("pwd");
5640 assert_eq!(r1.reason, r2.reason);
5642 }
5643}
5644
5645#[cfg(test)]
5646mod tests_bun_whitespace {
5647 use super::*;
5648 use crate::test_guard;
5649
5650 #[test]
5651 fn test_bun_whitespace_resilience() {
5652 let _guard = test_guard!();
5653
5654 let result = classify_command("bun test");
5656 assert!(result.is_compilation, "bun test failed");
5657 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5658
5659 let result = classify_command("bun test");
5661 assert!(result.is_compilation, "bun test failed");
5662 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5663
5664 let result = classify_command("bun\ttest");
5666 assert!(result.is_compilation, "bun\\ttest failed");
5667 assert_eq!(result.kind, Some(CompilationKind::BunTest));
5668
5669 let result = classify_command("bun typecheck src/");
5671 assert!(result.is_compilation, "bun typecheck with spaces failed");
5672 assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
5673 }
5674
5675 #[test]
5676 fn test_bun_watch_with_whitespace() {
5677 let _guard = test_guard!();
5678
5679 let result = classify_command("bun test --watch");
5681 assert!(
5682 !result.is_compilation,
5683 "bun test --watch should be rejected"
5684 );
5685 assert!(result.reason.contains("interactive"));
5686
5687 let result = classify_command("bun\ttypecheck\t-w");
5689 assert!(
5690 !result.is_compilation,
5691 "bun typecheck -w should be rejected"
5692 );
5693 assert!(result.reason.contains("interactive"));
5694 }
5695
5696 #[test]
5697 fn test_bun_x_whitespace() {
5698 let _guard = test_guard!();
5699
5700 let result = classify_command("bun x vitest");
5702 assert!(!result.is_compilation, "bun x should be rejected");
5703 assert!(result.reason.contains("bun x"));
5704 }
5705}
5706
5707#[cfg(test)]
5708mod tests_normalize_whitespace {
5709 use super::*;
5710 use crate::test_guard;
5711
5712 #[test]
5713 fn test_wrapper_whitespace_resilience() {
5714 let _guard = test_guard!();
5715
5716 assert_eq!(normalize_command("sudo cargo"), "cargo");
5718
5719 assert_eq!(normalize_command("sudo\tcargo"), "cargo");
5721
5722 assert_eq!(normalize_command("time\tsudo cargo"), "cargo");
5724
5725 assert_eq!(normalize_command("sudocargo"), "sudocargo");
5727
5728 assert_eq!(normalize_command("sudo"), "");
5730 }
5731
5732 #[test]
5733 fn wrapper_value_flags_consume_their_value_token() {
5734 let _guard = test_guard!();
5738 assert_eq!(normalize_command("nice -n 10 cargo build"), "cargo build");
5739 assert_eq!(
5740 normalize_command("taskset -c 0-3 cargo build"),
5741 "cargo build"
5742 );
5743 assert_eq!(normalize_command("ionice -c 2 cargo test"), "cargo test");
5744 assert_eq!(
5745 normalize_command("ionice -c 2 -n 4 cargo build"),
5746 "cargo build"
5747 );
5748 assert_eq!(normalize_command("chrt -r 10 cargo build"), "cargo build");
5749 assert_eq!(normalize_command("timeout 60 cargo build"), "cargo build");
5750 assert_eq!(
5751 normalize_command("timeout -s KILL 90 cargo build"),
5752 "cargo build"
5753 );
5754 assert_eq!(
5755 normalize_command("sudo nice -n 5 cargo build"),
5756 "cargo build"
5757 );
5758 assert_eq!(normalize_command("nice -n10 cargo build"), "cargo build");
5760 assert_eq!(normalize_command("ionice -c2 cargo build"), "cargo build");
5761 assert_eq!(
5763 normalize_command("env -u RUSTFLAGS cargo build"),
5764 "cargo build"
5765 );
5766 }
5767
5768 #[test]
5769 fn positional_wrapper_does_not_eat_a_non_numeric_command() {
5770 let _guard = test_guard!();
5773 assert_eq!(normalize_command("timeout cargo build"), "cargo build");
5774 }
5775
5776 #[test]
5777 fn wrapped_cargo_build_is_still_classified_as_compilation() {
5778 let _guard = test_guard!();
5779 for cmd in [
5780 "nice -n 10 cargo build",
5781 "taskset -c 0-3 cargo build",
5782 "ionice -c 2 cargo build",
5783 "chrt -r 10 cargo build",
5784 "timeout 60 cargo build",
5785 "timeout -s KILL 90 cargo test",
5786 "time '-f%U' cargo build",
5787 "time \"-f%U\" cargo build",
5788 "nice '--adjustment=+5' cargo build",
5789 "nice '-n' '5' cargo build",
5790 "env '--unset' 'RUSTFLAGS' cargo build",
5791 ] {
5792 assert!(
5793 classify_command(cmd).is_compilation,
5794 "wrapper must not defeat offload: {cmd}"
5795 );
5796 }
5797 assert!(!classify_command("nice -n 10 ls -la").is_compilation);
5799 assert!(!classify_command("timeout 60 echo hi").is_compilation);
5800 assert!(!classify_command("time '-f%U' echo cargo build").is_compilation);
5801 assert!(!classify_command("time '-f%U cargo build").is_compilation);
5802 assert!(!classify_command("time '-f%U'echo cargo build").is_compilation);
5803 }
5804
5805 #[test]
5806 fn test_env_var_whitespace() {
5807 let _guard = test_guard!();
5808
5809 for (cmd, normalized) in [
5810 ("env RUST_BACKTRACE=1 cargo", "cargo"),
5812 ("env\tRUST_BACKTRACE=1\tcargo", "cargo"),
5814 ("env -u RUST_LOG cargo test", "cargo test"),
5815 ("env --unset RUST_LOG cargo test", "cargo test"),
5816 ("env --unset=RUST_LOG cargo test", "cargo test"),
5817 ] {
5818 assert_eq!(normalize_command(cmd), normalized, "{cmd}");
5819 }
5820 }
5821}
5822
5823#[cfg(test)]
5832mod regression_classification {
5833 use super::*;
5834 use crate::test_guard;
5835
5836 fn assert_golden_json(
5837 actual: serde_json::Value,
5838 expected: &serde_json::Value,
5839 fixture_path: &str,
5840 ) {
5841 if &actual == expected {
5842 return;
5843 }
5844
5845 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
5846 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
5847 panic!(
5848 "golden mismatch in {fixture_path}\n\
5849 expected_blake3={}\n\
5850 actual_blake3={}\n\
5851 bless path: review the semantic diff, update the fixture expected_* field, \
5852 and record both hashes in the Beads closeout.\n\
5853 expected:\n{expected_json}\n\
5854 actual:\n{actual_json}",
5855 blake3::hash(expected_json.as_bytes()),
5856 blake3::hash(actual_json.as_bytes())
5857 );
5858 }
5859
5860 struct Case {
5862 cmd: &'static str,
5863 expect_compilation: bool,
5864 expected_kind: Option<CompilationKind>,
5865 reason_contains: &'static str,
5867 min_confidence: f64,
5869 }
5870
5871 fn run_cases(cases: &[Case]) {
5872 for (i, c) in cases.iter().enumerate() {
5873 let result = classify_command(c.cmd);
5874 assert_eq!(
5875 result.is_compilation, c.expect_compilation,
5876 "Case {i} ({:?}): expected is_compilation={}, got={}. reason={:?}",
5877 c.cmd, c.expect_compilation, result.is_compilation, result.reason
5878 );
5879 if c.expect_compilation {
5880 assert_eq!(
5881 result.kind, c.expected_kind,
5882 "Case {i} ({:?}): expected kind={:?}, got={:?}",
5883 c.cmd, c.expected_kind, result.kind
5884 );
5885 assert!(
5886 result.confidence >= c.min_confidence,
5887 "Case {i} ({:?}): expected confidence >= {}, got={}",
5888 c.cmd,
5889 c.min_confidence,
5890 result.confidence
5891 );
5892 }
5893 if !c.reason_contains.is_empty() {
5894 assert!(
5895 result.reason.contains(c.reason_contains),
5896 "Case {i} ({:?}): expected reason containing {:?}, got {:?}",
5897 c.cmd,
5898 c.reason_contains,
5899 result.reason
5900 );
5901 }
5902 }
5903 }
5904
5905 #[test]
5910 fn regression_cargo_compilation_positive() {
5911 let _guard = test_guard!();
5912 let cases = [
5913 Case {
5914 cmd: "cargo build",
5915 expect_compilation: true,
5916 expected_kind: Some(CompilationKind::CargoBuild),
5917 reason_contains: "cargo build",
5918 min_confidence: 0.90,
5919 },
5920 Case {
5921 cmd: "cargo build --release",
5922 expect_compilation: true,
5923 expected_kind: Some(CompilationKind::CargoBuild),
5924 reason_contains: "cargo build",
5925 min_confidence: 0.90,
5926 },
5927 Case {
5928 cmd: "cargo build --workspace",
5929 expect_compilation: true,
5930 expected_kind: Some(CompilationKind::CargoBuild),
5931 reason_contains: "cargo build",
5932 min_confidence: 0.90,
5933 },
5934 Case {
5935 cmd: "cargo build -p my-crate",
5936 expect_compilation: true,
5937 expected_kind: Some(CompilationKind::CargoBuild),
5938 reason_contains: "cargo build",
5939 min_confidence: 0.90,
5940 },
5941 Case {
5942 cmd: "cargo b",
5943 expect_compilation: true,
5944 expected_kind: Some(CompilationKind::CargoBuild),
5945 reason_contains: "cargo build",
5946 min_confidence: 0.90,
5947 },
5948 Case {
5949 cmd: "cargo test",
5950 expect_compilation: true,
5951 expected_kind: Some(CompilationKind::CargoTest),
5952 reason_contains: "cargo test",
5953 min_confidence: 0.90,
5954 },
5955 Case {
5956 cmd: "cargo test --release",
5957 expect_compilation: true,
5958 expected_kind: Some(CompilationKind::CargoTest),
5959 reason_contains: "cargo test",
5960 min_confidence: 0.90,
5961 },
5962 Case {
5963 cmd: "cargo test my_test",
5964 expect_compilation: true,
5965 expected_kind: Some(CompilationKind::CargoTest),
5966 reason_contains: "cargo test",
5967 min_confidence: 0.90,
5968 },
5969 Case {
5970 cmd: "cargo test -- --nocapture",
5971 expect_compilation: true,
5972 expected_kind: Some(CompilationKind::CargoTest),
5973 reason_contains: "cargo test",
5974 min_confidence: 0.90,
5975 },
5976 Case {
5977 cmd: "cargo test --workspace",
5978 expect_compilation: true,
5979 expected_kind: Some(CompilationKind::CargoTest),
5980 reason_contains: "cargo test",
5981 min_confidence: 0.90,
5982 },
5983 Case {
5984 cmd: "cargo test -p rch-common",
5985 expect_compilation: true,
5986 expected_kind: Some(CompilationKind::CargoTest),
5987 reason_contains: "cargo test",
5988 min_confidence: 0.90,
5989 },
5990 Case {
5991 cmd: "cargo t",
5992 expect_compilation: true,
5993 expected_kind: Some(CompilationKind::CargoTest),
5994 reason_contains: "cargo test",
5995 min_confidence: 0.90,
5996 },
5997 Case {
5998 cmd: "cargo test --lib",
5999 expect_compilation: true,
6000 expected_kind: Some(CompilationKind::CargoTest),
6001 reason_contains: "cargo test",
6002 min_confidence: 0.90,
6003 },
6004 Case {
6005 cmd: "cargo test --bins",
6006 expect_compilation: true,
6007 expected_kind: Some(CompilationKind::CargoTest),
6008 reason_contains: "cargo test",
6009 min_confidence: 0.90,
6010 },
6011 Case {
6012 cmd: "cargo test --doc",
6013 expect_compilation: true,
6014 expected_kind: Some(CompilationKind::CargoTest),
6015 reason_contains: "cargo test",
6016 min_confidence: 0.90,
6017 },
6018 Case {
6019 cmd: "cargo test -- --test-threads=4",
6020 expect_compilation: true,
6021 expected_kind: Some(CompilationKind::CargoTest),
6022 reason_contains: "cargo test",
6023 min_confidence: 0.90,
6024 },
6025 Case {
6026 cmd: "cargo check",
6027 expect_compilation: true,
6028 expected_kind: Some(CompilationKind::CargoCheck),
6029 reason_contains: "cargo check",
6030 min_confidence: 0.85,
6031 },
6032 Case {
6033 cmd: "cargo check --workspace --all-targets",
6034 expect_compilation: true,
6035 expected_kind: Some(CompilationKind::CargoCheck),
6036 reason_contains: "cargo check",
6037 min_confidence: 0.85,
6038 },
6039 Case {
6040 cmd: "cargo c",
6041 expect_compilation: true,
6042 expected_kind: Some(CompilationKind::CargoCheck),
6043 reason_contains: "cargo check",
6044 min_confidence: 0.85,
6045 },
6046 Case {
6047 cmd: "cargo clippy",
6048 expect_compilation: true,
6049 expected_kind: Some(CompilationKind::CargoClippy),
6050 reason_contains: "cargo clippy",
6051 min_confidence: 0.85,
6052 },
6053 Case {
6054 cmd: "cargo clippy --workspace --all-targets -- -D warnings",
6055 expect_compilation: true,
6056 expected_kind: Some(CompilationKind::CargoClippy),
6057 reason_contains: "cargo clippy",
6058 min_confidence: 0.85,
6059 },
6060 Case {
6061 cmd: "cargo doc",
6062 expect_compilation: true,
6063 expected_kind: Some(CompilationKind::CargoDoc),
6064 reason_contains: "cargo doc",
6065 min_confidence: 0.80,
6066 },
6067 Case {
6068 cmd: "cargo doc --no-deps",
6069 expect_compilation: true,
6070 expected_kind: Some(CompilationKind::CargoDoc),
6071 reason_contains: "cargo doc",
6072 min_confidence: 0.80,
6073 },
6074 Case {
6075 cmd: "cargo run",
6076 expect_compilation: true,
6077 expected_kind: Some(CompilationKind::CargoBuild),
6078 reason_contains: "cargo run",
6079 min_confidence: 0.80,
6080 },
6081 Case {
6082 cmd: "cargo run --release",
6083 expect_compilation: true,
6084 expected_kind: Some(CompilationKind::CargoBuild),
6085 reason_contains: "cargo run",
6086 min_confidence: 0.80,
6087 },
6088 Case {
6089 cmd: "cargo r",
6090 expect_compilation: true,
6091 expected_kind: Some(CompilationKind::CargoBuild),
6092 reason_contains: "cargo run",
6093 min_confidence: 0.80,
6094 },
6095 Case {
6096 cmd: "cargo bench",
6097 expect_compilation: true,
6098 expected_kind: Some(CompilationKind::CargoBench),
6099 reason_contains: "cargo bench",
6100 min_confidence: 0.85,
6101 },
6102 Case {
6103 cmd: "cargo bench --bench my_bench",
6104 expect_compilation: true,
6105 expected_kind: Some(CompilationKind::CargoBench),
6106 reason_contains: "cargo bench",
6107 min_confidence: 0.85,
6108 },
6109 Case {
6110 cmd: "cargo nextest run",
6111 expect_compilation: true,
6112 expected_kind: Some(CompilationKind::CargoNextest),
6113 reason_contains: "cargo nextest run",
6114 min_confidence: 0.90,
6115 },
6116 Case {
6117 cmd: "cargo nextest run --workspace",
6118 expect_compilation: true,
6119 expected_kind: Some(CompilationKind::CargoNextest),
6120 reason_contains: "cargo nextest run",
6121 min_confidence: 0.90,
6122 },
6123 Case {
6124 cmd: "cargo nextest r",
6125 expect_compilation: true,
6126 expected_kind: Some(CompilationKind::CargoNextest),
6127 reason_contains: "cargo nextest run",
6128 min_confidence: 0.90,
6129 },
6130 Case {
6132 cmd: "cargo +nightly build",
6133 expect_compilation: true,
6134 expected_kind: Some(CompilationKind::CargoBuild),
6135 reason_contains: "cargo build",
6136 min_confidence: 0.90,
6137 },
6138 Case {
6139 cmd: "cargo +1.80.0 test",
6140 expect_compilation: true,
6141 expected_kind: Some(CompilationKind::CargoTest),
6142 reason_contains: "cargo test",
6143 min_confidence: 0.90,
6144 },
6145 Case {
6146 cmd: "cargo +nightly nextest run",
6147 expect_compilation: true,
6148 expected_kind: Some(CompilationKind::CargoNextest),
6149 reason_contains: "cargo nextest run",
6150 min_confidence: 0.90,
6151 },
6152 Case {
6155 cmd: "cargo --offline build",
6156 expect_compilation: true,
6157 expected_kind: Some(CompilationKind::CargoBuild),
6158 reason_contains: "cargo build",
6159 min_confidence: 0.90,
6160 },
6161 Case {
6162 cmd: "cargo -q test",
6163 expect_compilation: true,
6164 expected_kind: Some(CompilationKind::CargoTest),
6165 reason_contains: "cargo test",
6166 min_confidence: 0.90,
6167 },
6168 Case {
6169 cmd: "cargo --locked --frozen check",
6170 expect_compilation: true,
6171 expected_kind: Some(CompilationKind::CargoCheck),
6172 reason_contains: "cargo check",
6173 min_confidence: 0.85,
6174 },
6175 Case {
6176 cmd: "cargo --color never build",
6177 expect_compilation: true,
6178 expected_kind: Some(CompilationKind::CargoBuild),
6179 reason_contains: "cargo build",
6180 min_confidence: 0.90,
6181 },
6182 Case {
6183 cmd: "cargo +nightly --offline -Z unstable-options build",
6184 expect_compilation: true,
6185 expected_kind: Some(CompilationKind::CargoBuild),
6186 reason_contains: "cargo build",
6187 min_confidence: 0.90,
6188 },
6189 Case {
6191 cmd: "cargo '--config=build.jobs=2' -vvv clippy",
6192 expect_compilation: true,
6193 expected_kind: Some(CompilationKind::CargoClippy),
6194 reason_contains: "cargo clippy",
6195 min_confidence: 0.85,
6196 },
6197 Case {
6198 cmd: "cargo-zigbuild '--color=never' zigbuild",
6199 expect_compilation: true,
6200 expected_kind: Some(CompilationKind::CargoZigbuild),
6201 reason_contains: "cargo-zigbuild build",
6202 min_confidence: 0.90,
6203 },
6204 Case {
6206 cmd: "cargo 'fetch'",
6207 expect_compilation: false,
6208 expected_kind: None,
6209 reason_contains: "",
6210 min_confidence: 0.0,
6211 },
6212 ];
6213 run_cases(&cases);
6214 }
6215
6216 #[test]
6217 fn regression_cargo_package_verification_is_compilation() {
6218 let _guard = test_guard!();
6219 for command in [
6220 "cargo package",
6221 "cargo package --workspace --locked -j2",
6222 "cargo package -p asupersync --features tls --target x86_64-unknown-linux-gnu",
6223 "cargo +nightly --offline package --allow-dirty --no-metadata",
6224 "cargo --color never package --target-dir=/data/tmp/package-check",
6225 "cargo publish --dry-run",
6226 "cargo publish -n --workspace --locked --keep-going -j 2",
6227 "cargo publish --registry crates-io -p asupersync --dry-run",
6228 "cargo +nightly --locked publish --dry-run --features=tls",
6229 "env CARGO_INCREMENTAL=0 cargo publish --dry-run --workspace",
6230 ] {
6231 let classification = classify_command(command);
6232 assert!(
6233 classification.is_compilation,
6234 "{command}: {classification:?}"
6235 );
6236 assert_eq!(classification.kind, Some(CompilationKind::CargoBuild));
6237 assert_eq!(
6238 classify_command_detailed(command).classification,
6239 classification,
6240 "diagnosis must agree with execution for {command}"
6241 );
6242 }
6243 }
6244
6245 #[test]
6246 fn regression_cargo_package_verification_declines_publication_and_metadata() {
6247 let _guard = test_guard!();
6248 for command in [
6249 "cargo publish",
6250 "cargo publish --workspace --locked",
6251 "cargo +nightly --offline publish",
6252 "cargo publish --no-verify",
6253 "cargo publish --dry-run --no-verify",
6254 "cargo publish --no-verify -n",
6255 "cargo publish -qn --no-verify",
6256 "cargo package --no-verify",
6257 "cargo package --list",
6258 "cargo package -l",
6259 "cargo package -vl",
6260 "cargo package --help",
6261 "cargo --help package",
6262 "cargo publish --dry-run -h",
6263 "cargo publish -- --dry-run",
6264 "cargo publish --dry-run -- --no-verify",
6265 "cargo publish --dry-run=false",
6266 "cargo publish --registry=--dry-run",
6267 "cargo publish -p--dry-run",
6268 "cargo publish --token=--dry-run",
6269 "cargo publish --config --dry-run",
6270 "cargo publish --config alias.note=--dry-run",
6271 "cargo publish --unknown-option --dry-run",
6272 "cargo package --target --no-verify",
6273 "cargo package --package --list",
6274 "cargo package --config",
6275 "cargo publish --config 'alias.note=\"--dry-run\"'",
6276 "cargo publish --registry 'example --dry-run registry'",
6277 "cargo publish --dry-run --config 'alias.note=\"--no-verify\"'",
6278 "cargo publish --dry-run $EXTRA_CARGO_OPTIONS",
6279 ] {
6280 let classification = classify_command(command);
6281 assert!(
6282 !classification.is_compilation,
6283 "{command}: {classification:?}"
6284 );
6285 let detailed = classify_command_detailed(command).classification;
6286 assert!(!detailed.is_compilation, "{command}: {detailed:?}");
6287 assert!(!is_cargo_package_verification(command), "{command}");
6288 }
6289 }
6290
6291 #[test]
6296 fn regression_cargo_never_intercept() {
6297 let _guard = test_guard!();
6298 let cases = [
6299 Case {
6300 cmd: "cargo install ripgrep",
6301 expect_compilation: false,
6302 expected_kind: None,
6303 reason_contains: "never-intercept",
6304 min_confidence: 0.0,
6305 },
6306 Case {
6307 cmd: "cargo publish",
6308 expect_compilation: false,
6309 expected_kind: None,
6310 reason_contains: "never-intercept",
6311 min_confidence: 0.0,
6312 },
6313 Case {
6314 cmd: "cargo login",
6315 expect_compilation: false,
6316 expected_kind: None,
6317 reason_contains: "never-intercept",
6318 min_confidence: 0.0,
6319 },
6320 Case {
6321 cmd: "cargo fmt",
6322 expect_compilation: false,
6323 expected_kind: None,
6324 reason_contains: "never-intercept",
6325 min_confidence: 0.0,
6326 },
6327 Case {
6328 cmd: "cargo fmt --check",
6329 expect_compilation: false,
6330 expected_kind: None,
6331 reason_contains: "never-intercept",
6332 min_confidence: 0.0,
6333 },
6334 Case {
6335 cmd: "cargo fix",
6336 expect_compilation: false,
6337 expected_kind: None,
6338 reason_contains: "never-intercept",
6339 min_confidence: 0.0,
6340 },
6341 Case {
6342 cmd: "cargo clean",
6343 expect_compilation: false,
6344 expected_kind: None,
6345 reason_contains: "never-intercept",
6346 min_confidence: 0.0,
6347 },
6348 Case {
6349 cmd: "cargo new my_project",
6350 expect_compilation: false,
6351 expected_kind: None,
6352 reason_contains: "never-intercept",
6353 min_confidence: 0.0,
6354 },
6355 Case {
6356 cmd: "cargo init",
6357 expect_compilation: false,
6358 expected_kind: None,
6359 reason_contains: "never-intercept",
6360 min_confidence: 0.0,
6361 },
6362 Case {
6363 cmd: "cargo add serde",
6364 expect_compilation: false,
6365 expected_kind: None,
6366 reason_contains: "never-intercept",
6367 min_confidence: 0.0,
6368 },
6369 Case {
6370 cmd: "cargo remove serde",
6371 expect_compilation: false,
6372 expected_kind: None,
6373 reason_contains: "never-intercept",
6374 min_confidence: 0.0,
6375 },
6376 Case {
6377 cmd: "cargo update",
6378 expect_compilation: false,
6379 expected_kind: None,
6380 reason_contains: "never-intercept",
6381 min_confidence: 0.0,
6382 },
6383 Case {
6384 cmd: "cargo generate-lockfile",
6385 expect_compilation: false,
6386 expected_kind: None,
6387 reason_contains: "never-intercept",
6388 min_confidence: 0.0,
6389 },
6390 Case {
6391 cmd: "cargo watch -x test",
6392 expect_compilation: false,
6393 expected_kind: None,
6394 reason_contains: "never-intercept",
6395 min_confidence: 0.0,
6396 },
6397 Case {
6398 cmd: "cargo --version",
6399 expect_compilation: false,
6400 expected_kind: None,
6401 reason_contains: "never-intercept",
6402 min_confidence: 0.0,
6403 },
6404 Case {
6405 cmd: "cargo -V",
6406 expect_compilation: false,
6407 expected_kind: None,
6408 reason_contains: "never-intercept",
6409 min_confidence: 0.0,
6410 },
6411 Case {
6413 cmd: "cargo nextest list",
6414 expect_compilation: false,
6415 expected_kind: None,
6416 reason_contains: "never-intercept",
6417 min_confidence: 0.0,
6418 },
6419 Case {
6420 cmd: "cargo nextest archive",
6421 expect_compilation: false,
6422 expected_kind: None,
6423 reason_contains: "never-intercept",
6424 min_confidence: 0.0,
6425 },
6426 Case {
6427 cmd: "cargo nextest show",
6428 expect_compilation: false,
6429 expected_kind: None,
6430 reason_contains: "never-intercept",
6431 min_confidence: 0.0,
6432 },
6433 Case {
6435 cmd: "cargo",
6436 expect_compilation: false,
6437 expected_kind: None,
6438 reason_contains: "bare cargo",
6439 min_confidence: 0.0,
6440 },
6441 Case {
6442 cmd: "cargo nextest",
6443 expect_compilation: false,
6444 expected_kind: None,
6445 reason_contains: "bare cargo nextest",
6446 min_confidence: 0.0,
6447 },
6448 Case {
6450 cmd: "cargo tree",
6451 expect_compilation: false,
6452 expected_kind: None,
6453 reason_contains: "not interceptable",
6454 min_confidence: 0.0,
6455 },
6456 Case {
6457 cmd: "cargo metadata",
6458 expect_compilation: false,
6459 expected_kind: None,
6460 reason_contains: "not interceptable",
6461 min_confidence: 0.0,
6462 },
6463 Case {
6464 cmd: "cargo search serde",
6465 expect_compilation: false,
6466 expected_kind: None,
6467 reason_contains: "not interceptable",
6468 min_confidence: 0.0,
6469 },
6470 Case {
6471 cmd: "cargo vendor",
6472 expect_compilation: false,
6473 expected_kind: None,
6474 reason_contains: "not interceptable",
6475 min_confidence: 0.0,
6476 },
6477 ];
6478 run_cases(&cases);
6479 }
6480
6481 #[test]
6486 fn regression_bun_compilation_positive() {
6487 let _guard = test_guard!();
6488 let cases = [
6489 Case {
6490 cmd: "bun test",
6491 expect_compilation: true,
6492 expected_kind: Some(CompilationKind::BunTest),
6493 reason_contains: "bun test",
6494 min_confidence: 0.90,
6495 },
6496 Case {
6497 cmd: "bun test src/",
6498 expect_compilation: true,
6499 expected_kind: Some(CompilationKind::BunTest),
6500 reason_contains: "bun test",
6501 min_confidence: 0.90,
6502 },
6503 Case {
6504 cmd: "bun test --bail",
6505 expect_compilation: true,
6506 expected_kind: Some(CompilationKind::BunTest),
6507 reason_contains: "bun test",
6508 min_confidence: 0.90,
6509 },
6510 Case {
6511 cmd: "bun test --timeout 5000",
6512 expect_compilation: true,
6513 expected_kind: Some(CompilationKind::BunTest),
6514 reason_contains: "bun test",
6515 min_confidence: 0.90,
6516 },
6517 Case {
6518 cmd: "bun typecheck",
6519 expect_compilation: true,
6520 expected_kind: Some(CompilationKind::BunTypecheck),
6521 reason_contains: "bun typecheck",
6522 min_confidence: 0.90,
6523 },
6524 Case {
6525 cmd: "bun typecheck src/",
6526 expect_compilation: true,
6527 expected_kind: Some(CompilationKind::BunTypecheck),
6528 reason_contains: "bun typecheck",
6529 min_confidence: 0.90,
6530 },
6531 ];
6532 run_cases(&cases);
6533 }
6534
6535 #[test]
6540 fn regression_bun_never_intercept() {
6541 let _guard = test_guard!();
6542 let cases = [
6543 Case {
6545 cmd: "bun install",
6546 expect_compilation: false,
6547 expected_kind: None,
6548 reason_contains: "never-intercept",
6549 min_confidence: 0.0,
6550 },
6551 Case {
6552 cmd: "bun add react",
6553 expect_compilation: false,
6554 expected_kind: None,
6555 reason_contains: "never-intercept",
6556 min_confidence: 0.0,
6557 },
6558 Case {
6559 cmd: "bun remove react",
6560 expect_compilation: false,
6561 expected_kind: None,
6562 reason_contains: "never-intercept",
6563 min_confidence: 0.0,
6564 },
6565 Case {
6566 cmd: "bun link",
6567 expect_compilation: false,
6568 expected_kind: None,
6569 reason_contains: "never-intercept",
6570 min_confidence: 0.0,
6571 },
6572 Case {
6573 cmd: "bun unlink",
6574 expect_compilation: false,
6575 expected_kind: None,
6576 reason_contains: "never-intercept",
6577 min_confidence: 0.0,
6578 },
6579 Case {
6580 cmd: "bun pm ls",
6581 expect_compilation: false,
6582 expected_kind: None,
6583 reason_contains: "never-intercept",
6584 min_confidence: 0.0,
6585 },
6586 Case {
6587 cmd: "bun init",
6588 expect_compilation: false,
6589 expected_kind: None,
6590 reason_contains: "never-intercept",
6591 min_confidence: 0.0,
6592 },
6593 Case {
6594 cmd: "bun create my-app",
6595 expect_compilation: false,
6596 expected_kind: None,
6597 reason_contains: "never-intercept",
6598 min_confidence: 0.0,
6599 },
6600 Case {
6601 cmd: "bun upgrade",
6602 expect_compilation: false,
6603 expected_kind: None,
6604 reason_contains: "never-intercept",
6605 min_confidence: 0.0,
6606 },
6607 Case {
6609 cmd: "bun run dev",
6610 expect_compilation: false,
6611 expected_kind: None,
6612 reason_contains: "never-intercept",
6613 min_confidence: 0.0,
6614 },
6615 Case {
6616 cmd: "bun build src/index.ts",
6617 expect_compilation: false,
6618 expected_kind: None,
6619 reason_contains: "never-intercept",
6620 min_confidence: 0.0,
6621 },
6622 Case {
6623 cmd: "bun dev",
6624 expect_compilation: false,
6625 expected_kind: None,
6626 reason_contains: "never-intercept",
6627 min_confidence: 0.0,
6628 },
6629 Case {
6630 cmd: "bun repl",
6631 expect_compilation: false,
6632 expected_kind: None,
6633 reason_contains: "never-intercept",
6634 min_confidence: 0.0,
6635 },
6636 Case {
6638 cmd: "bun x vitest",
6639 expect_compilation: false,
6640 expected_kind: None,
6641 reason_contains: "bun x",
6642 min_confidence: 0.0,
6643 },
6644 Case {
6645 cmd: "bun x tsc --noEmit",
6646 expect_compilation: false,
6647 expected_kind: None,
6648 reason_contains: "bun x",
6649 min_confidence: 0.0,
6650 },
6651 Case {
6653 cmd: "bun test --watch",
6654 expect_compilation: false,
6655 expected_kind: None,
6656 reason_contains: "interactive",
6657 min_confidence: 0.0,
6658 },
6659 Case {
6660 cmd: "bun test -w",
6661 expect_compilation: false,
6662 expected_kind: None,
6663 reason_contains: "interactive",
6664 min_confidence: 0.0,
6665 },
6666 Case {
6667 cmd: "bun typecheck --watch",
6668 expect_compilation: false,
6669 expected_kind: None,
6670 reason_contains: "interactive",
6671 min_confidence: 0.0,
6672 },
6673 Case {
6674 cmd: "bun typecheck -w",
6675 expect_compilation: false,
6676 expected_kind: None,
6677 reason_contains: "interactive",
6678 min_confidence: 0.0,
6679 },
6680 Case {
6682 cmd: "bun --version",
6683 expect_compilation: false,
6684 expected_kind: None,
6685 reason_contains: "never-intercept",
6686 min_confidence: 0.0,
6687 },
6688 Case {
6689 cmd: "bun -v",
6690 expect_compilation: false,
6691 expected_kind: None,
6692 reason_contains: "never-intercept",
6693 min_confidence: 0.0,
6694 },
6695 Case {
6696 cmd: "bun --help",
6697 expect_compilation: false,
6698 expected_kind: None,
6699 reason_contains: "never-intercept",
6700 min_confidence: 0.0,
6701 },
6702 Case {
6703 cmd: "bun -h",
6704 expect_compilation: false,
6705 expected_kind: None,
6706 reason_contains: "never-intercept",
6707 min_confidence: 0.0,
6708 },
6709 Case {
6710 cmd: "bun completions",
6711 expect_compilation: false,
6712 expected_kind: None,
6713 reason_contains: "never-intercept",
6714 min_confidence: 0.0,
6715 },
6716 ];
6717 run_cases(&cases);
6718 }
6719
6720 #[test]
6725 fn regression_c_cpp_compilation_positive() {
6726 let _guard = test_guard!();
6727 let cases = [
6728 Case {
6729 cmd: "gcc -c main.c -o main.o",
6730 expect_compilation: true,
6731 expected_kind: Some(CompilationKind::Gcc),
6732 reason_contains: "gcc",
6733 min_confidence: 0.85,
6734 },
6735 Case {
6736 cmd: "gcc main.c -o main",
6737 expect_compilation: true,
6738 expected_kind: Some(CompilationKind::Gcc),
6739 reason_contains: "gcc",
6740 min_confidence: 0.85,
6741 },
6742 Case {
6743 cmd: "g++ -c main.cpp -o main.o",
6744 expect_compilation: true,
6745 expected_kind: Some(CompilationKind::Gpp),
6746 reason_contains: "g++",
6747 min_confidence: 0.85,
6748 },
6749 Case {
6750 cmd: "g++ main.cc -o main",
6751 expect_compilation: true,
6752 expected_kind: Some(CompilationKind::Gpp),
6753 reason_contains: "g++",
6754 min_confidence: 0.85,
6755 },
6756 Case {
6757 cmd: "clang -c main.c -o main.o",
6758 expect_compilation: true,
6759 expected_kind: Some(CompilationKind::Clang),
6760 reason_contains: "clang",
6761 min_confidence: 0.85,
6762 },
6763 Case {
6764 cmd: "clang++ -c main.cpp -o main.o",
6765 expect_compilation: true,
6766 expected_kind: Some(CompilationKind::Clangpp),
6767 reason_contains: "clang++",
6768 min_confidence: 0.85,
6769 },
6770 Case {
6771 cmd: "clang++ main.cc -o main",
6772 expect_compilation: true,
6773 expected_kind: Some(CompilationKind::Clangpp),
6774 reason_contains: "clang++",
6775 min_confidence: 0.85,
6776 },
6777 Case {
6778 cmd: "cc -c main.c -o main.o",
6779 expect_compilation: true,
6780 expected_kind: Some(CompilationKind::Gcc),
6781 reason_contains: "cc",
6782 min_confidence: 0.80,
6783 },
6784 Case {
6785 cmd: "c++ -c main.cpp -o main.o",
6786 expect_compilation: true,
6787 expected_kind: Some(CompilationKind::Gpp),
6788 reason_contains: "c++",
6789 min_confidence: 0.80,
6790 },
6791 ];
6792 run_cases(&cases);
6793 }
6794
6795 #[test]
6800 fn regression_c_cpp_version_checks_not_intercepted() {
6801 let _guard = test_guard!();
6802 let cases = [
6803 Case {
6804 cmd: "rustc --version",
6805 expect_compilation: false,
6806 expected_kind: None,
6807 reason_contains: "never-intercept",
6808 min_confidence: 0.0,
6809 },
6810 Case {
6811 cmd: "rustc -V",
6812 expect_compilation: false,
6813 expected_kind: None,
6814 reason_contains: "never-intercept",
6815 min_confidence: 0.0,
6816 },
6817 Case {
6818 cmd: "gcc --version",
6819 expect_compilation: false,
6820 expected_kind: None,
6821 reason_contains: "never-intercept",
6822 min_confidence: 0.0,
6823 },
6824 Case {
6825 cmd: "gcc -v",
6826 expect_compilation: false,
6827 expected_kind: None,
6828 reason_contains: "never-intercept",
6829 min_confidence: 0.0,
6830 },
6831 Case {
6832 cmd: "clang --version",
6833 expect_compilation: false,
6834 expected_kind: None,
6835 reason_contains: "never-intercept",
6836 min_confidence: 0.0,
6837 },
6838 Case {
6839 cmd: "clang -v",
6840 expect_compilation: false,
6841 expected_kind: None,
6842 reason_contains: "never-intercept",
6843 min_confidence: 0.0,
6844 },
6845 Case {
6846 cmd: "make --version",
6847 expect_compilation: false,
6848 expected_kind: None,
6849 reason_contains: "never-intercept",
6850 min_confidence: 0.0,
6851 },
6852 Case {
6853 cmd: "make -v",
6854 expect_compilation: false,
6855 expected_kind: None,
6856 reason_contains: "never-intercept",
6857 min_confidence: 0.0,
6858 },
6859 Case {
6860 cmd: "cmake --version",
6861 expect_compilation: false,
6862 expected_kind: None,
6863 reason_contains: "never-intercept",
6864 min_confidence: 0.0,
6865 },
6866 ];
6867 run_cases(&cases);
6868 }
6869
6870 #[test]
6875 fn regression_build_systems_positive() {
6876 let _guard = test_guard!();
6877 let cases = [
6878 Case {
6879 cmd: "make",
6880 expect_compilation: true,
6881 expected_kind: Some(CompilationKind::Make),
6882 reason_contains: "make build",
6883 min_confidence: 0.80,
6884 },
6885 Case {
6886 cmd: "make -j8",
6887 expect_compilation: true,
6888 expected_kind: Some(CompilationKind::Make),
6889 reason_contains: "make build",
6890 min_confidence: 0.80,
6891 },
6892 Case {
6893 cmd: "make all",
6894 expect_compilation: true,
6895 expected_kind: Some(CompilationKind::Make),
6896 reason_contains: "make build",
6897 min_confidence: 0.80,
6898 },
6899 Case {
6900 cmd: "cmake --build build",
6901 expect_compilation: true,
6902 expected_kind: Some(CompilationKind::CmakeBuild),
6903 reason_contains: "cmake --build",
6904 min_confidence: 0.85,
6905 },
6906 Case {
6907 cmd: "cmake --build=build",
6908 expect_compilation: true,
6909 expected_kind: Some(CompilationKind::CmakeBuild),
6910 reason_contains: "cmake --build",
6911 min_confidence: 0.85,
6912 },
6913 Case {
6914 cmd: "ninja",
6915 expect_compilation: true,
6916 expected_kind: Some(CompilationKind::Ninja),
6917 reason_contains: "ninja build",
6918 min_confidence: 0.85,
6919 },
6920 Case {
6921 cmd: "ninja -j4",
6922 expect_compilation: true,
6923 expected_kind: Some(CompilationKind::Ninja),
6924 reason_contains: "ninja build",
6925 min_confidence: 0.85,
6926 },
6927 Case {
6928 cmd: "meson compile",
6929 expect_compilation: true,
6930 expected_kind: Some(CompilationKind::Meson),
6931 reason_contains: "meson compile",
6932 min_confidence: 0.80,
6933 },
6934 ];
6935 run_cases(&cases);
6936 }
6937
6938 #[test]
6943 fn regression_build_systems_not_intercepted() {
6944 let _guard = test_guard!();
6945 let cases = [
6946 Case {
6947 cmd: "make clean",
6948 expect_compilation: false,
6949 expected_kind: None,
6950 reason_contains: "make maintenance",
6951 min_confidence: 0.0,
6952 },
6953 Case {
6954 cmd: "make install",
6955 expect_compilation: false,
6956 expected_kind: None,
6957 reason_contains: "make maintenance",
6958 min_confidence: 0.0,
6959 },
6960 Case {
6961 cmd: "make distclean",
6962 expect_compilation: false,
6963 expected_kind: None,
6964 reason_contains: "make maintenance",
6965 min_confidence: 0.0,
6966 },
6967 Case {
6968 cmd: "ninja -t clean",
6969 expect_compilation: false,
6970 expected_kind: None,
6971 reason_contains: "ninja clean",
6972 min_confidence: 0.0,
6973 },
6974 Case {
6975 cmd: "ninja clean",
6976 expect_compilation: false,
6977 expected_kind: None,
6978 reason_contains: "ninja clean",
6979 min_confidence: 0.0,
6980 },
6981 Case {
6983 cmd: "meson setup build",
6984 expect_compilation: false,
6985 expected_kind: None,
6986 reason_contains: "no matching pattern",
6987 min_confidence: 0.0,
6988 },
6989 Case {
6990 cmd: "meson configure",
6991 expect_compilation: false,
6992 expected_kind: None,
6993 reason_contains: "no matching pattern",
6994 min_confidence: 0.0,
6995 },
6996 Case {
6998 cmd: "cmake .",
6999 expect_compilation: false,
7000 expected_kind: None,
7001 reason_contains: "no matching pattern",
7002 min_confidence: 0.0,
7003 },
7004 Case {
7005 cmd: "cmake -DCMAKE_BUILD_TYPE=Release ..",
7006 expect_compilation: false,
7007 expected_kind: None,
7008 reason_contains: "no matching pattern",
7009 min_confidence: 0.0,
7010 },
7011 ];
7012 run_cases(&cases);
7013 }
7014
7015 #[test]
7020 fn regression_rustc_positive() {
7021 let _guard = test_guard!();
7022 let cases = [
7023 Case {
7024 cmd: "rustc main.rs",
7025 expect_compilation: true,
7026 expected_kind: Some(CompilationKind::Rustc),
7027 reason_contains: "rustc",
7028 min_confidence: 0.90,
7029 },
7030 Case {
7031 cmd: "rustc main.rs -o main",
7032 expect_compilation: true,
7033 expected_kind: Some(CompilationKind::Rustc),
7034 reason_contains: "rustc",
7035 min_confidence: 0.90,
7036 },
7037 Case {
7038 cmd: "rustc --edition 2021 main.rs",
7039 expect_compilation: true,
7040 expected_kind: Some(CompilationKind::Rustc),
7041 reason_contains: "rustc",
7042 min_confidence: 0.90,
7043 },
7044 ];
7045 run_cases(&cases);
7046 }
7047
7048 #[test]
7053 fn regression_shell_structure_exclusions() {
7054 let _guard = test_guard!();
7055 let cases = [
7056 Case {
7059 cmd: "cargo build 2>&1 | xargs rm",
7060 expect_compilation: false,
7061 expected_kind: None,
7062 reason_contains: "piped",
7063 min_confidence: 0.0,
7064 },
7065 Case {
7067 cmd: "cargo build < input.txt",
7068 expect_compilation: false,
7069 expected_kind: None,
7070 reason_contains: "input redirect",
7071 min_confidence: 0.0,
7072 },
7073 Case {
7075 cmd: "(cargo build)",
7076 expect_compilation: false,
7077 expected_kind: None,
7078 reason_contains: "subshell",
7079 min_confidence: 0.0,
7080 },
7081 Case {
7082 cmd: "$(cargo build)",
7083 expect_compilation: false,
7084 expected_kind: None,
7085 reason_contains: "subshell",
7086 min_confidence: 0.0,
7087 },
7088 Case {
7089 cmd: "`cargo build`",
7090 expect_compilation: false,
7091 expected_kind: None,
7092 reason_contains: "subshell capture",
7093 min_confidence: 0.0,
7094 },
7095 Case {
7097 cmd: "cargo build --config <(echo ...)",
7098 expect_compilation: false,
7099 expected_kind: None,
7100 reason_contains: "subshell",
7101 min_confidence: 0.0,
7102 },
7103 Case {
7105 cmd: "cargo build; cargo test",
7106 expect_compilation: false,
7107 expected_kind: None,
7108 reason_contains: "chained",
7109 min_confidence: 0.0,
7110 },
7111 Case {
7113 cmd: "cargo build || echo failed",
7114 expect_compilation: false,
7115 expected_kind: None,
7116 reason_contains: "chained",
7117 min_confidence: 0.0,
7118 },
7119 Case {
7121 cmd: "cargo build\nrm -rf /",
7122 expect_compilation: false,
7123 expected_kind: None,
7124 reason_contains: "newline",
7125 min_confidence: 0.0,
7126 },
7127 Case {
7128 cmd: "cargo build\r\nevil",
7129 expect_compilation: false,
7130 expected_kind: None,
7131 reason_contains: "newline",
7132 min_confidence: 0.0,
7133 },
7134 ];
7135 run_cases(&cases);
7136 }
7137
7138 #[test]
7147 fn regression_compound_chain_offloads_every_compilation_segment() {
7148 let _guard = test_guard!();
7149
7150 let result = classify_command("cargo build --release && cargo test");
7151 assert!(result.is_compilation, "got: {:?}", result.reason);
7152 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7153 assert_eq!(
7154 result.command_prefix.as_deref(),
7155 Some("rch exec -- cargo build --release && ")
7156 );
7157 assert_eq!(result.extracted_command.as_deref(), Some("cargo test"));
7158
7159 let result = classify_command("touch marker && cargo build && cargo test --workspace");
7161 assert_eq!(
7162 result.command_prefix.as_deref(),
7163 Some("touch marker && rch exec -- cargo build && ")
7164 );
7165 assert_eq!(
7166 result.extracted_command.as_deref(),
7167 Some("cargo test --workspace")
7168 );
7169
7170 let result = classify_command("go vet ./... && cargo check");
7172 assert_eq!(
7173 result.command_prefix.as_deref(),
7174 Some("rch exec -- go vet ./... && ")
7175 );
7176
7177 let result = classify_command("cd /repo && cargo build");
7179 assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7180
7181 let result = classify_command("go build ./cmd && cargo test");
7183 assert!(result.is_compilation);
7184 assert_eq!(result.command_prefix.as_deref(), Some("go build ./cmd && "));
7185 assert_eq!(
7187 compound_local_compilation_segments("go build ./cmd && cargo test"),
7188 vec!["go build ./cmd".to_string()]
7189 );
7190 assert!(
7191 compound_local_compilation_segments("cargo build --release && cargo test").is_empty()
7192 );
7193 assert!(compound_local_compilation_segments("cd /repo && cargo build").is_empty());
7194 assert!(compound_local_compilation_segments("cargo test").is_empty());
7195 assert!(
7196 compound_local_compilation_segments("bash -c \"cargo build && cargo test\"").is_empty(),
7197 "quoted operators are not chain separators"
7198 );
7199
7200 assert!(!classify_command("cargo build ; cargo test").is_compilation);
7203 assert!(declined_compilation_due_to_structure("cargo build ; cargo test").is_some());
7204 assert!(declined_compilation_due_to_structure("cargo build || echo failed").is_some());
7205 assert!(declined_compilation_due_to_structure("cargo build && ./run").is_some());
7206 }
7207
7208 #[test]
7218 fn regression_benign_trailing_structure_offloaded() {
7219 let _guard = test_guard!();
7220 let expectations: &[(&str, CompilationKind, &str)] = &[
7221 (
7223 "cargo build > log.txt",
7224 CompilationKind::CargoBuild,
7225 "cargo build > log.txt",
7226 ),
7227 (
7228 "cargo build >> log.txt",
7229 CompilationKind::CargoBuild,
7230 "cargo build >> log.txt",
7231 ),
7232 (
7233 "cargo test --workspace > out.log 2>&1",
7234 CompilationKind::CargoTest,
7235 "cargo test --workspace > out.log 2>&1",
7236 ),
7237 (
7239 "cargo build 2>&1 | grep error",
7240 CompilationKind::CargoBuild,
7241 "cargo build 2>&1 | grep error",
7242 ),
7243 (
7244 "cargo test | tee output.log",
7245 CompilationKind::CargoTest,
7246 "cargo test | tee output.log",
7247 ),
7248 (
7249 "cargo check --lib 2>&1 | head",
7250 CompilationKind::CargoCheck,
7251 "cargo check --lib 2>&1 | head",
7252 ),
7253 (
7255 "cargo build &",
7256 CompilationKind::CargoBuild,
7257 "cargo build &",
7258 ),
7259 ];
7260 for (cmd, kind, extracted) in expectations {
7261 let result = classify_command(cmd);
7262 assert!(
7263 result.is_compilation,
7264 "'{cmd}' should be offloaded, got: {:?}",
7265 result.reason
7266 );
7267 assert_eq!(result.kind, Some(*kind), "kind mismatch for '{cmd}'");
7268 assert_eq!(
7269 result.extracted_command.as_deref(),
7270 Some(*extracted),
7271 "extracted command mismatch for '{cmd}'"
7272 );
7273 assert_eq!(result.command_prefix.as_deref(), Some(""));
7275 }
7276 }
7277
7278 #[test]
7279 fn regression_unsafe_trailing_structure_still_rejected() {
7280 let _guard = test_guard!();
7281 let rejected = [
7284 "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", ];
7290 for cmd in rejected {
7291 let result = classify_command(cmd);
7292 assert!(
7293 !result.is_compilation,
7294 "'{cmd}' should NOT be offloaded, got: {:?}",
7295 result
7296 );
7297 }
7298 }
7299
7300 #[test]
7301 fn regression_shell_dash_c_wrapper_offloaded() {
7302 let _guard = test_guard!();
7303 let result = classify_command("bash -c \"cargo test --workspace\"");
7305 assert!(result.is_compilation, "got: {:?}", result.reason);
7306 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7307 assert_eq!(
7308 result.extracted_command.as_deref(),
7309 Some("cargo test --workspace")
7310 );
7311
7312 let result = classify_command("sh -c 'cargo build --release'");
7313 assert!(result.is_compilation, "got: {:?}", result.reason);
7314 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7315 assert_eq!(
7316 result.extracted_command.as_deref(),
7317 Some("cargo build --release")
7318 );
7319
7320 let result = classify_command("/bin/bash -c \"cargo check\"");
7322 assert!(result.is_compilation, "got: {:?}", result.reason);
7323
7324 let result = classify_command("bash -c \"cd /repo && cargo build\"");
7326 assert!(result.is_compilation, "got: {:?}", result.reason);
7327 assert_eq!(
7328 result.extracted_command.as_deref(),
7329 Some("cargo build"),
7330 "inner compound should keep extracted command"
7331 );
7332 assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7333
7334 let result = classify_command("bash -c \"cargo test > log 2>&1\"");
7336 assert!(result.is_compilation, "got: {:?}", result.reason);
7337 assert_eq!(
7338 result.extracted_command.as_deref(),
7339 Some("cargo test > log 2>&1")
7340 );
7341
7342 let result = classify_command("bash -c \"ls -la\"");
7344 assert!(!result.is_compilation);
7345
7346 let result = classify_command("bash -c \"cargo fmt\"");
7348 assert!(!result.is_compilation);
7349 }
7350
7351 #[test]
7356 fn regression_fd_redirect_safe() {
7357 let _guard = test_guard!();
7358 let result = classify_command("cargo build 2>&1");
7364 assert!(
7365 result.is_compilation,
7366 "cargo build 2>&1 should be classified as compilation (fd redirect is safe), got: {:?}",
7367 result.reason
7368 );
7369 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7370 }
7371
7372 #[test]
7377 fn regression_compound_commands() {
7378 let _guard = test_guard!();
7379 let result = classify_command("cd /path && cargo build");
7381 assert!(
7382 result.is_compilation,
7383 "cd && cargo build should compile, got: {:?}",
7384 result.reason
7385 );
7386 assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7387 assert!(result.command_prefix.is_some(), "should have prefix");
7388 assert!(
7389 result.extracted_command.is_some(),
7390 "should have extracted command"
7391 );
7392
7393 let result = classify_command("cd /path && cargo test --workspace");
7394 assert!(
7395 result.is_compilation,
7396 "cd && cargo test should compile, got: {:?}",
7397 result.reason
7398 );
7399 assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7400
7401 let result = classify_command("cd /path && cargo fmt");
7403 assert!(!result.is_compilation, "cd && cargo fmt should NOT compile");
7404
7405 let result = classify_command("cd /path && ls -la");
7406 assert!(!result.is_compilation, "cd && ls should NOT compile");
7407 }
7408
7409 #[test]
7414 fn regression_wrapper_normalization() {
7415 let _guard = test_guard!();
7416 let cases = [
7417 Case {
7418 cmd: "sudo cargo build",
7419 expect_compilation: true,
7420 expected_kind: Some(CompilationKind::CargoBuild),
7421 reason_contains: "cargo build",
7422 min_confidence: 0.90,
7423 },
7424 Case {
7425 cmd: "time cargo test",
7426 expect_compilation: true,
7427 expected_kind: Some(CompilationKind::CargoTest),
7428 reason_contains: "cargo test",
7429 min_confidence: 0.90,
7430 },
7431 Case {
7432 cmd: "env RUST_BACKTRACE=1 cargo test",
7433 expect_compilation: true,
7434 expected_kind: Some(CompilationKind::CargoTest),
7435 reason_contains: "cargo test",
7436 min_confidence: 0.90,
7437 },
7438 Case {
7439 cmd: "env -u RUST_LOG cargo test",
7440 expect_compilation: true,
7441 expected_kind: Some(CompilationKind::CargoTest),
7442 reason_contains: "cargo test",
7443 min_confidence: 0.90,
7444 },
7445 Case {
7446 cmd: "RUST_BACKTRACE=1 cargo test",
7447 expect_compilation: true,
7448 expected_kind: Some(CompilationKind::CargoTest),
7449 reason_contains: "cargo test",
7450 min_confidence: 0.90,
7451 },
7452 Case {
7453 cmd: "nice cargo build --release",
7454 expect_compilation: true,
7455 expected_kind: Some(CompilationKind::CargoBuild),
7456 reason_contains: "cargo build",
7457 min_confidence: 0.90,
7458 },
7459 Case {
7460 cmd: "/usr/bin/cargo build",
7461 expect_compilation: true,
7462 expected_kind: Some(CompilationKind::CargoBuild),
7463 reason_contains: "cargo build",
7464 min_confidence: 0.90,
7465 },
7466 Case {
7467 cmd: "sudo env RUSTFLAGS=-Dwarnings cargo clippy",
7468 expect_compilation: true,
7469 expected_kind: Some(CompilationKind::CargoClippy),
7470 reason_contains: "cargo clippy",
7471 min_confidence: 0.85,
7472 },
7473 ];
7474 run_cases(&cases);
7475 }
7476
7477 #[test]
7482 fn regression_non_compilation_passthrough() {
7483 let _guard = test_guard!();
7484 let cases = [
7485 Case {
7486 cmd: "ls -la",
7487 expect_compilation: false,
7488 expected_kind: None,
7489 reason_contains: "no compilation keyword",
7490 min_confidence: 0.0,
7491 },
7492 Case {
7493 cmd: "pwd",
7494 expect_compilation: false,
7495 expected_kind: None,
7496 reason_contains: "no compilation keyword",
7497 min_confidence: 0.0,
7498 },
7499 Case {
7500 cmd: "echo hello",
7501 expect_compilation: false,
7502 expected_kind: None,
7503 reason_contains: "no compilation keyword",
7504 min_confidence: 0.0,
7505 },
7506 Case {
7507 cmd: "cat Cargo.toml",
7508 expect_compilation: false,
7509 expected_kind: None,
7510 reason_contains: "no compilation keyword",
7511 min_confidence: 0.0,
7512 },
7513 Case {
7514 cmd: "git status",
7515 expect_compilation: false,
7516 expected_kind: None,
7517 reason_contains: "no compilation keyword",
7518 min_confidence: 0.0,
7519 },
7520 Case {
7521 cmd: "git commit -m 'fix'",
7522 expect_compilation: false,
7523 expected_kind: None,
7524 reason_contains: "no compilation keyword",
7525 min_confidence: 0.0,
7526 },
7527 Case {
7528 cmd: "npm install",
7529 expect_compilation: false,
7530 expected_kind: None,
7531 reason_contains: "no compilation keyword",
7532 min_confidence: 0.0,
7533 },
7534 Case {
7535 cmd: "yarn build",
7536 expect_compilation: false,
7537 expected_kind: None,
7538 reason_contains: "no compilation keyword",
7539 min_confidence: 0.0,
7540 },
7541 Case {
7542 cmd: "python main.py",
7543 expect_compilation: false,
7544 expected_kind: None,
7545 reason_contains: "no compilation keyword",
7546 min_confidence: 0.0,
7547 },
7548 #[cfg(unix)]
7550 Case {
7551 cmd: "go build -o bin/app .",
7552 expect_compilation: true,
7553 expected_kind: Some(CompilationKind::GoBuild),
7554 reason_contains: "go build command",
7555 min_confidence: 0.9,
7556 },
7557 Case {
7559 cmd: "go build ./cmd/app",
7560 expect_compilation: false,
7561 expected_kind: None,
7562 reason_contains: "requires a supported explicit -o file",
7563 min_confidence: 0.0,
7564 },
7565 Case {
7566 cmd: "docker build -t myapp .",
7567 expect_compilation: false,
7568 expected_kind: None,
7569 reason_contains: "no compilation keyword",
7570 min_confidence: 0.0,
7571 },
7572 Case {
7573 cmd: "pip install -r requirements.txt",
7574 expect_compilation: false,
7575 expected_kind: None,
7576 reason_contains: "no compilation keyword",
7577 min_confidence: 0.0,
7578 },
7579 Case {
7580 cmd: "curl https://example.com",
7581 expect_compilation: false,
7582 expected_kind: None,
7583 reason_contains: "no compilation keyword",
7584 min_confidence: 0.0,
7585 },
7586 Case {
7587 cmd: "mkdir -p build",
7588 expect_compilation: false,
7589 expected_kind: None,
7590 reason_contains: "no compilation keyword",
7591 min_confidence: 0.0,
7592 },
7593 Case {
7594 cmd: "rm -rf target/",
7595 expect_compilation: false,
7596 expected_kind: None,
7597 reason_contains: "no compilation keyword",
7598 min_confidence: 0.0,
7599 },
7600 Case {
7601 cmd: "cp -r src/ backup/",
7602 expect_compilation: false,
7603 expected_kind: None,
7604 reason_contains: "no compilation keyword",
7605 min_confidence: 0.0,
7606 },
7607 Case {
7608 cmd: "",
7609 expect_compilation: false,
7610 expected_kind: None,
7611 reason_contains: "empty command",
7612 min_confidence: 0.0,
7613 },
7614 Case {
7615 cmd: " ",
7616 expect_compilation: false,
7617 expected_kind: None,
7618 reason_contains: "empty command",
7619 min_confidence: 0.0,
7620 },
7621 ];
7622 run_cases(&cases);
7623 }
7624
7625 #[test]
7630 fn regression_classification_details_fields() {
7631 let _guard = test_guard!();
7632
7633 let details = classify_command_detailed("cargo build --release");
7635 assert_eq!(details.original, "cargo build --release");
7636 assert!(details.classification.is_compilation);
7637 assert_eq!(
7638 details.classification.kind,
7639 Some(CompilationKind::CargoBuild)
7640 );
7641 assert!(details.classification.confidence >= 0.90);
7642 assert_eq!(details.tiers.len(), 5, "Should have 5 tier decisions");
7644 for tier in &details.tiers[..4] {
7645 assert_eq!(tier.decision, TierDecision::Pass);
7646 }
7647 assert_eq!(details.tiers[4].decision, TierDecision::Pass);
7649 assert_eq!(details.tiers[4].tier, 4);
7650
7651 let details = classify_command_detailed("ls -la");
7653 assert!(!details.classification.is_compilation);
7654 assert!(
7655 details.tiers.len() >= 3,
7656 "Should have at least 3 tiers for keyword rejection"
7657 );
7658 assert_eq!(details.tiers.last().unwrap().decision, TierDecision::Reject);
7659
7660 let details = classify_command_detailed("cargo fmt --check");
7662 assert!(!details.classification.is_compilation);
7663 assert!(
7664 details.tiers.len() >= 4,
7665 "Should have at least 4 tiers for never-intercept rejection"
7666 );
7667 let tier3 = details.tiers.iter().find(|t| t.tier == 3).unwrap();
7669 assert_eq!(tier3.decision, TierDecision::Reject);
7670 assert!(
7671 tier3.reason.contains("never-intercept"),
7672 "Tier 3 reason should mention never-intercept, got: {:?}",
7673 tier3.reason
7674 );
7675
7676 let details = classify_command_detailed("cargo build | xargs rm");
7680 assert!(!details.classification.is_compilation);
7681 let tier1 = details.tiers.iter().find(|t| t.tier == 1).unwrap();
7682 assert_eq!(tier1.decision, TierDecision::Reject);
7683 assert!(tier1.reason.contains("piped"));
7684
7685 let details = classify_command_detailed("");
7687 assert!(!details.classification.is_compilation);
7688 assert_eq!(details.tiers[0].tier, 0);
7689 assert_eq!(details.tiers[0].decision, TierDecision::Reject);
7690 }
7691
7692 #[test]
7697 fn regression_every_compilation_has_reason_and_confidence() {
7698 let _guard = test_guard!();
7699 let compilation_cmds = [
7700 "cargo build",
7701 "cargo test",
7702 "cargo check",
7703 "cargo clippy",
7704 "cargo doc",
7705 "cargo run",
7706 "cargo bench",
7707 "cargo nextest run",
7708 "rustc main.rs",
7709 "gcc -c main.c -o main.o",
7710 "g++ -c main.cpp -o main.o",
7711 "clang -c main.c -o main.o",
7712 "clang++ -c main.cpp -o main.o",
7713 "make",
7714 "cmake --build build",
7715 "ninja",
7716 "meson compile",
7717 "bun test",
7718 "bun typecheck",
7719 ];
7720 for cmd in compilation_cmds {
7721 let result = classify_command(cmd);
7722 assert!(result.is_compilation, "{cmd:?} should be compilation");
7723 assert!(
7724 result.kind.is_some(),
7725 "{cmd:?} should have a CompilationKind"
7726 );
7727 assert!(
7728 result.confidence > 0.0,
7729 "{cmd:?} should have non-zero confidence"
7730 );
7731 assert!(
7732 !result.reason.is_empty(),
7733 "{cmd:?} should have a non-empty reason"
7734 );
7735 }
7736 }
7737
7738 #[test]
7743 fn regression_every_rejection_has_reason() {
7744 let _guard = test_guard!();
7745 let non_compilation_cmds = [
7746 "",
7747 "ls",
7748 "pwd",
7749 "git status",
7750 "cargo fmt",
7751 "cargo install ripgrep",
7752 "cargo clean",
7753 "bun install",
7754 "bun add react",
7755 "bun run dev",
7756 "bun dev",
7757 "bun repl",
7758 "bun x vitest",
7759 "bun test --watch",
7760 "bun typecheck -w",
7761 "make clean",
7762 "ninja clean",
7763 "gcc --version",
7764 "rustc --version",
7765 "cargo build | xargs rm",
7766 "cargo build < input.txt",
7767 "cargo build || echo failed",
7768 "(cargo build)",
7769 ];
7770 for cmd in non_compilation_cmds {
7771 let result = classify_command(cmd);
7772 assert!(
7773 !result.is_compilation,
7774 "{cmd:?} should NOT be compilation, got kind={:?}",
7775 result.kind
7776 );
7777 assert!(
7778 !result.reason.is_empty(),
7779 "{cmd:?} should have a non-empty rejection reason"
7780 );
7781 assert_eq!(result.kind, None, "{cmd:?} should have no CompilationKind");
7782 assert_eq!(
7783 result.confidence, 0.0,
7784 "{cmd:?} should have zero confidence"
7785 );
7786 }
7787 }
7788
7789 #[test]
7794 fn regression_real_world_agent_workflows() {
7795 let _guard = test_guard!();
7796
7797 let cd_build = classify_command(
7799 "cd /data/projects/remote_compilation_helper && cargo build --workspace --all-targets",
7800 );
7801 assert!(
7802 cd_build.is_compilation,
7803 "agent cd+build workflow should be intercepted"
7804 );
7805
7806 let test_ws = classify_command("cargo test --workspace -- --nocapture");
7808 assert!(test_ws.is_compilation);
7809 assert_eq!(test_ws.kind, Some(CompilationKind::CargoTest));
7810
7811 let clippy = classify_command("cargo clippy --workspace --all-targets -- -D warnings");
7813 assert!(clippy.is_compilation);
7814 assert_eq!(clippy.kind, Some(CompilationKind::CargoClippy));
7815
7816 let fmt = classify_command("cargo fmt --check");
7818 assert!(!fmt.is_compilation);
7819
7820 let cat = classify_command("cat Cargo.toml");
7822 assert!(!cat.is_compilation);
7823
7824 let gs = classify_command("git status");
7826 assert!(!gs.is_compilation);
7827
7828 let install = classify_command("cargo install cargo-nextest");
7830 assert!(!install.is_compilation);
7831
7832 let pkg_test = classify_command("cargo test -p rch-common");
7834 assert!(pkg_test.is_compilation);
7835 assert_eq!(pkg_test.kind, Some(CompilationKind::CargoTest));
7836
7837 let env_test = classify_command("RUST_BACKTRACE=1 cargo test --workspace");
7839 assert!(env_test.is_compilation);
7840 assert_eq!(env_test.kind, Some(CompilationKind::CargoTest));
7841
7842 let nxt = classify_command("cargo nextest run --workspace --no-fail-fast");
7844 assert!(nxt.is_compilation);
7845 assert_eq!(nxt.kind, Some(CompilationKind::CargoNextest));
7846 }
7847
7848 #[test]
7853 fn regression_keyword_in_non_compilation_context() {
7854 let _guard = test_guard!();
7855 let result = classify_command("echo cargo build");
7857 assert!(
7861 !result.is_compilation,
7862 "echo cargo should not compile, got: {:?}",
7863 result.reason
7864 );
7865
7866 let result = classify_command("grep make Makefile");
7868 assert!(
7870 !result.is_compilation,
7871 "grep make should not compile, got: {:?}",
7872 result.reason
7873 );
7874
7875 let result = classify_command("cat gcc_output.log");
7877 assert!(
7879 !result.is_compilation,
7880 "cat gcc_output should not compile, got: {:?}",
7881 result.reason
7882 );
7883 }
7884
7885 #[test]
7886 fn regression_build_words_in_non_build_arguments_are_not_compilation() {
7887 let _guard = test_guard!();
7888 let cases = [
7889 r#"br comments add ft-4tp7g.1 "remote proof blocked: cargo test -p rchd --lib""#,
7890 r#"AGENT_NAME=Codex br comments add ft-4tp7g.1 "proof lane: cargo clippy --workspace""#,
7891 r#"printf "cargo build --release""#,
7892 r#"echo 'cargo test -p rchd -- --nocapture'"#,
7893 r#"grep "cmake --build" docs/proof-notes.md"#,
7894 ];
7895
7896 for cmd in cases {
7897 let result = classify_command(cmd);
7898 assert!(
7899 !result.is_compilation,
7900 "embedded build text should not compile for {cmd:?}: {:?}",
7901 result
7902 );
7903 assert_eq!(result.reason.as_ref(), "no compilation keyword");
7904 }
7905 }
7906
7907 #[test]
7908 fn golden_non_build_arguments_with_build_words_stay_non_compilation() {
7909 let _guard = test_guard!();
7910 const FIXTURE_PATH: &str =
7911 "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json";
7912
7913 #[derive(serde::Deserialize)]
7914 struct Golden {
7915 schema_version: String,
7916 cases: Vec<Case>,
7917 expected_results: serde_json::Value,
7918 }
7919
7920 #[derive(serde::Deserialize)]
7921 struct Case {
7922 name: String,
7923 command: String,
7924 }
7925
7926 let golden: Golden = serde_json::from_str(include_str!(
7927 "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json"
7928 ))
7929 .expect("classification golden fixture parses");
7930 assert_eq!(
7931 golden.schema_version,
7932 "rch.golden.classification_non_build_arguments.v1"
7933 );
7934
7935 let mut actual_results = Vec::with_capacity(golden.cases.len());
7936 for case in golden.cases {
7937 let result = classify_command(&case.command);
7938 actual_results.push(serde_json::json!({
7939 "name": case.name,
7940 "is_compilation": result.is_compilation,
7941 "kind": result.kind,
7942 "reason": result.reason.as_ref(),
7943 }));
7944 }
7945 assert_golden_json(
7946 serde_json::Value::Array(actual_results),
7947 &golden.expected_results,
7948 FIXTURE_PATH,
7949 );
7950 }
7951
7952 #[test]
7953 fn regression_keyword_filter_still_accepts_normalized_build_invocations() {
7954 let _guard = test_guard!();
7955 let cases = [
7956 ("RUST_BACKTRACE=1 cargo test", CompilationKind::CargoTest),
7957 (
7958 "env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build",
7959 CompilationKind::CargoBuild,
7960 ),
7961 ("sudo -E cargo check", CompilationKind::CargoCheck),
7962 ("/usr/bin/time cargo build", CompilationKind::CargoBuild),
7963 ];
7964
7965 for (cmd, expected_kind) in cases {
7966 let result = classify_command(cmd);
7967 assert!(
7968 result.is_compilation,
7969 "normalized build invocation should compile for {cmd:?}: {:?}",
7970 result
7971 );
7972 assert_eq!(result.kind, Some(expected_kind));
7973 }
7974 }
7975
7976 #[test]
7981 fn regression_compilation_kind_properties() {
7982 let _guard = test_guard!();
7983
7984 assert!(CompilationKind::CargoTest.is_test_command());
7986 assert!(CompilationKind::CargoNextest.is_test_command());
7987 assert!(CompilationKind::CargoBench.is_test_command());
7988 assert!(CompilationKind::BunTest.is_test_command());
7989
7990 assert!(!CompilationKind::CargoBuild.is_test_command());
7992 assert!(!CompilationKind::CargoCheck.is_test_command());
7993 assert!(!CompilationKind::CargoClippy.is_test_command());
7994 assert!(!CompilationKind::CargoDoc.is_test_command());
7995 assert!(!CompilationKind::BunTypecheck.is_test_command());
7996 assert!(!CompilationKind::Gcc.is_test_command());
7997 assert!(!CompilationKind::Gpp.is_test_command());
7998 assert!(!CompilationKind::Clang.is_test_command());
7999 assert!(!CompilationKind::Clangpp.is_test_command());
8000 assert!(!CompilationKind::Make.is_test_command());
8001 assert!(!CompilationKind::CmakeBuild.is_test_command());
8002 assert!(!CompilationKind::Ninja.is_test_command());
8003 assert!(!CompilationKind::Meson.is_test_command());
8004 assert!(!CompilationKind::Rustc.is_test_command());
8005
8006 assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
8008 assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
8009 assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
8010 assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
8011 assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
8012 assert_eq!(CompilationKind::Gpp.command_base(), "g++");
8013 assert_eq!(CompilationKind::Clang.command_base(), "clang");
8014 assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
8015 assert_eq!(CompilationKind::Make.command_base(), "make");
8016 assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
8017 assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
8018 assert_eq!(CompilationKind::Meson.command_base(), "meson");
8019 assert_eq!(CompilationKind::BunTest.command_base(), "bun");
8020 assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
8021 }
8022}
8023
8024#[cfg(test)]
8025mod tests_go_and_typescript {
8026 use super::*;
8027
8028 fn kind_of(cmd: &str) -> Option<CompilationKind> {
8029 classify_command(cmd).kind
8030 }
8031
8032 #[test]
8035 fn go_build_test_vet_are_classified() {
8036 assert_eq!(
8037 kind_of("go build -o bin/app ."),
8038 cfg!(unix).then_some(CompilationKind::GoBuild)
8039 );
8040 assert_eq!(kind_of("go test ./..."), Some(CompilationKind::GoTest));
8041 assert_eq!(kind_of("go vet ./..."), Some(CompilationKind::GoVet));
8042 assert_eq!(
8044 kind_of("go test -tags=e2e ./e2e -run ^TestE2EAtomicAssignment$"),
8045 Some(CompilationKind::GoTest)
8046 );
8047 assert_eq!(
8048 kind_of("go test ./internal/robot ./internal/cli -run TestExitCode"),
8049 Some(CompilationKind::GoTest)
8050 );
8051 }
8052
8053 #[test]
8054 fn go_commands_clear_the_confidence_threshold() {
8055 for cmd in ["go build -o app .", "go test ./...", "go vet ./..."] {
8058 if !cfg!(unix) && cmd.starts_with("go build") {
8059 continue;
8060 }
8061 let c = classify_command(cmd);
8062 assert!(
8063 c.confidence > 0.85,
8064 "{cmd} scored {} which does not clear the 0.85 threshold",
8065 c.confidence
8066 );
8067 }
8068 }
8069
8070 #[test]
8071 fn go_test_is_a_test_command() {
8072 assert!(CompilationKind::GoTest.is_test_command());
8074 assert!(!CompilationKind::GoBuild.is_test_command());
8075 assert!(!CompilationKind::GoVet.is_test_command());
8076 }
8077
8078 #[test]
8081 fn go_build_explicit_outputs_share_one_literal_contract() {
8082 for (command, path, option_index) in [
8083 ("go build -o bin/app ./cmd/app", "bin/app", 2),
8084 ("go build -o=bin/app ./cmd/app", "bin/app", 2),
8085 ("go build '-o=bin/app' main.go", "bin/app", 2),
8086 (
8087 "go build -trimpath -p=2 -o './products/app [dev]*?' .",
8088 "./products/app [dev]*?",
8089 4,
8090 ),
8091 ("go build -ldflags '-s -w' -o app", "app", 4),
8092 ("go build -ldflags '-s -X main.version=v2' -o app", "app", 4),
8093 ("go build '-buildmode=pie' -o app .", "app", 3),
8094 ("go build -tags -o -o app .", "app", 4),
8095 ("env GOFLAGS= go build -o app .", "app", 2),
8096 ] {
8097 let selection = go_build_output(command).unwrap_or_else(|| panic!("{command}"));
8098 assert_eq!(selection.path, path, "{command}");
8099 assert_eq!(selection.option_index, option_index, "{command}");
8100 assert_eq!(
8101 kind_of(command),
8102 cfg!(unix).then_some(CompilationKind::GoBuild),
8103 "{command}"
8104 );
8105 }
8106 }
8107
8108 #[test]
8109 fn go_build_implicit_ambiguous_and_extra_outputs_stay_local() {
8110 for command in [
8111 "go build",
8112 "go build .",
8113 "go build ./...",
8114 "go build main.go",
8115 "go build -o",
8116 "go build -o /tmp/app .",
8117 "go build -o ../app .",
8118 "go build -o . .",
8119 "go build -o bin/ .",
8120 "go build -o - .",
8121 "go build -o one -o two .",
8122 "go build -n -o app .",
8123 "go build -n=true -o app .",
8124 "go build -buildmode=c-shared -o app .",
8125 "go build -buildmode=c-archive -o app .",
8126 "go build -buildmode=plugin -o app .",
8127 "go build -toolexec wrapper -o app .",
8128 "go build -o $OUTPUT .",
8129 "go build -o app* .",
8130 "go build . -o app",
8131 "go build -tags -o app .",
8132 "go build -overlay overlay.json -o app .",
8133 "go build -mod=mod -o app .",
8134 "go build -ldflags '-cpuprofile=cpu.out' -o app .",
8135 "go build -ldflags '-s -w -tmpdir=products' -o app .",
8136 "go build -gcflags '-cpuprofile=cpu.out' -o app .",
8137 "go build -asmflags '-debug' -o app .",
8138 ] {
8139 assert!(go_build_output(command).is_none(), "{command}");
8140 assert_eq!(kind_of(command), None, "{command}");
8141 }
8142 assert!(go_build_output(&format!("go build -o {} .", "x".repeat(65_536))).is_none());
8143 assert!(go_build_output("go build -o 'app\nnext' .").is_none());
8144 }
8145
8146 #[test]
8147 fn go_test_binary_profile_and_exec_outputs_stay_local() {
8148 for command in [
8149 "go test -c ./pkg",
8150 "go test -c=true ./pkg",
8151 "go test '-c=true' ./pkg",
8152 "go test -exec sudo ./pkg",
8153 "go test '-exec=sudo' ./pkg",
8154 "go test -o test.bin ./pkg",
8155 "go test -o=test.bin ./pkg",
8156 "go test '-o=test binary' ./pkg",
8157 "go test -coverprofile=coverage.out ./pkg",
8158 "go test -cpuprofile cpu.out ./pkg",
8159 "go test -memprofile=mem.out ./pkg",
8160 "go test -blockprofile=block.out ./pkg",
8161 "go test -mutexprofile=mutex.out ./pkg",
8162 "go test -trace trace.out ./pkg",
8163 "go test ./pkg -args -test.cpuprofile=cpu.out",
8164 "go test ./pkg '-test.coverprofile=coverage.out'",
8165 "go test -outputdir profiles ./pkg",
8166 "go test -fuzz=FuzzParser ./pkg",
8167 "go test ./pkg -args -test.fuzz FuzzParser",
8168 "go test ./pkg -args -test.fuzzcachedir=fuzz-corpus",
8169 "go test ./pkg -args -test.gocoverdir=coverage",
8170 "go test ./pkg -args '-test.testlogfile=test log'",
8171 ] {
8172 assert_eq!(kind_of(command), None, "{command}");
8173 }
8174 assert_eq!(kind_of("go test c o exec"), Some(CompilationKind::GoTest));
8175 }
8176
8177 #[test]
8178 fn go_state_mutating_subcommands_are_never_intercepted() {
8179 for cmd in [
8180 "go get github.com/foo/bar",
8181 "go mod tidy",
8182 "go mod download",
8183 "go install ./cmd/tool",
8184 "go run main.go",
8185 "go generate ./...",
8186 "go fmt ./...",
8187 "go clean -cache",
8188 "go work sync",
8189 "go env GOPATH",
8190 "go version",
8191 "go list ./...",
8192 "go tool pprof",
8193 ] {
8194 assert_eq!(kind_of(cmd), None, "{cmd} must never be intercepted");
8195 }
8196 }
8197
8198 #[test]
8199 fn go_prefixed_binaries_are_not_matched() {
8200 assert_eq!(kind_of("gofmt -l ."), None);
8202 assert_eq!(kind_of("golangci-lint run"), None);
8203 }
8204
8205 #[test]
8208 fn tsc_noemit_is_classified() {
8209 assert_eq!(kind_of("tsc --noEmit"), Some(CompilationKind::Tsc));
8210 assert_eq!(kind_of("npx tsc --noEmit"), Some(CompilationKind::Tsc));
8211 assert_eq!(
8213 kind_of("npx tsc --noEmit --incremental false"),
8214 Some(CompilationKind::Tsc)
8215 );
8216 assert_eq!(kind_of("tsc --noemit"), Some(CompilationKind::Tsc));
8218 }
8219
8220 #[test]
8221 fn emitting_tsc_stays_local() {
8222 assert_eq!(kind_of("tsc"), None);
8225 assert_eq!(kind_of("tsc -p tsconfig.json"), None);
8226 assert_eq!(kind_of("npx tsc --outDir dist"), None);
8227 }
8228
8229 #[test]
8230 fn tsc_watch_is_not_intercepted() {
8231 assert_eq!(kind_of("tsc --noEmit --watch"), None);
8232 assert_eq!(kind_of("tsc --noEmit -w"), None);
8233 }
8234
8235 #[test]
8236 fn tsc_trivial_forms_are_not_intercepted() {
8237 assert_eq!(kind_of("tsc --version"), None);
8238 assert_eq!(kind_of("tsc --init"), None);
8239 }
8240
8241 #[test]
8242 fn npx_non_tsc_is_not_intercepted() {
8243 assert_eq!(kind_of("npx jest"), None);
8246 assert_eq!(kind_of("npx prettier --write ."), None);
8247 }
8248
8249 #[test]
8252 fn command_base_is_allowlisted() {
8253 use crate::types::ExecutionConfig;
8254 let exec = ExecutionConfig::default();
8255 for kind in [
8256 CompilationKind::GoBuild,
8257 CompilationKind::GoTest,
8258 CompilationKind::GoVet,
8259 CompilationKind::Tsc,
8260 ] {
8261 let base = kind.command_base();
8262 assert!(
8263 exec.is_allowed(base),
8264 "{base} missing from the execution allowlist — the hook would fail \
8265 open to LOCAL execution, silently losing offload"
8266 );
8267 }
8268 assert_eq!(CompilationKind::GoBuild.command_base(), "go");
8269 assert_eq!(CompilationKind::Tsc.command_base(), "tsc");
8270 }
8271}