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