Skip to main content

rch_common/
patterns.rs

1//! Command classification patterns for identifying compilation commands.
2//!
3//! Implements the 5-tier classification system:
4//! - Tier 0: Instant reject (non-Bash, empty)
5//! - Tier 1: Structure analysis (pipes, redirects, background)
6//! - Tier 2: SIMD keyword filter
7//! - Tier 3: Negative pattern check
8//! - Tier 4: Full classification with confidence
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::borrow::Cow;
13
14// Tier name constants (avoid allocations on hot path).
15const 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
21/// Keywords that indicate a potential compilation command.
22/// Used for SIMD-accelerated quick filtering (Tier 2).
23pub static COMPILATION_KEYWORDS: &[&str] = &[
24    "cargo",
25    // The standalone cargo-zigbuild binary (hyphenated) does NOT word-match
26    // "cargo", so it needs its own Tier-2 keyword or it is rejected before
27    // classify_full's `cargo-zigbuild` route ever runs.
28    "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
50/// Commands that should NEVER be intercepted, even if they contain compilation keywords.
51/// These either modify local state or have dependencies on local execution.
52pub static NEVER_INTERCEPT: &[&str] = &[
53    // Cargo commands that modify local state or shouldn't be intercepted
54    "cargo install",
55    // `cargo publish` is checked in classify_cargo: only an explicit dry-run
56    // with package verification enabled is a remote compilation command.
57    "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    // Compiler version checks
71    "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 package management - MUST NOT intercept (modifies local state)
81    "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 execution that shouldn't be intercepted
92    "bun run",
93    "bun build",
94    "bun --help",
95    "bun -h",
96    "bun --version",
97    "bun -v",
98    // Bun dev/repl - require local interactivity
99    "bun dev",
100    "bun repl",
101    // cargo-nextest commands that shouldn't be intercepted
102    "cargo nextest list",    // Lists tests only, doesn't run them
103    "cargo nextest archive", // Creates test archives
104    "cargo nextest show",    // Shows config/setup info
105    // Go commands that mutate local state, run programs, or are trivially fast.
106    // Only `go build` / `go test` / `go vet` are offloadable (see classify_go).
107    "go get",      // mutates go.mod / module cache
108    "go mod",      // mutates go.mod / go.sum
109    "go work",     // mutates go.work
110    "go install",  // writes a binary into local GOBIN
111    "go run",      // runs a program locally (may be interactive / long-lived)
112    "go generate", // executes arbitrary local codegen
113    "go fmt",      // rewrites local source files
114    "go clean",    // mutates local caches
115    "go env",      // trivial; also used to *set* local env
116    "go doc",      // trivial lookup
117    "go version",  // trivial
118    "go tool",     // arbitrary tool execution
119    "go list",     // metadata query; cheap, and agents parse it locally
120    // TypeScript: version/init are trivial or mutate local state
121    "tsc --version",
122    "tsc -v",
123    "tsc --init",
124];
125
126/// Result of command classification.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
128pub struct Classification {
129    /// Whether this is a compilation command.
130    pub is_compilation: bool,
131    /// Confidence score (0.0-1.0).
132    pub confidence: f64,
133    /// The kind of compilation if detected.
134    pub kind: Option<CompilationKind>,
135    /// Reason for the classification decision.
136    pub reason: Cow<'static, str>,
137    /// For compound commands (e.g., "cd /path && cargo build"), this contains
138    /// the prefix that should run before the compilation ("cd /path && ").
139    /// None for simple commands.
140    pub command_prefix: Option<String>,
141    /// For compound commands, this contains the extracted compilation command
142    /// (e.g., "cargo build --release"). None for simple commands where the
143    /// entire input is the compilation command.
144    pub extracted_command: Option<String>,
145}
146
147/// Decision outcome for a classification tier.
148#[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/// Detailed result for a single classification tier.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
157pub struct ClassificationTier {
158    /// Tier index (0-4).
159    pub tier: u8,
160    /// Tier name.
161    pub name: Cow<'static, str>,
162    /// Decision for this tier.
163    pub decision: TierDecision,
164    /// Reason for the decision.
165    pub reason: Cow<'static, str>,
166}
167
168/// Detailed classification results with per-tier decisions.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
170pub struct ClassificationDetails {
171    /// Original command string.
172    pub original: String,
173    /// Normalized command string (wrappers stripped).
174    pub normalized: String,
175    /// Per-tier decisions.
176    pub tiers: Vec<ClassificationTier>,
177    /// Final classification result.
178    pub classification: Classification,
179}
180
181impl Classification {
182    /// Create a non-compilation classification.
183    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    /// Create a compilation classification.
195    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    /// Create a compilation classification for a compound command.
211    /// The prefix contains everything before the compilation command (including the final "&&" or ";").
212    /// The extracted_command is the actual compilation command to intercept.
213    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/// Kind of compilation command detected.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
233#[serde(rename_all = "snake_case")]
234pub enum CompilationKind {
235    // Rust commands
236    /// cargo build, cargo test, cargo check, etc.
237    CargoBuild,
238    CargoTest,
239    CargoCheck,
240    CargoClippy,
241    CargoDoc,
242    /// cargo nextest run - next-generation test runner
243    CargoNextest,
244    /// cargo bench - run benchmarks
245    CargoBench,
246    /// `cargo zigbuild` / `cargo-zigbuild zigbuild` — a cargo build that uses
247    /// zig as the C compiler + cross-linker (`cargo-zigbuild`). It IS a Rust
248    /// build (RequiredRuntime::Rust), but additionally needs `zig` +
249    /// `cargo-zigbuild` on the worker and the requested `--target` rustup std.
250    /// Unlike Go/Nix it PRODUCES a real binary under `target/<triple>/<profile>/`,
251    /// so its artifacts must sync back (see artifact_patterns).
252    CargoZigbuild,
253    /// rustc invocation
254    Rustc,
255
256    // C/C++ commands
257    /// GCC compilation
258    Gcc,
259    /// G++ compilation
260    Gpp,
261    /// Clang compilation
262    Clang,
263    /// Clang++ compilation
264    Clangpp,
265
266    // Build systems
267    /// make build
268    Make,
269    /// cmake --build
270    CmakeBuild,
271    /// ninja build
272    Ninja,
273    /// meson compile
274    Meson,
275
276    // Bun commands
277    /// bun test - Runs Bun's built-in test runner
278    BunTest,
279    /// bun typecheck - Runs TypeScript type checking
280    BunTypecheck,
281
282    // Go commands
283    //
284    // Builds require an explicit file output and durable artifact delivery.
285    // Even ./... can select one main package and emit an implicit executable.
286    /// `go build -o <file>` (required output returned through staged retrieval)
287    GoBuild,
288    /// `go test ./...`
289    GoTest,
290    /// `go vet ./...`
291    GoVet,
292
293    // TypeScript
294    /// `tsc --noEmit` / `npx tsc --noEmit` (typecheck only; emitting runs stay local)
295    Tsc,
296
297    // Nix commands
298    /// `nix build` / `nix-build` (derivation build), `nix flake check`
299    /// (evaluate + build all outputs), and the non-interactive
300    /// `nix develop -c <cmd>` / `nix shell -c <cmd>` forms.
301    ///
302    /// Nix outputs live in the worker's `/nix/store` behind a `result` symlink
303    /// that is meaningless on a nix-less local host, so these run as
304    /// streaming, no-artifact-return commands (exit status is the payload).
305    NixBuild,
306
307    // Explicit job admission
308    /// Arbitrary non-compilation workload admitted explicitly via
309    /// `rch exec --job` (sharded tests, fuzzing, benchmarks, mutation testing).
310    ///
311    /// NEVER produced by [`classify_command`] or the PreToolUse hook — the sole
312    /// constructor is the explicit `--job` flag on `rch exec`, so the hook can
313    /// never auto-delegate a command the classifier did not intercept. A job
314    /// rides the normal selection/sync/execute/heartbeat/release rails, syncs
315    /// no artifacts back automatically (exit status is the payload in this
316    /// phase), and surfaces the remote exit status verbatim without
317    /// toolchain/worker-environment reinterpretation.
318    Job,
319}
320
321impl CompilationKind {
322    /// Returns true if this is a test-related command.
323    ///
324    /// Test commands have special cache affinity behavior because test binaries
325    /// (e.g., target/debug/deps/) are expensive to compile and benefit more
326    /// from warm caches than regular builds.
327    pub fn is_test_command(&self) -> bool {
328        matches!(
329            self,
330            CompilationKind::CargoTest
331                | CompilationKind::CargoNextest
332                | CompilationKind::CargoBench
333                | CompilationKind::BunTest
334                | CompilationKind::GoTest
335        )
336    }
337
338    /// Returns the base command name for allowlist matching (bd-785w).
339    ///
340    /// This is the primary executable name that should appear in the
341    /// execution allowlist configuration.
342    pub fn command_base(&self) -> &'static str {
343        match self {
344            // Rust commands
345            CompilationKind::CargoBuild
346            | CompilationKind::CargoTest
347            | CompilationKind::CargoCheck
348            | CompilationKind::CargoClippy
349            | CompilationKind::CargoDoc
350            | CompilationKind::CargoBench => "cargo",
351            CompilationKind::CargoNextest => "cargo", // cargo nextest, base is still cargo
352            // `cargo zigbuild` runs through cargo; the standalone `cargo-zigbuild`
353            // binary is normalized to this kind too. Base "cargo" so it matches
354            // the existing cargo allowlist entry.
355            CompilationKind::CargoZigbuild => "cargo",
356            CompilationKind::Rustc => "rustc",
357            // C/C++ commands
358            CompilationKind::Gcc => "gcc",
359            CompilationKind::Gpp => "g++",
360            CompilationKind::Clang => "clang",
361            CompilationKind::Clangpp => "clang++",
362            // Build systems
363            CompilationKind::Make => "make",
364            CompilationKind::CmakeBuild => "cmake",
365            CompilationKind::Ninja => "ninja",
366            CompilationKind::Meson => "meson",
367            // Bun commands
368            CompilationKind::BunTest | CompilationKind::BunTypecheck => "bun",
369            // Nix commands. Both `nix build` and the legacy `nix-build` route
370            // through this kind; the canonical allowlist base name is "nix".
371            CompilationKind::NixBuild => "nix",
372            // Go commands
373            CompilationKind::GoBuild | CompilationKind::GoTest | CompilationKind::GoVet => "go",
374            // TypeScript. Both `tsc` and `npx tsc` route through this kind; the
375            // canonical allowlist base name is "tsc".
376            CompilationKind::Tsc => "tsc",
377            // Explicit job admission has no single executable base: a job's
378            // argv[0] is arbitrary. The hook-path execution-allowlist gate can
379            // never observe this kind (only `rch exec --job` constructs it), so
380            // this token exists to keep the mapping total, not to match an
381            // allowlist entry.
382            CompilationKind::Job => "job",
383        }
384    }
385}
386
387/// Classify a shell command.
388///
389/// Implements the 5-tier classification system for maximum precision with
390/// minimal latency on non-compilation commands.
391///
392/// Pure `&&` chains with a compilation command as the final segment are
393/// accepted and rewritten to wrap only that final command. Other multi-command
394/// strings are rejected by Tier 1 structure analysis to prevent unsafe partial
395/// interception.
396pub fn classify_command(cmd: &str) -> Classification {
397    classify_command_inner(cmd, 0)
398}
399
400/// Detect a command that *would* be a compilation command if it weren't for an
401/// (unsafe / unhandled) pipe/redirect/background/subshell structure — i.e. one
402/// that the classifier declines purely because of its shell structure rather
403/// than because the underlying program is not a compiler (issue #24, item 3).
404///
405/// Returns the structural reason (e.g. "piped command", "subshell execution")
406/// when the leading program token IS a known compilation tool but the command
407/// is declined by Tier 1 structure analysis. Returns `None` otherwise (so the
408/// hint is only surfaced for genuine structure-driven declines, never for
409/// `ls | grep cargo` and friends).
410///
411/// Callers (the hook) can use this to emit an observable hint/warn when running
412/// on an orchestrator with `force_remote = true`, so the silent local fallback
413/// becomes visible.
414pub fn declined_compilation_due_to_structure(cmd: &str) -> Option<&'static str> {
415    let trimmed = cmd.trim();
416    if trimmed.is_empty() {
417        return None;
418    }
419    // If the full classifier already accepts (and will offload) it, there is no
420    // decline to report.
421    if classify_command(trimmed).is_compilation {
422        return None;
423    }
424    // Only report when Tier 1 structure analysis is the reason AND the leading
425    // program word is a recognized compilation tool.
426    let reason = check_structure(trimmed)?;
427    let normalized = normalize_command(trimmed);
428    if starts_with_compilation_command(normalized.as_ref()) {
429        Some(reason)
430    } else {
431        None
432    }
433}
434
435/// Maximum recursion depth for multi-command splitting.
436/// Depth 0 = top-level call that may split; depth 1 = sub-command (no further splitting).
437#[allow(dead_code)] // Reserved for future multi-command classification
438const MAX_CLASSIFY_DEPTH: u8 = 1;
439
440/// Maximum command length for multi-command splitting (10 KB).
441/// Commands longer than this skip splitting and are classified as a single command.
442#[allow(dead_code)] // Reserved for future multi-command classification
443const MAX_SPLIT_INPUT_LEN: usize = 10 * 1024;
444
445fn classify_command_inner(cmd: &str, depth: u8) -> Classification {
446    let cmd = cmd.trim();
447
448    // Tier 0: Instant reject - empty command
449    if cmd.is_empty() {
450        return Classification::not_compilation("empty command");
451    }
452
453    // Tier 0.5: Compound command handling (only at depth 0)
454    // For commands like "cd /path && cargo build", extract and classify the last segment.
455    // We only handle && chains (not || or ;) at depth 0 to avoid complex rewriting.
456    if depth == 0
457        && cmd.len() < MAX_SPLIT_INPUT_LEN
458        && let Some(result) = try_classify_compound_command(cmd)
459    {
460        return result;
461    }
462
463    // Tier 0.6: Wrapper / benign-suffix handling (only at depth 0).
464    //
465    // Handles the dominant agent invocation forms that would otherwise be
466    // rejected by Tier 1 structure analysis and silently fall back to local
467    // compilation (issue #24):
468    //   - `bash -c "cargo test ..."` / `sh -c "cargo test ..."` (extract inner)
469    //   - `cargo check --lib 2>&1 | head`  (pipe into a benign pager)
470    //   - `cargo test ... > file 2>&1`     (benign stdout/stderr file redirect)
471    //   - `cargo test ... &`               (backgrounding)
472    //
473    // We stay conservative: only the leading compilation command is offloaded,
474    // and the benign trailing structure is re-attached to the offloaded
475    // command's output stream (`rch exec -- <cmd> <suffix>`). We never offload
476    // when the pipeline transforms the command's INPUT or chains another build.
477    if depth == 0 && cmd.len() < MAX_SPLIT_INPUT_LEN {
478        if let Some(result) = try_classify_wrapped_command(cmd) {
479            return result;
480        }
481        if let Some(result) = try_classify_benign_suffix_command(cmd) {
482            return result;
483        }
484    }
485
486    // Tier 1: Structure analysis - reject complex shell structures
487    if let Some(reason) = check_structure(cmd) {
488        return Classification::not_compilation(reason);
489    }
490
491    // Normalize command for Tier 3 and 4
492    let normalized_cow = normalize_command(cmd);
493    let normalized = normalized_cow.as_ref();
494
495    // Tier 2: quick check for compilation commands.
496    //
497    // This intentionally checks the normalized command word, not arbitrary
498    // arguments. A Beads comment like `br comments add ... "cargo test ..."`
499    // mentions compilation text, but it is not invoking a compiler.
500    if !starts_with_compilation_command(normalized) {
501        return Classification::not_compilation("no compilation keyword");
502    }
503
504    // Tier 3: Negative pattern check - never intercept these
505    // Performance: use static string to avoid format! allocation on hot rejection path
506    for pattern in NEVER_INTERCEPT {
507        if let Some(rest) = normalized.strip_prefix(pattern) {
508            // Ensure exact match or boundary match (e.g. "cargo clean" matches "cargo clean"
509            // or "cargo clean ", but NOT "cargo cleanup")
510            if rest.is_empty() || rest.starts_with(' ') {
511                return Classification::not_compilation("matches never-intercept pattern");
512            }
513        }
514    }
515
516    // Tier 4: Full classification
517    classify_full(normalized)
518}
519
520/// Try to classify a compound command (e.g., "cd /path && cargo build").
521///
522/// For && chains, we check if the LAST segment is a compilation command.
523/// If so, we return a compound_compilation with the prefix (everything before
524/// the compilation) and the extracted compilation command.
525///
526/// This allows commands like:
527/// - "cd /path && cargo build" → prefix="cd /path && ", extracted="cargo build"
528/// - "touch file && cargo test" → prefix="touch file && ", extracted="cargo test"
529///
530/// We only handle && chains, not || or ; for safety (those have different semantics).
531/// We also reject commands with pipes, redirects, or subshells in ANY segment.
532fn try_classify_compound_command(cmd: &str) -> Option<Classification> {
533    // Quick check: must contain && but not other problematic operators
534    if !cmd.contains("&&") {
535        return None;
536    }
537
538    // Reject if command contains pipes, redirects, or subshells (anywhere)
539    // These make command rewriting unsafe
540    if cmd.contains('|') {
541        return None; // Pipe or || chain
542    }
543    if has_file_redirect(cmd) {
544        return None; // File redirects (but NOT fd-to-fd like 2>&1)
545    }
546    if cmd.contains('(') || cmd.contains('`') || cmd.contains("$(") {
547        return None; // Subshells
548    }
549    if cmd.contains(';') {
550        return None; // Only handle pure && chains for now
551    }
552
553    // Split on && (simple split, respects quotes via split_and_chain)
554    let segments = split_and_chain(cmd)?;
555    if segments.is_empty() {
556        return None;
557    }
558
559    // Get the last segment and classify it
560    let last_segment = segments.last()?.trim();
561    if last_segment.is_empty() {
562        return None;
563    }
564
565    // Classify the last segment at depth 1 (prevents infinite recursion)
566    let last_classification = classify_command_inner(last_segment, 1);
567
568    if !last_classification.is_compilation {
569        return None;
570    }
571
572    if segments.len() == 1 {
573        // No prefix, just the command itself
574        return None;
575    }
576
577    // Build the prefix: every earlier segment, joined back with " && ".
578    //
579    // Issue #50: earlier segments that are themselves compilations are wrapped
580    // with `rch exec -- ` here, so the hook's `{prefix}rch exec -- {extracted}`
581    // rewrite offloads EVERY build in the chain. Previously only the final
582    // segment was wrapped and `cargo build --release && cargo test` compiled
583    // the first half locally on the dispatcher with no summary line at all.
584    // Segments that do not classify (cd, touch, implicit `go build`, ...)
585    // are re-emitted verbatim.
586    let mut prefix = String::with_capacity(cmd.len() + segments.len() * EXEC_REWRITE_PREFIX.len());
587    for segment in &segments[..segments.len() - 1] {
588        if classify_command_inner(segment, 1).is_compilation {
589            prefix.push_str(EXEC_REWRITE_PREFIX);
590        }
591        prefix.push_str(segment);
592        prefix.push_str(" && ");
593    }
594
595    // Return compound classification
596    Some(Classification::compound_compilation(
597        last_classification.kind?,
598        last_classification.confidence,
599        "compound command with compilation suffix",
600        prefix,
601        last_segment.to_string(),
602    ))
603}
604
605/// The rewrite the hook applies to an offloaded segment. Kept here so the
606/// compound classifier and the hook agree byte-for-byte on the wrapped form.
607const EXEC_REWRITE_PREFIX: &str = "rch exec -- ";
608
609/// Segments of an `&&` chain (all but the last) that *look* like a compilation
610/// command but were not classified as one, and therefore stay local when the
611/// chain is rewritten by the hook (issue #50).
612///
613/// Example: `go build ./cmd && cargo test` — an implicit Go output cannot yet
614/// be enumerated, so the hook rewrites only `cargo test`;
615/// this returns `["go build ./cmd"]` so the hook can say so instead of
616/// staying silent.
617/// Returns an empty vector for anything that is not a plain `&&` chain.
618pub fn compound_local_compilation_segments(cmd: &str) -> Vec<String> {
619    let trimmed = cmd.trim();
620    if trimmed.len() >= MAX_SPLIT_INPUT_LEN {
621        return Vec::new();
622    }
623    let Some(segments) = split_and_chain(trimmed) else {
624        return Vec::new();
625    };
626    if segments.len() < 2 {
627        return Vec::new();
628    }
629    segments[..segments.len() - 1]
630        .iter()
631        .filter(|segment| {
632            !classify_command_inner(segment, 1).is_compilation
633                && starts_with_compilation_command(normalize_command(segment).as_ref())
634        })
635        .map(|segment| (*segment).to_string())
636        .collect()
637}
638
639/// Benign pager/sink commands that may sit on the right-hand side of a pipe
640/// without transforming the build's *input*. Piping a compilation command's
641/// output into one of these is safe to offload — the offloaded command's
642/// output is simply re-piped into the same pager locally.
643///
644/// We intentionally keep this list small and conservative. Commands that
645/// would alter exit status semantics in a surprising way, or that consume
646/// and re-emit transformed data feeding a subsequent build, are excluded.
647const BENIGN_PAGER_COMMANDS: &[&str] = &[
648    "tee", "head", "cat", "grep", "less", "more", "tail", "wc", "rg",
649];
650
651/// Try to classify a `bash -c "<cmd>"` / `sh -c "<cmd>"` wrapper by extracting
652/// and classifying the inner command (issue #24, item 2).
653///
654/// On success the inner command is offloaded directly (the shell wrapper is
655/// dropped, since `rch exec -- <inner>` runs the same program). We mirror the
656/// `cd X && ...` compound handling: the inner command is classified at depth 0
657/// so it can itself be a compound / benign-suffix / further-wrapped command.
658///
659/// Conservative: we only handle the simple, single-quoted-string form
660/// `bash -c "<inner>"` with no trailing tokens after the inner string and no
661/// extra flags before `-c`. Anything more exotic falls through to Tier 1.
662fn try_classify_wrapped_command(cmd: &str) -> Option<Classification> {
663    let cmd = cmd.trim();
664
665    // Match `bash -c` or `sh -c` (also /bin/bash, /usr/bin/sh, etc.) followed by
666    // a single quoted argument that is the entire remainder of the command.
667    let rest = strip_shell_dash_c_prefix(cmd)?;
668    let rest = rest.trim_start();
669
670    // The remainder must be a single quoted string and nothing else after it.
671    let inner = extract_single_quoted_argument(rest)?;
672    if inner.trim().is_empty() {
673        return None;
674    }
675
676    // Classify the inner command at depth 0 so it can itself be compound or
677    // carry a benign suffix (e.g. `bash -c "cd x && cargo test > log 2>&1"`).
678    let inner_classification = classify_command_inner(&inner, 0);
679    if !inner_classification.is_compilation {
680        return None;
681    }
682
683    // Rebuild the command that should actually be offloaded. If the inner
684    // command was itself compound/suffixed, prefer its rewritten form so the
685    // prefix/suffix is preserved; otherwise the inner command is offloaded
686    // verbatim.
687    let (prefix, extracted) = match (
688        &inner_classification.command_prefix,
689        &inner_classification.extracted_command,
690    ) {
691        (Some(p), Some(e)) => (p.clone(), e.clone()),
692        _ => (String::new(), inner.trim().to_string()),
693    };
694
695    Some(Classification::compound_compilation(
696        inner_classification.kind?,
697        inner_classification.confidence,
698        "shell -c wrapper with compilation command",
699        prefix,
700        extracted,
701    ))
702}
703
704/// Strip a leading `bash -c` / `sh -c` (with optional absolute path) and return
705/// the remainder of the command. Returns `None` if the prefix does not match.
706fn strip_shell_dash_c_prefix(cmd: &str) -> Option<&str> {
707    // Split the leading whitespace-delimited words to inspect the shell + flag,
708    // but return a slice of the ORIGINAL string for the remainder so quoting in
709    // the inner argument is preserved.
710    let trimmed = cmd.trim_start();
711    let mut idx = 0;
712    let bytes = trimmed.as_bytes();
713
714    // Word 1: the shell executable.
715    let word1_end = next_word_end(bytes, idx)?;
716    let shell = &trimmed[idx..word1_end];
717    if !shell_basename_is(shell, "bash") && !shell_basename_is(shell, "sh") {
718        return None;
719    }
720    idx = skip_spaces(bytes, word1_end);
721
722    // Word 2: must be exactly `-c`.
723    let word2_end = next_word_end(bytes, idx)?;
724    if &trimmed[idx..word2_end] != "-c" {
725        return None;
726    }
727    idx = skip_spaces(bytes, word2_end);
728
729    if idx >= trimmed.len() {
730        return None;
731    }
732    Some(&trimmed[idx..])
733}
734
735/// Return true if `word`'s basename (after the last `/`) equals `name`.
736fn shell_basename_is(word: &str, name: &str) -> bool {
737    word.rsplit('/').next().map(|b| b == name).unwrap_or(false)
738}
739
740/// Index just past the end of the (unquoted) word starting at `start`.
741/// Returns `None` if `start` is already at end of input.
742fn next_word_end(bytes: &[u8], start: usize) -> Option<usize> {
743    if start >= bytes.len() || bytes[start].is_ascii_whitespace() {
744        return None;
745    }
746    let mut i = start;
747    while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
748        i += 1;
749    }
750    Some(i)
751}
752
753/// Skip ASCII spaces/tabs starting at `start`, returning the next non-space index.
754fn skip_spaces(bytes: &[u8], start: usize) -> usize {
755    let mut i = start;
756    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
757        i += 1;
758    }
759    i
760}
761
762/// Extract the contents of a single `"..."` or `'...'` quoted argument that
763/// spans the ENTIRE input (modulo surrounding whitespace). Returns `None` if
764/// the input is not exactly one quoted string (e.g. it has trailing tokens, or
765/// embedded unescaped quotes of the same kind that would terminate early).
766fn extract_single_quoted_argument(s: &str) -> Option<String> {
767    let s = s.trim();
768    let bytes = s.as_bytes();
769    if bytes.len() < 2 {
770        return None;
771    }
772    let quote = bytes[0];
773    if quote != b'"' && quote != b'\'' {
774        return None;
775    }
776
777    let mut out = String::with_capacity(s.len());
778    let mut i = 1;
779    while i < bytes.len() {
780        let b = bytes[i];
781        // Backslash escaping only applies inside double quotes (POSIX).
782        if quote == b'"' && b == b'\\' && i + 1 < bytes.len() {
783            out.push(bytes[i + 1] as char);
784            i += 2;
785            continue;
786        }
787        if b == quote {
788            // Closing quote: everything after must be whitespace only.
789            if s[i + 1..].trim().is_empty() {
790                return Some(out);
791            }
792            return None;
793        }
794        out.push(b as char);
795        i += 1;
796    }
797    // Unterminated quote.
798    None
799}
800
801/// Try to classify a compilation command followed ONLY by a benign trailing
802/// structure (issue #24, item 1):
803///   - backgrounding: `cargo test ... &`
804///   - benign stdout/stderr file redirect: `cargo test ... > file 2>&1`
805///   - pipe into a benign pager: `cargo check --lib 2>&1 | head`
806///
807/// The leading command (everything before the first top-level structural
808/// operator) is classified as a normal command. If it is a compilation
809/// command, the benign suffix is re-attached to the offloaded command so the
810/// shell re-applies it to `rch exec`'s output:
811///   `cargo check --lib 2>&1 | head` → `rch exec -- cargo check --lib 2>&1 | head`
812///
813/// Conservative rejections (fall through to Tier 1):
814///   - input redirects (`< file`) — transform the command's INPUT
815///   - pipes into anything not on `BENIGN_PAGER_COMMANDS` (could chain a build
816///     or transform data)
817///   - any subshell / command substitution / `&&` / `||` / `;` chaining
818///   - more than one pipe stage (keep it simple and safe)
819fn try_classify_benign_suffix_command(cmd: &str) -> Option<Classification> {
820    let cmd = cmd.trim();
821
822    // This helper runs before the general Tier 1 structure check. Reject line
823    // breaks here too, or a redirect/pipe on the first line can make a second
824    // shell command look like part of an otherwise benign suffix.
825    if cmd.bytes().any(|byte| matches!(byte, b'\n' | b'\r')) {
826        return None;
827    }
828
829    // Bail out fast on chaining / subshell / capture / input-redirect forms;
830    // those are handled (or rejected) elsewhere and are never "benign suffix".
831    if cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
832        return None;
833    }
834    if cmd.contains('(') || cmd.contains('`') || cmd.contains("$(") {
835        return None;
836    }
837
838    // Locate the first top-level (unquoted) structural boundary: a single `|`,
839    // a standalone trailing `&`, or a file-redirect `>` / `<`.
840    let boundary = find_first_structural_boundary(cmd)?;
841    let (leading_raw, suffix_raw) = cmd.split_at(boundary.byte_pos);
842    let leading = leading_raw.trim();
843    let suffix = suffix_raw; // keep the operator + whatever follows
844    if leading.is_empty() {
845        return None;
846    }
847
848    // Validate the suffix is benign for this boundary kind.
849    if !suffix_is_benign(boundary.kind, suffix) {
850        return None;
851    }
852
853    // Classify the leading command at depth 1 so it is treated as a single
854    // command (no further splitting / wrapper handling).
855    let leading_classification = classify_command_inner(leading, 1);
856    if !leading_classification.is_compilation {
857        return None;
858    }
859
860    // Preserve the validated command verbatim. Rebuilding it with normalized
861    // whitespace changes the meaning of adjacent file-descriptor redirects:
862    // `cargo build 2>errors.txt` must not become `cargo build 2 >errors.txt`.
863    let extracted = cmd.to_string();
864
865    Some(Classification::compound_compilation(
866        leading_classification.kind?,
867        leading_classification.confidence,
868        "compilation command with benign trailing redirect/pipe/background",
869        String::new(),
870        extracted,
871    ))
872}
873
874/// Kind of structural boundary found at the top level of a command.
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876enum BoundaryKind {
877    /// A single `|` pipe.
878    Pipe,
879    /// A standalone trailing `&` (backgrounding).
880    Background,
881    /// A `>` / `>>` output file redirect.
882    OutputRedirect,
883    /// A `<` input file redirect (always rejected for offload).
884    InputRedirect,
885}
886
887struct StructuralBoundary {
888    byte_pos: usize,
889    kind: BoundaryKind,
890}
891
892/// Find the first top-level (unquoted) structural boundary in `cmd`, skipping
893/// fd-to-fd redirects like `2>&1` / `>&2` which are NOT boundaries (they belong
894/// to the preceding command and are safe to offload inline).
895fn find_first_structural_boundary(cmd: &str) -> Option<StructuralBoundary> {
896    let bytes = cmd.as_bytes();
897    let len = bytes.len();
898    let mut in_single = false;
899    let mut in_double = false;
900    let mut escaped = false;
901    let mut i = 0;
902
903    while i < len {
904        let b = bytes[i];
905        if escaped {
906            escaped = false;
907            i += 1;
908            continue;
909        }
910        if b == b'\\' && !in_single {
911            escaped = true;
912            i += 1;
913            continue;
914        }
915        if b == b'\'' && !in_double {
916            in_single = !in_single;
917            i += 1;
918            continue;
919        }
920        if b == b'"' && !in_single {
921            in_double = !in_double;
922            i += 1;
923            continue;
924        }
925        if in_single || in_double {
926            i += 1;
927            continue;
928        }
929
930        match b {
931            b'|' => {
932                // `||` is chaining and is handled/rejected by the caller's guard,
933                // but be defensive.
934                if i + 1 < len && bytes[i + 1] == b'|' {
935                    return None;
936                }
937                return Some(StructuralBoundary {
938                    byte_pos: i,
939                    kind: BoundaryKind::Pipe,
940                });
941            }
942            b'&' => {
943                // `&&` is chaining (rejected by caller). A standalone `&` is
944                // backgrounding. `2>&1` / `>&2` are fd dups: prev byte is `>`.
945                if i + 1 < len && bytes[i + 1] == b'&' {
946                    return None;
947                }
948                let prev = if i > 0 { bytes[i - 1] } else { 0 };
949                if prev == b'>' {
950                    // part of an fd-to-fd redirect, not a boundary
951                    i += 1;
952                    continue;
953                }
954                return Some(StructuralBoundary {
955                    byte_pos: i,
956                    kind: BoundaryKind::Background,
957                });
958            }
959            b'>' => {
960                // Skip fd-to-fd redirects `>&` (e.g. 2>&1) — not a boundary.
961                if i + 1 < len && bytes[i + 1] == b'&' {
962                    i += 2;
963                    if i < len && bytes[i].is_ascii_digit() {
964                        i += 1;
965                    }
966                    continue;
967                }
968                // `>(` process substitution — leave for Tier 1 to reject.
969                if i + 1 < len && bytes[i + 1] == b'(' {
970                    return None;
971                }
972                return Some(StructuralBoundary {
973                    byte_pos: i,
974                    kind: BoundaryKind::OutputRedirect,
975                });
976            }
977            b'<' => {
978                if i + 1 < len && bytes[i + 1] == b'(' {
979                    return None;
980                }
981                return Some(StructuralBoundary {
982                    byte_pos: i,
983                    kind: BoundaryKind::InputRedirect,
984                });
985            }
986            _ => {}
987        }
988        i += 1;
989    }
990    None
991}
992
993/// Validate that the trailing structure (operator + remainder) is benign for
994/// offloading, given the kind of the first boundary.
995fn suffix_is_benign(kind: BoundaryKind, suffix: &str) -> bool {
996    // The suffix must not itself contain a further top-level boundary that we
997    // haven't validated (e.g. `> log | tee x` or `| head | build`). We allow at
998    // most ONE structural stage by requiring that, after the first boundary,
999    // the remainder has no additional unquoted boundary — EXCEPT fd-to-fd dups.
1000    match kind {
1001        BoundaryKind::InputRedirect => false,
1002        BoundaryKind::Background => {
1003            // `cmd &` — only valid if `&` is the LAST meaningful token.
1004            // Reject `cmd & other` (a second command after backgrounding).
1005            let after = suffix.trim_start_matches('&').trim();
1006            after.is_empty()
1007        }
1008        BoundaryKind::OutputRedirect => {
1009            // `> file 2>&1`, `>> file`, `> a.log` — must not contain a further
1010            // pipe / background / input redirect / chaining.
1011            !suffix_has_disallowed_followup(suffix)
1012        }
1013        BoundaryKind::Pipe => {
1014            // `| <pager> ...` — the RHS command word must be a benign pager and
1015            // there must be no further pipe stage or chaining.
1016            let rhs = suffix.trim_start_matches('|').trim();
1017            if rhs.is_empty() {
1018                return false;
1019            }
1020            let rhs_cmd = rhs.split_whitespace().next().unwrap_or("");
1021            let rhs_base = rhs_cmd.rsplit('/').next().unwrap_or(rhs_cmd);
1022            if !BENIGN_PAGER_COMMANDS.contains(&rhs_base) {
1023                return false;
1024            }
1025            !suffix_has_disallowed_followup(rhs)
1026        }
1027    }
1028}
1029
1030/// Returns true if `s` contains ANY further disallowed top-level structural
1031/// operator (a pipe, backgrounding, or input redirect) anywhere after the
1032/// initial benign stage. Output redirects (`> file`, `2>&1`) are allowed to
1033/// repeat (e.g. `> out.log 2>&1`), but a pipe/background/input redirect mixed
1034/// into the suffix means it is more than a single benign stage and must not be
1035/// offloaded.
1036///
1037/// We scan the WHOLE string (not just the first boundary) so forms like
1038/// `> a.log < in.txt` — where the dangerous `< in.txt` follows a benign `>` —
1039/// are correctly rejected.
1040fn suffix_has_disallowed_followup(s: &str) -> bool {
1041    let mut rest = s;
1042    loop {
1043        match find_first_structural_boundary(rest) {
1044            Some(b) => {
1045                if matches!(
1046                    b.kind,
1047                    BoundaryKind::Pipe | BoundaryKind::Background | BoundaryKind::InputRedirect
1048                ) {
1049                    return true;
1050                }
1051                // Benign output redirect: advance past it and keep scanning the
1052                // remainder for any disallowed operator.
1053                let next = b.byte_pos + 1;
1054                if next >= rest.len() {
1055                    return false;
1056                }
1057                rest = &rest[next..];
1058            }
1059            None => return false,
1060        }
1061    }
1062}
1063
1064/// Split a command on && only (simpler than split_multi_command).
1065/// Returns None if no && found.
1066fn split_and_chain(cmd: &str) -> Option<Vec<&str>> {
1067    if !cmd.contains("&&") {
1068        return None;
1069    }
1070
1071    let mut segments = Vec::new();
1072    let mut current_start = 0;
1073    let mut in_single = false;
1074    let mut in_double = false;
1075    let mut escaped = false;
1076    let bytes = cmd.as_bytes();
1077    let mut i = 0;
1078
1079    while i < bytes.len() {
1080        let b = bytes[i];
1081
1082        if escaped {
1083            escaped = false;
1084            i += 1;
1085            continue;
1086        }
1087
1088        // POSIX: backslash is LITERAL inside single quotes; only honor it as an
1089        // escape when not in a single-quoted span, or the next `'` is wrongly
1090        // swallowed and the quote stays "open", hiding an exposed operator
1091        // (bd-review-singlequote-escape).
1092        if b == b'\\' && !in_single {
1093            escaped = true;
1094            i += 1;
1095            continue;
1096        }
1097
1098        if b == b'\'' && !in_double {
1099            in_single = !in_single;
1100        } else if b == b'"' && !in_single {
1101            in_double = !in_double;
1102        } else if !in_single
1103            && !in_double
1104            && b == b'&'
1105            && i + 1 < bytes.len()
1106            && bytes[i + 1] == b'&'
1107        {
1108            let segment = &cmd[current_start..i];
1109            let trimmed = segment.trim();
1110            if !trimmed.is_empty() {
1111                segments.push(trimmed);
1112            }
1113            current_start = i + 2;
1114            i += 1; // Skip second '&'
1115        }
1116
1117        i += 1;
1118    }
1119
1120    // Add final segment
1121    let final_segment = &cmd[current_start..];
1122    let trimmed = final_segment.trim();
1123    if !trimmed.is_empty() {
1124        segments.push(trimmed);
1125    }
1126
1127    if segments.len() > 1 {
1128        Some(segments)
1129    } else {
1130        None
1131    }
1132}
1133
1134/// Split a multi-command string on `&&`, `||`, and `;` while respecting quotes.
1135///
1136/// Returns `None` if no multi-command operators are found.
1137/// Returns `Some(segments)` where each segment is a trimmed sub-command.
1138#[allow(dead_code)] // May be used for future enhancements
1139fn split_multi_command(cmd: &str) -> Option<Vec<&str>> {
1140    // Quick check: if no operators, return None early
1141    if !cmd.contains("&&") && !cmd.contains("||") && !cmd.contains(';') {
1142        return None;
1143    }
1144
1145    let mut segments = Vec::new();
1146    let mut current_start = 0;
1147    let mut in_single = false;
1148    let mut in_double = false;
1149    let mut escaped = false;
1150    let chars: Vec<char> = cmd.chars().collect();
1151    let mut i = 0;
1152
1153    while i < chars.len() {
1154        let c = chars[i];
1155
1156        if escaped {
1157            escaped = false;
1158            i += 1;
1159            continue;
1160        }
1161
1162        // POSIX: backslash is LITERAL inside single quotes (see
1163        // bd-review-singlequote-escape); only treat it as an escape elsewhere.
1164        if c == '\\' && !in_single {
1165            escaped = true;
1166            i += 1;
1167            continue;
1168        }
1169
1170        if c == '\'' && !in_double {
1171            in_single = !in_single;
1172        } else if c == '"' && !in_single {
1173            in_double = !in_double;
1174        } else if !in_single && !in_double {
1175            // Check for operators
1176            if c == ';' {
1177                let segment = &cmd[current_start..byte_index(&chars, i)];
1178                let trimmed = segment.trim();
1179                if !trimmed.is_empty() {
1180                    segments.push(trimmed);
1181                }
1182                current_start = byte_index(&chars, i + 1);
1183            } else if c == '&' && i + 1 < chars.len() && chars[i + 1] == '&' {
1184                let segment = &cmd[current_start..byte_index(&chars, i)];
1185                let trimmed = segment.trim();
1186                if !trimmed.is_empty() {
1187                    segments.push(trimmed);
1188                }
1189                current_start = byte_index(&chars, i + 2);
1190                i += 1; // Skip second '&'
1191            } else if c == '|' && i + 1 < chars.len() && chars[i + 1] == '|' {
1192                let segment = &cmd[current_start..byte_index(&chars, i)];
1193                let trimmed = segment.trim();
1194                if !trimmed.is_empty() {
1195                    segments.push(trimmed);
1196                }
1197                current_start = byte_index(&chars, i + 2);
1198                i += 1; // Skip second '|'
1199            }
1200        }
1201
1202        i += 1;
1203    }
1204
1205    // Add final segment
1206    let final_segment = &cmd[current_start..];
1207    let trimmed = final_segment.trim();
1208    if !trimmed.is_empty() {
1209        segments.push(trimmed);
1210    }
1211
1212    // Only return Some if we actually split something
1213    if segments.len() > 1 {
1214        Some(segments)
1215    } else {
1216        None
1217    }
1218}
1219
1220/// Convert char index to byte index for string slicing.
1221#[allow(dead_code)] // Reserved for future multi-command classification
1222fn byte_index(chars: &[char], char_idx: usize) -> usize {
1223    chars.iter().take(char_idx).map(|c| c.len_utf8()).sum()
1224}
1225
1226/// Classify a multi-command string by evaluating each sub-command independently.
1227///
1228/// Returns compilation if ANY sub-command is compilation (highest confidence wins).
1229/// Returns non-compilation only if ALL sub-commands are non-compilation.
1230#[allow(dead_code)] // Reserved for future multi-command classification
1231fn classify_multi_command(segments: &[&str], depth: u8) -> Classification {
1232    let mut best_compilation: Option<Classification> = None;
1233
1234    for &segment in segments {
1235        let result = classify_command_inner(segment, depth + 1);
1236        if result.is_compilation {
1237            let dominated = match &best_compilation {
1238                Some(prev) => result.confidence > prev.confidence,
1239                None => true,
1240            };
1241            if dominated {
1242                best_compilation = Some(result);
1243            }
1244        }
1245    }
1246
1247    if let Some(compilation) = best_compilation {
1248        return compilation;
1249    }
1250
1251    Classification::not_compilation("no sub-command is compilation")
1252}
1253
1254/// Classify a shell command with detailed tier decisions for diagnostics.
1255pub fn classify_command_detailed(cmd: &str) -> ClassificationDetails {
1256    let original = cmd.to_string();
1257    let cmd = cmd.trim();
1258    let mut tiers = Vec::new();
1259
1260    // Tier 0: Instant reject - empty command
1261    if cmd.is_empty() {
1262        let classification = Classification::not_compilation("empty command");
1263        tiers.push(ClassificationTier {
1264            tier: 0,
1265            name: Cow::Borrowed(TIER_INSTANT_REJECT),
1266            decision: TierDecision::Reject,
1267            reason: Cow::Borrowed("empty command"),
1268        });
1269        return ClassificationDetails {
1270            original,
1271            normalized: cmd.to_string(),
1272            tiers,
1273            classification,
1274        };
1275    }
1276
1277    tiers.push(ClassificationTier {
1278        tier: 0,
1279        name: Cow::Borrowed(TIER_INSTANT_REJECT),
1280        decision: TierDecision::Pass,
1281        reason: Cow::Borrowed("command present"),
1282    });
1283
1284    // Tier 0.5: Compound / wrapper / benign-suffix handling, mirroring
1285    // classify_command_inner(). Without this branch, diagnostics could reject a
1286    // command such as `cd /repo && cargo build`, `bash -c "cargo test"`, or
1287    // `cargo check 2>&1 | head` while the hook classifier accepts and offloads
1288    // it (issue #24).
1289    if cmd.len() < MAX_SPLIT_INPUT_LEN
1290        && let Some((classification, structure_reason)) = try_classify_compound_command(cmd)
1291            .map(|c| (c, "safe compound command with compilation suffix"))
1292            .or_else(|| {
1293                try_classify_wrapped_command(cmd)
1294                    .map(|c| (c, "shell -c wrapper with compilation command"))
1295            })
1296            .or_else(|| {
1297                try_classify_benign_suffix_command(cmd).map(|c| {
1298                    (
1299                        c,
1300                        "compilation command with benign trailing redirect/pipe/background",
1301                    )
1302                })
1303            })
1304    {
1305        let normalized = classification
1306            .extracted_command
1307            .clone()
1308            .unwrap_or_else(|| cmd.to_string());
1309
1310        tiers.push(ClassificationTier {
1311            tier: 1,
1312            name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1313            decision: TierDecision::Pass,
1314            reason: Cow::Borrowed(structure_reason),
1315        });
1316        tiers.push(ClassificationTier {
1317            tier: 2,
1318            name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1319            decision: TierDecision::Pass,
1320            reason: Cow::Borrowed("keyword present in extracted command"),
1321        });
1322        tiers.push(ClassificationTier {
1323            tier: 3,
1324            name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1325            decision: TierDecision::Pass,
1326            reason: Cow::Borrowed("extracted command passed never-intercept checks"),
1327        });
1328        tiers.push(ClassificationTier {
1329            tier: 4,
1330            name: Cow::Borrowed(TIER_FULL_CLASSIFICATION),
1331            decision: TierDecision::Pass,
1332            reason: classification.reason.clone(),
1333        });
1334
1335        return ClassificationDetails {
1336            original,
1337            normalized,
1338            tiers,
1339            classification,
1340        };
1341    }
1342
1343    // Tier 1: Structure analysis
1344    if let Some(reason) = check_structure(cmd) {
1345        let classification = Classification::not_compilation(reason);
1346        tiers.push(ClassificationTier {
1347            tier: 1,
1348            name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1349            decision: TierDecision::Reject,
1350            reason: Cow::Borrowed(reason),
1351        });
1352        return ClassificationDetails {
1353            original,
1354            normalized: cmd.to_string(),
1355            tiers,
1356            classification,
1357        };
1358    }
1359
1360    tiers.push(ClassificationTier {
1361        tier: 1,
1362        name: Cow::Borrowed(TIER_STRUCTURE_ANALYSIS),
1363        decision: TierDecision::Pass,
1364        reason: Cow::Borrowed("no pipes/redirects/backgrounding"),
1365    });
1366
1367    // Normalize command for Tier 2, 3, and 4.
1368    let normalized_cow = normalize_command(cmd);
1369    let normalized = normalized_cow.as_ref();
1370
1371    // Tier 2: command-word compilation filter.
1372    if !starts_with_compilation_command(normalized) {
1373        let classification = Classification::not_compilation("no compilation keyword");
1374        tiers.push(ClassificationTier {
1375            tier: 2,
1376            name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1377            decision: TierDecision::Reject,
1378            reason: Cow::Borrowed("no compilation keyword"),
1379        });
1380        return ClassificationDetails {
1381            original,
1382            normalized: normalized.to_string(),
1383            tiers,
1384            classification,
1385        };
1386    }
1387
1388    tiers.push(ClassificationTier {
1389        tier: 2,
1390        name: Cow::Borrowed(TIER_KEYWORD_FILTER),
1391        decision: TierDecision::Pass,
1392        reason: Cow::Borrowed("keyword present"),
1393    });
1394
1395    // Tier 3: Negative pattern check - never intercept these
1396    for pattern in NEVER_INTERCEPT {
1397        if let Some(rest) = normalized.strip_prefix(pattern)
1398            && (rest.is_empty() || rest.starts_with(' '))
1399        {
1400            let reason: Cow<'static, str> =
1401                Cow::Owned(format!("matches never-intercept: {pattern}"));
1402            let classification = Classification::not_compilation(reason.clone());
1403            tiers.push(ClassificationTier {
1404                tier: 3,
1405                name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1406                decision: TierDecision::Reject,
1407                reason,
1408            });
1409            return ClassificationDetails {
1410                original,
1411                normalized: normalized.to_string(),
1412                tiers,
1413                classification,
1414            };
1415        }
1416    }
1417
1418    tiers.push(ClassificationTier {
1419        tier: 3,
1420        name: Cow::Borrowed(TIER_NEVER_INTERCEPT),
1421        decision: TierDecision::Pass,
1422        reason: Cow::Borrowed("no never-intercept match"),
1423    });
1424
1425    // Tier 4: Full classification
1426    let classification = classify_full(normalized);
1427    let decision = if classification.is_compilation {
1428        TierDecision::Pass
1429    } else {
1430        TierDecision::Reject
1431    };
1432    tiers.push(ClassificationTier {
1433        tier: 4,
1434        name: Cow::Borrowed(TIER_FULL_CLASSIFICATION),
1435        decision,
1436        reason: classification.reason.clone(),
1437    });
1438
1439    ClassificationDetails {
1440        original,
1441        normalized: normalized.to_string(),
1442        tiers,
1443        classification,
1444    }
1445}
1446
1447/// Whether a `-flag VALUE` form for `wrapper` consumes the following token as
1448/// its value (so the value isn't mistaken for the wrapped command word).
1449/// Only the space-separated forms are listed; `=`-joined and space-attached
1450/// forms (`--adjustment=10`, `-n10`) carry their value in the same token.
1451fn wrapper_flag_takes_value(wrapper: &str, flag: &str) -> bool {
1452    match wrapper {
1453        "env" => matches!(flag, "-u" | "--unset"),
1454        "nice" => matches!(flag, "-n" | "--adjustment"),
1455        "ionice" => matches!(
1456            flag,
1457            "-c" | "--class" | "-n" | "--classdata" | "-p" | "--pid"
1458        ),
1459        "taskset" => matches!(flag, "-c" | "--cpu-list" | "-p" | "--pid"),
1460        "chrt" => matches!(flag, "-p" | "--pid"),
1461        "timeout" => matches!(flag, "-s" | "--signal" | "-k" | "--kill-after"),
1462        _ => false,
1463    }
1464}
1465
1466/// Heuristic: does `token` look like a `timeout` duration (`60`, `1.5h`, `30s`)
1467/// or a `chrt` priority (`10`)? Used to decide whether to skip a positional
1468/// value token without ever eating a real command word.
1469fn looks_like_duration_or_priority(token: &str) -> bool {
1470    token.chars().next().is_some_and(|c| c.is_ascii_digit())
1471}
1472
1473/// Read a wrapper argument without splitting a fully quoted token. `rch exec`
1474/// quotes literal flags such as `-f%U` when rebuilding its shell command.
1475/// Mixed/unterminated quoting is left unrecognized rather than guessing where
1476/// the wrapped command starts.
1477pub(crate) fn split_wrapper_word(input: &str) -> Option<(&str, &str)> {
1478    let input = input.trim_start();
1479    let first = input.chars().next()?;
1480    if matches!(first, '\'' | '"') {
1481        let end = input[1..].find(first)? + 1;
1482        let word = &input[1..end];
1483        let rest = &input[end + 1..];
1484        if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
1485            return None;
1486        }
1487        // Escaped quotes and expansions need shell parsing; do not interpret
1488        // them as literal flag names here.
1489        if first == '"' && word.contains(['\\', '$', '`']) {
1490            return None;
1491        }
1492        Some((word, rest.trim_start()))
1493    } else {
1494        let (word, rest) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
1495        if word.contains(['\'', '"', '\\']) {
1496            return None;
1497        }
1498        Some((word, rest.trim_start()))
1499    }
1500}
1501
1502/// Normalize a command by stripping common wrappers (sudo, time, env, etc.)
1503pub fn normalize_command(cmd: &str) -> Cow<'_, str> {
1504    let mut result = cmd.trim();
1505
1506    // Strip common command prefixes/wrappers
1507    // Note: We match the command name, then ensure it's followed by whitespace
1508    let wrappers = [
1509        "sudo", "env", "time", "timeout", "nice", "ionice", "chrt", "strace", "ltrace", "perf",
1510        "taskset", "numactl",
1511    ];
1512
1513    loop {
1514        let mut changed = false;
1515        for wrapper in wrappers {
1516            if let Some(rest) = result.strip_prefix(wrapper) {
1517                // Must be followed by whitespace or end of string
1518                // (e.g., "sudo" matches "sudo ls", but not "sudoku"; "time"
1519                // must not swallow "timeout" — guarded by this whitespace check)
1520                if rest.is_empty() || rest.starts_with(char::is_whitespace) {
1521                    result = rest.trim_start();
1522                    changed = true;
1523
1524                    // Strip flags that might follow the wrapper (e.g., "time -v", "sudo -E").
1525                    // We heuristically strip any token starting with '-' until we find a non-flag.
1526                    // For value-taking flags (e.g. `nice -n 10`, `taskset -c 0-3`, `env -u VAR`)
1527                    // we must ALSO consume the following value token, or it is left as the
1528                    // command word and a real `cargo build` is misclassified as non-compilation
1529                    // (silent local fallback). Space-attached forms (`-n10`, `--adjustment=10`)
1530                    // carry their value in the same token and need no extra consume.
1531                    while let Some((flag, rest_after_flag)) = split_wrapper_word(result) {
1532                        if !flag.starts_with('-') {
1533                            break;
1534                        }
1535                        result = rest_after_flag;
1536
1537                        if wrapper_flag_takes_value(wrapper, flag) {
1538                            let Some((_value, rest_after_value)) = split_wrapper_word(result)
1539                            else {
1540                                break;
1541                            };
1542                            result = rest_after_value;
1543                        }
1544                    }
1545
1546                    // Positional value wrappers: `timeout <duration> cmd` and
1547                    // `chrt <prio> cmd` carry a bare (non-flag) value token before
1548                    // the command. Skip exactly one such token when it looks like a
1549                    // duration/priority (leading digit), so we never eat the real
1550                    // command word (e.g. `timeout cargo build` keeps `cargo build`).
1551                    if matches!(wrapper, "timeout" | "chrt")
1552                        && let Some((first, after_first)) = result.split_once(char::is_whitespace)
1553                        && !first.starts_with('-')
1554                        && looks_like_duration_or_priority(first)
1555                    {
1556                        result = after_first.trim_start();
1557                    }
1558                }
1559            }
1560        }
1561
1562        // Handle env VAR=val syntax
1563        // Logic: if result starts with VAR=val (potentially quoted), strip it.
1564        // We need to parse the first token respecting quotes.
1565        let chars = result.chars();
1566        let mut token_len = 0;
1567        let mut in_quote = None; // None, Some('\''), Some('"')
1568        let mut escaped = false;
1569        let mut has_equals = false;
1570        let mut has_space = false;
1571
1572        for c in chars {
1573            if escaped {
1574                escaped = false;
1575                token_len += c.len_utf8();
1576                continue;
1577            }
1578
1579            if c == '\\' {
1580                escaped = true;
1581                token_len += c.len_utf8();
1582                continue;
1583            }
1584
1585            if let Some(q) = in_quote {
1586                if c == q {
1587                    in_quote = None;
1588                } else if c == '=' {
1589                    has_equals = true;
1590                }
1591                token_len += c.len_utf8();
1592            } else if c == '"' || c == '\'' {
1593                in_quote = Some(c);
1594                token_len += c.len_utf8();
1595            } else if c.is_whitespace() {
1596                has_space = true;
1597                break;
1598            } else {
1599                if c == '=' {
1600                    has_equals = true;
1601                }
1602                token_len += c.len_utf8();
1603            }
1604        }
1605
1606        if has_equals && in_quote.is_none() && has_space {
1607            // It was a complete token with an equals sign followed by space.
1608            // We assume it's an env var and strip it.
1609            result = result[token_len..].trim_start();
1610            changed = true;
1611        }
1612
1613        // Strip absolute paths: /usr/bin/cargo -> cargo
1614        // We assume the command is the first word
1615        if result.starts_with('/') {
1616            if let Some(space_idx) = result.find(' ') {
1617                let cmd_part = &result[..space_idx];
1618                if let Some(last_slash) = cmd_part.rfind('/') {
1619                    // Result becomes substring starting after the last slash of the command word
1620                    result = &result[last_slash + 1..];
1621                    changed = true;
1622                }
1623            } else {
1624                // Single word command
1625                if let Some(last_slash) = result.rfind('/') {
1626                    result = &result[last_slash + 1..];
1627                    changed = true;
1628                }
1629            }
1630        }
1631
1632        if !changed {
1633            break;
1634        }
1635    }
1636
1637    if result.eq(cmd) {
1638        Cow::Borrowed(cmd)
1639    } else {
1640        Cow::Owned(result.to_string())
1641    }
1642}
1643
1644/// Check if a command contains file redirects (NOT fd-to-fd redirects like `2>&1`).
1645///
1646/// Returns true for file redirects: `> file`, `>> file`, `2> file`, `&> file`
1647/// Returns false for fd-to-fd redirects: `2>&1`, `>&2`, `1>&2`
1648/// Also returns true for input redirects: `< file`
1649///
1650/// This is used by `try_classify_compound_command` which needs a quick text-level
1651/// check before doing more expensive parsing.
1652fn has_file_redirect(cmd: &str) -> bool {
1653    let bytes = cmd.as_bytes();
1654    let len = bytes.len();
1655    let mut in_single = false;
1656    let mut in_double = false;
1657    let mut escaped = false;
1658    let mut i = 0;
1659
1660    while i < len {
1661        let b = bytes[i];
1662
1663        if escaped {
1664            escaped = false;
1665            i += 1;
1666            continue;
1667        }
1668        // POSIX: backslash is LITERAL inside single quotes; only honor it as an
1669        // escape when not in a single-quoted span, or the next `'` is wrongly
1670        // swallowed and the quote stays "open", hiding an exposed operator
1671        // (bd-review-singlequote-escape).
1672        if b == b'\\' && !in_single {
1673            escaped = true;
1674            i += 1;
1675            continue;
1676        }
1677        match b {
1678            b'\'' if !in_double => {
1679                in_single = !in_single;
1680            }
1681            b'"' if !in_single => {
1682                in_double = !in_double;
1683            }
1684            b'>' if !in_single && !in_double => {
1685                // Check if this is a fd-to-fd redirect (N>&M or >&N)
1686                if i + 1 < len && bytes[i + 1] == b'&' {
1687                    // >&N or N>&M — fd-to-fd, skip
1688                    i += 2; // skip > and &
1689                    // skip optional digit
1690                    if i < len && bytes[i].is_ascii_digit() {
1691                        i += 1;
1692                    }
1693                    continue;
1694                }
1695                // >( is process substitution, not a file redirect per se,
1696                // but it's caught by the subshell check separately
1697                if i + 1 < len && bytes[i + 1] == b'(' {
1698                    i += 1;
1699                    continue;
1700                }
1701                return true; // File redirect: > file, >> file, N> file
1702            }
1703            b'<' if !in_single && !in_double => {
1704                // <( is process substitution, caught separately
1705                if i + 1 < len && bytes[i + 1] == b'(' {
1706                    i += 1;
1707                    continue;
1708                }
1709                return true; // Input redirect: < file
1710            }
1711            _ => {}
1712        }
1713        i += 1;
1714    }
1715    false
1716}
1717
1718/// Check command structure for patterns that shouldn't be intercepted.
1719///
1720/// Performance: single-pass O(n) state machine instead of 11+ separate scans.
1721/// This is critical because structure analysis runs on EVERY command.
1722///
1723/// Detection priority (matches original behavior):
1724/// 1. Embedded newlines (security - command injection)
1725/// 2. Standalone & (backgrounding)
1726/// 3. Single | pipe (not ||)
1727/// 4. Subshell ( or >( or <(
1728/// 5. Output redirection >
1729/// 6. Input redirection <
1730/// 7. Semicolon ;
1731/// 8. && chaining
1732/// 9. || chaining
1733/// 10. Subshell capture $( or backtick
1734fn check_structure(cmd: &str) -> Option<&'static str> {
1735    let bytes = cmd.as_bytes();
1736    let len = bytes.len();
1737
1738    let mut in_single = false;
1739    let mut in_double = false;
1740    let mut escaped = false;
1741
1742    // Track what we find (in priority order, lower = higher priority)
1743    // Using Option to track if found, with priority encoded by check order
1744    let mut found_backgrounded = false;
1745    let mut found_piped = false;
1746    let mut found_subshell = false;
1747    let mut found_output_redirect = false;
1748    let mut found_input_redirect = false;
1749    let mut found_semicolon = false;
1750    let mut found_and_chain = false;
1751    let mut found_or_chain = false;
1752    let mut found_subshell_capture = false;
1753
1754    let mut i = 0;
1755    while i < len {
1756        let b = bytes[i];
1757
1758        // Handle escape sequences
1759        if escaped {
1760            escaped = false;
1761            i += 1;
1762            continue;
1763        }
1764        // POSIX: backslash is LITERAL inside single quotes; only honor it as an
1765        // escape when not in a single-quoted span, or the next `'` is wrongly
1766        // swallowed and the quote stays "open", hiding an exposed operator
1767        // (bd-review-singlequote-escape).
1768        if b == b'\\' && !in_single {
1769            escaped = true;
1770            i += 1;
1771            continue;
1772        }
1773
1774        // Handle quote state transitions
1775        if b == b'\'' && !in_double {
1776            in_single = !in_single;
1777            i += 1;
1778            continue;
1779        }
1780        if b == b'"' && !in_single {
1781            in_double = !in_double;
1782            i += 1;
1783            continue;
1784        }
1785
1786        // Skip quoted content
1787        if in_single || in_double {
1788            i += 1;
1789            continue;
1790        }
1791
1792        // Check for problematic characters/patterns (unquoted)
1793        match b {
1794            // Embedded newlines - return immediately (highest priority, security issue)
1795            b'\n' | b'\r' => return Some("contains embedded newline"),
1796
1797            // Ampersand: check for && vs standalone &
1798            b'&' => {
1799                if i + 1 < len && bytes[i + 1] == b'&' {
1800                    found_and_chain = true;
1801                    i += 1; // Skip second &
1802                } else {
1803                    // Check for redirection patterns like 2>&1 or &>
1804                    let prev = if i > 0 { bytes[i - 1] } else { 0 };
1805                    let next = if i + 1 < len { bytes[i + 1] } else { 0 };
1806                    if prev != b'>' && next != b'>' {
1807                        found_backgrounded = true;
1808                    }
1809                }
1810            }
1811
1812            // Pipe: check for || vs single |
1813            b'|' => {
1814                if i + 1 < len && bytes[i + 1] == b'|' {
1815                    found_or_chain = true;
1816                    i += 1; // Skip second |
1817                } else {
1818                    found_piped = true;
1819                }
1820            }
1821
1822            // Subshell/process substitution
1823            b'(' => found_subshell = true,
1824
1825            // Output redirection (>( is process substitution, caught by ( check)
1826            // fd-to-fd redirects like 2>&1, >&2 are safe to intercept
1827            b'>' => {
1828                if i + 1 < len && bytes[i + 1] == b'(' {
1829                    found_subshell = true;
1830                } else if i + 1 < len && bytes[i + 1] == b'&' {
1831                    // Pattern: N>&M or >&N (fd-to-fd redirect) — safe, skip
1832                    // e.g. 2>&1, 1>&2, >&2
1833                    // Skip the '&' and any following digit
1834                    i += 1; // skip '&'
1835                    if i + 1 < len && bytes[i + 1].is_ascii_digit() {
1836                        i += 1; // skip digit after &
1837                    }
1838                } else if i + 1 < len && bytes[i + 1] == b'>' {
1839                    // Append redirect >> (to file) — NOT safe
1840                    found_output_redirect = true;
1841                    i += 1; // skip second >
1842                } else {
1843                    found_output_redirect = true;
1844                }
1845            }
1846
1847            // Input redirection (<( is process substitution, caught by ( check)
1848            b'<' => {
1849                if i + 1 < len && bytes[i + 1] == b'(' {
1850                    found_subshell = true;
1851                } else {
1852                    found_input_redirect = true;
1853                }
1854            }
1855
1856            // Semicolon chaining
1857            b';' => found_semicolon = true,
1858
1859            // Backtick subshell capture
1860            b'`' => found_subshell_capture = true,
1861
1862            // Dollar sign: check for $( subshell capture
1863            b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
1864                found_subshell_capture = true;
1865            }
1866
1867            _ => {}
1868        }
1869
1870        i += 1;
1871    }
1872
1873    // Return in priority order (matches original check_structure behavior)
1874    if found_backgrounded {
1875        return Some("backgrounded command");
1876    }
1877    // Pipe check: only if not ||
1878    if found_piped && !found_or_chain {
1879        return Some("piped command");
1880    }
1881    if found_subshell {
1882        return Some("subshell execution");
1883    }
1884    if found_output_redirect {
1885        return Some("output redirected");
1886    }
1887    if found_input_redirect {
1888        return Some("input redirected");
1889    }
1890    if found_semicolon {
1891        return Some("chained command (;)");
1892    }
1893    if found_and_chain {
1894        return Some("chained command (&&)");
1895    }
1896    if found_or_chain {
1897        return Some("chained command (||)");
1898    }
1899    if found_subshell_capture {
1900        return Some("subshell capture");
1901    }
1902
1903    None
1904}
1905
1906/// Check whether the invoked command word is one of the compilation tools.
1907///
1908/// The keyword filter runs before full classification, so it must be cheap but
1909/// cannot treat arbitrary arguments as commands. `normalize_command` strips
1910/// wrappers and environment assignments before this helper is called.
1911fn starts_with_compilation_command(cmd: &str) -> bool {
1912    let cmd = cmd.trim_start();
1913    COMPILATION_KEYWORDS
1914        .iter()
1915        .any(|keyword| command_word_matches(cmd, keyword))
1916}
1917
1918fn command_word_matches(cmd: &str, keyword: &str) -> bool {
1919    let Some(rest) = cmd.strip_prefix(keyword) else {
1920        return false;
1921    };
1922    rest.is_empty() || rest.starts_with(char::is_whitespace)
1923}
1924
1925/// Bare positional target tokens of a `make`/`ninja` command.
1926///
1927/// Skips the program name, option flags (`-x`, `--long`, combined `-j8`), the
1928/// value token consumed by any flag in `value_flags`, and `VAR=value`
1929/// assignments. This lets the maintenance-target check match WHOLE tokens
1930/// (`clean`, `install`, …) instead of a substring anywhere in the command, so
1931/// real builds like `make CFLAGS=-Iinstall/include`, `make TARGET=cleanup`,
1932/// `ninja build/clean_obj.o`, and `make -C install` / `ninja -C clean` (a
1933/// directory argument, not the target) are no longer misclassified as
1934/// maintenance.
1935fn positional_build_targets<'a>(cmd: &'a str, value_flags: &[&str]) -> Vec<&'a str> {
1936    let mut targets = Vec::new();
1937    let toks: Vec<&str> = cmd.split_whitespace().skip(1).collect();
1938    let mut i = 0;
1939    while i < toks.len() {
1940        let tok = toks[i];
1941        if value_flags.contains(&tok) {
1942            i += 2; // flag consumes the following token as its value
1943        } else if tok.starts_with('-') || tok.contains('=') {
1944            i += 1; // other flag (incl. combined `-j8`) or VAR=value assignment
1945        } else {
1946            targets.push(tok);
1947            i += 1;
1948        }
1949    }
1950    targets
1951}
1952
1953/// Full classification of a command (Tier 4).
1954fn classify_full(cmd: &str) -> Classification {
1955    // Cargo commands
1956    if cmd.starts_with("cargo ") || cmd.eq("cargo") {
1957        return classify_cargo(cmd);
1958    }
1959
1960    // `cargo-zigbuild` invoked as a standalone binary (this is what `cargo
1961    // zigbuild ...` execs). Note the hyphen: it does NOT match `cargo ` above.
1962    if cmd.starts_with("cargo-zigbuild ") || cmd.eq("cargo-zigbuild") {
1963        return classify_cargo_zigbuild(cmd);
1964    }
1965
1966    if cmd.starts_with("cargo-xwin ") || cmd.eq("cargo-xwin") {
1967        return if let Some(kind) = cargo_xwin_compilation_kind(cmd) {
1968            Classification::compilation(kind, 0.95, "cargo-xwin compilation")
1969        } else {
1970            Classification::not_compilation("cargo-xwin subcommand not interceptable")
1971        };
1972    }
1973
1974    // rustc
1975    if cmd.starts_with("rustc ") || cmd.eq("rustc") {
1976        return Classification::compilation(CompilationKind::Rustc, 0.95, "rustc invocation");
1977    }
1978
1979    // GCC
1980    if cmd.starts_with("gcc ")
1981        && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
1982    {
1983        return Classification::compilation(CompilationKind::Gcc, 0.90, "gcc compilation");
1984    }
1985
1986    // G++
1987    if cmd.starts_with("g++ ")
1988        && (cmd.contains(" -c ")
1989            || cmd.contains(" -o ")
1990            || cmd.contains(".cpp")
1991            || cmd.contains(".cc"))
1992    {
1993        return Classification::compilation(CompilationKind::Gpp, 0.90, "g++ compilation");
1994    }
1995
1996    // Clang
1997    if cmd.starts_with("clang ")
1998        && !cmd.starts_with("clang++ ")
1999        && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
2000    {
2001        return Classification::compilation(CompilationKind::Clang, 0.90, "clang compilation");
2002    }
2003
2004    // Clang++
2005    if cmd.starts_with("clang++ ")
2006        && (cmd.contains(" -c ")
2007            || cmd.contains(" -o ")
2008            || cmd.contains(".cpp")
2009            || cmd.contains(".cc"))
2010    {
2011        return Classification::compilation(CompilationKind::Clangpp, 0.90, "clang++ compilation");
2012    }
2013
2014    // cc (standard C compiler)
2015    if cmd.starts_with("cc ")
2016        && (cmd.contains(" -c ") || cmd.contains(" -o ") || cmd.contains(".c"))
2017    {
2018        return Classification::compilation(CompilationKind::Gcc, 0.85, "cc compilation");
2019    }
2020
2021    // c++ (standard C++ compiler)
2022    if cmd.starts_with("c++ ")
2023        && (cmd.contains(" -c ")
2024            || cmd.contains(" -o ")
2025            || cmd.contains(".cpp")
2026            || cmd.contains(".cc"))
2027    {
2028        return Classification::compilation(CompilationKind::Gpp, 0.85, "c++ compilation");
2029    }
2030
2031    // Make
2032    if cmd.starts_with("make") && (cmd.eq("make") || cmd.starts_with("make ")) {
2033        // Don't intercept "make clean", "make install", etc. — but match them as
2034        // whole TARGET tokens, not as a substring anywhere. A `contains` check
2035        // rejected real builds like "make CFLAGS=-Iinstall/include" and
2036        // "make TARGET=cleanup" as maintenance.
2037        const MAKE_VALUE_FLAGS: [&str; 8] = [
2038            "-f",
2039            "-C",
2040            "-I",
2041            "-o",
2042            "-W",
2043            "--file",
2044            "--directory",
2045            "--include-dir",
2046        ];
2047        const MAKE_MAINTENANCE_TARGETS: [&str; 4] = ["clean", "install", "distclean", "uninstall"];
2048        let is_maintenance = positional_build_targets(cmd, &MAKE_VALUE_FLAGS)
2049            .iter()
2050            .any(|target| MAKE_MAINTENANCE_TARGETS.contains(target));
2051        if is_maintenance {
2052            return Classification::not_compilation("make maintenance command");
2053        }
2054        return Classification::compilation(CompilationKind::Make, 0.85, "make build");
2055    }
2056
2057    // CMake build (only when cmake is the invoked command)
2058    if cmd.starts_with("cmake ") || cmd.eq("cmake") {
2059        let mut tokens = cmd.split_whitespace();
2060        let _ = tokens.next(); // "cmake"
2061        if tokens.any(|token| token.eq("--build") || token.starts_with("--build=")) {
2062            return Classification::compilation(CompilationKind::CmakeBuild, 0.90, "cmake --build");
2063        }
2064    }
2065
2066    // Ninja
2067    if cmd.starts_with("ninja") && (cmd.eq("ninja") || cmd.starts_with("ninja ")) {
2068        // Match the `-t clean` tool form and a whole-token `clean` target — not
2069        // the substring "clean" anywhere ("ninja build/clean_obj.o" is a real
2070        // build, and "ninja -C clean" cleans a directory argument).
2071        const NINJA_VALUE_FLAGS: [&str; 8] = ["-C", "-f", "-j", "-k", "-l", "-d", "-t", "-w"];
2072        let raw: Vec<&str> = cmd.split_whitespace().collect();
2073        let is_clean_tool = raw.windows(2).any(|w| w[0] == "-t" && w[1] == "clean");
2074        let is_clean_target = positional_build_targets(cmd, &NINJA_VALUE_FLAGS).contains(&"clean");
2075        if is_clean_tool || is_clean_target {
2076            return Classification::not_compilation("ninja clean");
2077        }
2078        return Classification::compilation(CompilationKind::Ninja, 0.90, "ninja build");
2079    }
2080
2081    // Meson (only when meson is the invoked command)
2082    if cmd.starts_with("meson ") || cmd.eq("meson") {
2083        let mut tokens = cmd.split_whitespace();
2084        let _ = tokens.next(); // "meson"
2085        let mut subcommand = None;
2086        for token in tokens {
2087            if token.starts_with('-') {
2088                continue;
2089            }
2090            subcommand = Some(token);
2091            break;
2092        }
2093        if matches!(subcommand, Some("compile")) {
2094            return Classification::compilation(CompilationKind::Meson, 0.85, "meson compile");
2095        }
2096    }
2097
2098    // Nix commands (the `nix` multi-tool and the legacy `nix-build`).
2099    // Only build-like, NON-interactive, non-mutating invocations are offloaded;
2100    // classify_nix uses an allowlist so everything else falls back to local.
2101    if cmd.eq("nix-build")
2102        || cmd.starts_with("nix-build ")
2103        || cmd.eq("nix")
2104        || cmd.starts_with("nix ")
2105    {
2106        return classify_nix(cmd);
2107    }
2108
2109    // Bun commands
2110    let mut tokens = cmd.split_whitespace();
2111    if tokens.next().is_some_and(|token| token.eq("bun")) {
2112        match tokens.next() {
2113            Some("test") => {
2114                // Check for --watch flag - don't intercept interactive mode
2115                // Clone the iterator to check remaining args
2116                if tokens.any(|a| a.eq("-w") || a.eq("--watch")) {
2117                    return Classification::not_compilation(
2118                        "bun test --watch is interactive (not intercepted)",
2119                    );
2120                }
2121                return Classification::compilation(
2122                    CompilationKind::BunTest,
2123                    0.95,
2124                    "bun test command",
2125                );
2126            }
2127            Some("typecheck") => {
2128                // Check for --watch flag - don't intercept interactive mode
2129                if tokens.any(|a| a.eq("-w") || a.eq("--watch")) {
2130                    return Classification::not_compilation(
2131                        "bun typecheck --watch is interactive (not intercepted)",
2132                    );
2133                }
2134                return Classification::compilation(
2135                    CompilationKind::BunTypecheck,
2136                    0.95,
2137                    "bun typecheck command",
2138                );
2139            }
2140            Some("x") => {
2141                // bun x (alias for bunx - package runner)
2142                // NOT intercepted - similar to npx, runs arbitrary packages
2143                return Classification::not_compilation("bun x runs arbitrary packages");
2144            }
2145            _ => {}
2146        }
2147    }
2148
2149    // Go commands
2150    if cmd.starts_with("go ") {
2151        return classify_go(cmd);
2152    }
2153
2154    // TypeScript: `tsc ...` and `npx tsc ...`.
2155    //
2156    // `npx` is not a normalize_command wrapper (it can run arbitrary packages),
2157    // so match it explicitly here rather than stripping it globally.
2158    if let Some(rest) = cmd
2159        .strip_prefix("tsc ")
2160        .or_else(|| cmd.strip_prefix("npx tsc "))
2161    {
2162        return classify_tsc(rest);
2163    }
2164
2165    Classification::not_compilation("no matching pattern")
2166}
2167
2168/// Classify `tsc` / `npx tsc` invocations.
2169///
2170/// Only `--noEmit` typechecks are offloaded. An emitting `tsc` run writes `.js`
2171/// / `.d.ts` / `.tsbuildinfo` into the local tree, and these kinds are
2172/// stream-only (no artifact sync-back), so offloading an emitting run would
2173/// return exit 0 with no output files — a silent correctness bug. Emitting runs
2174/// stay local.
2175///
2176/// `args` is the command with the leading `tsc ` / `npx tsc ` already stripped.
2177fn classify_tsc(args: &str) -> Classification {
2178    let tokens = args.split_whitespace();
2179    let mut no_emit = false;
2180    for token in tokens {
2181        // --watch is interactive and never terminates; never intercept.
2182        if token.eq("-w") || token.eq("--watch") || token.eq("--watchFile") {
2183            return Classification::not_compilation("tsc --watch is interactive (not intercepted)");
2184        }
2185        // tsc's flag parsing is case-insensitive (--noemit == --noEmit).
2186        if token.eq_ignore_ascii_case("--noemit") {
2187            no_emit = true;
2188        }
2189    }
2190
2191    if !no_emit {
2192        return Classification::not_compilation(
2193            "tsc without --noEmit emits output files locally (not intercepted)",
2194        );
2195    }
2196
2197    Classification::compilation(CompilationKind::Tsc, 0.95, "tsc --noEmit typecheck")
2198}
2199
2200/// Parse a bounded literal argv without interpreting shell expansions. Whole
2201/// quoted words cover `rch exec`'s reconstructed flags and output filenames;
2202/// mixed or escaped quoting is conservatively left to local execution.
2203fn literal_flag_words(mut command: &str) -> Option<Vec<&str>> {
2204    if command.len() > 65_536 || command.contains(['\0', '\r', '\n']) {
2205        return None;
2206    }
2207    let mut words = Vec::new();
2208    while !command.trim().is_empty() {
2209        command = command.trim_start();
2210        let quoted = command.starts_with(['\'', '"']);
2211        let (word, rest) = split_wrapper_word(command)?;
2212        if !quoted
2213            && word.contains([
2214                '$', '`', '*', '?', '[', '{', '}', '~', '|', '&', ';', '<', '>', '(', ')', '#',
2215            ])
2216        {
2217            return None;
2218        }
2219        words.push(word);
2220        if words.len() > 4096 {
2221            return None;
2222        }
2223        command = rest;
2224    }
2225    Some(words)
2226}
2227
2228/// The common linker flags that do not introduce another output file. Go's
2229/// lower-level flags also include profiles, temporary directories and external
2230/// commands, so arbitrary -ldflags/-gcflags/-asmflags cannot share a one-file
2231/// delivery contract.
2232fn go_linker_flags_have_one_output(value: &str) -> bool {
2233    let mut flags = value.split_whitespace();
2234    let mut present = false;
2235    while let Some(flag) = flags.next() {
2236        present = true;
2237        match flag {
2238            "-s" | "-w" | "-s=true" | "-s=false" | "-w=true" | "-w=false" => {}
2239            "-X" => {
2240                if !flags.next().is_some_and(|value| {
2241                    value
2242                        .split_once('=')
2243                        .is_some_and(|(name, _)| !name.is_empty())
2244                }) {
2245                    return false;
2246                }
2247            }
2248            _ => return false,
2249        }
2250    }
2251    present
2252}
2253
2254/// The one project-relative file promised by a supported `go build -o`.
2255#[derive(Debug, Clone, PartialEq, Eq)]
2256pub struct GoBuildOutput {
2257    /// Literal path, relative to the invocation directory.
2258    pub path: String,
2259    /// Index of the -o option in the normalized argv, including `go build`.
2260    /// The executor uses this to replace only the actual output option, never
2261    /// an opaque argument value that happens to resemble a flag.
2262    pub option_index: usize,
2263}
2264
2265/// Plan the explicit file output of a supported Go build.
2266///
2267/// Classification and durable retrieval use this same parser. Implicit output
2268/// names depend on the selected packages (including what ./... matches), and
2269/// directories or native-library build modes can produce multiple files. Keep
2270/// those forms local until their complete output sets can be planned.
2271#[must_use]
2272pub fn go_build_output(command: &str) -> Option<GoBuildOutput> {
2273    let normalized = normalize_command(command);
2274    let words = literal_flag_words(&normalized)?;
2275    let mut args = words.into_iter().enumerate();
2276    if args.next()?.1 != "go" || args.next()?.1 != "build" {
2277        return None;
2278    }
2279    let mut output = None;
2280    let mut packages_started = false;
2281    while let Some((option_index, arg)) = args.next() {
2282        if !arg.starts_with('-') {
2283            packages_started = true;
2284            continue;
2285        }
2286        if packages_started {
2287            return None;
2288        }
2289        let (flag, inline) = arg
2290            .split_once('=')
2291            .map_or((arg, None), |(flag, value)| (flag, Some(value)));
2292        match flag {
2293            "-o" => {
2294                let path = inline.or_else(|| args.next().map(|(_, value)| value))?;
2295                if output
2296                    .replace(GoBuildOutput {
2297                        path: path.to_owned(),
2298                        option_index,
2299                    })
2300                    .is_some()
2301                    || path.is_empty()
2302                    || path == "-"
2303                    || path.ends_with('/')
2304                    || path.chars().any(char::is_control)
2305                    || !std::path::Path::new(path).components().all(|part| {
2306                        matches!(
2307                            part,
2308                            std::path::Component::Normal(_) | std::path::Component::CurDir
2309                        )
2310                    })
2311                    || !std::path::Path::new(path)
2312                        .components()
2313                        .any(|part| matches!(part, std::path::Component::Normal(_)))
2314                {
2315                    return None;
2316                }
2317            }
2318            "-buildmode" => {
2319                if !matches!(
2320                    inline.or_else(|| args.next().map(|(_, value)| value))?,
2321                    "default" | "exe" | "pie"
2322                ) {
2323                    return None;
2324                }
2325            }
2326            "-compiler" => {
2327                if inline.or_else(|| args.next().map(|(_, value)| value))? != "gc" {
2328                    return None;
2329                }
2330            }
2331            "-p" | "-tags" | "-covermode" | "-coverpkg" => {
2332                if inline
2333                    .or_else(|| args.next().map(|(_, value)| value))?
2334                    .is_empty()
2335                {
2336                    return None;
2337                }
2338            }
2339            "-ldflags" => {
2340                if !go_linker_flags_have_one_output(
2341                    inline.or_else(|| args.next().map(|(_, value)| value))?,
2342                ) {
2343                    return None;
2344                }
2345            }
2346            "-mod" => {
2347                if !matches!(
2348                    inline.or_else(|| args.next().map(|(_, value)| value))?,
2349                    "readonly" | "vendor"
2350                ) {
2351                    return None;
2352                }
2353            }
2354            "-buildvcs" => {
2355                if inline.is_some_and(|value| !matches!(value, "auto" | "true" | "false")) {
2356                    return None;
2357                }
2358            }
2359            "-a" | "-race" | "-msan" | "-asan" | "-v" | "-x" | "-trimpath" | "-modcacherw"
2360            | "-cover" => {
2361                if inline.is_some_and(|value| !matches!(value, "true" | "false")) {
2362                    return None;
2363                }
2364            }
2365            _ => return None,
2366        }
2367    }
2368    output
2369}
2370
2371/// Classify Go builds with a complete explicit output contract, and streaming
2372/// test/vet commands. File-producing test modes remain local until they have an
2373/// equivalent contract; arbitrary subcommands are never offloaded here.
2374fn classify_go(cmd: &str) -> Classification {
2375    let mut tokens = cmd.split_whitespace();
2376    // Skip the leading "go".
2377    tokens.next();
2378
2379    let subcommand = match tokens.next() {
2380        Some(sub) => sub,
2381        None => return Classification::not_compilation("bare `go` is not a compilation command"),
2382    };
2383
2384    // Remaining args, used for flag checks below.
2385    let args: Vec<&str> = tokens.collect();
2386
2387    match subcommand {
2388        "build" => {
2389            if go_build_output(cmd).is_none() {
2390                return Classification::not_compilation(
2391                    "go build requires a supported explicit -o file for artifact delivery",
2392                );
2393            }
2394            if !cfg!(unix) {
2395                return Classification::not_compilation(
2396                    "Go output delivery requires a Unix dispatcher and worker",
2397                );
2398            }
2399            Classification::compilation(CompilationKind::GoBuild, 0.95, "go build command")
2400        }
2401        "test" => {
2402            // These flags accept both separated and = values. Profiling flags
2403            // may also be passed directly to the test binary as -test.*.
2404            if args.iter().any(|arg| {
2405                // Also catch a quoted -flag=value whose value contains spaces.
2406                // A flag-looking value is conservatively left local.
2407                let arg = arg.trim_matches(['\'', '"']);
2408                if !arg.starts_with('-') {
2409                    return false;
2410                }
2411                let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag);
2412                let flag = flag.trim_start_matches('-');
2413                let flag = flag.strip_prefix("test.").unwrap_or(flag);
2414                matches!(
2415                    flag,
2416                    "c" | "o"
2417                        | "exec"
2418                        | "coverprofile"
2419                        | "cpuprofile"
2420                        | "memprofile"
2421                        | "blockprofile"
2422                        | "mutexprofile"
2423                        | "trace"
2424                        | "outputdir"
2425                        | "fuzz"
2426                        | "fuzzcachedir"
2427                        | "gocoverdir"
2428                        | "testlogfile"
2429                )
2430            }) {
2431                return Classification::not_compilation(
2432                    "go test emits files or uses a local execution wrapper (not intercepted)",
2433                );
2434            }
2435            Classification::compilation(CompilationKind::GoTest, 0.95, "go test command")
2436        }
2437        "vet" => Classification::compilation(CompilationKind::GoVet, 0.95, "go vet command"),
2438        _ => Classification::not_compilation("go subcommand is not a compilation command"),
2439    }
2440}
2441
2442/// Whether Cargo will build and verify a package archive without publishing it.
2443///
2444/// Kept separate from the protocol kind so the client can use the existing
2445/// `CargoBuild` worker contract and return the archive that Cargo verified.
2446/// Unknown options, shell expansion/quoting, and argument separators are
2447/// declined: a token that merely occurs inside an option value must never
2448/// authorize offloading a real `cargo publish` invocation.
2449pub fn is_cargo_package_verification(command: &str) -> bool {
2450    let normalized = normalize_command(command);
2451    let cmd = normalized.as_ref();
2452    if check_structure(cmd).is_some() || cmd.contains(['\'', '"', '\\', '$', '`']) {
2453        return false;
2454    }
2455
2456    let mut tokens = cmd.split_whitespace();
2457    if tokens.next() != Some("cargo") {
2458        return false;
2459    }
2460
2461    let mut subcommand = None;
2462    let mut dry_run = false;
2463    const VALUE_FLAGS: &[&str] = &[
2464        "--index",
2465        "--registry",
2466        "--token",
2467        "--color",
2468        "--config",
2469        "-Z",
2470        "-C",
2471        "-p",
2472        "--package",
2473        "--exclude",
2474        "-F",
2475        "--features",
2476        "-j",
2477        "--jobs",
2478        "--target",
2479        "--target-dir",
2480        "-m",
2481        "--manifest-path",
2482        "--lockfile-path",
2483        "--message-format",
2484    ];
2485
2486    while let Some(token) = tokens.next() {
2487        if subcommand.is_none() && token.starts_with('+') {
2488            continue;
2489        }
2490        if matches!(token, "package" | "publish") && subcommand.is_none() {
2491            subcommand = Some(token);
2492            continue;
2493        }
2494        if VALUE_FLAGS.contains(&token) {
2495            if tokens.next().is_none_or(|value| value.starts_with('-')) {
2496                return false;
2497            }
2498            continue;
2499        }
2500        if let Some((flag, value)) = token.split_once('=')
2501            && VALUE_FLAGS.contains(&flag)
2502            && !value.is_empty()
2503        {
2504            continue;
2505        }
2506        if ["-j", "-p", "-F", "-m", "-Z"]
2507            .iter()
2508            .any(|flag| token.starts_with(flag) && token.len() > flag.len())
2509        {
2510            continue;
2511        }
2512        match token {
2513            "--dry-run" | "-n" => dry_run = true,
2514            "--workspace"
2515            | "--allow-dirty"
2516            | "--no-metadata"
2517            | "--exclude-lockfile"
2518            | "--all-features"
2519            | "--no-default-features"
2520            | "--keep-going"
2521            | "--locked"
2522            | "--offline"
2523            | "--frozen"
2524            | "--verbose"
2525            | "-v"
2526            | "-vv"
2527            | "-vvv"
2528            | "--quiet"
2529            | "-q" => {}
2530            // Includes --no-verify, --list/-l, --help/-h and --. Reject unknown
2531            // value-taking flags rather than mistaking their value for -n.
2532            _ => return false,
2533        }
2534    }
2535
2536    matches!(subcommand, Some("package")) || (subcommand == Some("publish") && dry_run)
2537}
2538
2539/// Recognize explicit cargo-xwin build, check, and clippy entry points, without
2540/// treating a mention in a Cargo option value as permission to relax the native
2541/// MSVC gate. Requested Rust targets and toolchains still go through admission.
2542/// Cache mutation, environment inspection, and other subcommands are deliberately
2543/// outside this offload surface.
2544#[must_use]
2545pub fn cargo_xwin_compilation_kind(command: &str) -> Option<CompilationKind> {
2546    let normalized = normalize_command(command);
2547    let cmd = normalized.as_ref();
2548    let mut tokens = cmd.split_whitespace().map(unquote_literal_option_word);
2549    if check_structure(cmd).is_some()
2550        || tokens
2551            .clone()
2552            .any(|token| token.contains(['\'', '"', '\\', '$', '`']))
2553    {
2554        return None;
2555    }
2556    let subcommand = match tokens.next() {
2557        Some("cargo-xwin") => {
2558            let subcommand = tokens.next();
2559            if subcommand == Some("xwin") {
2560                tokens.next()
2561            } else {
2562                subcommand
2563            }
2564        }
2565        Some("cargo") => loop {
2566            match tokens.next() {
2567                Some("xwin") => break tokens.next(),
2568                Some("--color" | "--config" | "-Z" | "-C") => {
2569                    if tokens.next().is_none_or(|value| value.starts_with('-')) {
2570                        return None;
2571                    }
2572                }
2573                Some("--locked" | "--offline" | "--frozen" | "-v" | "-vv" | "-q") => {}
2574                Some(token)
2575                    if token.starts_with('+')
2576                        || token.starts_with("--color=")
2577                        || token.starts_with("--config=") => {}
2578                _ => return None,
2579            }
2580        },
2581        _ => return None,
2582    };
2583    let kind = match subcommand? {
2584        "build" => CompilationKind::CargoBuild,
2585        "check" => CompilationKind::CargoCheck,
2586        "clippy" => CompilationKind::CargoClippy,
2587        _ => return None,
2588    };
2589    if tokens.any(|token| matches!(token, "--help" | "-h" | "--version" | "-V")) {
2590        return None;
2591    }
2592    Some(kind)
2593}
2594
2595/// Backward compatibility helper for cargo-xwin build commands.
2596#[must_use]
2597pub fn is_cargo_xwin_build(command: &str) -> bool {
2598    matches!(
2599        cargo_xwin_compilation_kind(command),
2600        Some(CompilationKind::CargoBuild)
2601    )
2602}
2603
2604/// `rch exec` rebuilds its command with `shell_words::join`, which single-quotes
2605/// every word containing `=`: `cargo --config=build.jobs=2 clippy` arrives as
2606/// `cargo '--config=build.jobs=2' clippy`. Read such a fully quoted word as its
2607/// content; a word with any other quoting is returned unchanged.
2608fn unquote_whole_word(token: &str) -> &str {
2609    token
2610        .strip_prefix('\'')
2611        .and_then(|rest| rest.strip_suffix('\''))
2612        .filter(|inner| !inner.contains('\''))
2613        .unwrap_or(token)
2614}
2615
2616/// Read simple option words quoted by `shell_words::join` without interpreting
2617/// quoted command names, standalone values, mixed quoting, or shell syntax.
2618/// Keeping this decoder shared with admission preserves quoted target options.
2619pub(crate) fn unquote_literal_option_word(token: &str) -> &str {
2620    let word = unquote_whole_word(token);
2621    if word.starts_with('-')
2622        && word
2623            .bytes()
2624            .all(|byte| byte.is_ascii_alphanumeric() || b"-_./=:+,".contains(&byte))
2625    {
2626        word
2627    } else {
2628        token
2629    }
2630}
2631
2632/// Classify cargo subcommands.
2633///
2634/// Performance: uses iterator `.nth()` to avoid Vec allocation on the hot path.
2635/// We only need tokens 0-3 (cargo, [+toolchain], subcommand, [nextest-subcommand]).
2636fn classify_cargo(cmd: &str) -> Classification {
2637    let mut tokens = cmd.split_whitespace();
2638
2639    // Token 0: "cargo" (already validated by caller)
2640    let _cargo = tokens.next();
2641
2642    // Skip an optional +toolchain override AND any leading global flags that can
2643    // precede the subcommand (e.g. `cargo --offline build`, `cargo -q test`,
2644    // `cargo --locked check`, `cargo --color never build`). Without this, a
2645    // global flag was read AS the subcommand, so a real build was misclassified
2646    // non-compilation and silently run locally. Value-taking global flags
2647    // written without `=` consume their following value token.
2648    const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2649    let subcommand = loop {
2650        let Some(tok) = tokens.next().map(unquote_whole_word) else {
2651            return Classification::not_compilation("bare cargo command");
2652        };
2653        if tok.starts_with('+') {
2654            // +toolchain override — skip.
2655            continue;
2656        }
2657        if tok.starts_with('-') {
2658            if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2659                // Consume the separate value token (e.g. `--color never`).
2660                let _ = tokens.next();
2661            }
2662            continue;
2663        }
2664        break tok;
2665    };
2666
2667    match subcommand {
2668        "build" | "b" => {
2669            Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build")
2670        }
2671        "package" | "publish" => {
2672            if is_cargo_package_verification(cmd) {
2673                Classification::compilation(
2674                    CompilationKind::CargoBuild,
2675                    0.95,
2676                    "cargo package verification (includes build)",
2677                )
2678            } else {
2679                Classification::not_compilation(
2680                    "cargo package/publish without supported verification (never-intercept)",
2681                )
2682            }
2683        }
2684        "test" | "t" => Classification::compilation(CompilationKind::CargoTest, 0.95, "cargo test"),
2685        "check" | "c" => {
2686            Classification::compilation(CompilationKind::CargoCheck, 0.90, "cargo check")
2687        }
2688        "clippy" => Classification::compilation(CompilationKind::CargoClippy, 0.90, "cargo clippy"),
2689        "doc" => Classification::compilation(CompilationKind::CargoDoc, 0.85, "cargo doc"),
2690        "run" | "r" => {
2691            // cargo run compiles first, so it's a compilation command
2692            Classification::compilation(
2693                CompilationKind::CargoBuild,
2694                0.85,
2695                "cargo run (includes build)",
2696            )
2697        }
2698        "bench" => Classification::compilation(CompilationKind::CargoBench, 0.90, "cargo bench"),
2699        // `cargo zigbuild ...` — cross-compile via zig. Same offload shape as
2700        // `cargo build` (produces a real artifact under target/<triple>/), but
2701        // gated additionally on the worker having zig + cargo-zigbuild + the
2702        // requested rustup target (enforced downstream by needs_zig/needs_targets).
2703        "zigbuild" => {
2704            Classification::compilation(CompilationKind::CargoZigbuild, 0.95, "cargo zigbuild")
2705        }
2706        "xwin" => {
2707            if let Some(kind) = cargo_xwin_compilation_kind(cmd) {
2708                Classification::compilation(kind, 0.95, "cargo xwin compilation")
2709            } else {
2710                Classification::not_compilation("cargo xwin subcommand not interceptable")
2711            }
2712        }
2713        "nextest" => {
2714            // cargo nextest has subcommands: run, list, archive, show
2715            // Only intercept "run" - the actual test execution
2716            // Next token is the nextest subcommand (e.g., "run", "list")
2717            let Some(nextest_sub) = tokens.next() else {
2718                return Classification::not_compilation("bare cargo nextest without subcommand");
2719            };
2720
2721            match nextest_sub {
2722                "run" | "r" => Classification::compilation(
2723                    CompilationKind::CargoNextest,
2724                    0.95,
2725                    "cargo nextest run",
2726                ),
2727                _ => Classification::not_compilation("cargo nextest subcommand not interceptable"),
2728            }
2729        }
2730        _ => Classification::not_compilation("cargo subcommand not interceptable"),
2731    }
2732}
2733
2734/// Classify the standalone `cargo-zigbuild` binary (what `cargo zigbuild ...`
2735/// execs). Only the artifact-producing build forms are offloaded:
2736///
2737/// - `cargo-zigbuild zigbuild ...` → cross-build via zig (the canonical form)
2738/// - `cargo-zigbuild build ...` → same, spelled with the plain subcommand
2739///
2740/// Everything else (`check`, `test`, `run`, metadata) is declined here and runs
2741/// locally, matching the conservative stance in `classify_cargo` for non-build
2742/// forms. All accepted forms map to `CompilationKind::CargoZigbuild`.
2743fn classify_cargo_zigbuild(cmd: &str) -> Classification {
2744    let mut tokens = cmd.split_whitespace();
2745    // Token 0: "cargo-zigbuild" (already validated by caller).
2746    let _bin = tokens.next();
2747
2748    // Skip an optional +toolchain override and leading global flags, mirroring
2749    // classify_cargo so `cargo-zigbuild +nightly zigbuild ...` still classifies.
2750    const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2751    let subcommand = loop {
2752        let Some(tok) = tokens.next().map(unquote_whole_word) else {
2753            return Classification::not_compilation("bare cargo-zigbuild command");
2754        };
2755        if tok.starts_with('+') {
2756            continue;
2757        }
2758        if tok.starts_with('-') {
2759            if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2760                let _ = tokens.next();
2761            }
2762            continue;
2763        }
2764        break tok;
2765    };
2766
2767    match subcommand {
2768        "zigbuild" | "build" | "b" => Classification::compilation(
2769            CompilationKind::CargoZigbuild,
2770            0.95,
2771            "cargo-zigbuild build",
2772        ),
2773        _ => Classification::not_compilation("cargo-zigbuild subcommand not interceptable"),
2774    }
2775}
2776
2777/// Nix global flags (appearing BEFORE the subcommand) that consume the following
2778/// whitespace-separated token as their value. Without skipping the value token
2779/// we could read it AS the subcommand and either miss a real build or, worse,
2780/// misclassify — so on any ambiguity we prefer to fall back to local execution.
2781///
2782/// This is a best-effort list of the common value-taking global flags; the
2783/// two-value `--option NAME VALUE` form is handled separately. Space-attached
2784/// (`--max-jobs=4`) forms carry their value in the same token and need no skip.
2785const NIX_GLOBAL_VALUE_FLAGS: &[&str] = &[
2786    "--extra-experimental-features",
2787    "--experimental-features",
2788    "--log-format",
2789    "--max-jobs",
2790    "-j",
2791    "--cores",
2792    "--builders",
2793    "--store",
2794    "--file",
2795    "-f",
2796    "-I",
2797    "--include",
2798];
2799
2800/// Classify a `nix ...` (multi-tool) or `nix-build ...` (legacy) command.
2801///
2802/// Offloadable (build-like, non-interactive, non-mutating):
2803///   - `nix build ...`         → build a derivation
2804///   - `nix-build ...`         → legacy derivation build
2805///   - `nix flake check`       → evaluate + build ALL flake outputs
2806///   - `nix develop -c <cmd>`  → run a command non-interactively in a dev shell
2807///   - `nix shell -c <cmd>`    → run a command non-interactively with packages
2808///
2809/// NOT offloadable (declined here → local fallback):
2810///   - bare `nix develop` / `nix shell`  → interactive shells
2811///   - `nix run` / `nix repl`            → app execution / interactive
2812///   - `nix flake metadata|show|update|lock|...`, `nix eval|search|...` → metadata
2813///   - `nix profile|registry|store|copy|...`, `nix-env`, `nix-shell` (no keyword)
2814///     → mutating / package management
2815///
2816/// Nix outputs never travel back (they live in the worker's `/nix/store`); these
2817/// are streaming, exit-status-only commands. Requires the worker to have nix +
2818/// a populated `/nix/store` and network access to fetch flake inputs — enforced
2819/// by the `RequiredRuntime::Nix` capability gate in worker selection.
2820fn classify_nix(cmd: &str) -> Classification {
2821    // Legacy `nix-build` is unconditionally a derivation build.
2822    if cmd.eq("nix-build") || cmd.starts_with("nix-build ") {
2823        return Classification::compilation(
2824            CompilationKind::NixBuild,
2825            0.9,
2826            "nix-build (legacy) derivation build",
2827        );
2828    }
2829
2830    let mut tokens = cmd.split_whitespace();
2831    let _nix = tokens.next(); // "nix" (validated by caller)
2832
2833    // Locate the subcommand, skipping leading global flags (and their values).
2834    let subcommand = loop {
2835        let Some(tok) = tokens.next() else {
2836            return Classification::not_compilation("bare nix command (no subcommand)");
2837        };
2838        if tok.starts_with('-') {
2839            if tok.eq("--option") {
2840                // `--option NAME VALUE` consumes two following tokens.
2841                let _ = tokens.next();
2842                let _ = tokens.next();
2843            } else if !tok.contains('=') && NIX_GLOBAL_VALUE_FLAGS.contains(&tok) {
2844                let _ = tokens.next();
2845            }
2846            continue;
2847        }
2848        break tok;
2849    };
2850
2851    // Remaining tokens after the subcommand (for -c/--command and --help checks).
2852    let rest: Vec<&str> = tokens.collect();
2853    let wants_help = rest.iter().any(|t| t.eq(&"--help") || t.eq(&"-h"));
2854    if wants_help {
2855        return Classification::not_compilation("nix --help (not a build)");
2856    }
2857    let has_command_flag = rest.iter().any(|t| t.eq(&"-c") || t.eq(&"--command"));
2858
2859    match subcommand {
2860        // `nix build` builds a derivation → offload (streaming, no artifact return).
2861        "build" => {
2862            Classification::compilation(CompilationKind::NixBuild, 0.9, "nix build derivation")
2863        }
2864        // `nix flake check` evaluates AND builds every flake output (CPU-heavy) →
2865        // offload. Every other `nix flake <sub>` (metadata/show/update/lock/init/
2866        // archive/...) is metadata or mutating and must stay local.
2867        "flake" => {
2868            let flake_sub = rest.iter().find(|t| !t.starts_with('-'));
2869            if flake_sub == Some(&"check") {
2870                Classification::compilation(
2871                    CompilationKind::NixBuild,
2872                    0.9,
2873                    "nix flake check (evaluates + builds all outputs)",
2874                )
2875            } else {
2876                Classification::not_compilation("nix flake subcommand is not a build")
2877            }
2878        }
2879        // `nix develop -c <cmd>` / `nix shell -c <cmd>` run a command
2880        // non-interactively in the environment → offload. Bare forms are
2881        // interactive shells and must NOT be intercepted.
2882        "develop" | "shell" => {
2883            if has_command_flag {
2884                let reason = if subcommand == "develop" {
2885                    "nix develop -c (non-interactive command in dev shell)"
2886                } else {
2887                    "nix shell -c (non-interactive command with packages)"
2888                };
2889                Classification::compilation(CompilationKind::NixBuild, 0.9, reason)
2890            } else {
2891                Classification::not_compilation(
2892                    "interactive nix shell (no -c/--command; not intercepted)",
2893                )
2894            }
2895        }
2896        // run / repl / eval / search / profile / registry / store / copy /
2897        // path-info / why-depends / flake metadata|show|update / --version / ...
2898        // are execution, mutation, or metadata: never intercepted.
2899        _ => Classification::not_compilation("nix subcommand is not an offloadable build"),
2900    }
2901}
2902
2903/// Split a shell command string on unquoted `;`, `&&`, and `||` operators.
2904///
2905/// Returns a list of sub-commands with each segment trimmed of whitespace.
2906/// Characters inside single quotes, double quotes, or backticks are treated
2907/// as literal and are never split. Escaped quotes (`\'`, `\"`) do not toggle
2908/// quote state. Pipes (`|`) within a segment are preserved (they are part of
2909/// one command, not a command separator).
2910///
2911/// Performance: single-pass O(n) character state machine, no regex, no heap
2912/// allocation for the common single-command case.
2913pub fn split_shell_commands(cmd: &str) -> Vec<&str> {
2914    let bytes = cmd.as_bytes();
2915    let len = bytes.len();
2916    let mut segments: Vec<&str> = Vec::new();
2917    let mut start = 0;
2918    let mut i = 0;
2919
2920    // Quote state
2921    let mut in_single = false;
2922    let mut in_double = false;
2923    let mut in_backtick = false;
2924    let mut escaped = false;
2925
2926    while i < len {
2927        let b = bytes[i];
2928
2929        if escaped {
2930            escaped = false;
2931            i += 1;
2932            continue;
2933        }
2934
2935        // POSIX: backslash is LITERAL inside single quotes; only honor it as an
2936        // escape when not in a single-quoted span, or the next `'` is wrongly
2937        // swallowed and the quote stays "open", hiding an exposed operator
2938        // (bd-review-singlequote-escape).
2939        if b == b'\\' && !in_single {
2940            escaped = true;
2941            i += 1;
2942            continue;
2943        }
2944
2945        // Toggle quote state
2946        if !in_double && !in_backtick && b == b'\'' {
2947            in_single = !in_single;
2948            i += 1;
2949            continue;
2950        }
2951        if !in_single && !in_backtick && b == b'"' {
2952            in_double = !in_double;
2953            i += 1;
2954            continue;
2955        }
2956        if !in_single && !in_double && b == b'`' {
2957            in_backtick = !in_backtick;
2958            i += 1;
2959            continue;
2960        }
2961
2962        // Only split when outside all quotes
2963        if in_single || in_double || in_backtick {
2964            i += 1;
2965            continue;
2966        }
2967
2968        // Check for `&&`
2969        if b == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
2970            let seg = cmd[start..i].trim();
2971            if !seg.is_empty() {
2972                segments.push(seg);
2973            }
2974            i += 2;
2975            start = i;
2976            continue;
2977        }
2978
2979        // Check for `||`
2980        if b == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
2981            let seg = cmd[start..i].trim();
2982            if !seg.is_empty() {
2983                segments.push(seg);
2984            }
2985            i += 2;
2986            start = i;
2987            continue;
2988        }
2989
2990        // Check for `;`
2991        if b == b';' {
2992            let seg = cmd[start..i].trim();
2993            if !seg.is_empty() {
2994                segments.push(seg);
2995            }
2996            i += 1;
2997            start = i;
2998            continue;
2999        }
3000
3001        i += 1;
3002    }
3003
3004    // Push the final segment
3005    let seg = cmd[start..].trim();
3006    if !seg.is_empty() {
3007        segments.push(seg);
3008    }
3009
3010    segments
3011}
3012
3013#[cfg(test)]
3014mod tests {
3015    use super::*;
3016
3017    #[test]
3018    fn cargo_xwin_compilations_use_their_cargo_contracts() {
3019        for (subcommand, kind, arguments) in [
3020            (
3021                "build",
3022                CompilationKind::CargoBuild,
3023                "--release --locked --target x86_64-pc-windows-msvc",
3024            ),
3025            (
3026                "check",
3027                CompilationKind::CargoCheck,
3028                "--locked --all-targets --target aarch64-pc-windows-msvc",
3029            ),
3030            (
3031                "clippy",
3032                CompilationKind::CargoClippy,
3033                "--locked --all-targets --target=x86_64-pc-windows-msvc -- -D warnings",
3034            ),
3035        ] {
3036            for prefix in [
3037                "cargo xwin",
3038                "cargo +nightly-2026-08-31 xwin",
3039                "cargo --color never --locked xwin",
3040                "cargo --config build.jobs=2 xwin",
3041                "env CARGO_HOME=/root/.cargo cargo xwin",
3042                "cargo-xwin",
3043                "cargo-xwin xwin",
3044                "/home/ubuntu/.local/bin/cargo-xwin xwin",
3045            ] {
3046                let command = format!("{prefix} {subcommand} {arguments}");
3047                assert_eq!(
3048                    cargo_xwin_compilation_kind(&command),
3049                    Some(kind),
3050                    "{command}"
3051                );
3052                let classification = classify_command(&command);
3053                assert!(classification.is_compilation, "{command}");
3054                assert_eq!(classification.kind, Some(kind), "{command}");
3055            }
3056        }
3057    }
3058
3059    #[test]
3060    fn cargo_xwin_recognition_declines_unsupported_commands_and_argument_mentions() {
3061        for command in [
3062            "cargo xwin",
3063            "cargo xwin env",
3064            "cargo xwin cache clear",
3065            "cargo xwin publish",
3066            "cargo xwin test",
3067            "cargo xwin run",
3068            "cargo xwin unknown",
3069            "cargo xwin build --help",
3070            "cargo xwin check --help",
3071            "cargo xwin clippy --version",
3072            "cargo-xwin --version",
3073            "cargo-xwin cache clear",
3074            "cargo-xwin xwin build --help",
3075            "cargo-xwin xwin check -h",
3076            "cargo-xwin clippy -V",
3077            "cargo-xwin xwin env",
3078            "cargo-xwin check-extra",
3079            "cargo xwin --help check",
3080            "cargo xwin 'check'",
3081            "cargo xwin $SUBCOMMAND",
3082            "cargo --config xwin build --target x86_64-pc-windows-msvc",
3083            "cargo --config xwin check --target x86_64-pc-windows-msvc",
3084            "cargo --config xwin clippy --target x86_64-pc-windows-msvc",
3085            "cargo build --manifest-path cargo-xwin --target x86_64-pc-windows-msvc",
3086            "cargo check --manifest-path 'cargo xwin check' --target x86_64-pc-windows-msvc",
3087            "cargo clippy --config 'alias.cross=xwin' --target x86_64-pc-windows-msvc",
3088            "echo cargo xwin check",
3089            "cargo xwin build && cargo build",
3090            "cargo xwin check && cargo check",
3091            "cargo xwin clippy | cat",
3092        ] {
3093            assert_eq!(cargo_xwin_compilation_kind(command), None, "{command}");
3094        }
3095        for command in [
3096            "cargo xwin env",
3097            "cargo xwin cache clear",
3098            "cargo xwin publish",
3099            "cargo xwin test",
3100            "cargo xwin run",
3101            "cargo xwin unknown",
3102            "cargo xwin check --help",
3103            "cargo xwin clippy --version",
3104            "cargo-xwin --version",
3105            "cargo-xwin xwin check -h",
3106            "cargo-xwin clippy -V",
3107            "cargo-xwin xwin env",
3108            "cargo-xwin check-extra",
3109            "cargo xwin 'check'",
3110        ] {
3111            assert!(!classify_command(command).is_compilation, "{command}");
3112        }
3113    }
3114    use crate::test_guard;
3115
3116    #[test]
3117    fn test_cargo_build_with_toolchain() {
3118        let _guard = test_guard!();
3119        let result = classify_command("cargo +nightly build");
3120        assert!(result.is_compilation);
3121        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3122    }
3123
3124    // ===================== Nix classification (issue #26) =====================
3125
3126    #[test]
3127    fn test_nix_build_offloadable() {
3128        let _guard = test_guard!();
3129        for cmd in [
3130            "nix build",
3131            "nix build .#foo",
3132            "nix build .#packages.x86_64-linux.default",
3133            "nix build /path/to/flake#pkg",
3134            "nix build --extra-experimental-features 'nix-command flakes' .#foo",
3135            "nix --option sandbox true build .#foo",
3136            "nix -j4 build .#foo",
3137            "nix build nixpkgs#hello -o result-hello",
3138        ] {
3139            let result = classify_command(cmd);
3140            assert!(result.is_compilation, "expected offload for `{cmd}`");
3141            assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3142            assert!(result.confidence >= 0.85, "`{cmd}` confidence too low");
3143        }
3144    }
3145
3146    #[test]
3147    fn test_nix_build_legacy_offloadable() {
3148        let _guard = test_guard!();
3149        for cmd in ["nix-build", "nix-build .", "nix-build -A hello"] {
3150            let result = classify_command(cmd);
3151            assert!(result.is_compilation, "expected offload for `{cmd}`");
3152            assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3153        }
3154    }
3155
3156    #[test]
3157    fn test_nix_flake_check_offloadable_but_metadata_not() {
3158        let _guard = test_guard!();
3159        let check = classify_command("nix flake check");
3160        assert!(check.is_compilation, "nix flake check should offload");
3161        assert_eq!(check.kind, Some(CompilationKind::NixBuild));
3162
3163        for cmd in [
3164            "nix flake metadata",
3165            "nix flake show",
3166            "nix flake update",
3167            "nix flake lock",
3168            "nix flake info",
3169        ] {
3170            let result = classify_command(cmd);
3171            assert!(!result.is_compilation, "`{cmd}` must NOT offload");
3172        }
3173    }
3174
3175    #[test]
3176    fn test_nix_develop_and_shell_only_with_command() {
3177        let _guard = test_guard!();
3178        // Non-interactive `-c`/`--command` forms are offloadable.
3179        for cmd in [
3180            "nix develop -c make",
3181            "nix develop --command cargo build",
3182            "nix develop .#dev -c bun test",
3183            "nix shell nixpkgs#gcc -c make",
3184            "nix shell nixpkgs#hello --command hello",
3185        ] {
3186            let result = classify_command(cmd);
3187            assert!(result.is_compilation, "expected offload for `{cmd}`");
3188            assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
3189        }
3190        // Bare interactive shells must NOT be intercepted.
3191        for cmd in ["nix develop", "nix develop .#dev", "nix shell nixpkgs#gcc"] {
3192            let result = classify_command(cmd);
3193            assert!(
3194                !result.is_compilation,
3195                "`{cmd}` is interactive; must run local"
3196            );
3197        }
3198    }
3199
3200    #[test]
3201    fn test_nix_execution_and_mutation_not_offloaded() {
3202        let _guard = test_guard!();
3203        for cmd in [
3204            "nix run .#app",
3205            "nix run nixpkgs#hello",
3206            "nix repl",
3207            "nix eval .#foo",
3208            "nix search nixpkgs hello",
3209            "nix profile install nixpkgs#hello",
3210            "nix registry list",
3211            "nix store gc",
3212            "nix copy --to ssh://host .#foo",
3213            "nix path-info .#foo",
3214            "nix --version",
3215            "nix build --help",
3216            "nix",
3217        ] {
3218            let result = classify_command(cmd);
3219            assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
3220        }
3221    }
3222
3223    #[test]
3224    fn test_nix_legacy_mutating_tools_have_no_keyword() {
3225        let _guard = test_guard!();
3226        // `nix-env`, `nix-shell`, `nix-store`, ... only share the "nix-" prefix;
3227        // they are NOT the `nix`/`nix-build` keywords, so Tier-2 rejects them
3228        // before classification (no accidental interception).
3229        for cmd in [
3230            "nix-env -iA nixpkgs.hello",
3231            "nix-shell",
3232            "nix-shell -p gcc",
3233            "nix-store --gc",
3234            "nix-collect-garbage -d",
3235            "nix-channel --update",
3236        ] {
3237            assert!(
3238                !starts_with_compilation_command(cmd),
3239                "`{cmd}` should not match any compilation keyword"
3240            );
3241            let result = classify_command(cmd);
3242            assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
3243        }
3244    }
3245
3246    #[test]
3247    fn test_nix_build_maps_to_nix_runtime_base() {
3248        let _guard = test_guard!();
3249        assert_eq!(CompilationKind::NixBuild.command_base(), "nix");
3250        assert!(!CompilationKind::NixBuild.is_test_command());
3251    }
3252
3253    #[test]
3254    fn test_nix_build_compound_and_wrapped() {
3255        let _guard = test_guard!();
3256        // Compound `cd X && nix build` should offload the nix build tail.
3257        let compound = classify_command("cd /repo && nix build .#foo");
3258        assert!(compound.is_compilation, "compound nix build should offload");
3259        assert_eq!(compound.kind, Some(CompilationKind::NixBuild));
3260        // `bash -c "nix build .#foo"` wrapper should offload the inner command.
3261        let wrapped = classify_command("bash -c \"nix build .#foo\"");
3262        assert!(wrapped.is_compilation, "wrapped nix build should offload");
3263        assert_eq!(wrapped.kind, Some(CompilationKind::NixBuild));
3264    }
3265
3266    #[test]
3267    fn test_job_kind_serde_and_base_token() {
3268        let _guard = test_guard!();
3269        // The wire name is stable snake_case so rchd/telemetry can record it.
3270        let json = serde_json::to_string(&CompilationKind::Job).unwrap();
3271        assert_eq!(json, "\"job\"");
3272        let round: CompilationKind = serde_json::from_str(&json).unwrap();
3273        assert_eq!(round, CompilationKind::Job);
3274        // Total mapping token (see command_base docs — the hook-path allowlist
3275        // gate can never observe this kind).
3276        assert_eq!(CompilationKind::Job.command_base(), "job");
3277        assert!(!CompilationKind::Job.is_test_command());
3278    }
3279
3280    #[test]
3281    fn test_cargo_test_with_toolchain() {
3282        let _guard = test_guard!();
3283        let result = classify_command("cargo +1.80.0 test");
3284        assert!(result.is_compilation);
3285        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3286    }
3287
3288    #[test]
3289    fn test_cargo_nextest_with_toolchain() {
3290        let _guard = test_guard!();
3291        let result = classify_command("cargo +nightly nextest run");
3292        assert!(result.is_compilation);
3293        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
3294    }
3295
3296    #[test]
3297    fn test_cargo_build() {
3298        let _guard = test_guard!();
3299        let result = classify_command("cargo build");
3300        assert!(result.is_compilation);
3301        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3302        assert!(result.confidence >= 0.90);
3303    }
3304
3305    #[test]
3306    fn test_cargo_build_release() {
3307        let _guard = test_guard!();
3308        let result = classify_command("cargo build --release");
3309        assert!(result.is_compilation);
3310        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3311    }
3312
3313    #[test]
3314    fn test_cargo_test() {
3315        let _guard = test_guard!();
3316        let result = classify_command("cargo test");
3317        assert!(result.is_compilation);
3318        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3319    }
3320
3321    #[test]
3322    fn test_cargo_fmt_not_intercepted() {
3323        let _guard = test_guard!();
3324        let result = classify_command("cargo fmt");
3325        assert!(!result.is_compilation);
3326        assert!(result.reason.contains("never-intercept"));
3327    }
3328
3329    #[test]
3330    fn test_cargo_install_not_intercepted() {
3331        let _guard = test_guard!();
3332        let result = classify_command("cargo install ripgrep");
3333        assert!(!result.is_compilation);
3334        assert!(result.reason.contains("never-intercept"));
3335    }
3336
3337    #[test]
3338    fn test_piped_to_benign_pager_intercepted() {
3339        let _guard = test_guard!();
3340        // Issue #24: piping a compilation command's output into a benign pager
3341        // (grep/head/tee/...) is now offloaded, with the pipe re-attached to the
3342        // offloaded command's output.
3343        let result = classify_command("cargo build 2>&1 | grep error");
3344        assert!(result.is_compilation, "got: {:?}", result.reason);
3345        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3346        assert_eq!(
3347            result.extracted_command.as_deref(),
3348            Some("cargo build 2>&1 | grep error")
3349        );
3350    }
3351
3352    #[test]
3353    fn test_piped_to_nonbenign_command_not_intercepted() {
3354        let _guard = test_guard!();
3355        // Piping into a non-pager command stays conservative (declined).
3356        let result = classify_command("cargo build | xargs rm");
3357        assert!(!result.is_compilation);
3358        assert!(result.reason.contains("piped"));
3359    }
3360
3361    #[test]
3362    fn test_backgrounded_intercepted() {
3363        let _guard = test_guard!();
3364        // Issue #24: a trailing `&` (backgrounding) is now offloaded.
3365        let result = classify_command("cargo build &");
3366        assert!(result.is_compilation, "got: {:?}", result.reason);
3367        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3368        assert_eq!(result.extracted_command.as_deref(), Some("cargo build &"));
3369    }
3370
3371    #[test]
3372    fn test_newline_injection_not_intercepted() {
3373        let _guard = test_guard!();
3374        // Newlines could cause command injection via `sh -c`
3375        let result = classify_command("cargo build\nrm -rf /");
3376        assert!(!result.is_compilation);
3377        assert!(result.reason.contains("newline"));
3378
3379        let result = classify_command("cargo build\r\nrm -rf /");
3380        assert!(!result.is_compilation);
3381        assert!(result.reason.contains("newline"));
3382
3383        for command in [
3384            "cargo build > out.log\nrm -rf /",
3385            "cargo build | tee out.log\r\nrm -rf /",
3386            "cargo build &\nrm -rf /",
3387        ] {
3388            let result = classify_command(command);
3389            assert!(
3390                !result.is_compilation,
3391                "newline-bearing benign suffix must fail closed: {command:?}"
3392            );
3393            assert!(result.reason.contains("newline"), "{result:?}");
3394        }
3395    }
3396
3397    #[test]
3398    fn test_redirected_intercepted() {
3399        let _guard = test_guard!();
3400        // Issue #24: a benign stdout/stderr file redirect is now offloaded with
3401        // the redirect re-attached to the offloaded command's output.
3402        let result = classify_command("cargo build > log.txt");
3403        assert!(result.is_compilation, "got: {:?}", result.reason);
3404        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3405        assert_eq!(
3406            result.extracted_command.as_deref(),
3407            Some("cargo build > log.txt")
3408        );
3409
3410        let result = classify_command("cargo test --workspace > out.log 2>&1");
3411        assert!(result.is_compilation, "got: {:?}", result.reason);
3412        assert_eq!(
3413            result.extracted_command.as_deref(),
3414            Some("cargo test --workspace > out.log 2>&1")
3415        );
3416
3417        // File-descriptor redirects must remain byte-for-byte adjacent. A
3418        // space before `>` turns the descriptor into a command argument.
3419        for command in [
3420            "cargo build 2>errors.txt",
3421            "cargo build 2>>errors.txt",
3422            "cargo build >out.log 2>&1",
3423        ] {
3424            let result = classify_command(command);
3425            assert!(result.is_compilation, "got: {:?}", result.reason);
3426            assert_eq!(result.extracted_command.as_deref(), Some(command));
3427        }
3428    }
3429
3430    #[test]
3431    fn test_input_redirected_not_intercepted() {
3432        let _guard = test_guard!();
3433        // Input redirects transform the command's INPUT and are never offloaded.
3434        let result = classify_command("cargo build < input.txt");
3435        assert!(!result.is_compilation);
3436        assert!(result.reason.contains("input redirected"));
3437    }
3438
3439    #[test]
3440    fn test_process_substitution_not_intercepted() {
3441        let _guard = test_guard!();
3442        let result = classify_command("cargo build --config <(echo ...)");
3443        assert!(!result.is_compilation);
3444        assert!(result.reason.contains("subshell execution"));
3445    }
3446
3447    #[test]
3448    fn test_subshell_not_intercepted() {
3449        let _guard = test_guard!();
3450        let result = classify_command("(cargo build)");
3451        assert!(!result.is_compilation);
3452        assert!(result.reason.contains("subshell execution"));
3453    }
3454
3455    #[test]
3456    fn test_make_maintenance_matches_whole_target_tokens() {
3457        // Regression (bd-review-make-ninja-substring): maintenance targets must
3458        // match as whole TARGET tokens, not as a substring anywhere.
3459        let _guard = test_guard!();
3460
3461        // Real maintenance targets are still rejected.
3462        for cmd in [
3463            "make clean",
3464            "make install",
3465            "make distclean",
3466            "make uninstall",
3467        ] {
3468            let r = classify_command(cmd);
3469            assert!(!r.is_compilation, "{cmd} should not be intercepted");
3470            assert!(r.reason.contains("make maintenance command"), "{cmd}");
3471        }
3472        // With parallelism flags too.
3473        let r = classify_command("make -j8 clean");
3474        assert!(!r.is_compilation);
3475
3476        // Real builds whose tokens merely CONTAIN a maintenance word are builds.
3477        for cmd in [
3478            "make CFLAGS=-Iinstall/include",
3479            "make build INSTALL_DIR=/x",
3480            "make TARGET=cleanup",
3481            "make clean-all",  // distinct target, not "clean"
3482            "make -C install", // build in a directory named "install"
3483        ] {
3484            let r = classify_command(cmd);
3485            assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3486            assert_eq!(r.kind, Some(CompilationKind::Make), "{cmd}");
3487        }
3488    }
3489
3490    #[test]
3491    fn test_ninja_clean_matches_tool_and_whole_target() {
3492        // Regression (bd-review-make-ninja-substring): only `-t clean` and a
3493        // whole-token `clean` target are maintenance.
3494        let _guard = test_guard!();
3495
3496        for cmd in ["ninja clean", "ninja -t clean", "ninja -j8 clean"] {
3497            let r = classify_command(cmd);
3498            assert!(!r.is_compilation, "{cmd} should not be intercepted");
3499            assert!(r.reason.contains("ninja clean"), "{cmd}");
3500        }
3501
3502        for cmd in [
3503            "ninja build/clean_obj.o", // path containing "clean"
3504            "ninja -C clean",          // build in a directory named "clean"
3505            "ninja -j4",
3506        ] {
3507            let r = classify_command(cmd);
3508            assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3509            assert_eq!(r.kind, Some(CompilationKind::Ninja), "{cmd}");
3510        }
3511    }
3512
3513    #[test]
3514    fn test_gcc_compile() {
3515        let _guard = test_guard!();
3516        let result = classify_command("gcc -c main.c -o main.o");
3517        assert!(result.is_compilation);
3518        assert_eq!(result.kind, Some(CompilationKind::Gcc));
3519    }
3520
3521    #[test]
3522    fn test_make() {
3523        let _guard = test_guard!();
3524        let result = classify_command("make -j8");
3525        assert!(result.is_compilation);
3526        assert_eq!(result.kind, Some(CompilationKind::Make));
3527    }
3528
3529    #[test]
3530    fn test_make_clean_not_intercepted() {
3531        let _guard = test_guard!();
3532        let result = classify_command("make clean");
3533        assert!(!result.is_compilation);
3534        assert!(result.reason.contains("make maintenance command"));
3535    }
3536
3537    #[test]
3538    fn test_cmake_build_requires_cmake_command() {
3539        let _guard = test_guard!();
3540        let result = classify_command("echo cmake --build .");
3541        assert!(!result.is_compilation);
3542    }
3543
3544    #[test]
3545    fn test_cmake_build_with_equals_flag() {
3546        let _guard = test_guard!();
3547        let result = classify_command("cmake --build=build");
3548        assert!(result.is_compilation);
3549        assert_eq!(result.kind, Some(CompilationKind::CmakeBuild));
3550    }
3551
3552    #[test]
3553    fn test_meson_compile_requires_meson_command() {
3554        let _guard = test_guard!();
3555        let result = classify_command("echo meson compile");
3556        assert!(!result.is_compilation);
3557    }
3558
3559    #[test]
3560    fn test_non_compilation() {
3561        let _guard = test_guard!();
3562        let result = classify_command("ls -la");
3563        assert!(!result.is_compilation);
3564        assert!(result.reason.contains("no compilation keyword"));
3565    }
3566
3567    #[test]
3568    fn test_empty_command() {
3569        let _guard = test_guard!();
3570        let result = classify_command("");
3571        assert!(!result.is_compilation);
3572        assert!(result.reason.contains("empty command"));
3573    }
3574
3575    // Bun tests - keyword detection and never-intercept patterns
3576
3577    #[test]
3578    fn test_bun_keyword_detected() {
3579        let _guard = test_guard!();
3580        // Verify "bun" command words trigger Tier 2 before Tier 3 decides
3581        // whether the subcommand is safe to intercept.
3582        assert!(starts_with_compilation_command("bun test"));
3583        assert!(starts_with_compilation_command("bun typecheck"));
3584        assert!(starts_with_compilation_command("bun install"));
3585    }
3586
3587    #[test]
3588    fn test_bun_install_not_intercepted() {
3589        let _guard = test_guard!();
3590        // Package management - modifies node_modules
3591        let result = classify_command("bun install");
3592        assert!(!result.is_compilation);
3593        assert!(result.reason.contains("never-intercept"));
3594    }
3595
3596    #[test]
3597    fn test_bun_add_not_intercepted() {
3598        let _guard = test_guard!();
3599        // Adding packages - modifies package.json and node_modules
3600        let result = classify_command("bun add lodash");
3601        assert!(!result.is_compilation);
3602        assert!(result.reason.contains("never-intercept"));
3603    }
3604
3605    #[test]
3606    fn test_bun_remove_not_intercepted() {
3607        let _guard = test_guard!();
3608        let result = classify_command("bun remove lodash");
3609        assert!(!result.is_compilation);
3610        assert!(result.reason.contains("never-intercept"));
3611    }
3612
3613    #[test]
3614    fn test_bun_run_not_intercepted() {
3615        let _guard = test_guard!();
3616        // Generic script runner - could do anything
3617        let result = classify_command("bun run build");
3618        assert!(!result.is_compilation);
3619        assert!(result.reason.contains("never-intercept"));
3620    }
3621
3622    #[test]
3623    fn test_bun_build_not_intercepted() {
3624        let _guard = test_guard!();
3625        // Creates bundles in local directory
3626        let result = classify_command("bun build ./src/index.ts");
3627        assert!(!result.is_compilation);
3628        assert!(result.reason.contains("never-intercept"));
3629    }
3630
3631    #[test]
3632    fn test_bun_version_not_intercepted() {
3633        let _guard = test_guard!();
3634        let result = classify_command("bun --version");
3635        assert!(!result.is_compilation);
3636        assert!(result.reason.contains("never-intercept"));
3637
3638        let result = classify_command("bun -v");
3639        assert!(!result.is_compilation);
3640        assert!(result.reason.contains("never-intercept"));
3641    }
3642
3643    #[test]
3644    fn test_bun_dev_not_intercepted() {
3645        let _guard = test_guard!();
3646        // Development server needs local ports
3647        let result = classify_command("bun dev");
3648        assert!(!result.is_compilation);
3649        assert!(result.reason.contains("never-intercept"));
3650    }
3651
3652    #[test]
3653    fn test_bun_repl_not_intercepted() {
3654        let _guard = test_guard!();
3655        // Interactive REPL
3656        let result = classify_command("bun repl");
3657        assert!(!result.is_compilation);
3658        assert!(result.reason.contains("never-intercept"));
3659    }
3660
3661    #[test]
3662    fn test_bun_link_not_intercepted() {
3663        let _guard = test_guard!();
3664        let result = classify_command("bun link");
3665        assert!(!result.is_compilation);
3666        assert!(result.reason.contains("never-intercept"));
3667    }
3668
3669    #[test]
3670    fn test_bun_init_not_intercepted() {
3671        let _guard = test_guard!();
3672        let result = classify_command("bun init");
3673        assert!(!result.is_compilation);
3674        assert!(result.reason.contains("never-intercept"));
3675    }
3676
3677    #[test]
3678    fn test_bun_create_not_intercepted() {
3679        let _guard = test_guard!();
3680        let result = classify_command("bun create next-app");
3681        assert!(!result.is_compilation);
3682        assert!(result.reason.contains("never-intercept"));
3683    }
3684
3685    #[test]
3686    fn test_bun_pm_not_intercepted() {
3687        let _guard = test_guard!();
3688        let result = classify_command("bun pm cache");
3689        assert!(!result.is_compilation);
3690        assert!(result.reason.contains("never-intercept"));
3691    }
3692
3693    #[test]
3694    fn test_bun_help_not_intercepted() {
3695        let _guard = test_guard!();
3696        let result = classify_command("bun --help");
3697        assert!(!result.is_compilation);
3698        assert!(result.reason.contains("never-intercept"));
3699
3700        let result = classify_command("bun -h");
3701        assert!(!result.is_compilation);
3702        assert!(result.reason.contains("never-intercept"));
3703    }
3704
3705    // Bun Tier 4 classification tests
3706
3707    #[test]
3708    fn test_bun_test_classification() {
3709        let _guard = test_guard!();
3710        // Basic command
3711        let result = classify_command("bun test");
3712        assert!(result.is_compilation);
3713        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3714        assert!((result.confidence - 0.95).abs() < 0.001);
3715
3716        // With directory argument
3717        let result = classify_command("bun test src/");
3718        assert!(result.is_compilation);
3719        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3720
3721        // With coverage flag (non-interactive)
3722        let result = classify_command("bun test --coverage");
3723        assert!(result.is_compilation);
3724        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3725
3726        // Specific file
3727        let result = classify_command("bun test auth.test.ts");
3728        assert!(result.is_compilation);
3729        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3730
3731        // With multiple flags
3732        let result = classify_command("bun test --bail --timeout 5000");
3733        assert!(result.is_compilation);
3734        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3735
3736        // With reporter
3737        let result = classify_command("bun test --reporter json");
3738        assert!(result.is_compilation);
3739        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3740    }
3741
3742    #[test]
3743    fn test_bun_test_watch_not_intercepted() {
3744        let _guard = test_guard!();
3745        // --watch mode is interactive and should NOT be intercepted
3746        let result = classify_command("bun test --watch");
3747        assert!(!result.is_compilation);
3748        assert!(result.reason.contains("interactive"));
3749
3750        // Watch with other flags
3751        let result = classify_command("bun test --watch --coverage");
3752        assert!(!result.is_compilation);
3753        assert!(result.reason.contains("interactive"));
3754
3755        // Watch with directory
3756        let result = classify_command("bun test src/ --watch");
3757        assert!(!result.is_compilation);
3758
3759        // Short form -w
3760        let result = classify_command("bun test -w");
3761        assert!(!result.is_compilation);
3762
3763        // Short form with args
3764        let result = classify_command("bun test -w src/");
3765        assert!(!result.is_compilation);
3766    }
3767
3768    #[test]
3769    fn test_bun_typecheck_watch_not_intercepted() {
3770        let _guard = test_guard!();
3771        // --watch mode is interactive and should NOT be intercepted
3772        let result = classify_command("bun typecheck --watch");
3773        assert!(!result.is_compilation);
3774        assert!(result.reason.contains("interactive"));
3775
3776        // Short form -w
3777        let result = classify_command("bun typecheck -w");
3778        assert!(!result.is_compilation);
3779    }
3780
3781    #[test]
3782    fn test_bun_typecheck_classification() {
3783        let _guard = test_guard!();
3784        // Basic command
3785        let result = classify_command("bun typecheck");
3786        assert!(result.is_compilation);
3787        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3788        assert!((result.confidence - 0.95).abs() < 0.001);
3789
3790        // With directory argument
3791        let result = classify_command("bun typecheck src/");
3792        assert!(result.is_compilation);
3793        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3794
3795        // NOTE: --watch mode is tested separately in test_bun_typecheck_watch_not_intercepted
3796        // It should NOT be intercepted as it's interactive
3797    }
3798
3799    #[test]
3800    fn test_bun_edge_cases_not_matched() {
3801        let _guard = test_guard!();
3802        // Invalid commands should not match
3803        let result = classify_command("bun testing");
3804        assert!(!result.is_compilation);
3805        assert!(result.reason.contains("no matching pattern"));
3806
3807        let result = classify_command("bun typechecker");
3808        assert!(!result.is_compilation);
3809        assert!(result.reason.contains("no matching pattern"));
3810
3811        let result = classify_command("bun type");
3812        assert!(!result.is_compilation);
3813        assert!(result.reason.contains("no matching pattern"));
3814
3815        // bun x should not be intercepted (runs arbitrary packages like npx)
3816        let result = classify_command("bun x eslint");
3817        assert!(!result.is_compilation);
3818        assert!(result.reason.contains("bun x runs arbitrary packages"));
3819
3820        let result = classify_command("bun x prettier --write .");
3821        assert!(!result.is_compilation);
3822        assert!(result.reason.contains("bun x runs arbitrary packages"));
3823
3824        let result = classify_command("bun x vitest run");
3825        assert!(!result.is_compilation);
3826        assert!(result.reason.contains("bun x runs arbitrary packages"));
3827    }
3828
3829    #[test]
3830    fn test_bun_test_vs_never_intercept() {
3831        let _guard = test_guard!();
3832        // bun test should NOT be blocked by never-intercept
3833        let result = classify_command("bun test");
3834        assert!(!result.reason.contains("never-intercept"));
3835        assert!(result.is_compilation);
3836    }
3837
3838    #[test]
3839    fn test_bun_typecheck_vs_never_intercept() {
3840        let _guard = test_guard!();
3841        // bun typecheck should NOT be blocked by never-intercept
3842        let result = classify_command("bun typecheck");
3843        assert!(!result.reason.contains("never-intercept"));
3844        assert!(result.is_compilation);
3845    }
3846
3847    // Bun CompilationKind serialization tests
3848
3849    #[test]
3850    fn test_bun_compilation_kind_serde() {
3851        let _guard = test_guard!();
3852        // BunTest serialization
3853        let kind = CompilationKind::BunTest;
3854        let json = serde_json::to_string(&kind).unwrap();
3855        assert_eq!(json, "\"bun_test\"");
3856        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3857        assert_eq!(parsed, CompilationKind::BunTest);
3858
3859        // BunTypecheck serialization
3860        let kind = CompilationKind::BunTypecheck;
3861        let json = serde_json::to_string(&kind).unwrap();
3862        assert_eq!(json, "\"bun_typecheck\"");
3863        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3864        assert_eq!(parsed, CompilationKind::BunTypecheck);
3865    }
3866
3867    #[test]
3868    fn test_bun_kinds_are_distinct() {
3869        let _guard = test_guard!();
3870        // BunTest and BunTypecheck are different
3871        assert_ne!(CompilationKind::BunTest, CompilationKind::BunTypecheck);
3872        // BunTest is different from CargoTest (different ecosystems)
3873        assert_ne!(CompilationKind::BunTest, CompilationKind::CargoTest);
3874    }
3875
3876    #[test]
3877    fn test_wrapped_command_classification_repro() {
3878        let _guard = test_guard!();
3879        // This fails currently because classify_full expects command to start with "cargo"
3880        // Wrapper tools like "time", "sudo", "env" break this logic
3881        let result = classify_command("time cargo build");
3882        assert!(
3883            result.is_compilation,
3884            "Should classify 'time cargo build' as compilation"
3885        );
3886        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3887
3888        let result = classify_command("sudo cargo check");
3889        assert!(
3890            result.is_compilation,
3891            "Should classify 'sudo cargo check' as compilation"
3892        );
3893
3894        let result = classify_command("env RUST_BACKTRACE=1 cargo test");
3895        assert!(
3896            result.is_compilation,
3897            "Should classify env-wrapped cargo test as compilation"
3898        );
3899
3900        let result = classify_command("env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build");
3901        assert!(
3902            result.is_compilation,
3903            "Should classify shell-quoted env assignment before cargo build as compilation"
3904        );
3905        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3906    }
3907
3908    // =========================================================================
3909    // Bun E2E Edge Case Tests (from bead remote_compilation_helper-65m)
3910    // =========================================================================
3911
3912    #[test]
3913    fn test_bun_piped_to_benign_pager_intercepted() {
3914        let _guard = test_guard!();
3915        // Issue #24: piping a bun test/typecheck into a benign pager is offloaded.
3916        let result = classify_command("bun test | grep error");
3917        assert!(result.is_compilation, "got: {:?}", result.reason);
3918        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3919        assert_eq!(
3920            result.extracted_command.as_deref(),
3921            Some("bun test | grep error")
3922        );
3923
3924        // Piped with output filtering
3925        let result = classify_command("bun test 2>&1 | tee output.log");
3926        assert!(result.is_compilation, "got: {:?}", result.reason);
3927        assert_eq!(
3928            result.extracted_command.as_deref(),
3929            Some("bun test 2>&1 | tee output.log")
3930        );
3931
3932        // Piped typecheck
3933        let result = classify_command("bun typecheck | head -20");
3934        assert!(result.is_compilation, "got: {:?}", result.reason);
3935        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3936    }
3937
3938    #[test]
3939    fn test_bunx_tsc_not_intercepted() {
3940        let _guard = test_guard!();
3941        // bunx (bun x) runs arbitrary packages - should NOT be intercepted
3942        // even for typecheck-like commands like tsc
3943        let result = classify_command("bun x tsc --noEmit");
3944        assert!(!result.is_compilation);
3945        assert!(result.reason.contains("bun x"));
3946
3947        // bunx with other tools
3948        let result = classify_command("bun x eslint .");
3949        assert!(!result.is_compilation);
3950
3951        let result = classify_command("bun x prettier --write .");
3952        assert!(!result.is_compilation);
3953
3954        let result = classify_command("bun x vitest run");
3955        assert!(!result.is_compilation);
3956    }
3957
3958    #[test]
3959    fn test_bun_redirected_intercepted() {
3960        let _guard = test_guard!();
3961        // Issue #24: a benign output redirect is offloaded.
3962        let result = classify_command("bun test > results.txt");
3963        assert!(result.is_compilation, "got: {:?}", result.reason);
3964        assert_eq!(
3965            result.extracted_command.as_deref(),
3966            Some("bun test > results.txt")
3967        );
3968
3969        let result = classify_command("bun typecheck > errors.log");
3970        assert!(result.is_compilation, "got: {:?}", result.reason);
3971    }
3972
3973    #[test]
3974    fn test_bun_backgrounded_intercepted() {
3975        let _guard = test_guard!();
3976        // Issue #24: a trailing `&` is offloaded.
3977        let result = classify_command("bun test &");
3978        assert!(result.is_compilation, "got: {:?}", result.reason);
3979        assert_eq!(result.extracted_command.as_deref(), Some("bun test &"));
3980    }
3981
3982    #[test]
3983    fn test_bun_chained_commands_classified() {
3984        let _guard = test_guard!();
3985        // Multi-command strings should be rejected
3986        let result = classify_command("bun test && echo done");
3987        assert!(
3988            !result.is_compilation,
3989            "chained commands should be rejected"
3990        );
3991        assert!(result.reason.contains("chained"));
3992
3993        let result = classify_command("bun typecheck; bun test");
3994        assert!(
3995            !result.is_compilation,
3996            "chained commands should be rejected"
3997        );
3998        assert!(result.reason.contains("chained"));
3999    }
4000
4001    #[test]
4002    fn test_bun_subshell_capture_not_intercepted() {
4003        let _guard = test_guard!();
4004        // Subshell capture should be rejected at Tier 1
4005        let result = classify_command("bun test $(echo src/)");
4006        assert!(!result.is_compilation);
4007        assert!(result.reason.contains("subshell"));
4008    }
4009
4010    #[test]
4011    fn test_bun_wrapped_commands() {
4012        let _guard = test_guard!();
4013        // Wrapped bun commands should still be classified
4014        // (requires normalize_command to handle wrappers)
4015        let result = classify_command("time bun test");
4016        // Currently may or may not work depending on normalize_command
4017        // This test documents current behavior
4018        if result.is_compilation {
4019            assert_eq!(result.kind, Some(CompilationKind::BunTest));
4020        }
4021
4022        let result = classify_command("env DEBUG=1 bun test");
4023        if result.is_compilation {
4024            assert_eq!(result.kind, Some(CompilationKind::BunTest));
4025        }
4026    }
4027
4028    #[test]
4029    fn test_classify_command_detailed_matches_basic() {
4030        let _guard = test_guard!();
4031        let commands = [
4032            "cargo build",
4033            "cargo test --release",
4034            "bun typecheck",
4035            "gcc -c main.c -o main.o",
4036            "cd /tmp && cargo build --release",
4037            "ls -la",
4038        ];
4039
4040        for cmd in commands {
4041            let basic = classify_command(cmd);
4042            let detailed = classify_command_detailed(cmd);
4043            assert_eq!(basic, detailed.classification);
4044        }
4045    }
4046
4047    #[test]
4048    fn test_classify_command_detailed_rejects_piped() {
4049        let _guard = test_guard!();
4050        // Pipe into a NON-benign command is still rejected at Tier 1. (A pipe
4051        // into a benign pager like `tee` is now offloaded per issue #24.)
4052        let detailed = classify_command_detailed("cargo build | xargs rm");
4053        assert!(!detailed.classification.is_compilation);
4054        let tier1 = detailed.tiers.iter().find(|t| t.tier.eq(&1)).unwrap();
4055        assert_eq!(tier1.decision, TierDecision::Reject);
4056        assert!(tier1.reason.contains("piped"));
4057
4058        // A pipe into a benign pager passes Tier 1 and is offloaded.
4059        let detailed = classify_command_detailed("cargo build | tee log.txt");
4060        assert!(detailed.classification.is_compilation);
4061        assert_eq!(
4062            detailed.classification.extracted_command.as_deref(),
4063            Some("cargo build | tee log.txt")
4064        );
4065    }
4066
4067    #[test]
4068    fn test_classify_command_detailed_normalizes_wrappers() {
4069        let _guard = test_guard!();
4070        for (cmd, normalized) in [
4071            ("sudo cargo check", "cargo check"),
4072            ("env -u RUST_LOG cargo test", "cargo test"),
4073        ] {
4074            let detailed = classify_command_detailed(cmd);
4075            assert_eq!(
4076                (
4077                    detailed.normalized.as_str(),
4078                    detailed.classification.is_compilation
4079                ),
4080                (normalized, true),
4081                "{cmd}"
4082            );
4083        }
4084    }
4085
4086    #[test]
4087    fn test_classify_command_detailed_matches_compound_command() {
4088        let _guard = test_guard!();
4089        let detailed = classify_command_detailed("cd /tmp && cargo build --release");
4090
4091        assert!(detailed.classification.is_compilation);
4092        assert_eq!(
4093            detailed.classification.kind,
4094            Some(CompilationKind::CargoBuild)
4095        );
4096        assert_eq!(
4097            detailed.classification.command_prefix.as_deref(),
4098            Some("cd /tmp && ")
4099        );
4100        assert_eq!(
4101            detailed.classification.extracted_command.as_deref(),
4102            Some("cargo build --release")
4103        );
4104        assert_eq!(detailed.normalized, "cargo build --release");
4105        assert_eq!(detailed.tiers.len(), 5);
4106        assert!(
4107            detailed
4108                .tiers
4109                .iter()
4110                .all(|tier| tier.decision == TierDecision::Pass)
4111        );
4112    }
4113
4114    // =========================================================================
4115    // Comprehensive cargo test variant tests (bead remote_compilation_helper-xcvl)
4116    // =========================================================================
4117
4118    #[test]
4119    fn test_cargo_test_release() {
4120        let _guard = test_guard!();
4121        let result = classify_command("cargo test --release");
4122        assert!(
4123            result.is_compilation,
4124            "cargo test --release should be classified as compilation"
4125        );
4126        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4127        assert!(result.confidence >= 0.90);
4128    }
4129
4130    #[test]
4131    fn test_cargo_test_specific_test_name() {
4132        let _guard = test_guard!();
4133        let result = classify_command("cargo test my_test_function");
4134        assert!(
4135            result.is_compilation,
4136            "cargo test with specific test name should be classified"
4137        );
4138        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4139    }
4140
4141    #[test]
4142    fn test_cargo_test_with_nocapture() {
4143        let _guard = test_guard!();
4144        let result = classify_command("cargo test -- --nocapture");
4145        assert!(
4146            result.is_compilation,
4147            "cargo test -- --nocapture should be classified"
4148        );
4149        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4150    }
4151
4152    #[test]
4153    fn test_cargo_test_workspace() {
4154        let _guard = test_guard!();
4155        let result = classify_command("cargo test --workspace");
4156        assert!(
4157            result.is_compilation,
4158            "cargo test --workspace should be classified"
4159        );
4160        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4161    }
4162
4163    #[test]
4164    fn test_cargo_test_package() {
4165        let _guard = test_guard!();
4166        let result = classify_command("cargo test -p rch-common");
4167        assert!(result.is_compilation, "cargo test -p should be classified");
4168        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4169    }
4170
4171    #[test]
4172    fn test_cargo_test_short_alias() {
4173        let _guard = test_guard!();
4174        // cargo t is an alias for cargo test
4175        let result = classify_command("cargo t");
4176        assert!(
4177            result.is_compilation,
4178            "cargo t (short alias) should be classified"
4179        );
4180        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4181    }
4182
4183    #[test]
4184    fn test_cargo_test_all_features() {
4185        let _guard = test_guard!();
4186        let result = classify_command("cargo test --all-features");
4187        assert!(
4188            result.is_compilation,
4189            "cargo test --all-features should be classified"
4190        );
4191        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4192    }
4193
4194    #[test]
4195    fn test_cargo_test_no_default_features() {
4196        let _guard = test_guard!();
4197        let result = classify_command("cargo test --no-default-features");
4198        assert!(
4199            result.is_compilation,
4200            "cargo test --no-default-features should be classified"
4201        );
4202        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4203    }
4204
4205    #[test]
4206    fn test_cargo_test_with_env_var() {
4207        let _guard = test_guard!();
4208        let result = classify_command("RUST_BACKTRACE=1 cargo test");
4209        assert!(
4210            result.is_compilation,
4211            "cargo test with env var should be classified"
4212        );
4213        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4214    }
4215
4216    #[test]
4217    fn test_cargo_test_with_multiple_flags() {
4218        let _guard = test_guard!();
4219        let result = classify_command("cargo test --release --workspace -p rch -- --nocapture");
4220        assert!(
4221            result.is_compilation,
4222            "cargo test with multiple flags should be classified"
4223        );
4224        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4225    }
4226
4227    #[test]
4228    fn test_cargo_test_with_jobs() {
4229        let _guard = test_guard!();
4230        let result = classify_command("cargo test -j 8");
4231        assert!(result.is_compilation, "cargo test -j should be classified");
4232        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4233    }
4234
4235    #[test]
4236    fn test_cargo_test_target() {
4237        let _guard = test_guard!();
4238        let result = classify_command("cargo test --target x86_64-unknown-linux-gnu");
4239        assert!(
4240            result.is_compilation,
4241            "cargo test --target should be classified"
4242        );
4243        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4244    }
4245
4246    #[test]
4247    fn test_cargo_test_lib() {
4248        let _guard = test_guard!();
4249        let result = classify_command("cargo test --lib");
4250        assert!(
4251            result.is_compilation,
4252            "cargo test --lib should be classified"
4253        );
4254        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4255    }
4256
4257    #[test]
4258    fn test_cargo_test_bins() {
4259        let _guard = test_guard!();
4260        let result = classify_command("cargo test --bins");
4261        assert!(
4262            result.is_compilation,
4263            "cargo test --bins should be classified"
4264        );
4265        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4266    }
4267
4268    #[test]
4269    fn test_cargo_test_doc() {
4270        let _guard = test_guard!();
4271        let result = classify_command("cargo test --doc");
4272        assert!(
4273            result.is_compilation,
4274            "cargo test --doc should be classified"
4275        );
4276        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4277    }
4278
4279    #[test]
4280    fn test_cargo_test_filter_pattern() {
4281        let _guard = test_guard!();
4282        let result = classify_command("cargo test test_classification");
4283        assert!(
4284            result.is_compilation,
4285            "cargo test with filter pattern should be classified"
4286        );
4287        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4288    }
4289
4290    #[test]
4291    fn test_cargo_test_exact() {
4292        let _guard = test_guard!();
4293        let result = classify_command("cargo test --exact my_test");
4294        assert!(
4295            result.is_compilation,
4296            "cargo test --exact should be classified"
4297        );
4298        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4299    }
4300
4301    // =========================================================================
4302    // cargo-nextest tests (bead remote_compilation_helper-c7ky)
4303    // =========================================================================
4304
4305    #[test]
4306    fn test_nextest_keyword_detected() {
4307        let _guard = test_guard!();
4308        // Verify nextest command words trigger Tier 2 before subcommand checks.
4309        assert!(starts_with_compilation_command("cargo nextest run"));
4310        assert!(starts_with_compilation_command("cargo nextest list"));
4311    }
4312
4313    #[test]
4314    fn test_cargo_nextest_run_classification() {
4315        let _guard = test_guard!();
4316        // Basic command
4317        let result = classify_command("cargo nextest run");
4318        assert!(
4319            result.is_compilation,
4320            "cargo nextest run should be classified"
4321        );
4322        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4323        assert!((result.confidence - 0.95).abs() < 0.001);
4324    }
4325
4326    #[test]
4327    fn test_cargo_nextest_run_with_flags() {
4328        let _guard = test_guard!();
4329        // With release flag
4330        let result = classify_command("cargo nextest run --release");
4331        assert!(result.is_compilation);
4332        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4333
4334        // With profile flag
4335        let result = classify_command("cargo nextest run --cargo-profile ci");
4336        assert!(result.is_compilation);
4337        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4338
4339        // With workspace flag
4340        let result = classify_command("cargo nextest run --workspace");
4341        assert!(result.is_compilation);
4342        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4343
4344        // With package filter
4345        let result = classify_command("cargo nextest run -p rch-common");
4346        assert!(result.is_compilation);
4347        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4348
4349        // With test filter
4350        let result = classify_command("cargo nextest run test_classification");
4351        assert!(result.is_compilation);
4352        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4353
4354        // With multiple flags
4355        let result = classify_command("cargo nextest run --release --no-fail-fast -j 8");
4356        assert!(result.is_compilation);
4357        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4358    }
4359
4360    #[test]
4361    fn test_cargo_nextest_run_short_alias() {
4362        let _guard = test_guard!();
4363        // cargo nextest r is an alias for cargo nextest run
4364        let result = classify_command("cargo nextest r");
4365        assert!(
4366            result.is_compilation,
4367            "cargo nextest r should be classified"
4368        );
4369        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4370    }
4371
4372    #[test]
4373    fn test_cargo_nextest_list_not_intercepted() {
4374        let _guard = test_guard!();
4375        // cargo nextest list only shows tests, doesn't run them
4376        let result = classify_command("cargo nextest list");
4377        assert!(!result.is_compilation);
4378        assert!(result.reason.contains("never-intercept"));
4379    }
4380
4381    #[test]
4382    fn test_cargo_nextest_archive_not_intercepted() {
4383        let _guard = test_guard!();
4384        // cargo nextest archive creates archives
4385        let result = classify_command("cargo nextest archive");
4386        assert!(!result.is_compilation);
4387        assert!(result.reason.contains("never-intercept"));
4388    }
4389
4390    #[test]
4391    fn test_cargo_nextest_show_not_intercepted() {
4392        let _guard = test_guard!();
4393        // cargo nextest show displays config info
4394        let result = classify_command("cargo nextest show");
4395        assert!(!result.is_compilation);
4396        assert!(result.reason.contains("never-intercept"));
4397    }
4398
4399    #[test]
4400    fn test_bare_cargo_nextest_not_intercepted() {
4401        let _guard = test_guard!();
4402        // bare "cargo nextest" without subcommand
4403        let result = classify_command("cargo nextest");
4404        assert!(!result.is_compilation);
4405        assert!(result.reason.contains("without subcommand"));
4406    }
4407
4408    #[test]
4409    fn test_cargo_nextest_wrapped_commands() {
4410        let _guard = test_guard!();
4411        // Wrapped nextest commands should still be classified
4412        let result = classify_command("time cargo nextest run");
4413        assert!(
4414            result.is_compilation,
4415            "time cargo nextest run should be classified"
4416        );
4417        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4418
4419        let result = classify_command("RUST_BACKTRACE=1 cargo nextest run");
4420        assert!(
4421            result.is_compilation,
4422            "env-wrapped cargo nextest run should be classified"
4423        );
4424        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4425    }
4426
4427    #[test]
4428    fn test_cargo_nextest_compilation_kind_serde() {
4429        let _guard = test_guard!();
4430        // CargoNextest serialization
4431        let kind = CompilationKind::CargoNextest;
4432        let json = serde_json::to_string(&kind).unwrap();
4433        assert_eq!(json, "\"cargo_nextest\"");
4434        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4435        assert_eq!(parsed, CompilationKind::CargoNextest);
4436    }
4437
4438    #[test]
4439    fn test_cargo_nextest_vs_cargo_test_distinct() {
4440        let _guard = test_guard!();
4441        // CargoNextest and CargoTest are different
4442        assert_ne!(CompilationKind::CargoNextest, CompilationKind::CargoTest);
4443
4444        // Both should be classified but as different kinds
4445        let nextest_result = classify_command("cargo nextest run");
4446        let test_result = classify_command("cargo test");
4447        assert!(nextest_result.is_compilation);
4448        assert!(test_result.is_compilation);
4449        assert_eq!(nextest_result.kind, Some(CompilationKind::CargoNextest));
4450        assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4451    }
4452
4453    #[test]
4454    fn test_cargo_nextest_piped_to_pager_intercepted() {
4455        let _guard = test_guard!();
4456        // Issue #24: piping into a benign pager is offloaded.
4457        let result = classify_command("cargo nextest run | grep FAIL");
4458        assert!(result.is_compilation, "got: {:?}", result.reason);
4459        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4460        assert_eq!(
4461            result.extracted_command.as_deref(),
4462            Some("cargo nextest run | grep FAIL")
4463        );
4464    }
4465
4466    #[test]
4467    fn test_cargo_nextest_redirected_intercepted() {
4468        let _guard = test_guard!();
4469        // Issue #24: a benign output redirect is offloaded.
4470        let result = classify_command("cargo nextest run > results.txt");
4471        assert!(result.is_compilation, "got: {:?}", result.reason);
4472        assert_eq!(
4473            result.extracted_command.as_deref(),
4474            Some("cargo nextest run > results.txt")
4475        );
4476    }
4477
4478    #[test]
4479    fn test_cargo_nextest_backgrounded_intercepted() {
4480        let _guard = test_guard!();
4481        // Issue #24: a trailing `&` is offloaded.
4482        let result = classify_command("cargo nextest run &");
4483        assert!(result.is_compilation, "got: {:?}", result.reason);
4484        assert_eq!(
4485            result.extracted_command.as_deref(),
4486            Some("cargo nextest run &")
4487        );
4488    }
4489
4490    // =========================================================================
4491    // cargo bench tests (bead remote_compilation_helper-8o52)
4492    // =========================================================================
4493
4494    #[test]
4495    fn test_cargo_bench_classification() {
4496        let _guard = test_guard!();
4497        let result = classify_command("cargo bench");
4498        assert!(result.is_compilation, "cargo bench should be classified");
4499        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4500        assert!((result.confidence - 0.90).abs() < 0.001);
4501    }
4502
4503    #[test]
4504    fn test_cargo_bench_with_filter() {
4505        let _guard = test_guard!();
4506        // Benchmarks with name filter
4507        let result = classify_command("cargo bench my_bench");
4508        assert!(result.is_compilation);
4509        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4510    }
4511
4512    #[test]
4513    fn test_cargo_bench_with_flags() {
4514        let _guard = test_guard!();
4515        // With release flag (benchmarks typically use release)
4516        let result = classify_command("cargo bench --release");
4517        assert!(result.is_compilation);
4518        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4519
4520        // With package flag
4521        let result = classify_command("cargo bench -p rch-common");
4522        assert!(result.is_compilation);
4523        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4524
4525        // With features
4526        let result = classify_command("cargo bench --features benchmarks");
4527        assert!(result.is_compilation);
4528        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4529
4530        // With target filter
4531        let result = classify_command("cargo bench --bench criterion_bench");
4532        assert!(result.is_compilation);
4533        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4534    }
4535
4536    #[test]
4537    fn test_cargo_bench_wrapped() {
4538        let _guard = test_guard!();
4539        // Wrapped with time
4540        let result = classify_command("time cargo bench");
4541        assert!(
4542            result.is_compilation,
4543            "time cargo bench should be classified"
4544        );
4545        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4546
4547        // With env var
4548        let result = classify_command("CARGO_INCREMENTAL=0 cargo bench");
4549        assert!(result.is_compilation);
4550        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4551    }
4552
4553    #[test]
4554    fn test_cargo_bench_serde() {
4555        let _guard = test_guard!();
4556        // CargoBench serialization
4557        let kind = CompilationKind::CargoBench;
4558        let json = serde_json::to_string(&kind).unwrap();
4559        assert_eq!(json, "\"cargo_bench\"");
4560        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4561        assert_eq!(parsed, CompilationKind::CargoBench);
4562    }
4563
4564    #[test]
4565    fn test_cargo_bench_distinct_from_test() {
4566        let _guard = test_guard!();
4567        // CargoBench and CargoTest are different
4568        assert_ne!(CompilationKind::CargoBench, CompilationKind::CargoTest);
4569
4570        // Both should be classified but as different kinds
4571        let bench_result = classify_command("cargo bench");
4572        let test_result = classify_command("cargo test");
4573        assert!(bench_result.is_compilation);
4574        assert!(test_result.is_compilation);
4575        assert_eq!(bench_result.kind, Some(CompilationKind::CargoBench));
4576        assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4577    }
4578
4579    #[test]
4580    fn test_cargo_bench_piped_to_pager_intercepted() {
4581        let _guard = test_guard!();
4582        // Issue #24: piping into a benign pager (tee) is offloaded.
4583        let result = classify_command("cargo bench | tee output.txt");
4584        assert!(result.is_compilation, "got: {:?}", result.reason);
4585        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4586        assert_eq!(
4587            result.extracted_command.as_deref(),
4588            Some("cargo bench | tee output.txt")
4589        );
4590    }
4591
4592    #[test]
4593
4594    fn test_normalize_path_and_wrapper_bug_repro() {
4595        let _guard = test_guard!();
4596        // Case: /usr/bin/time cargo build
4597
4598        // Current behavior: strips path -> "time cargo build", then returns.
4599
4600        // Desired behavior: strips path -> "time cargo build", then strips wrapper -> "cargo build".
4601
4602        // This test asserts the CURRENT BROKEN behavior.
4603
4604        let normalized = normalize_command("/usr/bin/time cargo build");
4605
4606        assert_eq!(normalized, "cargo build");
4607
4608        let result = classify_command("/usr/bin/time cargo build");
4609
4610        assert!(result.is_compilation);
4611    }
4612
4613    // ==========================================================================
4614    // Command Base Tests (bd-785w)
4615    // ==========================================================================
4616
4617    #[test]
4618    fn test_command_base_rust() {
4619        let _guard = test_guard!();
4620        assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
4621        assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
4622        assert_eq!(CompilationKind::CargoCheck.command_base(), "cargo");
4623        assert_eq!(CompilationKind::CargoClippy.command_base(), "cargo");
4624        assert_eq!(CompilationKind::CargoDoc.command_base(), "cargo");
4625        assert_eq!(CompilationKind::CargoBench.command_base(), "cargo");
4626        assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
4627        assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
4628    }
4629
4630    #[test]
4631    fn test_command_base_c_cpp() {
4632        let _guard = test_guard!();
4633        assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
4634        assert_eq!(CompilationKind::Gpp.command_base(), "g++");
4635        assert_eq!(CompilationKind::Clang.command_base(), "clang");
4636        assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
4637    }
4638
4639    #[test]
4640    fn test_command_base_build_systems() {
4641        let _guard = test_guard!();
4642        assert_eq!(CompilationKind::Make.command_base(), "make");
4643        assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
4644        assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
4645        assert_eq!(CompilationKind::Meson.command_base(), "meson");
4646    }
4647
4648    #[test]
4649    fn test_command_base_bun() {
4650        let _guard = test_guard!();
4651        assert_eq!(CompilationKind::BunTest.command_base(), "bun");
4652        assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
4653    }
4654
4655    // ==========================================================================
4656    // Proptest: Command Classification with Random Inputs (bd-2kdm)
4657    // ==========================================================================
4658
4659    mod proptest_classification {
4660        use super::*;
4661        use proptest::prelude::*;
4662
4663        // Strategy for arbitrary strings (pure random)
4664        fn arbitrary_string() -> impl Strategy<Value = String> {
4665            prop::string::string_regex(".{0,500}").unwrap()
4666        }
4667
4668        // Strategy for command-like strings with random suffixes
4669        fn command_like_string() -> impl Strategy<Value = String> {
4670            let prefixes = prop::sample::select(vec![
4671                "cargo", "rustc", "gcc", "g++", "clang", "make", "cmake", "ninja", "bun", "ls",
4672                "cd", "echo", "cat", "grep", "find", "rm", "mv", "cp", "mkdir",
4673            ]);
4674            (prefixes, "[ a-zA-Z0-9_.-]{0,200}")
4675                .prop_map(|(prefix, suffix)| format!("{}{}", prefix, suffix))
4676        }
4677
4678        // Strategy for known compilation commands with random flags
4679        fn known_command_with_flags() -> impl Strategy<Value = String> {
4680            let base_commands = prop::sample::select(vec![
4681                "cargo build",
4682                "cargo test",
4683                "cargo check",
4684                "cargo clippy",
4685                "cargo run",
4686                "rustc",
4687                "gcc",
4688                "g++",
4689                "make",
4690                "bun test",
4691                "bun typecheck",
4692            ]);
4693            let flags = prop::collection::vec(
4694                prop::sample::select(vec![
4695                    "--release",
4696                    "--verbose",
4697                    "-j8",
4698                    "-p",
4699                    "--all",
4700                    "--workspace",
4701                    "--lib",
4702                    "--bin",
4703                    "-o",
4704                    "-c",
4705                ]),
4706                0..5,
4707            );
4708            (base_commands, flags).prop_map(|(cmd, flags)| {
4709                if flags.is_empty() {
4710                    cmd.to_string()
4711                } else {
4712                    format!("{} {}", cmd, flags.join(" "))
4713                }
4714            })
4715        }
4716
4717        // Strategy for strings with unicode and control characters
4718        fn unicode_and_control_chars() -> impl Strategy<Value = String> {
4719            prop::string::string_regex(
4720                r"[\x00-\x1f\x80-\xff\u{100}-\u{10FFFF}a-zA-Z0-9 |>&;$()]{0,100}",
4721            )
4722            .unwrap()
4723        }
4724
4725        // Strategy for shell-like commands with special characters
4726        fn shell_special_commands() -> impl Strategy<Value = String> {
4727            let components = prop::sample::select(vec![
4728                "cargo build",
4729                "ls -la",
4730                "echo test",
4731                "cat file.txt",
4732                "grep pattern",
4733            ]);
4734            let operators = prop::sample::select(vec![
4735                " | ", " && ", " || ", " ; ", " > ", " < ", " & ", " 2>&1 ", " $(", " `",
4736            ]);
4737            prop::collection::vec((components, operators), 1..4).prop_map(|pairs| {
4738                let mut result = String::new();
4739                for (i, (comp, op)) in pairs.iter().enumerate() {
4740                    if i > 0 {
4741                        result.push_str(op);
4742                    }
4743                    result.push_str(comp);
4744                }
4745                result
4746            })
4747        }
4748
4749        // Strategy for wrapper-prefixed commands
4750        fn wrapped_commands() -> impl Strategy<Value = String> {
4751            let wrappers = prop::sample::select(vec![
4752                "sudo ",
4753                "env ",
4754                "time ",
4755                "nice ",
4756                "/usr/bin/time ",
4757                "RUST_BACKTRACE=1 ",
4758                "CC=clang ",
4759                "",
4760            ]);
4761            let commands = prop::sample::select(vec![
4762                "cargo build",
4763                "cargo test",
4764                "make",
4765                "gcc -c main.c",
4766                "ls -la",
4767            ]);
4768            (wrappers, commands).prop_map(|(wrapper, cmd)| format!("{}{}", wrapper, cmd))
4769        }
4770
4771        proptest! {
4772            // Configure proptest for high-volume testing
4773            #![proptest_config(ProptestConfig::with_cases(1000))]
4774
4775            // Test 1: Arbitrary strings never cause panics
4776            #[test]
4777            fn test_classify_arbitrary_no_panic(s in arbitrary_string()) {
4778                // Just call classify_command - if it panics, proptest will catch it
4779                let _ = classify_command(&s);
4780            }
4781
4782            // Test 2: Command-like strings never cause panics
4783            #[test]
4784            fn test_classify_command_like_no_panic(s in command_like_string()) {
4785                let _ = classify_command(&s);
4786            }
4787
4788            // Test 3: Known commands with flags produce valid classification
4789            #[test]
4790            fn test_classify_known_commands_valid(s in known_command_with_flags()) {
4791                let result = classify_command(&s);
4792                // Confidence should be in valid range
4793                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0,
4794                    "Confidence {} out of range for command: {}", result.confidence, s);
4795                // If is_compilation, must have a kind
4796                if result.is_compilation {
4797                    prop_assert!(result.kind.is_some(),
4798                        "is_compilation=true but kind=None for: {}", s);
4799                }
4800            }
4801
4802            // Test 4: Unicode and control characters never panic
4803            #[test]
4804            fn test_classify_unicode_no_panic(s in unicode_and_control_chars()) {
4805                let _ = classify_command(&s);
4806            }
4807
4808            // Test 5: Shell special commands are handled
4809            #[test]
4810            fn test_classify_shell_special(s in shell_special_commands()) {
4811                let result = classify_command(&s);
4812                // Commands with shell operators should typically NOT be intercepted
4813                // (pipes, redirects, backgrounding, chaining)
4814                if s.contains(" | ") || s.contains(" > ") || s.contains(" < ") || s.contains(" & ") {
4815                    // May or may not be classified depending on quote handling
4816                    let _ = result; // Just ensure no panic
4817                }
4818            }
4819
4820            // Test 6: Wrapped commands are handled
4821            #[test]
4822            fn test_classify_wrapped_commands(s in wrapped_commands()) {
4823                let result = classify_command(&s);
4824                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4825            }
4826
4827            // Test 7: Classification is deterministic
4828            #[test]
4829            fn test_classify_deterministic(s in arbitrary_string()) {
4830                let result1 = classify_command(&s);
4831                let result2 = classify_command(&s);
4832                prop_assert_eq!(result1, result2,
4833                    "Non-deterministic classification for: {}", s);
4834            }
4835
4836            // Test 8: Empty-ish strings handled correctly
4837            #[test]
4838            fn test_classify_whitespace_variants(s in "[ \t\n\r]{0,20}") {
4839        let _guard = test_guard!();
4840                let result = classify_command(&s);
4841                prop_assert!(!result.is_compilation,
4842                    "Whitespace-only command should not be classified as compilation: {:?}", s);
4843                prop_assert!(result.reason.contains("empty") || result.reason.contains("keyword"),
4844                    "Unexpected reason for whitespace: {}", result.reason);
4845            }
4846
4847            // Test 9: Very long commands don't panic
4848            #[test]
4849            fn test_classify_long_commands(
4850                prefix in "cargo (build|test|check)",
4851                suffix in "[a-zA-Z0-9_ -]{0,10000}"
4852            ) {
4853                let long_cmd = format!("{} {}", prefix, suffix);
4854                let result = classify_command(&long_cmd);
4855                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4856            }
4857
4858            // Test 10: Null bytes and special sequences
4859            #[test]
4860            fn test_classify_special_bytes(s in prop::collection::vec(any::<u8>(), 0..200)) {
4861                if let Ok(valid_str) = String::from_utf8(s.clone()) {
4862                    let _ = classify_command(&valid_str);
4863                }
4864                // Non-UTF8 sequences can't be tested with &str
4865            }
4866        }
4867
4868        // Additional targeted proptest tests
4869
4870        proptest! {
4871            #![proptest_config(ProptestConfig::with_cases(500))]
4872
4873            // Test: Cargo subcommands with arbitrary suffixes
4874            #[test]
4875            fn test_cargo_subcommand_robustness(
4876                subcommand in "(build|test|check|clippy|fmt|clean|run|doc|bench|nextest)",
4877                suffix in "[a-zA-Z0-9_ -]{0,50}"
4878            ) {
4879                let cmd = format!("cargo {} {}", subcommand, suffix);
4880                let result = classify_command(&cmd);
4881                // Ensure valid result structure
4882                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4883                // fmt and clean should NEVER be classified as compilation
4884                if subcommand.eq("fmt") || subcommand.eq("clean") {
4885                    prop_assert!(!result.is_compilation,
4886                        "cargo {} should not be compilation: {}", subcommand, cmd);
4887                }
4888            }
4889
4890            // Test: Bun commands robustness
4891            #[test]
4892            fn test_bun_command_robustness(
4893                subcommand in "(test|typecheck|install|add|remove|run|build|dev)",
4894                suffix in "[a-zA-Z0-9_ -]{0,30}"
4895            ) {
4896                let cmd = format!("bun {} {}", subcommand, suffix);
4897                let result = classify_command(&cmd);
4898                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4899                // install, add, remove, run, build, dev should NOT be compilation
4900                if matches!(subcommand.as_str(), "install" | "add" | "remove" | "run" | "build" | "dev") {
4901                    prop_assert!(!result.is_compilation,
4902                        "bun {} should not be compilation", subcommand);
4903                }
4904            }
4905
4906            // Test: GCC/Clang commands with file patterns
4907            #[test]
4908            fn test_c_compiler_robustness(
4909                compiler in "(gcc|g\\+\\+|clang|clang\\+\\+)",
4910                flags in "(-[cCoOgW][a-z0-9]* )*",
4911                file in "[a-zA-Z_][a-zA-Z0-9_]*\\.(c|cpp|cc|h|hpp)"
4912            ) {
4913                let cmd = format!("{} {}{}", compiler, flags, file);
4914                let result = classify_command(&cmd);
4915                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4916            }
4917
4918            // Test: Commands with quoted strings preserve correctness
4919            #[test]
4920            fn test_quoted_args_handling(
4921                base in "(cargo build|cargo test|gcc|make)",
4922                quoted_content in "[a-zA-Z0-9 _-]{0,30}"
4923            ) {
4924                // Single quotes
4925                let cmd_single = format!("{} '{}'", base, quoted_content);
4926                let _ = classify_command(&cmd_single);
4927
4928                // Double quotes
4929                let cmd_double = format!("{} \"{}\"", base, quoted_content);
4930                let _ = classify_command(&cmd_double);
4931            }
4932        }
4933
4934        // Regression test: ensure specific problematic inputs are handled
4935        #[test]
4936        fn test_known_edge_cases() {
4937            let _guard = test_guard!();
4938            // These are edge cases that might have caused issues
4939            let edge_cases = [
4940                "",
4941                " ",
4942                "\t",
4943                "\n",
4944                "cargo",
4945                "cargo ",
4946                "cargo  ",
4947                "cargo\tbuild",
4948                "cargo\nbuild",
4949                "cargo\rbuild",
4950                "cargo+nightly",
4951                "cargo +nightly",
4952                "cargo +nightly build",
4953                "  cargo build  ",
4954                "CARGO_TARGET_DIR=/tmp cargo build",
4955                "/usr/local/bin/cargo build",
4956                "~/.cargo/bin/cargo build",
4957                "./cargo build",
4958                "../cargo build",
4959                "cargo build 'test file.rs'",
4960                "cargo build \"test file.rs\"",
4961                "cargo build test\\ file.rs",
4962                "cargo build -- --test-threads=1",
4963                "cargo build --features \"feat1 feat2\"",
4964                "cargo build 2>&1",
4965                "cargo build 2>/dev/null",
4966                "gcc -DFOO=\"bar baz\" main.c",
4967                "make -j$(nproc)",
4968                "ninja -C build",
4969                "cmake --build . --target all",
4970                "bun test --timeout 5000 src/",
4971                "env -i PATH=/usr/bin cargo build",
4972            ];
4973
4974            for cmd in edge_cases {
4975                let result = classify_command(cmd);
4976                assert!(
4977                    result.confidence >= 0.0 && result.confidence <= 1.0,
4978                    "Invalid confidence for: {:?}",
4979                    cmd
4980                );
4981            }
4982        }
4983
4984        // Test that detailed classification matches simple classification
4985        #[test]
4986        fn test_detailed_matches_simple_proptest() {
4987            let _guard = test_guard!();
4988            let test_cases = [
4989                "cargo build --release",
4990                "cargo test",
4991                "make -j8",
4992                "ls -la",
4993                "echo hello",
4994            ];
4995
4996            for cmd in test_cases {
4997                let simple = classify_command(cmd);
4998                let detailed = classify_command_detailed(cmd);
4999                assert_eq!(simple, detailed.classification, "Mismatch for: {}", cmd);
5000            }
5001        }
5002
5003        // =================== split_shell_commands tests ===================
5004
5005        #[test]
5006        fn test_split_single_command() {
5007            let _guard = test_guard!();
5008            assert_eq!(split_shell_commands("cargo build"), vec!["cargo build"]);
5009        }
5010
5011        #[test]
5012        fn test_split_and_operator() {
5013            let _guard = test_guard!();
5014            assert_eq!(
5015                split_shell_commands("cargo fmt && cargo build"),
5016                vec!["cargo fmt", "cargo build"]
5017            );
5018        }
5019
5020        #[test]
5021        fn test_split_quoted_operator() {
5022            let _guard = test_guard!();
5023            assert_eq!(
5024                split_shell_commands("echo '&&' && cargo build"),
5025                vec!["echo '&&'", "cargo build"]
5026            );
5027        }
5028
5029        #[test]
5030        fn test_split_mixed_operators() {
5031            let _guard = test_guard!();
5032            assert_eq!(
5033                split_shell_commands("cd /tmp && make -j4 || echo fail"),
5034                vec!["cd /tmp", "make -j4", "echo fail"]
5035            );
5036        }
5037
5038        #[test]
5039        fn test_split_semicolons() {
5040            let _guard = test_guard!();
5041            assert_eq!(split_shell_commands("a ; b ; c"), vec!["a", "b", "c"]);
5042        }
5043
5044        #[test]
5045        fn test_split_quoted_semicolon() {
5046            let _guard = test_guard!();
5047            assert_eq!(
5048                split_shell_commands("echo 'hello;world' && make"),
5049                vec!["echo 'hello;world'", "make"]
5050            );
5051        }
5052
5053        #[test]
5054        fn test_split_pipe_preserved() {
5055            let _guard = test_guard!();
5056            // Single pipe is NOT a command separator
5057            assert_eq!(
5058                split_shell_commands("cargo build 2>&1 | tee log"),
5059                vec!["cargo build 2>&1 | tee log"]
5060            );
5061        }
5062
5063        #[test]
5064        fn test_split_double_quoted_operator() {
5065            let _guard = test_guard!();
5066            assert_eq!(
5067                split_shell_commands(r#"echo "&&" && cargo build"#),
5068                vec![r#"echo "&&""#, "cargo build"]
5069            );
5070        }
5071
5072        #[test]
5073        fn test_split_nested_quotes() {
5074            let _guard = test_guard!();
5075            assert_eq!(
5076                split_shell_commands("echo \"he said 'hello && bye'\" && cargo test"),
5077                vec!["echo \"he said 'hello && bye'\"", "cargo test"]
5078            );
5079        }
5080
5081        #[test]
5082        fn test_split_escaped_quote() {
5083            let _guard = test_guard!();
5084            // Escaped quotes don't toggle state
5085            assert_eq!(
5086                split_shell_commands(r"echo it\'s && cargo build"),
5087                vec![r"echo it\'s", "cargo build"]
5088            );
5089        }
5090
5091        #[test]
5092        fn test_split_backslash_literal_in_single_quotes() {
5093            let _guard = test_guard!();
5094            // POSIX: backslash is LITERAL inside single quotes, so 'a\' is the
5095            // string a\ and the closing quote ENDS the span — exposing the
5096            // following operator. Previously the \ swallowed the closing ',
5097            // kept the quote "open", and hid the && (bd-review-singlequote-escape).
5098            assert_eq!(
5099                split_shell_commands(r"cargo build 'a\' && rm -rf x"),
5100                vec![r"cargo build 'a\'", "rm -rf x"]
5101            );
5102        }
5103
5104        #[test]
5105        fn test_split_empty_string() {
5106            let _guard = test_guard!();
5107            assert!(split_shell_commands("").is_empty());
5108        }
5109
5110        #[test]
5111        fn test_split_only_whitespace() {
5112            let _guard = test_guard!();
5113            assert!(split_shell_commands("   ").is_empty());
5114        }
5115
5116        #[test]
5117        fn test_split_backtick_quoting() {
5118            let _guard = test_guard!();
5119            assert_eq!(
5120                split_shell_commands("echo `echo && fail` && cargo build"),
5121                vec!["echo `echo && fail`", "cargo build"]
5122            );
5123        }
5124
5125        #[test]
5126        fn test_split_trailing_operator() {
5127            let _guard = test_guard!();
5128            // Trailing && with nothing after should yield just the first segment
5129            assert_eq!(split_shell_commands("cargo build &&"), vec!["cargo build"]);
5130        }
5131
5132        // =================== fail-open safeguard tests (bd-16t3) ===================
5133
5134        #[test]
5135        fn test_split_unclosed_single_quote() {
5136            let _guard = test_guard!();
5137            // Unclosed quote: everything stays "inside quotes", no split found
5138            let result = split_shell_commands("echo 'hello && cargo build");
5139            assert_eq!(result, vec!["echo 'hello && cargo build"]);
5140        }
5141
5142        #[test]
5143        fn test_split_unclosed_double_quote() {
5144            let _guard = test_guard!();
5145            let result = split_shell_commands("echo \"hello && cargo build");
5146            assert_eq!(result, vec!["echo \"hello && cargo build"]);
5147        }
5148
5149        #[test]
5150        fn test_split_unclosed_backtick() {
5151            let _guard = test_guard!();
5152            let result = split_shell_commands("echo `hello && cargo build");
5153            assert_eq!(result, vec!["echo `hello && cargo build"]);
5154        }
5155
5156        #[test]
5157        fn test_split_embedded_nulls() {
5158            let _guard = test_guard!();
5159            // Embedded null bytes should not cause panic
5160            let input = "cargo build\0 && echo done";
5161            let result = split_shell_commands(input);
5162            assert_eq!(result.len(), 2);
5163        }
5164
5165        #[test]
5166        fn test_split_unicode_input() {
5167            let _guard = test_guard!();
5168            let result = split_shell_commands("echo 'こんにちは' && cargo build");
5169            assert_eq!(result, vec!["echo 'こんにちは'", "cargo build"]);
5170        }
5171
5172        #[test]
5173        fn test_split_extremely_long_input() {
5174            let _guard = test_guard!();
5175            // 20KB string should still work (split_shell_commands has no length limit)
5176            let long_cmd = format!("echo {} && cargo build", "x".repeat(20_000));
5177            let result = split_shell_commands(&long_cmd);
5178            assert_eq!(result.len(), 2);
5179        }
5180
5181        #[test]
5182        fn test_classify_long_input_skips_splitting() {
5183            let _guard = test_guard!();
5184            // Commands >10KB skip multi-command splitting in classify_command
5185            let long_cmd = format!("cargo build && echo {}", "x".repeat(11_000));
5186            let result = classify_command(&long_cmd);
5187            // Falls through to single-command classification which rejects at check_structure
5188            assert!(!result.is_compilation);
5189            assert!(
5190                result.reason.to_string().contains("chained"),
5191                "long input should be rejected by check_structure, not split"
5192            );
5193        }
5194
5195        #[test]
5196        fn test_split_only_operators() {
5197            let _guard = test_guard!();
5198            // Just operators with no commands
5199            let result = split_shell_commands("&& || ;");
5200            assert!(
5201                result.is_empty(),
5202                "only operators should yield empty result"
5203            );
5204        }
5205
5206        #[test]
5207        fn test_split_consecutive_operators() {
5208            let _guard = test_guard!();
5209            let result = split_shell_commands("cargo build && && echo done");
5210            // Middle empty segment is dropped, yielding 2 segments
5211            assert_eq!(result, vec!["cargo build", "echo done"]);
5212        }
5213
5214        #[test]
5215        fn test_classify_empty_after_split() {
5216            let _guard = test_guard!();
5217            // All sub-commands are non-compilation
5218            let result = classify_command("echo hello && ls -la || pwd");
5219            assert!(!result.is_compilation);
5220        }
5221
5222        // =================== comprehensive multi-command tests (bd-1q0e) ===================
5223
5224        // --- Split edge cases ---
5225
5226        #[test]
5227        fn test_split_three_segment_chain() {
5228            let _guard = test_guard!();
5229            assert_eq!(
5230                split_shell_commands("cd /proj && cmake .. && make"),
5231                vec!["cd /proj", "cmake ..", "make"]
5232            );
5233        }
5234
5235        #[test]
5236        fn test_split_single_with_flags() {
5237            let _guard = test_guard!();
5238            assert_eq!(
5239                split_shell_commands("cargo build --release"),
5240                vec!["cargo build --release"]
5241            );
5242        }
5243
5244        #[test]
5245        fn test_split_nested_quotes_semicolon() {
5246            let _guard = test_guard!();
5247            assert_eq!(
5248                split_shell_commands("echo \"it's && done\" && make"),
5249                vec!["echo \"it's && done\"", "make"]
5250            );
5251        }
5252
5253        #[test]
5254        fn test_split_pipe_then_and() {
5255            let _guard = test_guard!();
5256            // Single pipe within segment, && between segments
5257            assert_eq!(
5258                split_shell_commands("make 2>&1 | grep error && echo done"),
5259                vec!["make 2>&1 | grep error", "echo done"]
5260            );
5261        }
5262
5263        #[test]
5264        fn test_split_leading_operator() {
5265            let _guard = test_guard!();
5266            // Leading && with nothing before
5267            assert_eq!(split_shell_commands("&& cargo build"), vec!["cargo build"]);
5268        }
5269
5270        // --- Compound command classification tests ---
5271        // Compound && commands with compilation suffix are now ACCEPTED
5272        // This enables patterns like "cd /path && cargo build"
5273
5274        #[test]
5275        fn test_classify_cargo_fmt_and_build() {
5276            let _guard = test_guard!();
5277            // Last segment is "cargo build" (compilation) → accepted
5278            let result = classify_command("cargo fmt && cargo build");
5279            assert!(
5280                result.is_compilation,
5281                "compound command with compilation suffix should be accepted"
5282            );
5283            assert!(result.reason.contains("compound"));
5284        }
5285
5286        #[test]
5287        fn test_classify_cd_and_make() {
5288            let _guard = test_guard!();
5289            // Last segment is "make -j8" (compilation) → accepted
5290            let result = classify_command("cd /project && make -j8");
5291            assert!(
5292                result.is_compilation,
5293                "compound command with compilation suffix should be accepted"
5294            );
5295            assert!(result.reason.contains("compound"));
5296        }
5297        #[test]
5298        fn test_classify_export_and_cargo_build() {
5299            let _guard = test_guard!();
5300            // Last segment is "cargo build --release" (compilation) → accepted
5301            let result =
5302                classify_command("export RUSTFLAGS='-C opt-level=3' && cargo build --release");
5303            assert!(
5304                result.is_compilation,
5305                "compound command with compilation suffix should be accepted"
5306            );
5307            assert!(result.reason.contains("compound"));
5308        }
5309        #[test]
5310        fn test_classify_mkdir_cmake_chain() {
5311            let _guard = test_guard!();
5312            // Last segment is "cmake --build build" (compilation) → accepted
5313            let result =
5314                classify_command("mkdir -p build && cmake -B build && cmake --build build");
5315            assert!(
5316                result.is_compilation,
5317                "compound command with compilation suffix should be accepted"
5318            );
5319            assert!(result.reason.contains("compound"));
5320        }
5321        #[test]
5322        fn test_classify_echo_and_cargo_test() {
5323            let _guard = test_guard!();
5324            // Last segment is "cargo test" (compilation) → accepted
5325            let result = classify_command("echo 'Starting...' && cargo test");
5326            assert!(
5327                result.is_compilation,
5328                "compound command with compilation suffix should be accepted"
5329            );
5330            assert!(result.reason.contains("compound"));
5331        }
5332        #[test]
5333        fn test_classify_semicolon_chain_with_compilation() {
5334            let _guard = test_guard!();
5335            // Semicolon chains are NOT supported (only && is supported)
5336            let result = classify_command("cargo fmt; cargo build; cargo test");
5337            assert!(
5338                !result.is_compilation,
5339                "semicolon chained commands should be rejected"
5340            );
5341            assert!(result.reason.contains("chained"));
5342        }
5343        // --- Classification integration: should classify as NON-COMPILATION ---
5344
5345        #[test]
5346        fn test_classify_echo_chain_non_compilation() {
5347            let _guard = test_guard!();
5348            let result = classify_command("echo hello && echo world");
5349            assert!(!result.is_compilation);
5350        }
5351
5352        #[test]
5353        fn test_classify_ls_cat_non_compilation() {
5354            let _guard = test_guard!();
5355            let result = classify_command("ls -la && cat file.txt");
5356            assert!(!result.is_compilation);
5357        }
5358
5359        #[test]
5360        fn test_classify_git_chain_non_compilation() {
5361            let _guard = test_guard!();
5362            let result = classify_command("git status && git log");
5363            assert!(!result.is_compilation);
5364        }
5365
5366        // --- Performance test ---
5367
5368        #[test]
5369        fn test_classify_multi_command_performance() {
5370            let _guard = test_guard!();
5371            let start = std::time::Instant::now();
5372            for _ in 0..100 {
5373                let _ = classify_command("cargo fmt && cargo build && cargo test");
5374            }
5375            let elapsed = start.elapsed();
5376            let per_call_us = elapsed.as_micros() / 100;
5377            // Each classify_command call should be well under 5ms
5378            assert!(
5379                per_call_us < 5_000,
5380                "classify_command took {}us per call, should be <5000us",
5381                per_call_us
5382            );
5383            eprintln!(
5384                "[perf] classify_command multi-command: {}us avg per call",
5385                per_call_us
5386            );
5387        }
5388
5389        // --- Pipe preservation with split ---
5390
5391        #[test]
5392        fn test_classify_pipe_to_pager_preserved() {
5393            let _guard = test_guard!();
5394            // Issue #24: a compilation command piped into a benign pager is
5395            // offloaded with the pipe re-attached to the offloaded output.
5396            let result = classify_command("cargo build 2>&1 | tee log");
5397            assert!(result.is_compilation, "got: {:?}", result.reason);
5398            assert_eq!(
5399                result.extracted_command.as_deref(),
5400                Some("cargo build 2>&1 | tee log")
5401            );
5402        }
5403
5404        #[test]
5405        fn test_classify_pipe_segment_and_operator() {
5406            let _guard = test_guard!();
5407            // Pipe within first segment, && separates from second
5408            let result = classify_command("make 2>&1 | grep error && cargo build");
5409            // Should be rejected due to chaining (&&) AND piping (|)
5410            assert!(
5411                !result.is_compilation,
5412                "chained commands should be rejected"
5413            );
5414            // The reason will likely be "chained command (&&)" because check_structure checks separators first or pipes first?
5415            // Let's check check_structure impl: pipes are checked AFTER backgrounding, separators are after pipes?
5416            // Actually, check_structure checks pipes, then redirects, then chaining.
5417            // So "piped command" might be the reason. Either is fine.
5418            assert!(result.reason.contains("piped") || result.reason.contains("chained"));
5419        }
5420    }
5421
5422    // =========================================================================
5423    // WS2.4: Zero-allocation reject path tests (bd-3mog)
5424    //
5425    // Verify that the Cow<'static, str> conversion in Classification and
5426    // ClassificationTier produces Cow::Borrowed on the reject path (zero heap
5427    // allocations) and that serde roundtrips work correctly.
5428    // =========================================================================
5429
5430    /// Helper: assert that a Cow is the Borrowed variant (no heap allocation).
5431    /// We need the actual &Cow to distinguish Borrowed from Owned.
5432    #[allow(clippy::ptr_arg)]
5433    fn assert_cow_borrowed(cow: &Cow<'static, str>, context: &str) {
5434        assert!(
5435            matches!(cow, Cow::Borrowed(_)),
5436            "{context}: expected Cow::Borrowed, got Cow::Owned({:?})",
5437            cow,
5438        );
5439    }
5440
5441    // --- 1. Classification correctness after Cow conversion ---
5442
5443    #[test]
5444    fn test_cow_reject_empty_command_correctness() {
5445        let _guard = test_guard!();
5446        let result = classify_command("");
5447        assert!(!result.is_compilation);
5448        assert_eq!(result.confidence, 0.0);
5449        assert_eq!(result.kind, None);
5450        assert!(result.reason.contains("empty"), "reason: {}", result.reason);
5451    }
5452
5453    #[test]
5454    fn test_cow_reject_non_compilation_commands() {
5455        let _guard = test_guard!();
5456        let non_compilation = [
5457            "ls -la",
5458            "cat file.txt",
5459            "echo hello",
5460            "git status",
5461            "pwd",
5462            "cd /tmp",
5463        ];
5464        for cmd in non_compilation {
5465            let result = classify_command(cmd);
5466            assert!(
5467                !result.is_compilation,
5468                "'{cmd}' should NOT be classified as compilation"
5469            );
5470            assert_eq!(result.confidence, 0.0, "'{cmd}' should have 0.0 confidence");
5471            assert_eq!(result.kind, None, "'{cmd}' should have no CompilationKind");
5472            assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5473        }
5474    }
5475
5476    #[test]
5477    fn test_cow_accept_compilation_commands() {
5478        let _guard = test_guard!();
5479        let cases: &[(&str, CompilationKind)] = &[
5480            ("cargo build", CompilationKind::CargoBuild),
5481            ("cargo test", CompilationKind::CargoTest),
5482            ("cargo check", CompilationKind::CargoCheck),
5483            ("cargo clippy", CompilationKind::CargoClippy),
5484            ("gcc -o hello hello.c", CompilationKind::Gcc),
5485            ("make", CompilationKind::Make),
5486            ("bun test", CompilationKind::BunTest),
5487        ];
5488        for &(cmd, expected_kind) in cases {
5489            let result = classify_command(cmd);
5490            assert!(
5491                result.is_compilation,
5492                "'{cmd}' should be classified as compilation"
5493            );
5494            assert_eq!(
5495                result.kind,
5496                Some(expected_kind),
5497                "'{cmd}' should have kind {expected_kind:?}"
5498            );
5499            assert!(
5500                result.confidence > 0.0,
5501                "'{cmd}' should have positive confidence"
5502            );
5503            assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5504        }
5505    }
5506
5507    // --- 2. Zero-allocation verification on reject path ---
5508
5509    #[test]
5510    fn test_cow_borrowed_on_tier0_reject() {
5511        let _guard = test_guard!();
5512        // Empty command -> Tier 0 instant reject
5513        let result = classify_command("");
5514        assert_cow_borrowed(&result.reason, "Tier 0 empty command reason");
5515    }
5516
5517    #[test]
5518    fn test_cow_borrowed_on_tier1_reject() {
5519        let _guard = test_guard!();
5520        // Pipe into a NON-benign command -> Tier 1 structure analysis reject
5521        // (benign pagers like tee/head are now offloaded per issue #24).
5522        let result = classify_command("cargo build | xargs rm");
5523        assert!(!result.is_compilation);
5524        assert_cow_borrowed(&result.reason, "Tier 1 piped command reason");
5525
5526        // Input redirect -> still rejected (transforms the command's INPUT)
5527        let result = classify_command("cargo build < input.txt");
5528        assert!(!result.is_compilation);
5529        assert_cow_borrowed(&result.reason, "Tier 1 input redirect reason");
5530
5531        // Subshell -> still rejected
5532        let result = classify_command("cargo build $(echo args)");
5533        assert!(!result.is_compilation);
5534        assert_cow_borrowed(&result.reason, "Tier 1 subshell reason");
5535    }
5536
5537    #[test]
5538    fn test_cow_borrowed_on_tier2_reject() {
5539        let _guard = test_guard!();
5540        // Commands with no compilation keyword -> Tier 2 keyword filter reject
5541        let non_keyword_commands = [
5542            "ls -la",
5543            "cat file.txt",
5544            "echo hello world",
5545            "git status",
5546            "pwd",
5547            "cd /tmp",
5548            "grep pattern file",
5549            "find . -name '*.rs'",
5550            "cp src dst",
5551            "rm file.txt",
5552        ];
5553        for cmd in non_keyword_commands {
5554            let result = classify_command(cmd);
5555            assert!(!result.is_compilation, "'{cmd}' should be rejected");
5556            assert_cow_borrowed(&result.reason, &format!("Tier 2 reject for '{cmd}'"));
5557        }
5558    }
5559
5560    #[test]
5561    fn test_cow_borrowed_on_tier3_reject() {
5562        let _guard = test_guard!();
5563        // Never-intercept commands now use static strings for performance (no format! allocation)
5564        let never_intercept = ["cargo fmt", "cargo install ripgrep", "cargo clean"];
5565        for cmd in never_intercept {
5566            let result = classify_command(cmd);
5567            assert!(!result.is_compilation, "'{cmd}' should be rejected");
5568            assert!(
5569                result.reason.contains("never-intercept"),
5570                "'{cmd}' reason should mention never-intercept: {}",
5571                result.reason
5572            );
5573            // Tier 3 now uses static string for performance -> Cow::Borrowed
5574            assert_cow_borrowed(&result.reason, &format!("Tier 3 reject for '{cmd}'"));
5575        }
5576    }
5577
5578    // --- 3. ClassificationDetails tier Cow verification ---
5579
5580    #[test]
5581    fn test_detailed_tiers_use_borrowed_names() {
5582        let _guard = test_guard!();
5583        // classify_command_detailed produces ClassificationTier entries
5584        // All tier names should be Cow::Borrowed (static constants)
5585        let details = classify_command_detailed("ls -la");
5586        for tier in &details.tiers {
5587            assert_cow_borrowed(&tier.name, &format!("Tier {} name", tier.tier));
5588        }
5589    }
5590
5591    #[test]
5592    fn test_detailed_tier_reasons_borrowed_on_reject() {
5593        let _guard = test_guard!();
5594        // Non-compilation commands should have Cow::Borrowed reasons on all tiers
5595        let details = classify_command_detailed("ls -la");
5596        assert!(!details.classification.is_compilation);
5597        for tier in &details.tiers {
5598            assert_cow_borrowed(
5599                &tier.reason,
5600                &format!("Tier {} reason for 'ls -la'", tier.tier),
5601            );
5602        }
5603    }
5604
5605    #[test]
5606    fn test_detailed_compilation_tier_names_borrowed() {
5607        let _guard = test_guard!();
5608        // Even for compilation commands, tier NAMES should always be Cow::Borrowed
5609        let details = classify_command_detailed("cargo build");
5610        assert!(details.classification.is_compilation);
5611        for tier in &details.tiers {
5612            assert_cow_borrowed(
5613                &tier.name,
5614                &format!("Tier {} name for 'cargo build'", tier.tier),
5615            );
5616        }
5617    }
5618
5619    // --- 4. Serde roundtrip tests for Cow fields ---
5620
5621    #[test]
5622    fn test_classification_serde_roundtrip_borrowed() {
5623        let _guard = test_guard!();
5624        let original = Classification::not_compilation("no compilation keyword");
5625        assert_cow_borrowed(&original.reason, "before serialize");
5626
5627        let json = serde_json::to_string(&original).expect("serialize");
5628        let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5629
5630        assert_eq!(deserialized.is_compilation, original.is_compilation);
5631        assert_eq!(deserialized.confidence, original.confidence);
5632        assert_eq!(deserialized.kind, original.kind);
5633        assert_eq!(deserialized.reason, original.reason);
5634        // Note: after deserialization, Cow will be Owned (serde deserializes into String)
5635        // This is correct behavior — only the pre-serialization path needs to be allocation-free
5636    }
5637
5638    #[test]
5639    fn test_classification_serde_roundtrip_compilation() {
5640        let _guard = test_guard!();
5641        let original =
5642            Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build detected");
5643
5644        let json = serde_json::to_string(&original).expect("serialize");
5645        let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5646
5647        assert_eq!(deserialized.is_compilation, original.is_compilation);
5648        assert_eq!(deserialized.confidence, original.confidence);
5649        assert_eq!(deserialized.kind, original.kind);
5650        assert_eq!(deserialized.reason.as_ref(), original.reason.as_ref());
5651    }
5652
5653    #[test]
5654    fn test_classification_tier_serde_roundtrip() {
5655        let _guard = test_guard!();
5656        let tier = ClassificationTier {
5657            tier: 2,
5658            name: Cow::Borrowed(TIER_KEYWORD_FILTER),
5659            decision: TierDecision::Reject,
5660            reason: Cow::Borrowed("no compilation keyword"),
5661        };
5662        assert_cow_borrowed(&tier.name, "tier name before serialize");
5663        assert_cow_borrowed(&tier.reason, "tier reason before serialize");
5664
5665        let json = serde_json::to_string(&tier).expect("serialize");
5666        let deserialized: ClassificationTier = serde_json::from_str(&json).expect("deserialize");
5667
5668        assert_eq!(deserialized.tier, tier.tier);
5669        assert_eq!(deserialized.name.as_ref(), tier.name.as_ref());
5670        assert_eq!(deserialized.decision, tier.decision);
5671        assert_eq!(deserialized.reason.as_ref(), tier.reason.as_ref());
5672    }
5673
5674    #[test]
5675    fn test_classification_details_serde_roundtrip() {
5676        let _guard = test_guard!();
5677        let details = classify_command_detailed("cargo build");
5678        assert!(details.classification.is_compilation);
5679
5680        let json = serde_json::to_string(&details).expect("serialize");
5681        let deserialized: ClassificationDetails = serde_json::from_str(&json).expect("deserialize");
5682
5683        assert_eq!(deserialized.original, details.original);
5684        assert_eq!(deserialized.normalized, details.normalized);
5685        assert_eq!(deserialized.tiers.len(), details.tiers.len());
5686        assert_eq!(
5687            deserialized.classification.is_compilation,
5688            details.classification.is_compilation
5689        );
5690        assert_eq!(
5691            deserialized.classification.kind,
5692            details.classification.kind
5693        );
5694    }
5695
5696    // --- 5. Cow display and comparison tests ---
5697
5698    #[test]
5699    fn test_cow_reason_display() {
5700        let _guard = test_guard!();
5701        let result = classify_command("ls");
5702        // Cow<str> should Display/Debug correctly
5703        let displayed = format!("{}", result.reason);
5704        assert!(!displayed.is_empty());
5705        let debugged = format!("{:?}", result.reason);
5706        assert!(!debugged.is_empty());
5707    }
5708
5709    #[test]
5710    fn test_cow_reason_comparison() {
5711        let _guard = test_guard!();
5712        // Cow::Borrowed and Cow::Owned with same content should be equal
5713        let borrowed: Cow<'static, str> = Cow::Borrowed("no compilation keyword");
5714        let owned: Cow<'static, str> = Cow::Owned("no compilation keyword".to_string());
5715        assert_eq!(borrowed, owned);
5716
5717        // Classification reasons should be comparable
5718        let r1 = classify_command("ls");
5719        let r2 = classify_command("pwd");
5720        // Both should be "no compilation keyword" (Tier 2 reject)
5721        assert_eq!(r1.reason, r2.reason);
5722    }
5723}
5724
5725#[cfg(test)]
5726mod tests_bun_whitespace {
5727    use super::*;
5728    use crate::test_guard;
5729
5730    #[test]
5731    fn test_bun_whitespace_resilience() {
5732        let _guard = test_guard!();
5733
5734        // Standard single space
5735        let result = classify_command("bun test");
5736        assert!(result.is_compilation, "bun test failed");
5737        assert_eq!(result.kind, Some(CompilationKind::BunTest));
5738
5739        // Multiple spaces
5740        let result = classify_command("bun  test");
5741        assert!(result.is_compilation, "bun  test failed");
5742        assert_eq!(result.kind, Some(CompilationKind::BunTest));
5743
5744        // Tab separation
5745        let result = classify_command("bun\ttest");
5746        assert!(result.is_compilation, "bun\\ttest failed");
5747        assert_eq!(result.kind, Some(CompilationKind::BunTest));
5748
5749        // Typecheck with extra spaces
5750        let result = classify_command("bun   typecheck   src/");
5751        assert!(result.is_compilation, "bun typecheck with spaces failed");
5752        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
5753    }
5754
5755    #[test]
5756    fn test_bun_watch_with_whitespace() {
5757        let _guard = test_guard!();
5758
5759        // Watch with extra spaces
5760        let result = classify_command("bun  test  --watch");
5761        assert!(
5762            !result.is_compilation,
5763            "bun test --watch should be rejected"
5764        );
5765        assert!(result.reason.contains("interactive"));
5766
5767        // Watch with short flag and tabs
5768        let result = classify_command("bun\ttypecheck\t-w");
5769        assert!(
5770            !result.is_compilation,
5771            "bun typecheck -w should be rejected"
5772        );
5773        assert!(result.reason.contains("interactive"));
5774    }
5775
5776    #[test]
5777    fn test_bun_x_whitespace() {
5778        let _guard = test_guard!();
5779
5780        // bun x with extra spaces
5781        let result = classify_command("bun  x  vitest");
5782        assert!(!result.is_compilation, "bun x should be rejected");
5783        assert!(result.reason.contains("bun x"));
5784    }
5785}
5786
5787#[cfg(test)]
5788mod tests_normalize_whitespace {
5789    use super::*;
5790    use crate::test_guard;
5791
5792    #[test]
5793    fn test_wrapper_whitespace_resilience() {
5794        let _guard = test_guard!();
5795
5796        // Multiple spaces
5797        assert_eq!(normalize_command("sudo  cargo"), "cargo");
5798
5799        // Tabs
5800        assert_eq!(normalize_command("sudo\tcargo"), "cargo");
5801
5802        // Mixed wrappers with weird spacing
5803        assert_eq!(normalize_command("time\tsudo  cargo"), "cargo");
5804
5805        // Prefix matching safety
5806        assert_eq!(normalize_command("sudocargo"), "sudocargo");
5807
5808        // Bare wrapper (should become empty)
5809        assert_eq!(normalize_command("sudo"), "");
5810    }
5811
5812    #[test]
5813    fn wrapper_value_flags_consume_their_value_token() {
5814        // Regression (bd-review-wrapper-flag-eater): value-taking wrapper flags
5815        // must consume the VALUE token too, or it is left as the command word
5816        // and a real build silently runs local.
5817        let _guard = test_guard!();
5818        assert_eq!(normalize_command("nice -n 10 cargo build"), "cargo build");
5819        assert_eq!(
5820            normalize_command("taskset -c 0-3 cargo build"),
5821            "cargo build"
5822        );
5823        assert_eq!(normalize_command("ionice -c 2 cargo test"), "cargo test");
5824        assert_eq!(
5825            normalize_command("ionice -c 2 -n 4 cargo build"),
5826            "cargo build"
5827        );
5828        assert_eq!(normalize_command("chrt -r 10 cargo build"), "cargo build");
5829        assert_eq!(normalize_command("timeout 60 cargo build"), "cargo build");
5830        assert_eq!(
5831            normalize_command("timeout -s KILL 90 cargo build"),
5832            "cargo build"
5833        );
5834        assert_eq!(
5835            normalize_command("sudo nice -n 5 cargo build"),
5836            "cargo build"
5837        );
5838        // Attached / `=`-joined values carry their own value (no extra consume).
5839        assert_eq!(normalize_command("nice -n10 cargo build"), "cargo build");
5840        assert_eq!(normalize_command("ionice -c2 cargo build"), "cargo build");
5841        // Existing env -u behavior preserved.
5842        assert_eq!(
5843            normalize_command("env -u RUSTFLAGS cargo build"),
5844            "cargo build"
5845        );
5846    }
5847
5848    #[test]
5849    fn positional_wrapper_does_not_eat_a_non_numeric_command() {
5850        // Malformed `timeout cargo build` (no duration): the leading non-digit
5851        // token is the command and must NOT be skipped.
5852        let _guard = test_guard!();
5853        assert_eq!(normalize_command("timeout cargo build"), "cargo build");
5854    }
5855
5856    #[test]
5857    fn wrapped_cargo_build_is_still_classified_as_compilation() {
5858        let _guard = test_guard!();
5859        for cmd in [
5860            "nice -n 10 cargo build",
5861            "taskset -c 0-3 cargo build",
5862            "ionice -c 2 cargo build",
5863            "chrt -r 10 cargo build",
5864            "timeout 60 cargo build",
5865            "timeout -s KILL 90 cargo test",
5866            "time '-f%U' cargo build",
5867            "time \"-f%U\" cargo build",
5868            "nice '--adjustment=+5' cargo build",
5869            "nice '-n' '5' cargo build",
5870            "env '--unset' 'RUSTFLAGS' cargo build",
5871        ] {
5872            assert!(
5873                classify_command(cmd).is_compilation,
5874                "wrapper must not defeat offload: {cmd}"
5875            );
5876        }
5877        // A wrapper around a non-build must still pass through (not offloaded).
5878        assert!(!classify_command("nice -n 10 ls -la").is_compilation);
5879        assert!(!classify_command("timeout 60 echo hi").is_compilation);
5880        assert!(!classify_command("time '-f%U' echo cargo build").is_compilation);
5881        assert!(!classify_command("time '-f%U cargo build").is_compilation);
5882        assert!(!classify_command("time '-f%U'echo cargo build").is_compilation);
5883    }
5884
5885    #[test]
5886    fn test_env_var_whitespace() {
5887        let _guard = test_guard!();
5888
5889        for (cmd, normalized) in [
5890            // env with multiple spaces before VAR
5891            ("env  RUST_BACKTRACE=1 cargo", "cargo"),
5892            // env with tabs
5893            ("env\tRUST_BACKTRACE=1\tcargo", "cargo"),
5894            ("env -u RUST_LOG cargo test", "cargo test"),
5895            ("env --unset RUST_LOG cargo test", "cargo test"),
5896            ("env --unset=RUST_LOG cargo test", "cargo test"),
5897        ] {
5898            assert_eq!(normalize_command(cmd), normalized, "{cmd}");
5899        }
5900    }
5901}
5902
5903// ============================================================================
5904// REGRESSION SUITE: Command Classification (bd-vvmd.2.9)
5905//
5906// Table-driven regression tests protecting non-compilation UX. Every entry
5907// documents expected behavior with rationale so accidental interception of
5908// local-only commands is caught immediately.
5909// ============================================================================
5910
5911#[cfg(test)]
5912mod regression_classification {
5913    use super::*;
5914    use crate::test_guard;
5915
5916    fn assert_golden_json(
5917        actual: serde_json::Value,
5918        expected: &serde_json::Value,
5919        fixture_path: &str,
5920    ) {
5921        if &actual == expected {
5922            return;
5923        }
5924
5925        let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
5926        let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
5927        panic!(
5928            "golden mismatch in {fixture_path}\n\
5929             expected_blake3={}\n\
5930             actual_blake3={}\n\
5931             bless path: review the semantic diff, update the fixture expected_* field, \
5932             and record both hashes in the Beads closeout.\n\
5933             expected:\n{expected_json}\n\
5934             actual:\n{actual_json}",
5935            blake3::hash(expected_json.as_bytes()),
5936            blake3::hash(actual_json.as_bytes())
5937        );
5938    }
5939
5940    /// A single regression test case.
5941    struct Case {
5942        cmd: &'static str,
5943        expect_compilation: bool,
5944        expected_kind: Option<CompilationKind>,
5945        /// Substring that MUST appear in the classification reason.
5946        reason_contains: &'static str,
5947        /// Minimum confidence (only checked when expect_compilation is true).
5948        min_confidence: f64,
5949    }
5950
5951    fn run_cases(cases: &[Case]) {
5952        for (i, c) in cases.iter().enumerate() {
5953            let result = classify_command(c.cmd);
5954            assert_eq!(
5955                result.is_compilation, c.expect_compilation,
5956                "Case {i} ({:?}): expected is_compilation={}, got={}. reason={:?}",
5957                c.cmd, c.expect_compilation, result.is_compilation, result.reason
5958            );
5959            if c.expect_compilation {
5960                assert_eq!(
5961                    result.kind, c.expected_kind,
5962                    "Case {i} ({:?}): expected kind={:?}, got={:?}",
5963                    c.cmd, c.expected_kind, result.kind
5964                );
5965                assert!(
5966                    result.confidence >= c.min_confidence,
5967                    "Case {i} ({:?}): expected confidence >= {}, got={}",
5968                    c.cmd,
5969                    c.min_confidence,
5970                    result.confidence
5971                );
5972            }
5973            if !c.reason_contains.is_empty() {
5974                assert!(
5975                    result.reason.contains(c.reason_contains),
5976                    "Case {i} ({:?}): expected reason containing {:?}, got {:?}",
5977                    c.cmd,
5978                    c.reason_contains,
5979                    result.reason
5980                );
5981            }
5982        }
5983    }
5984
5985    // ------------------------------------------------------------------
5986    // 1. Cargo: Positive compilation commands
5987    // ------------------------------------------------------------------
5988
5989    #[test]
5990    fn regression_cargo_compilation_positive() {
5991        let _guard = test_guard!();
5992        let cases = [
5993            Case {
5994                cmd: "cargo build",
5995                expect_compilation: true,
5996                expected_kind: Some(CompilationKind::CargoBuild),
5997                reason_contains: "cargo build",
5998                min_confidence: 0.90,
5999            },
6000            Case {
6001                cmd: "cargo build --release",
6002                expect_compilation: true,
6003                expected_kind: Some(CompilationKind::CargoBuild),
6004                reason_contains: "cargo build",
6005                min_confidence: 0.90,
6006            },
6007            Case {
6008                cmd: "cargo build --workspace",
6009                expect_compilation: true,
6010                expected_kind: Some(CompilationKind::CargoBuild),
6011                reason_contains: "cargo build",
6012                min_confidence: 0.90,
6013            },
6014            Case {
6015                cmd: "cargo build -p my-crate",
6016                expect_compilation: true,
6017                expected_kind: Some(CompilationKind::CargoBuild),
6018                reason_contains: "cargo build",
6019                min_confidence: 0.90,
6020            },
6021            Case {
6022                cmd: "cargo b",
6023                expect_compilation: true,
6024                expected_kind: Some(CompilationKind::CargoBuild),
6025                reason_contains: "cargo build",
6026                min_confidence: 0.90,
6027            },
6028            Case {
6029                cmd: "cargo test",
6030                expect_compilation: true,
6031                expected_kind: Some(CompilationKind::CargoTest),
6032                reason_contains: "cargo test",
6033                min_confidence: 0.90,
6034            },
6035            Case {
6036                cmd: "cargo test --release",
6037                expect_compilation: true,
6038                expected_kind: Some(CompilationKind::CargoTest),
6039                reason_contains: "cargo test",
6040                min_confidence: 0.90,
6041            },
6042            Case {
6043                cmd: "cargo test my_test",
6044                expect_compilation: true,
6045                expected_kind: Some(CompilationKind::CargoTest),
6046                reason_contains: "cargo test",
6047                min_confidence: 0.90,
6048            },
6049            Case {
6050                cmd: "cargo test -- --nocapture",
6051                expect_compilation: true,
6052                expected_kind: Some(CompilationKind::CargoTest),
6053                reason_contains: "cargo test",
6054                min_confidence: 0.90,
6055            },
6056            Case {
6057                cmd: "cargo test --workspace",
6058                expect_compilation: true,
6059                expected_kind: Some(CompilationKind::CargoTest),
6060                reason_contains: "cargo test",
6061                min_confidence: 0.90,
6062            },
6063            Case {
6064                cmd: "cargo test -p rch-common",
6065                expect_compilation: true,
6066                expected_kind: Some(CompilationKind::CargoTest),
6067                reason_contains: "cargo test",
6068                min_confidence: 0.90,
6069            },
6070            Case {
6071                cmd: "cargo t",
6072                expect_compilation: true,
6073                expected_kind: Some(CompilationKind::CargoTest),
6074                reason_contains: "cargo test",
6075                min_confidence: 0.90,
6076            },
6077            Case {
6078                cmd: "cargo test --lib",
6079                expect_compilation: true,
6080                expected_kind: Some(CompilationKind::CargoTest),
6081                reason_contains: "cargo test",
6082                min_confidence: 0.90,
6083            },
6084            Case {
6085                cmd: "cargo test --bins",
6086                expect_compilation: true,
6087                expected_kind: Some(CompilationKind::CargoTest),
6088                reason_contains: "cargo test",
6089                min_confidence: 0.90,
6090            },
6091            Case {
6092                cmd: "cargo test --doc",
6093                expect_compilation: true,
6094                expected_kind: Some(CompilationKind::CargoTest),
6095                reason_contains: "cargo test",
6096                min_confidence: 0.90,
6097            },
6098            Case {
6099                cmd: "cargo test -- --test-threads=4",
6100                expect_compilation: true,
6101                expected_kind: Some(CompilationKind::CargoTest),
6102                reason_contains: "cargo test",
6103                min_confidence: 0.90,
6104            },
6105            Case {
6106                cmd: "cargo check",
6107                expect_compilation: true,
6108                expected_kind: Some(CompilationKind::CargoCheck),
6109                reason_contains: "cargo check",
6110                min_confidence: 0.85,
6111            },
6112            Case {
6113                cmd: "cargo check --workspace --all-targets",
6114                expect_compilation: true,
6115                expected_kind: Some(CompilationKind::CargoCheck),
6116                reason_contains: "cargo check",
6117                min_confidence: 0.85,
6118            },
6119            Case {
6120                cmd: "cargo c",
6121                expect_compilation: true,
6122                expected_kind: Some(CompilationKind::CargoCheck),
6123                reason_contains: "cargo check",
6124                min_confidence: 0.85,
6125            },
6126            Case {
6127                cmd: "cargo clippy",
6128                expect_compilation: true,
6129                expected_kind: Some(CompilationKind::CargoClippy),
6130                reason_contains: "cargo clippy",
6131                min_confidence: 0.85,
6132            },
6133            Case {
6134                cmd: "cargo clippy --workspace --all-targets -- -D warnings",
6135                expect_compilation: true,
6136                expected_kind: Some(CompilationKind::CargoClippy),
6137                reason_contains: "cargo clippy",
6138                min_confidence: 0.85,
6139            },
6140            Case {
6141                cmd: "cargo doc",
6142                expect_compilation: true,
6143                expected_kind: Some(CompilationKind::CargoDoc),
6144                reason_contains: "cargo doc",
6145                min_confidence: 0.80,
6146            },
6147            Case {
6148                cmd: "cargo doc --no-deps",
6149                expect_compilation: true,
6150                expected_kind: Some(CompilationKind::CargoDoc),
6151                reason_contains: "cargo doc",
6152                min_confidence: 0.80,
6153            },
6154            Case {
6155                cmd: "cargo run",
6156                expect_compilation: true,
6157                expected_kind: Some(CompilationKind::CargoBuild),
6158                reason_contains: "cargo run",
6159                min_confidence: 0.80,
6160            },
6161            Case {
6162                cmd: "cargo run --release",
6163                expect_compilation: true,
6164                expected_kind: Some(CompilationKind::CargoBuild),
6165                reason_contains: "cargo run",
6166                min_confidence: 0.80,
6167            },
6168            Case {
6169                cmd: "cargo r",
6170                expect_compilation: true,
6171                expected_kind: Some(CompilationKind::CargoBuild),
6172                reason_contains: "cargo run",
6173                min_confidence: 0.80,
6174            },
6175            Case {
6176                cmd: "cargo bench",
6177                expect_compilation: true,
6178                expected_kind: Some(CompilationKind::CargoBench),
6179                reason_contains: "cargo bench",
6180                min_confidence: 0.85,
6181            },
6182            Case {
6183                cmd: "cargo bench --bench my_bench",
6184                expect_compilation: true,
6185                expected_kind: Some(CompilationKind::CargoBench),
6186                reason_contains: "cargo bench",
6187                min_confidence: 0.85,
6188            },
6189            Case {
6190                cmd: "cargo nextest run",
6191                expect_compilation: true,
6192                expected_kind: Some(CompilationKind::CargoNextest),
6193                reason_contains: "cargo nextest run",
6194                min_confidence: 0.90,
6195            },
6196            Case {
6197                cmd: "cargo nextest run --workspace",
6198                expect_compilation: true,
6199                expected_kind: Some(CompilationKind::CargoNextest),
6200                reason_contains: "cargo nextest run",
6201                min_confidence: 0.90,
6202            },
6203            Case {
6204                cmd: "cargo nextest r",
6205                expect_compilation: true,
6206                expected_kind: Some(CompilationKind::CargoNextest),
6207                reason_contains: "cargo nextest run",
6208                min_confidence: 0.90,
6209            },
6210            // Toolchain override variants
6211            Case {
6212                cmd: "cargo +nightly build",
6213                expect_compilation: true,
6214                expected_kind: Some(CompilationKind::CargoBuild),
6215                reason_contains: "cargo build",
6216                min_confidence: 0.90,
6217            },
6218            Case {
6219                cmd: "cargo +1.80.0 test",
6220                expect_compilation: true,
6221                expected_kind: Some(CompilationKind::CargoTest),
6222                reason_contains: "cargo test",
6223                min_confidence: 0.90,
6224            },
6225            Case {
6226                cmd: "cargo +nightly nextest run",
6227                expect_compilation: true,
6228                expected_kind: Some(CompilationKind::CargoNextest),
6229                reason_contains: "cargo nextest run",
6230                min_confidence: 0.90,
6231            },
6232            // Leading global flags before the subcommand must NOT hide the build
6233            // (regression for the flag-read-as-subcommand misclassification).
6234            Case {
6235                cmd: "cargo --offline build",
6236                expect_compilation: true,
6237                expected_kind: Some(CompilationKind::CargoBuild),
6238                reason_contains: "cargo build",
6239                min_confidence: 0.90,
6240            },
6241            Case {
6242                cmd: "cargo -q test",
6243                expect_compilation: true,
6244                expected_kind: Some(CompilationKind::CargoTest),
6245                reason_contains: "cargo test",
6246                min_confidence: 0.90,
6247            },
6248            Case {
6249                cmd: "cargo --locked --frozen check",
6250                expect_compilation: true,
6251                expected_kind: Some(CompilationKind::CargoCheck),
6252                reason_contains: "cargo check",
6253                min_confidence: 0.85,
6254            },
6255            Case {
6256                cmd: "cargo --color never build",
6257                expect_compilation: true,
6258                expected_kind: Some(CompilationKind::CargoBuild),
6259                reason_contains: "cargo build",
6260                min_confidence: 0.90,
6261            },
6262            Case {
6263                cmd: "cargo +nightly --offline -Z unstable-options build",
6264                expect_compilation: true,
6265                expected_kind: Some(CompilationKind::CargoBuild),
6266                reason_contains: "cargo build",
6267                min_confidence: 0.90,
6268            },
6269            // `rch exec` re-quotes every word containing `=` (shell_words::join).
6270            Case {
6271                cmd: "cargo '--config=build.jobs=2' -vvv clippy",
6272                expect_compilation: true,
6273                expected_kind: Some(CompilationKind::CargoClippy),
6274                reason_contains: "cargo clippy",
6275                min_confidence: 0.85,
6276            },
6277            Case {
6278                cmd: "cargo-zigbuild '--color=never' zigbuild",
6279                expect_compilation: true,
6280                expected_kind: Some(CompilationKind::CargoZigbuild),
6281                reason_contains: "cargo-zigbuild build",
6282                min_confidence: 0.90,
6283            },
6284            // A quoted word is still read by its content, never as a flag.
6285            Case {
6286                cmd: "cargo 'fetch'",
6287                expect_compilation: false,
6288                expected_kind: None,
6289                reason_contains: "",
6290                min_confidence: 0.0,
6291            },
6292        ];
6293        run_cases(&cases);
6294    }
6295
6296    #[test]
6297    fn regression_cargo_package_verification_is_compilation() {
6298        let _guard = test_guard!();
6299        for command in [
6300            "cargo package",
6301            "cargo package --workspace --locked -j2",
6302            "cargo package -p asupersync --features tls --target x86_64-unknown-linux-gnu",
6303            "cargo +nightly --offline package --allow-dirty --no-metadata",
6304            "cargo --color never package --target-dir=/data/tmp/package-check",
6305            "cargo publish --dry-run",
6306            "cargo publish -n --workspace --locked --keep-going -j 2",
6307            "cargo publish --registry crates-io -p asupersync --dry-run",
6308            "cargo +nightly --locked publish --dry-run --features=tls",
6309            "env CARGO_INCREMENTAL=0 cargo publish --dry-run --workspace",
6310        ] {
6311            let classification = classify_command(command);
6312            assert!(
6313                classification.is_compilation,
6314                "{command}: {classification:?}"
6315            );
6316            assert_eq!(classification.kind, Some(CompilationKind::CargoBuild));
6317            assert_eq!(
6318                classify_command_detailed(command).classification,
6319                classification,
6320                "diagnosis must agree with execution for {command}"
6321            );
6322        }
6323    }
6324
6325    #[test]
6326    fn regression_cargo_package_verification_declines_publication_and_metadata() {
6327        let _guard = test_guard!();
6328        for command in [
6329            "cargo publish",
6330            "cargo publish --workspace --locked",
6331            "cargo +nightly --offline publish",
6332            "cargo publish --no-verify",
6333            "cargo publish --dry-run --no-verify",
6334            "cargo publish --no-verify -n",
6335            "cargo publish -qn --no-verify",
6336            "cargo package --no-verify",
6337            "cargo package --list",
6338            "cargo package -l",
6339            "cargo package -vl",
6340            "cargo package --help",
6341            "cargo --help package",
6342            "cargo publish --dry-run -h",
6343            "cargo publish -- --dry-run",
6344            "cargo publish --dry-run -- --no-verify",
6345            "cargo publish --dry-run=false",
6346            "cargo publish --registry=--dry-run",
6347            "cargo publish -p--dry-run",
6348            "cargo publish --token=--dry-run",
6349            "cargo publish --config --dry-run",
6350            "cargo publish --config alias.note=--dry-run",
6351            "cargo publish --unknown-option --dry-run",
6352            "cargo package --target --no-verify",
6353            "cargo package --package --list",
6354            "cargo package --config",
6355            "cargo publish --config 'alias.note=\"--dry-run\"'",
6356            "cargo publish --registry 'example --dry-run registry'",
6357            "cargo publish --dry-run --config 'alias.note=\"--no-verify\"'",
6358            "cargo publish --dry-run $EXTRA_CARGO_OPTIONS",
6359        ] {
6360            let classification = classify_command(command);
6361            assert!(
6362                !classification.is_compilation,
6363                "{command}: {classification:?}"
6364            );
6365            let detailed = classify_command_detailed(command).classification;
6366            assert!(!detailed.is_compilation, "{command}: {detailed:?}");
6367            assert!(!is_cargo_package_verification(command), "{command}");
6368        }
6369    }
6370
6371    // ------------------------------------------------------------------
6372    // 2. Cargo: Negative (NEVER intercept — must stay local)
6373    // ------------------------------------------------------------------
6374
6375    #[test]
6376    fn regression_cargo_never_intercept() {
6377        let _guard = test_guard!();
6378        let cases = [
6379            Case {
6380                cmd: "cargo install ripgrep",
6381                expect_compilation: false,
6382                expected_kind: None,
6383                reason_contains: "never-intercept",
6384                min_confidence: 0.0,
6385            },
6386            Case {
6387                cmd: "cargo publish",
6388                expect_compilation: false,
6389                expected_kind: None,
6390                reason_contains: "never-intercept",
6391                min_confidence: 0.0,
6392            },
6393            Case {
6394                cmd: "cargo login",
6395                expect_compilation: false,
6396                expected_kind: None,
6397                reason_contains: "never-intercept",
6398                min_confidence: 0.0,
6399            },
6400            Case {
6401                cmd: "cargo fmt",
6402                expect_compilation: false,
6403                expected_kind: None,
6404                reason_contains: "never-intercept",
6405                min_confidence: 0.0,
6406            },
6407            Case {
6408                cmd: "cargo fmt --check",
6409                expect_compilation: false,
6410                expected_kind: None,
6411                reason_contains: "never-intercept",
6412                min_confidence: 0.0,
6413            },
6414            Case {
6415                cmd: "cargo fix",
6416                expect_compilation: false,
6417                expected_kind: None,
6418                reason_contains: "never-intercept",
6419                min_confidence: 0.0,
6420            },
6421            Case {
6422                cmd: "cargo clean",
6423                expect_compilation: false,
6424                expected_kind: None,
6425                reason_contains: "never-intercept",
6426                min_confidence: 0.0,
6427            },
6428            Case {
6429                cmd: "cargo new my_project",
6430                expect_compilation: false,
6431                expected_kind: None,
6432                reason_contains: "never-intercept",
6433                min_confidence: 0.0,
6434            },
6435            Case {
6436                cmd: "cargo init",
6437                expect_compilation: false,
6438                expected_kind: None,
6439                reason_contains: "never-intercept",
6440                min_confidence: 0.0,
6441            },
6442            Case {
6443                cmd: "cargo add serde",
6444                expect_compilation: false,
6445                expected_kind: None,
6446                reason_contains: "never-intercept",
6447                min_confidence: 0.0,
6448            },
6449            Case {
6450                cmd: "cargo remove serde",
6451                expect_compilation: false,
6452                expected_kind: None,
6453                reason_contains: "never-intercept",
6454                min_confidence: 0.0,
6455            },
6456            Case {
6457                cmd: "cargo update",
6458                expect_compilation: false,
6459                expected_kind: None,
6460                reason_contains: "never-intercept",
6461                min_confidence: 0.0,
6462            },
6463            Case {
6464                cmd: "cargo generate-lockfile",
6465                expect_compilation: false,
6466                expected_kind: None,
6467                reason_contains: "never-intercept",
6468                min_confidence: 0.0,
6469            },
6470            Case {
6471                cmd: "cargo watch -x test",
6472                expect_compilation: false,
6473                expected_kind: None,
6474                reason_contains: "never-intercept",
6475                min_confidence: 0.0,
6476            },
6477            Case {
6478                cmd: "cargo --version",
6479                expect_compilation: false,
6480                expected_kind: None,
6481                reason_contains: "never-intercept",
6482                min_confidence: 0.0,
6483            },
6484            Case {
6485                cmd: "cargo -V",
6486                expect_compilation: false,
6487                expected_kind: None,
6488                reason_contains: "never-intercept",
6489                min_confidence: 0.0,
6490            },
6491            // cargo nextest non-run subcommands
6492            Case {
6493                cmd: "cargo nextest list",
6494                expect_compilation: false,
6495                expected_kind: None,
6496                reason_contains: "never-intercept",
6497                min_confidence: 0.0,
6498            },
6499            Case {
6500                cmd: "cargo nextest archive",
6501                expect_compilation: false,
6502                expected_kind: None,
6503                reason_contains: "never-intercept",
6504                min_confidence: 0.0,
6505            },
6506            Case {
6507                cmd: "cargo nextest show",
6508                expect_compilation: false,
6509                expected_kind: None,
6510                reason_contains: "never-intercept",
6511                min_confidence: 0.0,
6512            },
6513            // Bare cargo / bare cargo nextest
6514            Case {
6515                cmd: "cargo",
6516                expect_compilation: false,
6517                expected_kind: None,
6518                reason_contains: "bare cargo",
6519                min_confidence: 0.0,
6520            },
6521            Case {
6522                cmd: "cargo nextest",
6523                expect_compilation: false,
6524                expected_kind: None,
6525                reason_contains: "bare cargo nextest",
6526                min_confidence: 0.0,
6527            },
6528            // Non-interceptable subcommands
6529            Case {
6530                cmd: "cargo tree",
6531                expect_compilation: false,
6532                expected_kind: None,
6533                reason_contains: "not interceptable",
6534                min_confidence: 0.0,
6535            },
6536            Case {
6537                cmd: "cargo metadata",
6538                expect_compilation: false,
6539                expected_kind: None,
6540                reason_contains: "not interceptable",
6541                min_confidence: 0.0,
6542            },
6543            Case {
6544                cmd: "cargo search serde",
6545                expect_compilation: false,
6546                expected_kind: None,
6547                reason_contains: "not interceptable",
6548                min_confidence: 0.0,
6549            },
6550            Case {
6551                cmd: "cargo vendor",
6552                expect_compilation: false,
6553                expected_kind: None,
6554                reason_contains: "not interceptable",
6555                min_confidence: 0.0,
6556            },
6557        ];
6558        run_cases(&cases);
6559    }
6560
6561    // ------------------------------------------------------------------
6562    // 3. Bun: Positive compilation (test, typecheck)
6563    // ------------------------------------------------------------------
6564
6565    #[test]
6566    fn regression_bun_compilation_positive() {
6567        let _guard = test_guard!();
6568        let cases = [
6569            Case {
6570                cmd: "bun test",
6571                expect_compilation: true,
6572                expected_kind: Some(CompilationKind::BunTest),
6573                reason_contains: "bun test",
6574                min_confidence: 0.90,
6575            },
6576            Case {
6577                cmd: "bun test src/",
6578                expect_compilation: true,
6579                expected_kind: Some(CompilationKind::BunTest),
6580                reason_contains: "bun test",
6581                min_confidence: 0.90,
6582            },
6583            Case {
6584                cmd: "bun test --bail",
6585                expect_compilation: true,
6586                expected_kind: Some(CompilationKind::BunTest),
6587                reason_contains: "bun test",
6588                min_confidence: 0.90,
6589            },
6590            Case {
6591                cmd: "bun test --timeout 5000",
6592                expect_compilation: true,
6593                expected_kind: Some(CompilationKind::BunTest),
6594                reason_contains: "bun test",
6595                min_confidence: 0.90,
6596            },
6597            Case {
6598                cmd: "bun typecheck",
6599                expect_compilation: true,
6600                expected_kind: Some(CompilationKind::BunTypecheck),
6601                reason_contains: "bun typecheck",
6602                min_confidence: 0.90,
6603            },
6604            Case {
6605                cmd: "bun typecheck src/",
6606                expect_compilation: true,
6607                expected_kind: Some(CompilationKind::BunTypecheck),
6608                reason_contains: "bun typecheck",
6609                min_confidence: 0.90,
6610            },
6611        ];
6612        run_cases(&cases);
6613    }
6614
6615    // ------------------------------------------------------------------
6616    // 4. Bun: Negative (package management, execution, interactive)
6617    // ------------------------------------------------------------------
6618
6619    #[test]
6620    fn regression_bun_never_intercept() {
6621        let _guard = test_guard!();
6622        let cases = [
6623            // Package management — modifies local node_modules
6624            Case {
6625                cmd: "bun install",
6626                expect_compilation: false,
6627                expected_kind: None,
6628                reason_contains: "never-intercept",
6629                min_confidence: 0.0,
6630            },
6631            Case {
6632                cmd: "bun add react",
6633                expect_compilation: false,
6634                expected_kind: None,
6635                reason_contains: "never-intercept",
6636                min_confidence: 0.0,
6637            },
6638            Case {
6639                cmd: "bun remove react",
6640                expect_compilation: false,
6641                expected_kind: None,
6642                reason_contains: "never-intercept",
6643                min_confidence: 0.0,
6644            },
6645            Case {
6646                cmd: "bun link",
6647                expect_compilation: false,
6648                expected_kind: None,
6649                reason_contains: "never-intercept",
6650                min_confidence: 0.0,
6651            },
6652            Case {
6653                cmd: "bun unlink",
6654                expect_compilation: false,
6655                expected_kind: None,
6656                reason_contains: "never-intercept",
6657                min_confidence: 0.0,
6658            },
6659            Case {
6660                cmd: "bun pm ls",
6661                expect_compilation: false,
6662                expected_kind: None,
6663                reason_contains: "never-intercept",
6664                min_confidence: 0.0,
6665            },
6666            Case {
6667                cmd: "bun init",
6668                expect_compilation: false,
6669                expected_kind: None,
6670                reason_contains: "never-intercept",
6671                min_confidence: 0.0,
6672            },
6673            Case {
6674                cmd: "bun create my-app",
6675                expect_compilation: false,
6676                expected_kind: None,
6677                reason_contains: "never-intercept",
6678                min_confidence: 0.0,
6679            },
6680            Case {
6681                cmd: "bun upgrade",
6682                expect_compilation: false,
6683                expected_kind: None,
6684                reason_contains: "never-intercept",
6685                min_confidence: 0.0,
6686            },
6687            // Execution — binds local ports or runs scripts
6688            Case {
6689                cmd: "bun run dev",
6690                expect_compilation: false,
6691                expected_kind: None,
6692                reason_contains: "never-intercept",
6693                min_confidence: 0.0,
6694            },
6695            Case {
6696                cmd: "bun build src/index.ts",
6697                expect_compilation: false,
6698                expected_kind: None,
6699                reason_contains: "never-intercept",
6700                min_confidence: 0.0,
6701            },
6702            Case {
6703                cmd: "bun dev",
6704                expect_compilation: false,
6705                expected_kind: None,
6706                reason_contains: "never-intercept",
6707                min_confidence: 0.0,
6708            },
6709            Case {
6710                cmd: "bun repl",
6711                expect_compilation: false,
6712                expected_kind: None,
6713                reason_contains: "never-intercept",
6714                min_confidence: 0.0,
6715            },
6716            // Package runner — arbitrary execution like npx
6717            Case {
6718                cmd: "bun x vitest",
6719                expect_compilation: false,
6720                expected_kind: None,
6721                reason_contains: "bun x",
6722                min_confidence: 0.0,
6723            },
6724            Case {
6725                cmd: "bun x tsc --noEmit",
6726                expect_compilation: false,
6727                expected_kind: None,
6728                reason_contains: "bun x",
6729                min_confidence: 0.0,
6730            },
6731            // Interactive watch modes — must run locally for interactivity
6732            Case {
6733                cmd: "bun test --watch",
6734                expect_compilation: false,
6735                expected_kind: None,
6736                reason_contains: "interactive",
6737                min_confidence: 0.0,
6738            },
6739            Case {
6740                cmd: "bun test -w",
6741                expect_compilation: false,
6742                expected_kind: None,
6743                reason_contains: "interactive",
6744                min_confidence: 0.0,
6745            },
6746            Case {
6747                cmd: "bun typecheck --watch",
6748                expect_compilation: false,
6749                expected_kind: None,
6750                reason_contains: "interactive",
6751                min_confidence: 0.0,
6752            },
6753            Case {
6754                cmd: "bun typecheck -w",
6755                expect_compilation: false,
6756                expected_kind: None,
6757                reason_contains: "interactive",
6758                min_confidence: 0.0,
6759            },
6760            // Version/help
6761            Case {
6762                cmd: "bun --version",
6763                expect_compilation: false,
6764                expected_kind: None,
6765                reason_contains: "never-intercept",
6766                min_confidence: 0.0,
6767            },
6768            Case {
6769                cmd: "bun -v",
6770                expect_compilation: false,
6771                expected_kind: None,
6772                reason_contains: "never-intercept",
6773                min_confidence: 0.0,
6774            },
6775            Case {
6776                cmd: "bun --help",
6777                expect_compilation: false,
6778                expected_kind: None,
6779                reason_contains: "never-intercept",
6780                min_confidence: 0.0,
6781            },
6782            Case {
6783                cmd: "bun -h",
6784                expect_compilation: false,
6785                expected_kind: None,
6786                reason_contains: "never-intercept",
6787                min_confidence: 0.0,
6788            },
6789            Case {
6790                cmd: "bun completions",
6791                expect_compilation: false,
6792                expected_kind: None,
6793                reason_contains: "never-intercept",
6794                min_confidence: 0.0,
6795            },
6796        ];
6797        run_cases(&cases);
6798    }
6799
6800    // ------------------------------------------------------------------
6801    // 5. C/C++ compilers: Positive compilation
6802    // ------------------------------------------------------------------
6803
6804    #[test]
6805    fn regression_c_cpp_compilation_positive() {
6806        let _guard = test_guard!();
6807        let cases = [
6808            Case {
6809                cmd: "gcc -c main.c -o main.o",
6810                expect_compilation: true,
6811                expected_kind: Some(CompilationKind::Gcc),
6812                reason_contains: "gcc",
6813                min_confidence: 0.85,
6814            },
6815            Case {
6816                cmd: "gcc main.c -o main",
6817                expect_compilation: true,
6818                expected_kind: Some(CompilationKind::Gcc),
6819                reason_contains: "gcc",
6820                min_confidence: 0.85,
6821            },
6822            Case {
6823                cmd: "g++ -c main.cpp -o main.o",
6824                expect_compilation: true,
6825                expected_kind: Some(CompilationKind::Gpp),
6826                reason_contains: "g++",
6827                min_confidence: 0.85,
6828            },
6829            Case {
6830                cmd: "g++ main.cc -o main",
6831                expect_compilation: true,
6832                expected_kind: Some(CompilationKind::Gpp),
6833                reason_contains: "g++",
6834                min_confidence: 0.85,
6835            },
6836            Case {
6837                cmd: "clang -c main.c -o main.o",
6838                expect_compilation: true,
6839                expected_kind: Some(CompilationKind::Clang),
6840                reason_contains: "clang",
6841                min_confidence: 0.85,
6842            },
6843            Case {
6844                cmd: "clang++ -c main.cpp -o main.o",
6845                expect_compilation: true,
6846                expected_kind: Some(CompilationKind::Clangpp),
6847                reason_contains: "clang++",
6848                min_confidence: 0.85,
6849            },
6850            Case {
6851                cmd: "clang++ main.cc -o main",
6852                expect_compilation: true,
6853                expected_kind: Some(CompilationKind::Clangpp),
6854                reason_contains: "clang++",
6855                min_confidence: 0.85,
6856            },
6857            Case {
6858                cmd: "cc -c main.c -o main.o",
6859                expect_compilation: true,
6860                expected_kind: Some(CompilationKind::Gcc),
6861                reason_contains: "cc",
6862                min_confidence: 0.80,
6863            },
6864            Case {
6865                cmd: "c++ -c main.cpp -o main.o",
6866                expect_compilation: true,
6867                expected_kind: Some(CompilationKind::Gpp),
6868                reason_contains: "c++",
6869                min_confidence: 0.80,
6870            },
6871        ];
6872        run_cases(&cases);
6873    }
6874
6875    // ------------------------------------------------------------------
6876    // 6. C/C++ compilers: Version checks (NEVER intercept)
6877    // ------------------------------------------------------------------
6878
6879    #[test]
6880    fn regression_c_cpp_version_checks_not_intercepted() {
6881        let _guard = test_guard!();
6882        let cases = [
6883            Case {
6884                cmd: "rustc --version",
6885                expect_compilation: false,
6886                expected_kind: None,
6887                reason_contains: "never-intercept",
6888                min_confidence: 0.0,
6889            },
6890            Case {
6891                cmd: "rustc -V",
6892                expect_compilation: false,
6893                expected_kind: None,
6894                reason_contains: "never-intercept",
6895                min_confidence: 0.0,
6896            },
6897            Case {
6898                cmd: "gcc --version",
6899                expect_compilation: false,
6900                expected_kind: None,
6901                reason_contains: "never-intercept",
6902                min_confidence: 0.0,
6903            },
6904            Case {
6905                cmd: "gcc -v",
6906                expect_compilation: false,
6907                expected_kind: None,
6908                reason_contains: "never-intercept",
6909                min_confidence: 0.0,
6910            },
6911            Case {
6912                cmd: "clang --version",
6913                expect_compilation: false,
6914                expected_kind: None,
6915                reason_contains: "never-intercept",
6916                min_confidence: 0.0,
6917            },
6918            Case {
6919                cmd: "clang -v",
6920                expect_compilation: false,
6921                expected_kind: None,
6922                reason_contains: "never-intercept",
6923                min_confidence: 0.0,
6924            },
6925            Case {
6926                cmd: "make --version",
6927                expect_compilation: false,
6928                expected_kind: None,
6929                reason_contains: "never-intercept",
6930                min_confidence: 0.0,
6931            },
6932            Case {
6933                cmd: "make -v",
6934                expect_compilation: false,
6935                expected_kind: None,
6936                reason_contains: "never-intercept",
6937                min_confidence: 0.0,
6938            },
6939            Case {
6940                cmd: "cmake --version",
6941                expect_compilation: false,
6942                expected_kind: None,
6943                reason_contains: "never-intercept",
6944                min_confidence: 0.0,
6945            },
6946        ];
6947        run_cases(&cases);
6948    }
6949
6950    // ------------------------------------------------------------------
6951    // 7. Build systems: Positive compilation
6952    // ------------------------------------------------------------------
6953
6954    #[test]
6955    fn regression_build_systems_positive() {
6956        let _guard = test_guard!();
6957        let cases = [
6958            Case {
6959                cmd: "make",
6960                expect_compilation: true,
6961                expected_kind: Some(CompilationKind::Make),
6962                reason_contains: "make build",
6963                min_confidence: 0.80,
6964            },
6965            Case {
6966                cmd: "make -j8",
6967                expect_compilation: true,
6968                expected_kind: Some(CompilationKind::Make),
6969                reason_contains: "make build",
6970                min_confidence: 0.80,
6971            },
6972            Case {
6973                cmd: "make all",
6974                expect_compilation: true,
6975                expected_kind: Some(CompilationKind::Make),
6976                reason_contains: "make build",
6977                min_confidence: 0.80,
6978            },
6979            Case {
6980                cmd: "cmake --build build",
6981                expect_compilation: true,
6982                expected_kind: Some(CompilationKind::CmakeBuild),
6983                reason_contains: "cmake --build",
6984                min_confidence: 0.85,
6985            },
6986            Case {
6987                cmd: "cmake --build=build",
6988                expect_compilation: true,
6989                expected_kind: Some(CompilationKind::CmakeBuild),
6990                reason_contains: "cmake --build",
6991                min_confidence: 0.85,
6992            },
6993            Case {
6994                cmd: "ninja",
6995                expect_compilation: true,
6996                expected_kind: Some(CompilationKind::Ninja),
6997                reason_contains: "ninja build",
6998                min_confidence: 0.85,
6999            },
7000            Case {
7001                cmd: "ninja -j4",
7002                expect_compilation: true,
7003                expected_kind: Some(CompilationKind::Ninja),
7004                reason_contains: "ninja build",
7005                min_confidence: 0.85,
7006            },
7007            Case {
7008                cmd: "meson compile",
7009                expect_compilation: true,
7010                expected_kind: Some(CompilationKind::Meson),
7011                reason_contains: "meson compile",
7012                min_confidence: 0.80,
7013            },
7014        ];
7015        run_cases(&cases);
7016    }
7017
7018    // ------------------------------------------------------------------
7019    // 8. Build systems: Negative (clean, install, version)
7020    // ------------------------------------------------------------------
7021
7022    #[test]
7023    fn regression_build_systems_not_intercepted() {
7024        let _guard = test_guard!();
7025        let cases = [
7026            Case {
7027                cmd: "make clean",
7028                expect_compilation: false,
7029                expected_kind: None,
7030                reason_contains: "make maintenance",
7031                min_confidence: 0.0,
7032            },
7033            Case {
7034                cmd: "make install",
7035                expect_compilation: false,
7036                expected_kind: None,
7037                reason_contains: "make maintenance",
7038                min_confidence: 0.0,
7039            },
7040            Case {
7041                cmd: "make distclean",
7042                expect_compilation: false,
7043                expected_kind: None,
7044                reason_contains: "make maintenance",
7045                min_confidence: 0.0,
7046            },
7047            Case {
7048                cmd: "ninja -t clean",
7049                expect_compilation: false,
7050                expected_kind: None,
7051                reason_contains: "ninja clean",
7052                min_confidence: 0.0,
7053            },
7054            Case {
7055                cmd: "ninja clean",
7056                expect_compilation: false,
7057                expected_kind: None,
7058                reason_contains: "ninja clean",
7059                min_confidence: 0.0,
7060            },
7061            // meson without compile subcommand
7062            Case {
7063                cmd: "meson setup build",
7064                expect_compilation: false,
7065                expected_kind: None,
7066                reason_contains: "no matching pattern",
7067                min_confidence: 0.0,
7068            },
7069            Case {
7070                cmd: "meson configure",
7071                expect_compilation: false,
7072                expected_kind: None,
7073                reason_contains: "no matching pattern",
7074                min_confidence: 0.0,
7075            },
7076            // cmake without --build
7077            Case {
7078                cmd: "cmake .",
7079                expect_compilation: false,
7080                expected_kind: None,
7081                reason_contains: "no matching pattern",
7082                min_confidence: 0.0,
7083            },
7084            Case {
7085                cmd: "cmake -DCMAKE_BUILD_TYPE=Release ..",
7086                expect_compilation: false,
7087                expected_kind: None,
7088                reason_contains: "no matching pattern",
7089                min_confidence: 0.0,
7090            },
7091        ];
7092        run_cases(&cases);
7093    }
7094
7095    // ------------------------------------------------------------------
7096    // 9. Rustc: Direct invocation
7097    // ------------------------------------------------------------------
7098
7099    #[test]
7100    fn regression_rustc_positive() {
7101        let _guard = test_guard!();
7102        let cases = [
7103            Case {
7104                cmd: "rustc main.rs",
7105                expect_compilation: true,
7106                expected_kind: Some(CompilationKind::Rustc),
7107                reason_contains: "rustc",
7108                min_confidence: 0.90,
7109            },
7110            Case {
7111                cmd: "rustc main.rs -o main",
7112                expect_compilation: true,
7113                expected_kind: Some(CompilationKind::Rustc),
7114                reason_contains: "rustc",
7115                min_confidence: 0.90,
7116            },
7117            Case {
7118                cmd: "rustc --edition 2021 main.rs",
7119                expect_compilation: true,
7120                expected_kind: Some(CompilationKind::Rustc),
7121                reason_contains: "rustc",
7122                min_confidence: 0.90,
7123            },
7124        ];
7125        run_cases(&cases);
7126    }
7127
7128    // ------------------------------------------------------------------
7129    // 10. Shell structure exclusions (pipes, redirects, bg, chains)
7130    // ------------------------------------------------------------------
7131
7132    #[test]
7133    fn regression_shell_structure_exclusions() {
7134        let _guard = test_guard!();
7135        let cases = [
7136            // Pipe into a NON-benign command -> still rejected (issue #24 only
7137            // offloads pipes into benign pagers like tee/head/grep).
7138            Case {
7139                cmd: "cargo build 2>&1 | xargs rm",
7140                expect_compilation: false,
7141                expected_kind: None,
7142                reason_contains: "piped",
7143                min_confidence: 0.0,
7144            },
7145            // Input redirect
7146            Case {
7147                cmd: "cargo build < input.txt",
7148                expect_compilation: false,
7149                expected_kind: None,
7150                reason_contains: "input redirect",
7151                min_confidence: 0.0,
7152            },
7153            // Subshell
7154            Case {
7155                cmd: "(cargo build)",
7156                expect_compilation: false,
7157                expected_kind: None,
7158                reason_contains: "subshell",
7159                min_confidence: 0.0,
7160            },
7161            Case {
7162                cmd: "$(cargo build)",
7163                expect_compilation: false,
7164                expected_kind: None,
7165                reason_contains: "subshell",
7166                min_confidence: 0.0,
7167            },
7168            Case {
7169                cmd: "`cargo build`",
7170                expect_compilation: false,
7171                expected_kind: None,
7172                reason_contains: "subshell capture",
7173                min_confidence: 0.0,
7174            },
7175            // Process substitution
7176            Case {
7177                cmd: "cargo build --config <(echo ...)",
7178                expect_compilation: false,
7179                expected_kind: None,
7180                reason_contains: "subshell",
7181                min_confidence: 0.0,
7182            },
7183            // Semicolon chains
7184            Case {
7185                cmd: "cargo build; cargo test",
7186                expect_compilation: false,
7187                expected_kind: None,
7188                reason_contains: "chained",
7189                min_confidence: 0.0,
7190            },
7191            // || chains
7192            Case {
7193                cmd: "cargo build || echo failed",
7194                expect_compilation: false,
7195                expected_kind: None,
7196                reason_contains: "chained",
7197                min_confidence: 0.0,
7198            },
7199            // Newline injection (security)
7200            Case {
7201                cmd: "cargo build\nrm -rf /",
7202                expect_compilation: false,
7203                expected_kind: None,
7204                reason_contains: "newline",
7205                min_confidence: 0.0,
7206            },
7207            Case {
7208                cmd: "cargo build\r\nevil",
7209                expect_compilation: false,
7210                expected_kind: None,
7211                reason_contains: "newline",
7212                min_confidence: 0.0,
7213            },
7214        ];
7215        run_cases(&cases);
7216    }
7217
7218    // ------------------------------------------------------------------
7219    // 10a. Every compilation segment of an `&&` chain is offloaded (issue #50).
7220    //
7221    // `cargo build --release && cargo test` used to rewrite only the final
7222    // segment; the first build ran locally on the dispatcher with no summary
7223    // line. Now every segment that classifies as a compilation is wrapped.
7224    // ------------------------------------------------------------------
7225
7226    #[test]
7227    fn regression_compound_chain_offloads_every_compilation_segment() {
7228        let _guard = test_guard!();
7229
7230        let result = classify_command("cargo build --release && cargo test");
7231        assert!(result.is_compilation, "got: {:?}", result.reason);
7232        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7233        assert_eq!(
7234            result.command_prefix.as_deref(),
7235            Some("rch exec -- cargo build --release && ")
7236        );
7237        assert_eq!(result.extracted_command.as_deref(), Some("cargo test"));
7238
7239        // Three segments, non-compilation head: only the builds are wrapped.
7240        let result = classify_command("touch marker && cargo build && cargo test --workspace");
7241        assert_eq!(
7242            result.command_prefix.as_deref(),
7243            Some("touch marker && rch exec -- cargo build && ")
7244        );
7245        assert_eq!(
7246            result.extracted_command.as_deref(),
7247            Some("cargo test --workspace")
7248        );
7249
7250        // Mixed tool families are each wrapped.
7251        let result = classify_command("go vet ./... && cargo check");
7252        assert_eq!(
7253            result.command_prefix.as_deref(),
7254            Some("rch exec -- go vet ./... && ")
7255        );
7256
7257        // Existing contract: a plain `cd` prefix is untouched.
7258        let result = classify_command("cd /repo && cargo build");
7259        assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7260
7261        // A build-looking segment that must stay local is left verbatim ...
7262        let result = classify_command("go build ./cmd && cargo test");
7263        assert!(result.is_compilation);
7264        assert_eq!(result.command_prefix.as_deref(), Some("go build ./cmd && "));
7265        // ... and reported, so the hook can announce it instead of staying silent.
7266        assert_eq!(
7267            compound_local_compilation_segments("go build ./cmd && cargo test"),
7268            vec!["go build ./cmd".to_string()]
7269        );
7270        assert!(
7271            compound_local_compilation_segments("cargo build --release && cargo test").is_empty()
7272        );
7273        assert!(compound_local_compilation_segments("cd /repo && cargo build").is_empty());
7274        assert!(compound_local_compilation_segments("cargo test").is_empty());
7275        assert!(
7276            compound_local_compilation_segments("bash -c \"cargo build && cargo test\"").is_empty(),
7277            "quoted operators are not chain separators"
7278        );
7279
7280        // `;` / `||` chains and a chain whose final segment is not a build stay
7281        // declined, and each carries a structure reason the hook prints.
7282        assert!(!classify_command("cargo build ; cargo test").is_compilation);
7283        assert!(declined_compilation_due_to_structure("cargo build ; cargo test").is_some());
7284        assert!(declined_compilation_due_to_structure("cargo build || echo failed").is_some());
7285        assert!(declined_compilation_due_to_structure("cargo build && ./run").is_some());
7286    }
7287
7288    // ------------------------------------------------------------------
7289    // 10b. Benign trailing structure IS offloaded (issue #24).
7290    //
7291    // A compilation command followed only by a benign stdout/stderr redirect,
7292    // a pipe into a benign pager (tee/head/cat/grep/less/...), or backgrounding
7293    // is now extracted and offloaded, with the trailing structure re-attached
7294    // to the offloaded command's output.
7295    // ------------------------------------------------------------------
7296
7297    #[test]
7298    fn regression_benign_trailing_structure_offloaded() {
7299        let _guard = test_guard!();
7300        let expectations: &[(&str, CompilationKind, &str)] = &[
7301            // Output redirects
7302            (
7303                "cargo build > log.txt",
7304                CompilationKind::CargoBuild,
7305                "cargo build > log.txt",
7306            ),
7307            (
7308                "cargo build >> log.txt",
7309                CompilationKind::CargoBuild,
7310                "cargo build >> log.txt",
7311            ),
7312            (
7313                "cargo test --workspace > out.log 2>&1",
7314                CompilationKind::CargoTest,
7315                "cargo test --workspace > out.log 2>&1",
7316            ),
7317            // Pipes into benign pagers
7318            (
7319                "cargo build 2>&1 | grep error",
7320                CompilationKind::CargoBuild,
7321                "cargo build 2>&1 | grep error",
7322            ),
7323            (
7324                "cargo test | tee output.log",
7325                CompilationKind::CargoTest,
7326                "cargo test | tee output.log",
7327            ),
7328            (
7329                "cargo check --lib 2>&1 | head",
7330                CompilationKind::CargoCheck,
7331                "cargo check --lib 2>&1 | head",
7332            ),
7333            // Backgrounding
7334            (
7335                "cargo build &",
7336                CompilationKind::CargoBuild,
7337                "cargo build &",
7338            ),
7339        ];
7340        for (cmd, kind, extracted) in expectations {
7341            let result = classify_command(cmd);
7342            assert!(
7343                result.is_compilation,
7344                "'{cmd}' should be offloaded, got: {:?}",
7345                result.reason
7346            );
7347            assert_eq!(result.kind, Some(*kind), "kind mismatch for '{cmd}'");
7348            assert_eq!(
7349                result.extracted_command.as_deref(),
7350                Some(*extracted),
7351                "extracted command mismatch for '{cmd}'"
7352            );
7353            // command_prefix is empty for benign-suffix (no leading prefix).
7354            assert_eq!(result.command_prefix.as_deref(), Some(""));
7355        }
7356    }
7357
7358    #[test]
7359    fn regression_unsafe_trailing_structure_still_rejected() {
7360        let _guard = test_guard!();
7361        // Stay conservative: these forms transform input / chain a build / pipe
7362        // into a non-pager, and must NOT be offloaded.
7363        let rejected = [
7364            "cargo build < input.txt",           // input redirect
7365            "cargo build | xargs rm",            // non-benign pipe target
7366            "cargo build 2>&1 | grep e | sh",    // multi-stage pipe
7367            "cargo test | tee log && cargo run", // chained build
7368            "cargo build > a.log < in.txt",      // input redirect present
7369        ];
7370        for cmd in rejected {
7371            let result = classify_command(cmd);
7372            assert!(
7373                !result.is_compilation,
7374                "'{cmd}' should NOT be offloaded, got: {:?}",
7375                result
7376            );
7377        }
7378    }
7379
7380    #[test]
7381    fn regression_shell_dash_c_wrapper_offloaded() {
7382        let _guard = test_guard!();
7383        // Issue #24 item 2: bash -c / sh -c wrappers extract the inner command.
7384        let result = classify_command("bash -c \"cargo test --workspace\"");
7385        assert!(result.is_compilation, "got: {:?}", result.reason);
7386        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7387        assert_eq!(
7388            result.extracted_command.as_deref(),
7389            Some("cargo test --workspace")
7390        );
7391
7392        let result = classify_command("sh -c 'cargo build --release'");
7393        assert!(result.is_compilation, "got: {:?}", result.reason);
7394        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7395        assert_eq!(
7396            result.extracted_command.as_deref(),
7397            Some("cargo build --release")
7398        );
7399
7400        // Absolute-path shell.
7401        let result = classify_command("/bin/bash -c \"cargo check\"");
7402        assert!(result.is_compilation, "got: {:?}", result.reason);
7403
7404        // Inner compound is preserved (cd prefix kept).
7405        let result = classify_command("bash -c \"cd /repo && cargo build\"");
7406        assert!(result.is_compilation, "got: {:?}", result.reason);
7407        assert_eq!(
7408            result.extracted_command.as_deref(),
7409            Some("cargo build"),
7410            "inner compound should keep extracted command"
7411        );
7412        assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7413
7414        // Inner benign suffix is preserved.
7415        let result = classify_command("bash -c \"cargo test > log 2>&1\"");
7416        assert!(result.is_compilation, "got: {:?}", result.reason);
7417        assert_eq!(
7418            result.extracted_command.as_deref(),
7419            Some("cargo test > log 2>&1")
7420        );
7421
7422        // Non-compilation inner -> not intercepted.
7423        let result = classify_command("bash -c \"ls -la\"");
7424        assert!(!result.is_compilation);
7425
7426        // Inner command that is itself a never-intercept stays declined.
7427        let result = classify_command("bash -c \"cargo fmt\"");
7428        assert!(!result.is_compilation);
7429    }
7430
7431    // ------------------------------------------------------------------
7432    // 11. fd-to-fd redirects are SAFE and should NOT block interception
7433    // ------------------------------------------------------------------
7434
7435    #[test]
7436    fn regression_fd_redirect_safe() {
7437        let _guard = test_guard!();
7438        // Compound: "cd /path && cargo build 2>&1" should detect cargo build
7439        // via compound command handling (depth-0 splits on &&, depth-1 checks segment)
7440        // Simple fd-to-fd redirect like "cargo build 2>&1" is handled by check_structure
7441        // which explicitly skips fd-to-fd redirects. So "cargo build 2>&1" should be
7442        // classified as compilation.
7443        let result = classify_command("cargo build 2>&1");
7444        assert!(
7445            result.is_compilation,
7446            "cargo build 2>&1 should be classified as compilation (fd redirect is safe), got: {:?}",
7447            result.reason
7448        );
7449        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7450    }
7451
7452    // ------------------------------------------------------------------
7453    // 12. Compound commands: && chains with compilation suffix
7454    // ------------------------------------------------------------------
7455
7456    #[test]
7457    fn regression_compound_commands() {
7458        let _guard = test_guard!();
7459        // cd && cargo build should be detected as compilation
7460        let result = classify_command("cd /path && cargo build");
7461        assert!(
7462            result.is_compilation,
7463            "cd && cargo build should compile, got: {:?}",
7464            result.reason
7465        );
7466        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7467        assert!(result.command_prefix.is_some(), "should have prefix");
7468        assert!(
7469            result.extracted_command.is_some(),
7470            "should have extracted command"
7471        );
7472
7473        let result = classify_command("cd /path && cargo test --workspace");
7474        assert!(
7475            result.is_compilation,
7476            "cd && cargo test should compile, got: {:?}",
7477            result.reason
7478        );
7479        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7480
7481        // Non-compilation suffix should not match
7482        let result = classify_command("cd /path && cargo fmt");
7483        assert!(!result.is_compilation, "cd && cargo fmt should NOT compile");
7484
7485        let result = classify_command("cd /path && ls -la");
7486        assert!(!result.is_compilation, "cd && ls should NOT compile");
7487    }
7488
7489    // ------------------------------------------------------------------
7490    // 13. Wrapper normalization: env, sudo, time, etc.
7491    // ------------------------------------------------------------------
7492
7493    #[test]
7494    fn regression_wrapper_normalization() {
7495        let _guard = test_guard!();
7496        let cases = [
7497            Case {
7498                cmd: "sudo cargo build",
7499                expect_compilation: true,
7500                expected_kind: Some(CompilationKind::CargoBuild),
7501                reason_contains: "cargo build",
7502                min_confidence: 0.90,
7503            },
7504            Case {
7505                cmd: "time cargo test",
7506                expect_compilation: true,
7507                expected_kind: Some(CompilationKind::CargoTest),
7508                reason_contains: "cargo test",
7509                min_confidence: 0.90,
7510            },
7511            Case {
7512                cmd: "env RUST_BACKTRACE=1 cargo test",
7513                expect_compilation: true,
7514                expected_kind: Some(CompilationKind::CargoTest),
7515                reason_contains: "cargo test",
7516                min_confidence: 0.90,
7517            },
7518            Case {
7519                cmd: "env -u RUST_LOG cargo test",
7520                expect_compilation: true,
7521                expected_kind: Some(CompilationKind::CargoTest),
7522                reason_contains: "cargo test",
7523                min_confidence: 0.90,
7524            },
7525            Case {
7526                cmd: "RUST_BACKTRACE=1 cargo test",
7527                expect_compilation: true,
7528                expected_kind: Some(CompilationKind::CargoTest),
7529                reason_contains: "cargo test",
7530                min_confidence: 0.90,
7531            },
7532            Case {
7533                cmd: "nice cargo build --release",
7534                expect_compilation: true,
7535                expected_kind: Some(CompilationKind::CargoBuild),
7536                reason_contains: "cargo build",
7537                min_confidence: 0.90,
7538            },
7539            Case {
7540                cmd: "/usr/bin/cargo build",
7541                expect_compilation: true,
7542                expected_kind: Some(CompilationKind::CargoBuild),
7543                reason_contains: "cargo build",
7544                min_confidence: 0.90,
7545            },
7546            Case {
7547                cmd: "sudo env RUSTFLAGS=-Dwarnings cargo clippy",
7548                expect_compilation: true,
7549                expected_kind: Some(CompilationKind::CargoClippy),
7550                reason_contains: "cargo clippy",
7551                min_confidence: 0.85,
7552            },
7553        ];
7554        run_cases(&cases);
7555    }
7556
7557    // ------------------------------------------------------------------
7558    // 14. Non-compilation commands (must never trigger)
7559    // ------------------------------------------------------------------
7560
7561    #[test]
7562    fn regression_non_compilation_passthrough() {
7563        let _guard = test_guard!();
7564        let cases = [
7565            Case {
7566                cmd: "ls -la",
7567                expect_compilation: false,
7568                expected_kind: None,
7569                reason_contains: "no compilation keyword",
7570                min_confidence: 0.0,
7571            },
7572            Case {
7573                cmd: "pwd",
7574                expect_compilation: false,
7575                expected_kind: None,
7576                reason_contains: "no compilation keyword",
7577                min_confidence: 0.0,
7578            },
7579            Case {
7580                cmd: "echo hello",
7581                expect_compilation: false,
7582                expected_kind: None,
7583                reason_contains: "no compilation keyword",
7584                min_confidence: 0.0,
7585            },
7586            Case {
7587                cmd: "cat Cargo.toml",
7588                expect_compilation: false,
7589                expected_kind: None,
7590                reason_contains: "no compilation keyword",
7591                min_confidence: 0.0,
7592            },
7593            Case {
7594                cmd: "git status",
7595                expect_compilation: false,
7596                expected_kind: None,
7597                reason_contains: "no compilation keyword",
7598                min_confidence: 0.0,
7599            },
7600            Case {
7601                cmd: "git commit -m 'fix'",
7602                expect_compilation: false,
7603                expected_kind: None,
7604                reason_contains: "no compilation keyword",
7605                min_confidence: 0.0,
7606            },
7607            Case {
7608                cmd: "npm install",
7609                expect_compilation: false,
7610                expected_kind: None,
7611                reason_contains: "no compilation keyword",
7612                min_confidence: 0.0,
7613            },
7614            Case {
7615                cmd: "yarn build",
7616                expect_compilation: false,
7617                expected_kind: None,
7618                reason_contains: "no compilation keyword",
7619                min_confidence: 0.0,
7620            },
7621            Case {
7622                cmd: "python main.py",
7623                expect_compilation: false,
7624                expected_kind: None,
7625                reason_contains: "no compilation keyword",
7626                min_confidence: 0.0,
7627            },
7628            // Explicit Go outputs use durable delivery on Unix.
7629            #[cfg(unix)]
7630            Case {
7631                cmd: "go build -o bin/app .",
7632                expect_compilation: true,
7633                expected_kind: Some(CompilationKind::GoBuild),
7634                reason_contains: "go build command",
7635                min_confidence: 0.9,
7636            },
7637            // Implicit output names require package-aware output planning.
7638            Case {
7639                cmd: "go build ./cmd/app",
7640                expect_compilation: false,
7641                expected_kind: None,
7642                reason_contains: "requires a supported explicit -o file",
7643                min_confidence: 0.0,
7644            },
7645            Case {
7646                cmd: "docker build -t myapp .",
7647                expect_compilation: false,
7648                expected_kind: None,
7649                reason_contains: "no compilation keyword",
7650                min_confidence: 0.0,
7651            },
7652            Case {
7653                cmd: "pip install -r requirements.txt",
7654                expect_compilation: false,
7655                expected_kind: None,
7656                reason_contains: "no compilation keyword",
7657                min_confidence: 0.0,
7658            },
7659            Case {
7660                cmd: "curl https://example.com",
7661                expect_compilation: false,
7662                expected_kind: None,
7663                reason_contains: "no compilation keyword",
7664                min_confidence: 0.0,
7665            },
7666            Case {
7667                cmd: "mkdir -p build",
7668                expect_compilation: false,
7669                expected_kind: None,
7670                reason_contains: "no compilation keyword",
7671                min_confidence: 0.0,
7672            },
7673            Case {
7674                cmd: "rm -rf target/",
7675                expect_compilation: false,
7676                expected_kind: None,
7677                reason_contains: "no compilation keyword",
7678                min_confidence: 0.0,
7679            },
7680            Case {
7681                cmd: "cp -r src/ backup/",
7682                expect_compilation: false,
7683                expected_kind: None,
7684                reason_contains: "no compilation keyword",
7685                min_confidence: 0.0,
7686            },
7687            Case {
7688                cmd: "",
7689                expect_compilation: false,
7690                expected_kind: None,
7691                reason_contains: "empty command",
7692                min_confidence: 0.0,
7693            },
7694            Case {
7695                cmd: "   ",
7696                expect_compilation: false,
7697                expected_kind: None,
7698                reason_contains: "empty command",
7699                min_confidence: 0.0,
7700            },
7701        ];
7702        run_cases(&cases);
7703    }
7704
7705    // ------------------------------------------------------------------
7706    // 15. ClassificationDetails: structured log field verification
7707    // ------------------------------------------------------------------
7708
7709    #[test]
7710    fn regression_classification_details_fields() {
7711        let _guard = test_guard!();
7712
7713        // Compilation: verify all tiers pass and final has kind + confidence
7714        let details = classify_command_detailed("cargo build --release");
7715        assert_eq!(details.original, "cargo build --release");
7716        assert!(details.classification.is_compilation);
7717        assert_eq!(
7718            details.classification.kind,
7719            Some(CompilationKind::CargoBuild)
7720        );
7721        assert!(details.classification.confidence >= 0.90);
7722        // Should have 5 tiers: 0-4
7723        assert_eq!(details.tiers.len(), 5, "Should have 5 tier decisions");
7724        for tier in &details.tiers[..4] {
7725            assert_eq!(tier.decision, TierDecision::Pass);
7726        }
7727        // Tier 4 should also pass (compilation detected)
7728        assert_eq!(details.tiers[4].decision, TierDecision::Pass);
7729        assert_eq!(details.tiers[4].tier, 4);
7730
7731        // Non-compilation: should reject early at Tier 2 (keyword filter)
7732        let details = classify_command_detailed("ls -la");
7733        assert!(!details.classification.is_compilation);
7734        assert!(
7735            details.tiers.len() >= 3,
7736            "Should have at least 3 tiers for keyword rejection"
7737        );
7738        assert_eq!(details.tiers.last().unwrap().decision, TierDecision::Reject);
7739
7740        // Never-intercept: should reject at Tier 3
7741        let details = classify_command_detailed("cargo fmt --check");
7742        assert!(!details.classification.is_compilation);
7743        assert!(
7744            details.tiers.len() >= 4,
7745            "Should have at least 4 tiers for never-intercept rejection"
7746        );
7747        // Find tier 3
7748        let tier3 = details.tiers.iter().find(|t| t.tier == 3).unwrap();
7749        assert_eq!(tier3.decision, TierDecision::Reject);
7750        assert!(
7751            tier3.reason.contains("never-intercept"),
7752            "Tier 3 reason should mention never-intercept, got: {:?}",
7753            tier3.reason
7754        );
7755
7756        // Pipe into a NON-benign command: rejected at Tier 1 (structure
7757        // analysis). A pipe into a benign pager (grep/tee/head) is offloaded
7758        // instead (issue #24), so use a non-pager target here.
7759        let details = classify_command_detailed("cargo build | xargs rm");
7760        assert!(!details.classification.is_compilation);
7761        let tier1 = details.tiers.iter().find(|t| t.tier == 1).unwrap();
7762        assert_eq!(tier1.decision, TierDecision::Reject);
7763        assert!(tier1.reason.contains("piped"));
7764
7765        // Empty command: should reject at Tier 0
7766        let details = classify_command_detailed("");
7767        assert!(!details.classification.is_compilation);
7768        assert_eq!(details.tiers[0].tier, 0);
7769        assert_eq!(details.tiers[0].decision, TierDecision::Reject);
7770    }
7771
7772    // ------------------------------------------------------------------
7773    // 16. Decision path completeness: every positive case has reason code
7774    // ------------------------------------------------------------------
7775
7776    #[test]
7777    fn regression_every_compilation_has_reason_and_confidence() {
7778        let _guard = test_guard!();
7779        let compilation_cmds = [
7780            "cargo build",
7781            "cargo test",
7782            "cargo check",
7783            "cargo clippy",
7784            "cargo doc",
7785            "cargo run",
7786            "cargo bench",
7787            "cargo nextest run",
7788            "rustc main.rs",
7789            "gcc -c main.c -o main.o",
7790            "g++ -c main.cpp -o main.o",
7791            "clang -c main.c -o main.o",
7792            "clang++ -c main.cpp -o main.o",
7793            "make",
7794            "cmake --build build",
7795            "ninja",
7796            "meson compile",
7797            "bun test",
7798            "bun typecheck",
7799        ];
7800        for cmd in compilation_cmds {
7801            let result = classify_command(cmd);
7802            assert!(result.is_compilation, "{cmd:?} should be compilation");
7803            assert!(
7804                result.kind.is_some(),
7805                "{cmd:?} should have a CompilationKind"
7806            );
7807            assert!(
7808                result.confidence > 0.0,
7809                "{cmd:?} should have non-zero confidence"
7810            );
7811            assert!(
7812                !result.reason.is_empty(),
7813                "{cmd:?} should have a non-empty reason"
7814            );
7815        }
7816    }
7817
7818    // ------------------------------------------------------------------
7819    // 17. Decision path completeness: every negative case has reason code
7820    // ------------------------------------------------------------------
7821
7822    #[test]
7823    fn regression_every_rejection_has_reason() {
7824        let _guard = test_guard!();
7825        let non_compilation_cmds = [
7826            "",
7827            "ls",
7828            "pwd",
7829            "git status",
7830            "cargo fmt",
7831            "cargo install ripgrep",
7832            "cargo clean",
7833            "bun install",
7834            "bun add react",
7835            "bun run dev",
7836            "bun dev",
7837            "bun repl",
7838            "bun x vitest",
7839            "bun test --watch",
7840            "bun typecheck -w",
7841            "make clean",
7842            "ninja clean",
7843            "gcc --version",
7844            "rustc --version",
7845            "cargo build | xargs rm",
7846            "cargo build < input.txt",
7847            "cargo build || echo failed",
7848            "(cargo build)",
7849        ];
7850        for cmd in non_compilation_cmds {
7851            let result = classify_command(cmd);
7852            assert!(
7853                !result.is_compilation,
7854                "{cmd:?} should NOT be compilation, got kind={:?}",
7855                result.kind
7856            );
7857            assert!(
7858                !result.reason.is_empty(),
7859                "{cmd:?} should have a non-empty rejection reason"
7860            );
7861            assert_eq!(result.kind, None, "{cmd:?} should have no CompilationKind");
7862            assert_eq!(
7863                result.confidence, 0.0,
7864                "{cmd:?} should have zero confidence"
7865            );
7866        }
7867    }
7868
7869    // ------------------------------------------------------------------
7870    // 18. Representative real-world agent workflow commands
7871    // ------------------------------------------------------------------
7872
7873    #[test]
7874    fn regression_real_world_agent_workflows() {
7875        let _guard = test_guard!();
7876
7877        // Typical Claude Code agent workflow: navigation + build
7878        let cd_build = classify_command(
7879            "cd /data/projects/remote_compilation_helper && cargo build --workspace --all-targets",
7880        );
7881        assert!(
7882            cd_build.is_compilation,
7883            "agent cd+build workflow should be intercepted"
7884        );
7885
7886        // Agent running tests after code change
7887        let test_ws = classify_command("cargo test --workspace -- --nocapture");
7888        assert!(test_ws.is_compilation);
7889        assert_eq!(test_ws.kind, Some(CompilationKind::CargoTest));
7890
7891        // Agent running clippy check
7892        let clippy = classify_command("cargo clippy --workspace --all-targets -- -D warnings");
7893        assert!(clippy.is_compilation);
7894        assert_eq!(clippy.kind, Some(CompilationKind::CargoClippy));
7895
7896        // Agent checking format (should NOT intercept — modifies source)
7897        let fmt = classify_command("cargo fmt --check");
7898        assert!(!fmt.is_compilation);
7899
7900        // Agent reading Cargo.toml (should NOT intercept)
7901        let cat = classify_command("cat Cargo.toml");
7902        assert!(!cat.is_compilation);
7903
7904        // Agent checking git status (should NOT intercept)
7905        let gs = classify_command("git status");
7906        assert!(!gs.is_compilation);
7907
7908        // Agent installing a tool (should NOT intercept)
7909        let install = classify_command("cargo install cargo-nextest");
7910        assert!(!install.is_compilation);
7911
7912        // Agent running specific package tests
7913        let pkg_test = classify_command("cargo test -p rch-common");
7914        assert!(pkg_test.is_compilation);
7915        assert_eq!(pkg_test.kind, Some(CompilationKind::CargoTest));
7916
7917        // Agent running with environment variable
7918        let env_test = classify_command("RUST_BACKTRACE=1 cargo test --workspace");
7919        assert!(env_test.is_compilation);
7920        assert_eq!(env_test.kind, Some(CompilationKind::CargoTest));
7921
7922        // Agent running nextest
7923        let nxt = classify_command("cargo nextest run --workspace --no-fail-fast");
7924        assert!(nxt.is_compilation);
7925        assert_eq!(nxt.kind, Some(CompilationKind::CargoNextest));
7926    }
7927
7928    // ------------------------------------------------------------------
7929    // 19. Boundary: commands that contain keywords but are NOT compilation
7930    // ------------------------------------------------------------------
7931
7932    #[test]
7933    fn regression_keyword_in_non_compilation_context() {
7934        let _guard = test_guard!();
7935        // "cargo" appears as substring in path or argument, not as command
7936        let result = classify_command("echo cargo build");
7937        // "echo" has no compilation keyword... wait, "cargo" IS a keyword
7938        // but the command starts with "echo", which isn't a compilation command.
7939        // After normalization, it should still start with "echo".
7940        assert!(
7941            !result.is_compilation,
7942            "echo cargo should not compile, got: {:?}",
7943            result.reason
7944        );
7945
7946        // grep for "make" in a log file - keyword present but not a build command
7947        let result = classify_command("grep make Makefile");
7948        // "make" is a keyword so passes Tier 2, but starts with "grep", not "make"
7949        assert!(
7950            !result.is_compilation,
7951            "grep make should not compile, got: {:?}",
7952            result.reason
7953        );
7954
7955        // cat a file with "gcc" in its name
7956        let result = classify_command("cat gcc_output.log");
7957        // "gcc" keyword passes Tier 2, but the command is "cat"
7958        assert!(
7959            !result.is_compilation,
7960            "cat gcc_output should not compile, got: {:?}",
7961            result.reason
7962        );
7963    }
7964
7965    #[test]
7966    fn regression_build_words_in_non_build_arguments_are_not_compilation() {
7967        let _guard = test_guard!();
7968        let cases = [
7969            r#"br comments add ft-4tp7g.1 "remote proof blocked: cargo test -p rchd --lib""#,
7970            r#"AGENT_NAME=Codex br comments add ft-4tp7g.1 "proof lane: cargo clippy --workspace""#,
7971            r#"printf "cargo build --release""#,
7972            r#"echo 'cargo test -p rchd -- --nocapture'"#,
7973            r#"grep "cmake --build" docs/proof-notes.md"#,
7974        ];
7975
7976        for cmd in cases {
7977            let result = classify_command(cmd);
7978            assert!(
7979                !result.is_compilation,
7980                "embedded build text should not compile for {cmd:?}: {:?}",
7981                result
7982            );
7983            assert_eq!(result.reason.as_ref(), "no compilation keyword");
7984        }
7985    }
7986
7987    #[test]
7988    fn golden_non_build_arguments_with_build_words_stay_non_compilation() {
7989        let _guard = test_guard!();
7990        const FIXTURE_PATH: &str =
7991            "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json";
7992
7993        #[derive(serde::Deserialize)]
7994        struct Golden {
7995            schema_version: String,
7996            cases: Vec<Case>,
7997            expected_results: serde_json::Value,
7998        }
7999
8000        #[derive(serde::Deserialize)]
8001        struct Case {
8002            name: String,
8003            command: String,
8004        }
8005
8006        let golden: Golden = serde_json::from_str(include_str!(
8007            "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json"
8008        ))
8009        .expect("classification golden fixture parses");
8010        assert_eq!(
8011            golden.schema_version,
8012            "rch.golden.classification_non_build_arguments.v1"
8013        );
8014
8015        let mut actual_results = Vec::with_capacity(golden.cases.len());
8016        for case in golden.cases {
8017            let result = classify_command(&case.command);
8018            actual_results.push(serde_json::json!({
8019                "name": case.name,
8020                "is_compilation": result.is_compilation,
8021                "kind": result.kind,
8022                "reason": result.reason.as_ref(),
8023            }));
8024        }
8025        assert_golden_json(
8026            serde_json::Value::Array(actual_results),
8027            &golden.expected_results,
8028            FIXTURE_PATH,
8029        );
8030    }
8031
8032    #[test]
8033    fn regression_keyword_filter_still_accepts_normalized_build_invocations() {
8034        let _guard = test_guard!();
8035        let cases = [
8036            ("RUST_BACKTRACE=1 cargo test", CompilationKind::CargoTest),
8037            (
8038                "env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build",
8039                CompilationKind::CargoBuild,
8040            ),
8041            ("sudo -E cargo check", CompilationKind::CargoCheck),
8042            ("/usr/bin/time cargo build", CompilationKind::CargoBuild),
8043        ];
8044
8045        for (cmd, expected_kind) in cases {
8046            let result = classify_command(cmd);
8047            assert!(
8048                result.is_compilation,
8049                "normalized build invocation should compile for {cmd:?}: {:?}",
8050                result
8051            );
8052            assert_eq!(result.kind, Some(expected_kind));
8053        }
8054    }
8055
8056    // ------------------------------------------------------------------
8057    // 20. CompilationKind properties: is_test_command, command_base
8058    // ------------------------------------------------------------------
8059
8060    #[test]
8061    fn regression_compilation_kind_properties() {
8062        let _guard = test_guard!();
8063
8064        // Test commands
8065        assert!(CompilationKind::CargoTest.is_test_command());
8066        assert!(CompilationKind::CargoNextest.is_test_command());
8067        assert!(CompilationKind::CargoBench.is_test_command());
8068        assert!(CompilationKind::BunTest.is_test_command());
8069
8070        // Non-test commands
8071        assert!(!CompilationKind::CargoBuild.is_test_command());
8072        assert!(!CompilationKind::CargoCheck.is_test_command());
8073        assert!(!CompilationKind::CargoClippy.is_test_command());
8074        assert!(!CompilationKind::CargoDoc.is_test_command());
8075        assert!(!CompilationKind::BunTypecheck.is_test_command());
8076        assert!(!CompilationKind::Gcc.is_test_command());
8077        assert!(!CompilationKind::Gpp.is_test_command());
8078        assert!(!CompilationKind::Clang.is_test_command());
8079        assert!(!CompilationKind::Clangpp.is_test_command());
8080        assert!(!CompilationKind::Make.is_test_command());
8081        assert!(!CompilationKind::CmakeBuild.is_test_command());
8082        assert!(!CompilationKind::Ninja.is_test_command());
8083        assert!(!CompilationKind::Meson.is_test_command());
8084        assert!(!CompilationKind::Rustc.is_test_command());
8085
8086        // command_base
8087        assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
8088        assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
8089        assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
8090        assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
8091        assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
8092        assert_eq!(CompilationKind::Gpp.command_base(), "g++");
8093        assert_eq!(CompilationKind::Clang.command_base(), "clang");
8094        assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
8095        assert_eq!(CompilationKind::Make.command_base(), "make");
8096        assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
8097        assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
8098        assert_eq!(CompilationKind::Meson.command_base(), "meson");
8099        assert_eq!(CompilationKind::BunTest.command_base(), "bun");
8100        assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
8101    }
8102}
8103
8104#[cfg(test)]
8105mod tests_go_and_typescript {
8106    use super::*;
8107
8108    fn kind_of(cmd: &str) -> Option<CompilationKind> {
8109        classify_command(cmd).kind
8110    }
8111
8112    // --- Go: the offloadable forms ---
8113
8114    #[test]
8115    fn go_build_test_vet_are_classified() {
8116        assert_eq!(
8117            kind_of("go build -o bin/app ."),
8118            cfg!(unix).then_some(CompilationKind::GoBuild)
8119        );
8120        assert_eq!(kind_of("go test ./..."), Some(CompilationKind::GoTest));
8121        assert_eq!(kind_of("go vet ./..."), Some(CompilationKind::GoVet));
8122        // Real commands observed hammering the orchestrator.
8123        assert_eq!(
8124            kind_of("go test -tags=e2e ./e2e -run ^TestE2EAtomicAssignment$"),
8125            Some(CompilationKind::GoTest)
8126        );
8127        assert_eq!(
8128            kind_of("go test ./internal/robot ./internal/cli -run TestExitCode"),
8129            Some(CompilationKind::GoTest)
8130        );
8131    }
8132
8133    #[test]
8134    fn go_commands_clear_the_confidence_threshold() {
8135        // Default confidence_threshold is 0.85; anything at or below it is never
8136        // intercepted, which would silently keep these local.
8137        for cmd in ["go build -o app .", "go test ./...", "go vet ./..."] {
8138            if !cfg!(unix) && cmd.starts_with("go build") {
8139                continue;
8140            }
8141            let c = classify_command(cmd);
8142            assert!(
8143                c.confidence > 0.85,
8144                "{cmd} scored {} which does not clear the 0.85 threshold",
8145                c.confidence
8146            );
8147        }
8148    }
8149
8150    #[test]
8151    fn go_test_is_a_test_command() {
8152        // Drives cache affinity, the test-slot bucket, and the test timeout.
8153        assert!(CompilationKind::GoTest.is_test_command());
8154        assert!(!CompilationKind::GoBuild.is_test_command());
8155        assert!(!CompilationKind::GoVet.is_test_command());
8156    }
8157
8158    // --- Go: forms that MUST stay local (would otherwise lose artifacts) ---
8159
8160    #[test]
8161    fn go_build_explicit_outputs_share_one_literal_contract() {
8162        for (command, path, option_index) in [
8163            ("go build -o bin/app ./cmd/app", "bin/app", 2),
8164            ("go build -o=bin/app ./cmd/app", "bin/app", 2),
8165            ("go build '-o=bin/app' main.go", "bin/app", 2),
8166            (
8167                "go build -trimpath -p=2 -o './products/app [dev]*?' .",
8168                "./products/app [dev]*?",
8169                4,
8170            ),
8171            ("go build -ldflags '-s -w' -o app", "app", 4),
8172            ("go build -ldflags '-s -X main.version=v2' -o app", "app", 4),
8173            ("go build '-buildmode=pie' -o app .", "app", 3),
8174            ("go build -tags -o -o app .", "app", 4),
8175            ("env GOFLAGS= go build -o app .", "app", 2),
8176        ] {
8177            let selection = go_build_output(command).unwrap_or_else(|| panic!("{command}"));
8178            assert_eq!(selection.path, path, "{command}");
8179            assert_eq!(selection.option_index, option_index, "{command}");
8180            assert_eq!(
8181                kind_of(command),
8182                cfg!(unix).then_some(CompilationKind::GoBuild),
8183                "{command}"
8184            );
8185        }
8186    }
8187
8188    #[test]
8189    fn go_build_implicit_ambiguous_and_extra_outputs_stay_local() {
8190        for command in [
8191            "go build",
8192            "go build .",
8193            "go build ./...",
8194            "go build main.go",
8195            "go build -o",
8196            "go build -o /tmp/app .",
8197            "go build -o ../app .",
8198            "go build -o . .",
8199            "go build -o bin/ .",
8200            "go build -o - .",
8201            "go build -o one -o two .",
8202            "go build -n -o app .",
8203            "go build -n=true -o app .",
8204            "go build -buildmode=c-shared -o app .",
8205            "go build -buildmode=c-archive -o app .",
8206            "go build -buildmode=plugin -o app .",
8207            "go build -toolexec wrapper -o app .",
8208            "go build -o $OUTPUT .",
8209            "go build -o app* .",
8210            "go build . -o app",
8211            "go build -tags -o app .",
8212            "go build -overlay overlay.json -o app .",
8213            "go build -mod=mod -o app .",
8214            "go build -ldflags '-cpuprofile=cpu.out' -o app .",
8215            "go build -ldflags '-s -w -tmpdir=products' -o app .",
8216            "go build -gcflags '-cpuprofile=cpu.out' -o app .",
8217            "go build -asmflags '-debug' -o app .",
8218        ] {
8219            assert!(go_build_output(command).is_none(), "{command}");
8220            assert_eq!(kind_of(command), None, "{command}");
8221        }
8222        assert!(go_build_output(&format!("go build -o {} .", "x".repeat(65_536))).is_none());
8223        assert!(go_build_output("go build -o 'app\nnext' .").is_none());
8224    }
8225
8226    #[test]
8227    fn go_test_binary_profile_and_exec_outputs_stay_local() {
8228        for command in [
8229            "go test -c ./pkg",
8230            "go test -c=true ./pkg",
8231            "go test '-c=true' ./pkg",
8232            "go test -exec sudo ./pkg",
8233            "go test '-exec=sudo' ./pkg",
8234            "go test -o test.bin ./pkg",
8235            "go test -o=test.bin ./pkg",
8236            "go test '-o=test binary' ./pkg",
8237            "go test -coverprofile=coverage.out ./pkg",
8238            "go test -cpuprofile cpu.out ./pkg",
8239            "go test -memprofile=mem.out ./pkg",
8240            "go test -blockprofile=block.out ./pkg",
8241            "go test -mutexprofile=mutex.out ./pkg",
8242            "go test -trace trace.out ./pkg",
8243            "go test ./pkg -args -test.cpuprofile=cpu.out",
8244            "go test ./pkg '-test.coverprofile=coverage.out'",
8245            "go test -outputdir profiles ./pkg",
8246            "go test -fuzz=FuzzParser ./pkg",
8247            "go test ./pkg -args -test.fuzz FuzzParser",
8248            "go test ./pkg -args -test.fuzzcachedir=fuzz-corpus",
8249            "go test ./pkg -args -test.gocoverdir=coverage",
8250            "go test ./pkg -args '-test.testlogfile=test log'",
8251        ] {
8252            assert_eq!(kind_of(command), None, "{command}");
8253        }
8254        assert_eq!(kind_of("go test c o exec"), Some(CompilationKind::GoTest));
8255    }
8256
8257    #[test]
8258    fn go_state_mutating_subcommands_are_never_intercepted() {
8259        for cmd in [
8260            "go get github.com/foo/bar",
8261            "go mod tidy",
8262            "go mod download",
8263            "go install ./cmd/tool",
8264            "go run main.go",
8265            "go generate ./...",
8266            "go fmt ./...",
8267            "go clean -cache",
8268            "go work sync",
8269            "go env GOPATH",
8270            "go version",
8271            "go list ./...",
8272            "go tool pprof",
8273        ] {
8274            assert_eq!(kind_of(cmd), None, "{cmd} must never be intercepted");
8275        }
8276    }
8277
8278    #[test]
8279    fn go_prefixed_binaries_are_not_matched() {
8280        // Keyword matching is whole-word, so these must not be treated as `go`.
8281        assert_eq!(kind_of("gofmt -l ."), None);
8282        assert_eq!(kind_of("golangci-lint run"), None);
8283    }
8284
8285    // --- TypeScript ---
8286
8287    #[test]
8288    fn tsc_noemit_is_classified() {
8289        assert_eq!(kind_of("tsc --noEmit"), Some(CompilationKind::Tsc));
8290        assert_eq!(kind_of("npx tsc --noEmit"), Some(CompilationKind::Tsc));
8291        // Observed in the wild.
8292        assert_eq!(
8293            kind_of("npx tsc --noEmit --incremental false"),
8294            Some(CompilationKind::Tsc)
8295        );
8296        // tsc flags are case-insensitive.
8297        assert_eq!(kind_of("tsc --noemit"), Some(CompilationKind::Tsc));
8298    }
8299
8300    #[test]
8301    fn emitting_tsc_stays_local() {
8302        // Without --noEmit, tsc writes .js/.d.ts into the local tree. Tsc is
8303        // stream-only, so offloading it would silently produce no output.
8304        assert_eq!(kind_of("tsc"), None);
8305        assert_eq!(kind_of("tsc -p tsconfig.json"), None);
8306        assert_eq!(kind_of("npx tsc --outDir dist"), None);
8307    }
8308
8309    #[test]
8310    fn tsc_watch_is_not_intercepted() {
8311        assert_eq!(kind_of("tsc --noEmit --watch"), None);
8312        assert_eq!(kind_of("tsc --noEmit -w"), None);
8313    }
8314
8315    #[test]
8316    fn tsc_trivial_forms_are_not_intercepted() {
8317        assert_eq!(kind_of("tsc --version"), None);
8318        assert_eq!(kind_of("tsc --init"), None);
8319    }
8320
8321    #[test]
8322    fn npx_non_tsc_is_not_intercepted() {
8323        // "npx" is a compilation keyword only so `npx tsc` reaches classify_full;
8324        // every other npx invocation must fall through.
8325        assert_eq!(kind_of("npx jest"), None);
8326        assert_eq!(kind_of("npx prettier --write ."), None);
8327    }
8328
8329    // --- command_base drives the execution allowlist ---
8330
8331    #[test]
8332    fn command_base_is_allowlisted() {
8333        use crate::types::ExecutionConfig;
8334        let exec = ExecutionConfig::default();
8335        for kind in [
8336            CompilationKind::GoBuild,
8337            CompilationKind::GoTest,
8338            CompilationKind::GoVet,
8339            CompilationKind::Tsc,
8340        ] {
8341            let base = kind.command_base();
8342            assert!(
8343                exec.is_allowed(base),
8344                "{base} missing from the execution allowlist — the hook would fail \
8345                 open to LOCAL execution, silently losing offload"
8346            );
8347        }
8348        assert_eq!(CompilationKind::GoBuild.command_base(), "go");
8349        assert_eq!(CompilationKind::Tsc.command_base(), "tsc");
8350    }
8351}