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