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/// Classify cargo subcommands.
2410///
2411/// Performance: uses iterator `.nth()` to avoid Vec allocation on the hot path.
2412/// We only need tokens 0-3 (cargo, [+toolchain], subcommand, [nextest-subcommand]).
2413fn classify_cargo(cmd: &str) -> Classification {
2414    let mut tokens = cmd.split_whitespace();
2415
2416    // Token 0: "cargo" (already validated by caller)
2417    let _cargo = tokens.next();
2418
2419    // Skip an optional +toolchain override AND any leading global flags that can
2420    // precede the subcommand (e.g. `cargo --offline build`, `cargo -q test`,
2421    // `cargo --locked check`, `cargo --color never build`). Without this, a
2422    // global flag was read AS the subcommand, so a real build was misclassified
2423    // non-compilation and silently run locally. Value-taking global flags
2424    // written without `=` consume their following value token.
2425    const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2426    let subcommand = loop {
2427        let Some(tok) = tokens.next() else {
2428            return Classification::not_compilation("bare cargo command");
2429        };
2430        if tok.starts_with('+') {
2431            // +toolchain override — skip.
2432            continue;
2433        }
2434        if tok.starts_with('-') {
2435            if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2436                // Consume the separate value token (e.g. `--color never`).
2437                let _ = tokens.next();
2438            }
2439            continue;
2440        }
2441        break tok;
2442    };
2443
2444    match subcommand {
2445        "build" | "b" => {
2446            Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build")
2447        }
2448        "package" | "publish" => {
2449            if is_cargo_package_verification(cmd) {
2450                Classification::compilation(
2451                    CompilationKind::CargoBuild,
2452                    0.95,
2453                    "cargo package verification (includes build)",
2454                )
2455            } else {
2456                Classification::not_compilation(
2457                    "cargo package/publish without supported verification (never-intercept)",
2458                )
2459            }
2460        }
2461        "test" | "t" => Classification::compilation(CompilationKind::CargoTest, 0.95, "cargo test"),
2462        "check" | "c" => {
2463            Classification::compilation(CompilationKind::CargoCheck, 0.90, "cargo check")
2464        }
2465        "clippy" => Classification::compilation(CompilationKind::CargoClippy, 0.90, "cargo clippy"),
2466        "doc" => Classification::compilation(CompilationKind::CargoDoc, 0.85, "cargo doc"),
2467        "run" | "r" => {
2468            // cargo run compiles first, so it's a compilation command
2469            Classification::compilation(
2470                CompilationKind::CargoBuild,
2471                0.85,
2472                "cargo run (includes build)",
2473            )
2474        }
2475        "bench" => Classification::compilation(CompilationKind::CargoBench, 0.90, "cargo bench"),
2476        // `cargo zigbuild ...` — cross-compile via zig. Same offload shape as
2477        // `cargo build` (produces a real artifact under target/<triple>/), but
2478        // gated additionally on the worker having zig + cargo-zigbuild + the
2479        // requested rustup target (enforced downstream by needs_zig/needs_targets).
2480        "zigbuild" => {
2481            Classification::compilation(CompilationKind::CargoZigbuild, 0.95, "cargo zigbuild")
2482        }
2483        "xwin" => {
2484            if is_cargo_xwin_build(cmd) {
2485                Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo xwin build")
2486            } else {
2487                Classification::not_compilation("cargo xwin subcommand not interceptable")
2488            }
2489        }
2490        "nextest" => {
2491            // cargo nextest has subcommands: run, list, archive, show
2492            // Only intercept "run" - the actual test execution
2493            // Next token is the nextest subcommand (e.g., "run", "list")
2494            let Some(nextest_sub) = tokens.next() else {
2495                return Classification::not_compilation("bare cargo nextest without subcommand");
2496            };
2497
2498            match nextest_sub {
2499                "run" | "r" => Classification::compilation(
2500                    CompilationKind::CargoNextest,
2501                    0.95,
2502                    "cargo nextest run",
2503                ),
2504                _ => Classification::not_compilation("cargo nextest subcommand not interceptable"),
2505            }
2506        }
2507        _ => Classification::not_compilation("cargo subcommand not interceptable"),
2508    }
2509}
2510
2511/// Classify the standalone `cargo-zigbuild` binary (what `cargo zigbuild ...`
2512/// execs). Only the artifact-producing build forms are offloaded:
2513///
2514/// - `cargo-zigbuild zigbuild ...` → cross-build via zig (the canonical form)
2515/// - `cargo-zigbuild build ...` → same, spelled with the plain subcommand
2516///
2517/// Everything else (`check`, `test`, `run`, metadata) is declined here and runs
2518/// locally, matching the conservative stance in `classify_cargo` for non-build
2519/// forms. All accepted forms map to `CompilationKind::CargoZigbuild`.
2520fn classify_cargo_zigbuild(cmd: &str) -> Classification {
2521    let mut tokens = cmd.split_whitespace();
2522    // Token 0: "cargo-zigbuild" (already validated by caller).
2523    let _bin = tokens.next();
2524
2525    // Skip an optional +toolchain override and leading global flags, mirroring
2526    // classify_cargo so `cargo-zigbuild +nightly zigbuild ...` still classifies.
2527    const CARGO_VALUE_FLAGS: &[&str] = &["--color", "-Z", "-C", "--config"];
2528    let subcommand = loop {
2529        let Some(tok) = tokens.next() else {
2530            return Classification::not_compilation("bare cargo-zigbuild command");
2531        };
2532        if tok.starts_with('+') {
2533            continue;
2534        }
2535        if tok.starts_with('-') {
2536            if !tok.contains('=') && CARGO_VALUE_FLAGS.contains(&tok) {
2537                let _ = tokens.next();
2538            }
2539            continue;
2540        }
2541        break tok;
2542    };
2543
2544    match subcommand {
2545        "zigbuild" | "build" | "b" => Classification::compilation(
2546            CompilationKind::CargoZigbuild,
2547            0.95,
2548            "cargo-zigbuild build",
2549        ),
2550        _ => Classification::not_compilation("cargo-zigbuild subcommand not interceptable"),
2551    }
2552}
2553
2554/// Nix global flags (appearing BEFORE the subcommand) that consume the following
2555/// whitespace-separated token as their value. Without skipping the value token
2556/// we could read it AS the subcommand and either miss a real build or, worse,
2557/// misclassify — so on any ambiguity we prefer to fall back to local execution.
2558///
2559/// This is a best-effort list of the common value-taking global flags; the
2560/// two-value `--option NAME VALUE` form is handled separately. Space-attached
2561/// (`--max-jobs=4`) forms carry their value in the same token and need no skip.
2562const NIX_GLOBAL_VALUE_FLAGS: &[&str] = &[
2563    "--extra-experimental-features",
2564    "--experimental-features",
2565    "--log-format",
2566    "--max-jobs",
2567    "-j",
2568    "--cores",
2569    "--builders",
2570    "--store",
2571    "--file",
2572    "-f",
2573    "-I",
2574    "--include",
2575];
2576
2577/// Classify a `nix ...` (multi-tool) or `nix-build ...` (legacy) command.
2578///
2579/// Offloadable (build-like, non-interactive, non-mutating):
2580///   - `nix build ...`         → build a derivation
2581///   - `nix-build ...`         → legacy derivation build
2582///   - `nix flake check`       → evaluate + build ALL flake outputs
2583///   - `nix develop -c <cmd>`  → run a command non-interactively in a dev shell
2584///   - `nix shell -c <cmd>`    → run a command non-interactively with packages
2585///
2586/// NOT offloadable (declined here → local fallback):
2587///   - bare `nix develop` / `nix shell`  → interactive shells
2588///   - `nix run` / `nix repl`            → app execution / interactive
2589///   - `nix flake metadata|show|update|lock|...`, `nix eval|search|...` → metadata
2590///   - `nix profile|registry|store|copy|...`, `nix-env`, `nix-shell` (no keyword)
2591///     → mutating / package management
2592///
2593/// Nix outputs never travel back (they live in the worker's `/nix/store`); these
2594/// are streaming, exit-status-only commands. Requires the worker to have nix +
2595/// a populated `/nix/store` and network access to fetch flake inputs — enforced
2596/// by the `RequiredRuntime::Nix` capability gate in worker selection.
2597fn classify_nix(cmd: &str) -> Classification {
2598    // Legacy `nix-build` is unconditionally a derivation build.
2599    if cmd.eq("nix-build") || cmd.starts_with("nix-build ") {
2600        return Classification::compilation(
2601            CompilationKind::NixBuild,
2602            0.9,
2603            "nix-build (legacy) derivation build",
2604        );
2605    }
2606
2607    let mut tokens = cmd.split_whitespace();
2608    let _nix = tokens.next(); // "nix" (validated by caller)
2609
2610    // Locate the subcommand, skipping leading global flags (and their values).
2611    let subcommand = loop {
2612        let Some(tok) = tokens.next() else {
2613            return Classification::not_compilation("bare nix command (no subcommand)");
2614        };
2615        if tok.starts_with('-') {
2616            if tok.eq("--option") {
2617                // `--option NAME VALUE` consumes two following tokens.
2618                let _ = tokens.next();
2619                let _ = tokens.next();
2620            } else if !tok.contains('=') && NIX_GLOBAL_VALUE_FLAGS.contains(&tok) {
2621                let _ = tokens.next();
2622            }
2623            continue;
2624        }
2625        break tok;
2626    };
2627
2628    // Remaining tokens after the subcommand (for -c/--command and --help checks).
2629    let rest: Vec<&str> = tokens.collect();
2630    let wants_help = rest.iter().any(|t| t.eq(&"--help") || t.eq(&"-h"));
2631    if wants_help {
2632        return Classification::not_compilation("nix --help (not a build)");
2633    }
2634    let has_command_flag = rest.iter().any(|t| t.eq(&"-c") || t.eq(&"--command"));
2635
2636    match subcommand {
2637        // `nix build` builds a derivation → offload (streaming, no artifact return).
2638        "build" => {
2639            Classification::compilation(CompilationKind::NixBuild, 0.9, "nix build derivation")
2640        }
2641        // `nix flake check` evaluates AND builds every flake output (CPU-heavy) →
2642        // offload. Every other `nix flake <sub>` (metadata/show/update/lock/init/
2643        // archive/...) is metadata or mutating and must stay local.
2644        "flake" => {
2645            let flake_sub = rest.iter().find(|t| !t.starts_with('-'));
2646            if flake_sub == Some(&"check") {
2647                Classification::compilation(
2648                    CompilationKind::NixBuild,
2649                    0.9,
2650                    "nix flake check (evaluates + builds all outputs)",
2651                )
2652            } else {
2653                Classification::not_compilation("nix flake subcommand is not a build")
2654            }
2655        }
2656        // `nix develop -c <cmd>` / `nix shell -c <cmd>` run a command
2657        // non-interactively in the environment → offload. Bare forms are
2658        // interactive shells and must NOT be intercepted.
2659        "develop" | "shell" => {
2660            if has_command_flag {
2661                let reason = if subcommand == "develop" {
2662                    "nix develop -c (non-interactive command in dev shell)"
2663                } else {
2664                    "nix shell -c (non-interactive command with packages)"
2665                };
2666                Classification::compilation(CompilationKind::NixBuild, 0.9, reason)
2667            } else {
2668                Classification::not_compilation(
2669                    "interactive nix shell (no -c/--command; not intercepted)",
2670                )
2671            }
2672        }
2673        // run / repl / eval / search / profile / registry / store / copy /
2674        // path-info / why-depends / flake metadata|show|update / --version / ...
2675        // are execution, mutation, or metadata: never intercepted.
2676        _ => Classification::not_compilation("nix subcommand is not an offloadable build"),
2677    }
2678}
2679
2680/// Split a shell command string on unquoted `;`, `&&`, and `||` operators.
2681///
2682/// Returns a list of sub-commands with each segment trimmed of whitespace.
2683/// Characters inside single quotes, double quotes, or backticks are treated
2684/// as literal and are never split. Escaped quotes (`\'`, `\"`) do not toggle
2685/// quote state. Pipes (`|`) within a segment are preserved (they are part of
2686/// one command, not a command separator).
2687///
2688/// Performance: single-pass O(n) character state machine, no regex, no heap
2689/// allocation for the common single-command case.
2690pub fn split_shell_commands(cmd: &str) -> Vec<&str> {
2691    let bytes = cmd.as_bytes();
2692    let len = bytes.len();
2693    let mut segments: Vec<&str> = Vec::new();
2694    let mut start = 0;
2695    let mut i = 0;
2696
2697    // Quote state
2698    let mut in_single = false;
2699    let mut in_double = false;
2700    let mut in_backtick = false;
2701    let mut escaped = false;
2702
2703    while i < len {
2704        let b = bytes[i];
2705
2706        if escaped {
2707            escaped = false;
2708            i += 1;
2709            continue;
2710        }
2711
2712        // POSIX: backslash is LITERAL inside single quotes; only honor it as an
2713        // escape when not in a single-quoted span, or the next `'` is wrongly
2714        // swallowed and the quote stays "open", hiding an exposed operator
2715        // (bd-review-singlequote-escape).
2716        if b == b'\\' && !in_single {
2717            escaped = true;
2718            i += 1;
2719            continue;
2720        }
2721
2722        // Toggle quote state
2723        if !in_double && !in_backtick && b == b'\'' {
2724            in_single = !in_single;
2725            i += 1;
2726            continue;
2727        }
2728        if !in_single && !in_backtick && b == b'"' {
2729            in_double = !in_double;
2730            i += 1;
2731            continue;
2732        }
2733        if !in_single && !in_double && b == b'`' {
2734            in_backtick = !in_backtick;
2735            i += 1;
2736            continue;
2737        }
2738
2739        // Only split when outside all quotes
2740        if in_single || in_double || in_backtick {
2741            i += 1;
2742            continue;
2743        }
2744
2745        // Check for `&&`
2746        if b == b'&' && i + 1 < len && bytes[i + 1] == b'&' {
2747            let seg = cmd[start..i].trim();
2748            if !seg.is_empty() {
2749                segments.push(seg);
2750            }
2751            i += 2;
2752            start = i;
2753            continue;
2754        }
2755
2756        // Check for `||`
2757        if b == b'|' && i + 1 < len && bytes[i + 1] == b'|' {
2758            let seg = cmd[start..i].trim();
2759            if !seg.is_empty() {
2760                segments.push(seg);
2761            }
2762            i += 2;
2763            start = i;
2764            continue;
2765        }
2766
2767        // Check for `;`
2768        if b == b';' {
2769            let seg = cmd[start..i].trim();
2770            if !seg.is_empty() {
2771                segments.push(seg);
2772            }
2773            i += 1;
2774            start = i;
2775            continue;
2776        }
2777
2778        i += 1;
2779    }
2780
2781    // Push the final segment
2782    let seg = cmd[start..].trim();
2783    if !seg.is_empty() {
2784        segments.push(seg);
2785    }
2786
2787    segments
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792    use super::*;
2793
2794    #[test]
2795    fn cargo_xwin_builds_use_the_cargo_build_contract() {
2796        for command in [
2797            "cargo xwin build --release --locked --target x86_64-pc-windows-msvc",
2798            "cargo +nightly-2026-08-31 xwin build --target=aarch64-pc-windows-msvc",
2799            "cargo --color never --locked xwin build",
2800            "env CARGO_HOME=/root/.cargo cargo xwin build --release",
2801            "cargo-xwin build --release",
2802            "cargo-xwin xwin build --release",
2803            "/home/ubuntu/.local/bin/cargo-xwin xwin build --release",
2804        ] {
2805            assert!(is_cargo_xwin_build(command), "{command}");
2806            let classification = classify_command(command);
2807            assert!(classification.is_compilation, "{command}");
2808            assert_eq!(
2809                classification.kind,
2810                Some(CompilationKind::CargoBuild),
2811                "{command}"
2812            );
2813        }
2814    }
2815
2816    #[test]
2817    fn cargo_xwin_recognition_declines_non_builds_and_argument_mentions() {
2818        for command in [
2819            "cargo xwin",
2820            "cargo xwin env",
2821            "cargo xwin cache clear",
2822            "cargo xwin check",
2823            "cargo xwin publish",
2824            "cargo xwin build --help",
2825            "cargo-xwin --version",
2826            "cargo-xwin cache clear",
2827            "cargo-xwin xwin build --help",
2828            "cargo --config xwin build --target x86_64-pc-windows-msvc",
2829            "cargo build --manifest-path cargo-xwin --target x86_64-pc-windows-msvc",
2830            "echo cargo xwin build",
2831            "cargo xwin build && cargo build",
2832        ] {
2833            assert!(!is_cargo_xwin_build(command), "{command}");
2834        }
2835        for command in [
2836            "cargo xwin env",
2837            "cargo xwin cache clear",
2838            "cargo-xwin --version",
2839        ] {
2840            assert!(!classify_command(command).is_compilation, "{command}");
2841        }
2842    }
2843    use crate::test_guard;
2844
2845    #[test]
2846    fn test_cargo_build_with_toolchain() {
2847        let _guard = test_guard!();
2848        let result = classify_command("cargo +nightly build");
2849        assert!(result.is_compilation);
2850        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
2851    }
2852
2853    // ===================== Nix classification (issue #26) =====================
2854
2855    #[test]
2856    fn test_nix_build_offloadable() {
2857        let _guard = test_guard!();
2858        for cmd in [
2859            "nix build",
2860            "nix build .#foo",
2861            "nix build .#packages.x86_64-linux.default",
2862            "nix build /path/to/flake#pkg",
2863            "nix build --extra-experimental-features 'nix-command flakes' .#foo",
2864            "nix --option sandbox true build .#foo",
2865            "nix -j4 build .#foo",
2866            "nix build nixpkgs#hello -o result-hello",
2867        ] {
2868            let result = classify_command(cmd);
2869            assert!(result.is_compilation, "expected offload for `{cmd}`");
2870            assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
2871            assert!(result.confidence >= 0.85, "`{cmd}` confidence too low");
2872        }
2873    }
2874
2875    #[test]
2876    fn test_nix_build_legacy_offloadable() {
2877        let _guard = test_guard!();
2878        for cmd in ["nix-build", "nix-build .", "nix-build -A hello"] {
2879            let result = classify_command(cmd);
2880            assert!(result.is_compilation, "expected offload for `{cmd}`");
2881            assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
2882        }
2883    }
2884
2885    #[test]
2886    fn test_nix_flake_check_offloadable_but_metadata_not() {
2887        let _guard = test_guard!();
2888        let check = classify_command("nix flake check");
2889        assert!(check.is_compilation, "nix flake check should offload");
2890        assert_eq!(check.kind, Some(CompilationKind::NixBuild));
2891
2892        for cmd in [
2893            "nix flake metadata",
2894            "nix flake show",
2895            "nix flake update",
2896            "nix flake lock",
2897            "nix flake info",
2898        ] {
2899            let result = classify_command(cmd);
2900            assert!(!result.is_compilation, "`{cmd}` must NOT offload");
2901        }
2902    }
2903
2904    #[test]
2905    fn test_nix_develop_and_shell_only_with_command() {
2906        let _guard = test_guard!();
2907        // Non-interactive `-c`/`--command` forms are offloadable.
2908        for cmd in [
2909            "nix develop -c make",
2910            "nix develop --command cargo build",
2911            "nix develop .#dev -c bun test",
2912            "nix shell nixpkgs#gcc -c make",
2913            "nix shell nixpkgs#hello --command hello",
2914        ] {
2915            let result = classify_command(cmd);
2916            assert!(result.is_compilation, "expected offload for `{cmd}`");
2917            assert_eq!(result.kind, Some(CompilationKind::NixBuild), "`{cmd}`");
2918        }
2919        // Bare interactive shells must NOT be intercepted.
2920        for cmd in ["nix develop", "nix develop .#dev", "nix shell nixpkgs#gcc"] {
2921            let result = classify_command(cmd);
2922            assert!(
2923                !result.is_compilation,
2924                "`{cmd}` is interactive; must run local"
2925            );
2926        }
2927    }
2928
2929    #[test]
2930    fn test_nix_execution_and_mutation_not_offloaded() {
2931        let _guard = test_guard!();
2932        for cmd in [
2933            "nix run .#app",
2934            "nix run nixpkgs#hello",
2935            "nix repl",
2936            "nix eval .#foo",
2937            "nix search nixpkgs hello",
2938            "nix profile install nixpkgs#hello",
2939            "nix registry list",
2940            "nix store gc",
2941            "nix copy --to ssh://host .#foo",
2942            "nix path-info .#foo",
2943            "nix --version",
2944            "nix build --help",
2945            "nix",
2946        ] {
2947            let result = classify_command(cmd);
2948            assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
2949        }
2950    }
2951
2952    #[test]
2953    fn test_nix_legacy_mutating_tools_have_no_keyword() {
2954        let _guard = test_guard!();
2955        // `nix-env`, `nix-shell`, `nix-store`, ... only share the "nix-" prefix;
2956        // they are NOT the `nix`/`nix-build` keywords, so Tier-2 rejects them
2957        // before classification (no accidental interception).
2958        for cmd in [
2959            "nix-env -iA nixpkgs.hello",
2960            "nix-shell",
2961            "nix-shell -p gcc",
2962            "nix-store --gc",
2963            "nix-collect-garbage -d",
2964            "nix-channel --update",
2965        ] {
2966            assert!(
2967                !starts_with_compilation_command(cmd),
2968                "`{cmd}` should not match any compilation keyword"
2969            );
2970            let result = classify_command(cmd);
2971            assert!(!result.is_compilation, "`{cmd}` must NOT be intercepted");
2972        }
2973    }
2974
2975    #[test]
2976    fn test_nix_build_maps_to_nix_runtime_base() {
2977        let _guard = test_guard!();
2978        assert_eq!(CompilationKind::NixBuild.command_base(), "nix");
2979        assert!(!CompilationKind::NixBuild.is_test_command());
2980    }
2981
2982    #[test]
2983    fn test_nix_build_compound_and_wrapped() {
2984        let _guard = test_guard!();
2985        // Compound `cd X && nix build` should offload the nix build tail.
2986        let compound = classify_command("cd /repo && nix build .#foo");
2987        assert!(compound.is_compilation, "compound nix build should offload");
2988        assert_eq!(compound.kind, Some(CompilationKind::NixBuild));
2989        // `bash -c "nix build .#foo"` wrapper should offload the inner command.
2990        let wrapped = classify_command("bash -c \"nix build .#foo\"");
2991        assert!(wrapped.is_compilation, "wrapped nix build should offload");
2992        assert_eq!(wrapped.kind, Some(CompilationKind::NixBuild));
2993    }
2994
2995    #[test]
2996    fn test_job_kind_serde_and_base_token() {
2997        let _guard = test_guard!();
2998        // The wire name is stable snake_case so rchd/telemetry can record it.
2999        let json = serde_json::to_string(&CompilationKind::Job).unwrap();
3000        assert_eq!(json, "\"job\"");
3001        let round: CompilationKind = serde_json::from_str(&json).unwrap();
3002        assert_eq!(round, CompilationKind::Job);
3003        // Total mapping token (see command_base docs — the hook-path allowlist
3004        // gate can never observe this kind).
3005        assert_eq!(CompilationKind::Job.command_base(), "job");
3006        assert!(!CompilationKind::Job.is_test_command());
3007    }
3008
3009    #[test]
3010    fn test_cargo_test_with_toolchain() {
3011        let _guard = test_guard!();
3012        let result = classify_command("cargo +1.80.0 test");
3013        assert!(result.is_compilation);
3014        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3015    }
3016
3017    #[test]
3018    fn test_cargo_nextest_with_toolchain() {
3019        let _guard = test_guard!();
3020        let result = classify_command("cargo +nightly nextest run");
3021        assert!(result.is_compilation);
3022        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
3023    }
3024
3025    #[test]
3026    fn test_cargo_build() {
3027        let _guard = test_guard!();
3028        let result = classify_command("cargo build");
3029        assert!(result.is_compilation);
3030        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3031        assert!(result.confidence >= 0.90);
3032    }
3033
3034    #[test]
3035    fn test_cargo_build_release() {
3036        let _guard = test_guard!();
3037        let result = classify_command("cargo build --release");
3038        assert!(result.is_compilation);
3039        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3040    }
3041
3042    #[test]
3043    fn test_cargo_test() {
3044        let _guard = test_guard!();
3045        let result = classify_command("cargo test");
3046        assert!(result.is_compilation);
3047        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3048    }
3049
3050    #[test]
3051    fn test_cargo_fmt_not_intercepted() {
3052        let _guard = test_guard!();
3053        let result = classify_command("cargo fmt");
3054        assert!(!result.is_compilation);
3055        assert!(result.reason.contains("never-intercept"));
3056    }
3057
3058    #[test]
3059    fn test_cargo_install_not_intercepted() {
3060        let _guard = test_guard!();
3061        let result = classify_command("cargo install ripgrep");
3062        assert!(!result.is_compilation);
3063        assert!(result.reason.contains("never-intercept"));
3064    }
3065
3066    #[test]
3067    fn test_piped_to_benign_pager_intercepted() {
3068        let _guard = test_guard!();
3069        // Issue #24: piping a compilation command's output into a benign pager
3070        // (grep/head/tee/...) is now offloaded, with the pipe re-attached to the
3071        // offloaded command's output.
3072        let result = classify_command("cargo build 2>&1 | grep error");
3073        assert!(result.is_compilation, "got: {:?}", result.reason);
3074        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3075        assert_eq!(
3076            result.extracted_command.as_deref(),
3077            Some("cargo build 2>&1 | grep error")
3078        );
3079    }
3080
3081    #[test]
3082    fn test_piped_to_nonbenign_command_not_intercepted() {
3083        let _guard = test_guard!();
3084        // Piping into a non-pager command stays conservative (declined).
3085        let result = classify_command("cargo build | xargs rm");
3086        assert!(!result.is_compilation);
3087        assert!(result.reason.contains("piped"));
3088    }
3089
3090    #[test]
3091    fn test_backgrounded_intercepted() {
3092        let _guard = test_guard!();
3093        // Issue #24: a trailing `&` (backgrounding) is now offloaded.
3094        let result = classify_command("cargo build &");
3095        assert!(result.is_compilation, "got: {:?}", result.reason);
3096        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3097        assert_eq!(result.extracted_command.as_deref(), Some("cargo build &"));
3098    }
3099
3100    #[test]
3101    fn test_newline_injection_not_intercepted() {
3102        let _guard = test_guard!();
3103        // Newlines could cause command injection via `sh -c`
3104        let result = classify_command("cargo build\nrm -rf /");
3105        assert!(!result.is_compilation);
3106        assert!(result.reason.contains("newline"));
3107
3108        let result = classify_command("cargo build\r\nrm -rf /");
3109        assert!(!result.is_compilation);
3110        assert!(result.reason.contains("newline"));
3111
3112        for command in [
3113            "cargo build > out.log\nrm -rf /",
3114            "cargo build | tee out.log\r\nrm -rf /",
3115            "cargo build &\nrm -rf /",
3116        ] {
3117            let result = classify_command(command);
3118            assert!(
3119                !result.is_compilation,
3120                "newline-bearing benign suffix must fail closed: {command:?}"
3121            );
3122            assert!(result.reason.contains("newline"), "{result:?}");
3123        }
3124    }
3125
3126    #[test]
3127    fn test_redirected_intercepted() {
3128        let _guard = test_guard!();
3129        // Issue #24: a benign stdout/stderr file redirect is now offloaded with
3130        // the redirect re-attached to the offloaded command's output.
3131        let result = classify_command("cargo build > log.txt");
3132        assert!(result.is_compilation, "got: {:?}", result.reason);
3133        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3134        assert_eq!(
3135            result.extracted_command.as_deref(),
3136            Some("cargo build > log.txt")
3137        );
3138
3139        let result = classify_command("cargo test --workspace > out.log 2>&1");
3140        assert!(result.is_compilation, "got: {:?}", result.reason);
3141        assert_eq!(
3142            result.extracted_command.as_deref(),
3143            Some("cargo test --workspace > out.log 2>&1")
3144        );
3145
3146        // File-descriptor redirects must remain byte-for-byte adjacent. A
3147        // space before `>` turns the descriptor into a command argument.
3148        for command in [
3149            "cargo build 2>errors.txt",
3150            "cargo build 2>>errors.txt",
3151            "cargo build >out.log 2>&1",
3152        ] {
3153            let result = classify_command(command);
3154            assert!(result.is_compilation, "got: {:?}", result.reason);
3155            assert_eq!(result.extracted_command.as_deref(), Some(command));
3156        }
3157    }
3158
3159    #[test]
3160    fn test_input_redirected_not_intercepted() {
3161        let _guard = test_guard!();
3162        // Input redirects transform the command's INPUT and are never offloaded.
3163        let result = classify_command("cargo build < input.txt");
3164        assert!(!result.is_compilation);
3165        assert!(result.reason.contains("input redirected"));
3166    }
3167
3168    #[test]
3169    fn test_process_substitution_not_intercepted() {
3170        let _guard = test_guard!();
3171        let result = classify_command("cargo build --config <(echo ...)");
3172        assert!(!result.is_compilation);
3173        assert!(result.reason.contains("subshell execution"));
3174    }
3175
3176    #[test]
3177    fn test_subshell_not_intercepted() {
3178        let _guard = test_guard!();
3179        let result = classify_command("(cargo build)");
3180        assert!(!result.is_compilation);
3181        assert!(result.reason.contains("subshell execution"));
3182    }
3183
3184    #[test]
3185    fn test_make_maintenance_matches_whole_target_tokens() {
3186        // Regression (bd-review-make-ninja-substring): maintenance targets must
3187        // match as whole TARGET tokens, not as a substring anywhere.
3188        let _guard = test_guard!();
3189
3190        // Real maintenance targets are still rejected.
3191        for cmd in [
3192            "make clean",
3193            "make install",
3194            "make distclean",
3195            "make uninstall",
3196        ] {
3197            let r = classify_command(cmd);
3198            assert!(!r.is_compilation, "{cmd} should not be intercepted");
3199            assert!(r.reason.contains("make maintenance command"), "{cmd}");
3200        }
3201        // With parallelism flags too.
3202        let r = classify_command("make -j8 clean");
3203        assert!(!r.is_compilation);
3204
3205        // Real builds whose tokens merely CONTAIN a maintenance word are builds.
3206        for cmd in [
3207            "make CFLAGS=-Iinstall/include",
3208            "make build INSTALL_DIR=/x",
3209            "make TARGET=cleanup",
3210            "make clean-all",  // distinct target, not "clean"
3211            "make -C install", // build in a directory named "install"
3212        ] {
3213            let r = classify_command(cmd);
3214            assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3215            assert_eq!(r.kind, Some(CompilationKind::Make), "{cmd}");
3216        }
3217    }
3218
3219    #[test]
3220    fn test_ninja_clean_matches_tool_and_whole_target() {
3221        // Regression (bd-review-make-ninja-substring): only `-t clean` and a
3222        // whole-token `clean` target are maintenance.
3223        let _guard = test_guard!();
3224
3225        for cmd in ["ninja clean", "ninja -t clean", "ninja -j8 clean"] {
3226            let r = classify_command(cmd);
3227            assert!(!r.is_compilation, "{cmd} should not be intercepted");
3228            assert!(r.reason.contains("ninja clean"), "{cmd}");
3229        }
3230
3231        for cmd in [
3232            "ninja build/clean_obj.o", // path containing "clean"
3233            "ninja -C clean",          // build in a directory named "clean"
3234            "ninja -j4",
3235        ] {
3236            let r = classify_command(cmd);
3237            assert!(r.is_compilation, "{cmd} should be intercepted as a build");
3238            assert_eq!(r.kind, Some(CompilationKind::Ninja), "{cmd}");
3239        }
3240    }
3241
3242    #[test]
3243    fn test_gcc_compile() {
3244        let _guard = test_guard!();
3245        let result = classify_command("gcc -c main.c -o main.o");
3246        assert!(result.is_compilation);
3247        assert_eq!(result.kind, Some(CompilationKind::Gcc));
3248    }
3249
3250    #[test]
3251    fn test_make() {
3252        let _guard = test_guard!();
3253        let result = classify_command("make -j8");
3254        assert!(result.is_compilation);
3255        assert_eq!(result.kind, Some(CompilationKind::Make));
3256    }
3257
3258    #[test]
3259    fn test_make_clean_not_intercepted() {
3260        let _guard = test_guard!();
3261        let result = classify_command("make clean");
3262        assert!(!result.is_compilation);
3263        assert!(result.reason.contains("make maintenance command"));
3264    }
3265
3266    #[test]
3267    fn test_cmake_build_requires_cmake_command() {
3268        let _guard = test_guard!();
3269        let result = classify_command("echo cmake --build .");
3270        assert!(!result.is_compilation);
3271    }
3272
3273    #[test]
3274    fn test_cmake_build_with_equals_flag() {
3275        let _guard = test_guard!();
3276        let result = classify_command("cmake --build=build");
3277        assert!(result.is_compilation);
3278        assert_eq!(result.kind, Some(CompilationKind::CmakeBuild));
3279    }
3280
3281    #[test]
3282    fn test_meson_compile_requires_meson_command() {
3283        let _guard = test_guard!();
3284        let result = classify_command("echo meson compile");
3285        assert!(!result.is_compilation);
3286    }
3287
3288    #[test]
3289    fn test_non_compilation() {
3290        let _guard = test_guard!();
3291        let result = classify_command("ls -la");
3292        assert!(!result.is_compilation);
3293        assert!(result.reason.contains("no compilation keyword"));
3294    }
3295
3296    #[test]
3297    fn test_empty_command() {
3298        let _guard = test_guard!();
3299        let result = classify_command("");
3300        assert!(!result.is_compilation);
3301        assert!(result.reason.contains("empty command"));
3302    }
3303
3304    // Bun tests - keyword detection and never-intercept patterns
3305
3306    #[test]
3307    fn test_bun_keyword_detected() {
3308        let _guard = test_guard!();
3309        // Verify "bun" command words trigger Tier 2 before Tier 3 decides
3310        // whether the subcommand is safe to intercept.
3311        assert!(starts_with_compilation_command("bun test"));
3312        assert!(starts_with_compilation_command("bun typecheck"));
3313        assert!(starts_with_compilation_command("bun install"));
3314    }
3315
3316    #[test]
3317    fn test_bun_install_not_intercepted() {
3318        let _guard = test_guard!();
3319        // Package management - modifies node_modules
3320        let result = classify_command("bun install");
3321        assert!(!result.is_compilation);
3322        assert!(result.reason.contains("never-intercept"));
3323    }
3324
3325    #[test]
3326    fn test_bun_add_not_intercepted() {
3327        let _guard = test_guard!();
3328        // Adding packages - modifies package.json and node_modules
3329        let result = classify_command("bun add lodash");
3330        assert!(!result.is_compilation);
3331        assert!(result.reason.contains("never-intercept"));
3332    }
3333
3334    #[test]
3335    fn test_bun_remove_not_intercepted() {
3336        let _guard = test_guard!();
3337        let result = classify_command("bun remove lodash");
3338        assert!(!result.is_compilation);
3339        assert!(result.reason.contains("never-intercept"));
3340    }
3341
3342    #[test]
3343    fn test_bun_run_not_intercepted() {
3344        let _guard = test_guard!();
3345        // Generic script runner - could do anything
3346        let result = classify_command("bun run build");
3347        assert!(!result.is_compilation);
3348        assert!(result.reason.contains("never-intercept"));
3349    }
3350
3351    #[test]
3352    fn test_bun_build_not_intercepted() {
3353        let _guard = test_guard!();
3354        // Creates bundles in local directory
3355        let result = classify_command("bun build ./src/index.ts");
3356        assert!(!result.is_compilation);
3357        assert!(result.reason.contains("never-intercept"));
3358    }
3359
3360    #[test]
3361    fn test_bun_version_not_intercepted() {
3362        let _guard = test_guard!();
3363        let result = classify_command("bun --version");
3364        assert!(!result.is_compilation);
3365        assert!(result.reason.contains("never-intercept"));
3366
3367        let result = classify_command("bun -v");
3368        assert!(!result.is_compilation);
3369        assert!(result.reason.contains("never-intercept"));
3370    }
3371
3372    #[test]
3373    fn test_bun_dev_not_intercepted() {
3374        let _guard = test_guard!();
3375        // Development server needs local ports
3376        let result = classify_command("bun dev");
3377        assert!(!result.is_compilation);
3378        assert!(result.reason.contains("never-intercept"));
3379    }
3380
3381    #[test]
3382    fn test_bun_repl_not_intercepted() {
3383        let _guard = test_guard!();
3384        // Interactive REPL
3385        let result = classify_command("bun repl");
3386        assert!(!result.is_compilation);
3387        assert!(result.reason.contains("never-intercept"));
3388    }
3389
3390    #[test]
3391    fn test_bun_link_not_intercepted() {
3392        let _guard = test_guard!();
3393        let result = classify_command("bun link");
3394        assert!(!result.is_compilation);
3395        assert!(result.reason.contains("never-intercept"));
3396    }
3397
3398    #[test]
3399    fn test_bun_init_not_intercepted() {
3400        let _guard = test_guard!();
3401        let result = classify_command("bun init");
3402        assert!(!result.is_compilation);
3403        assert!(result.reason.contains("never-intercept"));
3404    }
3405
3406    #[test]
3407    fn test_bun_create_not_intercepted() {
3408        let _guard = test_guard!();
3409        let result = classify_command("bun create next-app");
3410        assert!(!result.is_compilation);
3411        assert!(result.reason.contains("never-intercept"));
3412    }
3413
3414    #[test]
3415    fn test_bun_pm_not_intercepted() {
3416        let _guard = test_guard!();
3417        let result = classify_command("bun pm cache");
3418        assert!(!result.is_compilation);
3419        assert!(result.reason.contains("never-intercept"));
3420    }
3421
3422    #[test]
3423    fn test_bun_help_not_intercepted() {
3424        let _guard = test_guard!();
3425        let result = classify_command("bun --help");
3426        assert!(!result.is_compilation);
3427        assert!(result.reason.contains("never-intercept"));
3428
3429        let result = classify_command("bun -h");
3430        assert!(!result.is_compilation);
3431        assert!(result.reason.contains("never-intercept"));
3432    }
3433
3434    // Bun Tier 4 classification tests
3435
3436    #[test]
3437    fn test_bun_test_classification() {
3438        let _guard = test_guard!();
3439        // Basic command
3440        let result = classify_command("bun test");
3441        assert!(result.is_compilation);
3442        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3443        assert!((result.confidence - 0.95).abs() < 0.001);
3444
3445        // With directory argument
3446        let result = classify_command("bun test src/");
3447        assert!(result.is_compilation);
3448        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3449
3450        // With coverage flag (non-interactive)
3451        let result = classify_command("bun test --coverage");
3452        assert!(result.is_compilation);
3453        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3454
3455        // Specific file
3456        let result = classify_command("bun test auth.test.ts");
3457        assert!(result.is_compilation);
3458        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3459
3460        // With multiple flags
3461        let result = classify_command("bun test --bail --timeout 5000");
3462        assert!(result.is_compilation);
3463        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3464
3465        // With reporter
3466        let result = classify_command("bun test --reporter json");
3467        assert!(result.is_compilation);
3468        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3469    }
3470
3471    #[test]
3472    fn test_bun_test_watch_not_intercepted() {
3473        let _guard = test_guard!();
3474        // --watch mode is interactive and should NOT be intercepted
3475        let result = classify_command("bun test --watch");
3476        assert!(!result.is_compilation);
3477        assert!(result.reason.contains("interactive"));
3478
3479        // Watch with other flags
3480        let result = classify_command("bun test --watch --coverage");
3481        assert!(!result.is_compilation);
3482        assert!(result.reason.contains("interactive"));
3483
3484        // Watch with directory
3485        let result = classify_command("bun test src/ --watch");
3486        assert!(!result.is_compilation);
3487
3488        // Short form -w
3489        let result = classify_command("bun test -w");
3490        assert!(!result.is_compilation);
3491
3492        // Short form with args
3493        let result = classify_command("bun test -w src/");
3494        assert!(!result.is_compilation);
3495    }
3496
3497    #[test]
3498    fn test_bun_typecheck_watch_not_intercepted() {
3499        let _guard = test_guard!();
3500        // --watch mode is interactive and should NOT be intercepted
3501        let result = classify_command("bun typecheck --watch");
3502        assert!(!result.is_compilation);
3503        assert!(result.reason.contains("interactive"));
3504
3505        // Short form -w
3506        let result = classify_command("bun typecheck -w");
3507        assert!(!result.is_compilation);
3508    }
3509
3510    #[test]
3511    fn test_bun_typecheck_classification() {
3512        let _guard = test_guard!();
3513        // Basic command
3514        let result = classify_command("bun typecheck");
3515        assert!(result.is_compilation);
3516        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3517        assert!((result.confidence - 0.95).abs() < 0.001);
3518
3519        // With directory argument
3520        let result = classify_command("bun typecheck src/");
3521        assert!(result.is_compilation);
3522        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3523
3524        // NOTE: --watch mode is tested separately in test_bun_typecheck_watch_not_intercepted
3525        // It should NOT be intercepted as it's interactive
3526    }
3527
3528    #[test]
3529    fn test_bun_edge_cases_not_matched() {
3530        let _guard = test_guard!();
3531        // Invalid commands should not match
3532        let result = classify_command("bun testing");
3533        assert!(!result.is_compilation);
3534        assert!(result.reason.contains("no matching pattern"));
3535
3536        let result = classify_command("bun typechecker");
3537        assert!(!result.is_compilation);
3538        assert!(result.reason.contains("no matching pattern"));
3539
3540        let result = classify_command("bun type");
3541        assert!(!result.is_compilation);
3542        assert!(result.reason.contains("no matching pattern"));
3543
3544        // bun x should not be intercepted (runs arbitrary packages like npx)
3545        let result = classify_command("bun x eslint");
3546        assert!(!result.is_compilation);
3547        assert!(result.reason.contains("bun x runs arbitrary packages"));
3548
3549        let result = classify_command("bun x prettier --write .");
3550        assert!(!result.is_compilation);
3551        assert!(result.reason.contains("bun x runs arbitrary packages"));
3552
3553        let result = classify_command("bun x vitest run");
3554        assert!(!result.is_compilation);
3555        assert!(result.reason.contains("bun x runs arbitrary packages"));
3556    }
3557
3558    #[test]
3559    fn test_bun_test_vs_never_intercept() {
3560        let _guard = test_guard!();
3561        // bun test should NOT be blocked by never-intercept
3562        let result = classify_command("bun test");
3563        assert!(!result.reason.contains("never-intercept"));
3564        assert!(result.is_compilation);
3565    }
3566
3567    #[test]
3568    fn test_bun_typecheck_vs_never_intercept() {
3569        let _guard = test_guard!();
3570        // bun typecheck should NOT be blocked by never-intercept
3571        let result = classify_command("bun typecheck");
3572        assert!(!result.reason.contains("never-intercept"));
3573        assert!(result.is_compilation);
3574    }
3575
3576    // Bun CompilationKind serialization tests
3577
3578    #[test]
3579    fn test_bun_compilation_kind_serde() {
3580        let _guard = test_guard!();
3581        // BunTest serialization
3582        let kind = CompilationKind::BunTest;
3583        let json = serde_json::to_string(&kind).unwrap();
3584        assert_eq!(json, "\"bun_test\"");
3585        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3586        assert_eq!(parsed, CompilationKind::BunTest);
3587
3588        // BunTypecheck serialization
3589        let kind = CompilationKind::BunTypecheck;
3590        let json = serde_json::to_string(&kind).unwrap();
3591        assert_eq!(json, "\"bun_typecheck\"");
3592        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
3593        assert_eq!(parsed, CompilationKind::BunTypecheck);
3594    }
3595
3596    #[test]
3597    fn test_bun_kinds_are_distinct() {
3598        let _guard = test_guard!();
3599        // BunTest and BunTypecheck are different
3600        assert_ne!(CompilationKind::BunTest, CompilationKind::BunTypecheck);
3601        // BunTest is different from CargoTest (different ecosystems)
3602        assert_ne!(CompilationKind::BunTest, CompilationKind::CargoTest);
3603    }
3604
3605    #[test]
3606    fn test_wrapped_command_classification_repro() {
3607        let _guard = test_guard!();
3608        // This fails currently because classify_full expects command to start with "cargo"
3609        // Wrapper tools like "time", "sudo", "env" break this logic
3610        let result = classify_command("time cargo build");
3611        assert!(
3612            result.is_compilation,
3613            "Should classify 'time cargo build' as compilation"
3614        );
3615        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3616
3617        let result = classify_command("sudo cargo check");
3618        assert!(
3619            result.is_compilation,
3620            "Should classify 'sudo cargo check' as compilation"
3621        );
3622
3623        let result = classify_command("env RUST_BACKTRACE=1 cargo test");
3624        assert!(
3625            result.is_compilation,
3626            "Should classify env-wrapped cargo test as compilation"
3627        );
3628
3629        let result = classify_command("env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build");
3630        assert!(
3631            result.is_compilation,
3632            "Should classify shell-quoted env assignment before cargo build as compilation"
3633        );
3634        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
3635    }
3636
3637    // =========================================================================
3638    // Bun E2E Edge Case Tests (from bead remote_compilation_helper-65m)
3639    // =========================================================================
3640
3641    #[test]
3642    fn test_bun_piped_to_benign_pager_intercepted() {
3643        let _guard = test_guard!();
3644        // Issue #24: piping a bun test/typecheck into a benign pager is offloaded.
3645        let result = classify_command("bun test | grep error");
3646        assert!(result.is_compilation, "got: {:?}", result.reason);
3647        assert_eq!(result.kind, Some(CompilationKind::BunTest));
3648        assert_eq!(
3649            result.extracted_command.as_deref(),
3650            Some("bun test | grep error")
3651        );
3652
3653        // Piped with output filtering
3654        let result = classify_command("bun test 2>&1 | tee output.log");
3655        assert!(result.is_compilation, "got: {:?}", result.reason);
3656        assert_eq!(
3657            result.extracted_command.as_deref(),
3658            Some("bun test 2>&1 | tee output.log")
3659        );
3660
3661        // Piped typecheck
3662        let result = classify_command("bun typecheck | head -20");
3663        assert!(result.is_compilation, "got: {:?}", result.reason);
3664        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
3665    }
3666
3667    #[test]
3668    fn test_bunx_tsc_not_intercepted() {
3669        let _guard = test_guard!();
3670        // bunx (bun x) runs arbitrary packages - should NOT be intercepted
3671        // even for typecheck-like commands like tsc
3672        let result = classify_command("bun x tsc --noEmit");
3673        assert!(!result.is_compilation);
3674        assert!(result.reason.contains("bun x"));
3675
3676        // bunx with other tools
3677        let result = classify_command("bun x eslint .");
3678        assert!(!result.is_compilation);
3679
3680        let result = classify_command("bun x prettier --write .");
3681        assert!(!result.is_compilation);
3682
3683        let result = classify_command("bun x vitest run");
3684        assert!(!result.is_compilation);
3685    }
3686
3687    #[test]
3688    fn test_bun_redirected_intercepted() {
3689        let _guard = test_guard!();
3690        // Issue #24: a benign output redirect is offloaded.
3691        let result = classify_command("bun test > results.txt");
3692        assert!(result.is_compilation, "got: {:?}", result.reason);
3693        assert_eq!(
3694            result.extracted_command.as_deref(),
3695            Some("bun test > results.txt")
3696        );
3697
3698        let result = classify_command("bun typecheck > errors.log");
3699        assert!(result.is_compilation, "got: {:?}", result.reason);
3700    }
3701
3702    #[test]
3703    fn test_bun_backgrounded_intercepted() {
3704        let _guard = test_guard!();
3705        // Issue #24: a trailing `&` is offloaded.
3706        let result = classify_command("bun test &");
3707        assert!(result.is_compilation, "got: {:?}", result.reason);
3708        assert_eq!(result.extracted_command.as_deref(), Some("bun test &"));
3709    }
3710
3711    #[test]
3712    fn test_bun_chained_commands_classified() {
3713        let _guard = test_guard!();
3714        // Multi-command strings should be rejected
3715        let result = classify_command("bun test && echo done");
3716        assert!(
3717            !result.is_compilation,
3718            "chained commands should be rejected"
3719        );
3720        assert!(result.reason.contains("chained"));
3721
3722        let result = classify_command("bun typecheck; bun test");
3723        assert!(
3724            !result.is_compilation,
3725            "chained commands should be rejected"
3726        );
3727        assert!(result.reason.contains("chained"));
3728    }
3729
3730    #[test]
3731    fn test_bun_subshell_capture_not_intercepted() {
3732        let _guard = test_guard!();
3733        // Subshell capture should be rejected at Tier 1
3734        let result = classify_command("bun test $(echo src/)");
3735        assert!(!result.is_compilation);
3736        assert!(result.reason.contains("subshell"));
3737    }
3738
3739    #[test]
3740    fn test_bun_wrapped_commands() {
3741        let _guard = test_guard!();
3742        // Wrapped bun commands should still be classified
3743        // (requires normalize_command to handle wrappers)
3744        let result = classify_command("time bun test");
3745        // Currently may or may not work depending on normalize_command
3746        // This test documents current behavior
3747        if result.is_compilation {
3748            assert_eq!(result.kind, Some(CompilationKind::BunTest));
3749        }
3750
3751        let result = classify_command("env DEBUG=1 bun test");
3752        if result.is_compilation {
3753            assert_eq!(result.kind, Some(CompilationKind::BunTest));
3754        }
3755    }
3756
3757    #[test]
3758    fn test_classify_command_detailed_matches_basic() {
3759        let _guard = test_guard!();
3760        let commands = [
3761            "cargo build",
3762            "cargo test --release",
3763            "bun typecheck",
3764            "gcc -c main.c -o main.o",
3765            "cd /tmp && cargo build --release",
3766            "ls -la",
3767        ];
3768
3769        for cmd in commands {
3770            let basic = classify_command(cmd);
3771            let detailed = classify_command_detailed(cmd);
3772            assert_eq!(basic, detailed.classification);
3773        }
3774    }
3775
3776    #[test]
3777    fn test_classify_command_detailed_rejects_piped() {
3778        let _guard = test_guard!();
3779        // Pipe into a NON-benign command is still rejected at Tier 1. (A pipe
3780        // into a benign pager like `tee` is now offloaded per issue #24.)
3781        let detailed = classify_command_detailed("cargo build | xargs rm");
3782        assert!(!detailed.classification.is_compilation);
3783        let tier1 = detailed.tiers.iter().find(|t| t.tier.eq(&1)).unwrap();
3784        assert_eq!(tier1.decision, TierDecision::Reject);
3785        assert!(tier1.reason.contains("piped"));
3786
3787        // A pipe into a benign pager passes Tier 1 and is offloaded.
3788        let detailed = classify_command_detailed("cargo build | tee log.txt");
3789        assert!(detailed.classification.is_compilation);
3790        assert_eq!(
3791            detailed.classification.extracted_command.as_deref(),
3792            Some("cargo build | tee log.txt")
3793        );
3794    }
3795
3796    #[test]
3797    fn test_classify_command_detailed_normalizes_wrappers() {
3798        let _guard = test_guard!();
3799        for (cmd, normalized) in [
3800            ("sudo cargo check", "cargo check"),
3801            ("env -u RUST_LOG cargo test", "cargo test"),
3802        ] {
3803            let detailed = classify_command_detailed(cmd);
3804            assert_eq!(
3805                (
3806                    detailed.normalized.as_str(),
3807                    detailed.classification.is_compilation
3808                ),
3809                (normalized, true),
3810                "{cmd}"
3811            );
3812        }
3813    }
3814
3815    #[test]
3816    fn test_classify_command_detailed_matches_compound_command() {
3817        let _guard = test_guard!();
3818        let detailed = classify_command_detailed("cd /tmp && cargo build --release");
3819
3820        assert!(detailed.classification.is_compilation);
3821        assert_eq!(
3822            detailed.classification.kind,
3823            Some(CompilationKind::CargoBuild)
3824        );
3825        assert_eq!(
3826            detailed.classification.command_prefix.as_deref(),
3827            Some("cd /tmp && ")
3828        );
3829        assert_eq!(
3830            detailed.classification.extracted_command.as_deref(),
3831            Some("cargo build --release")
3832        );
3833        assert_eq!(detailed.normalized, "cargo build --release");
3834        assert_eq!(detailed.tiers.len(), 5);
3835        assert!(
3836            detailed
3837                .tiers
3838                .iter()
3839                .all(|tier| tier.decision == TierDecision::Pass)
3840        );
3841    }
3842
3843    // =========================================================================
3844    // Comprehensive cargo test variant tests (bead remote_compilation_helper-xcvl)
3845    // =========================================================================
3846
3847    #[test]
3848    fn test_cargo_test_release() {
3849        let _guard = test_guard!();
3850        let result = classify_command("cargo test --release");
3851        assert!(
3852            result.is_compilation,
3853            "cargo test --release should be classified as compilation"
3854        );
3855        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3856        assert!(result.confidence >= 0.90);
3857    }
3858
3859    #[test]
3860    fn test_cargo_test_specific_test_name() {
3861        let _guard = test_guard!();
3862        let result = classify_command("cargo test my_test_function");
3863        assert!(
3864            result.is_compilation,
3865            "cargo test with specific test name should be classified"
3866        );
3867        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3868    }
3869
3870    #[test]
3871    fn test_cargo_test_with_nocapture() {
3872        let _guard = test_guard!();
3873        let result = classify_command("cargo test -- --nocapture");
3874        assert!(
3875            result.is_compilation,
3876            "cargo test -- --nocapture should be classified"
3877        );
3878        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3879    }
3880
3881    #[test]
3882    fn test_cargo_test_workspace() {
3883        let _guard = test_guard!();
3884        let result = classify_command("cargo test --workspace");
3885        assert!(
3886            result.is_compilation,
3887            "cargo test --workspace should be classified"
3888        );
3889        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3890    }
3891
3892    #[test]
3893    fn test_cargo_test_package() {
3894        let _guard = test_guard!();
3895        let result = classify_command("cargo test -p rch-common");
3896        assert!(result.is_compilation, "cargo test -p should be classified");
3897        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3898    }
3899
3900    #[test]
3901    fn test_cargo_test_short_alias() {
3902        let _guard = test_guard!();
3903        // cargo t is an alias for cargo test
3904        let result = classify_command("cargo t");
3905        assert!(
3906            result.is_compilation,
3907            "cargo t (short alias) should be classified"
3908        );
3909        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3910    }
3911
3912    #[test]
3913    fn test_cargo_test_all_features() {
3914        let _guard = test_guard!();
3915        let result = classify_command("cargo test --all-features");
3916        assert!(
3917            result.is_compilation,
3918            "cargo test --all-features should be classified"
3919        );
3920        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3921    }
3922
3923    #[test]
3924    fn test_cargo_test_no_default_features() {
3925        let _guard = test_guard!();
3926        let result = classify_command("cargo test --no-default-features");
3927        assert!(
3928            result.is_compilation,
3929            "cargo test --no-default-features should be classified"
3930        );
3931        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3932    }
3933
3934    #[test]
3935    fn test_cargo_test_with_env_var() {
3936        let _guard = test_guard!();
3937        let result = classify_command("RUST_BACKTRACE=1 cargo test");
3938        assert!(
3939            result.is_compilation,
3940            "cargo test with env var should be classified"
3941        );
3942        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3943    }
3944
3945    #[test]
3946    fn test_cargo_test_with_multiple_flags() {
3947        let _guard = test_guard!();
3948        let result = classify_command("cargo test --release --workspace -p rch -- --nocapture");
3949        assert!(
3950            result.is_compilation,
3951            "cargo test with multiple flags should be classified"
3952        );
3953        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3954    }
3955
3956    #[test]
3957    fn test_cargo_test_with_jobs() {
3958        let _guard = test_guard!();
3959        let result = classify_command("cargo test -j 8");
3960        assert!(result.is_compilation, "cargo test -j should be classified");
3961        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3962    }
3963
3964    #[test]
3965    fn test_cargo_test_target() {
3966        let _guard = test_guard!();
3967        let result = classify_command("cargo test --target x86_64-unknown-linux-gnu");
3968        assert!(
3969            result.is_compilation,
3970            "cargo test --target should be classified"
3971        );
3972        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3973    }
3974
3975    #[test]
3976    fn test_cargo_test_lib() {
3977        let _guard = test_guard!();
3978        let result = classify_command("cargo test --lib");
3979        assert!(
3980            result.is_compilation,
3981            "cargo test --lib should be classified"
3982        );
3983        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3984    }
3985
3986    #[test]
3987    fn test_cargo_test_bins() {
3988        let _guard = test_guard!();
3989        let result = classify_command("cargo test --bins");
3990        assert!(
3991            result.is_compilation,
3992            "cargo test --bins should be classified"
3993        );
3994        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
3995    }
3996
3997    #[test]
3998    fn test_cargo_test_doc() {
3999        let _guard = test_guard!();
4000        let result = classify_command("cargo test --doc");
4001        assert!(
4002            result.is_compilation,
4003            "cargo test --doc should be classified"
4004        );
4005        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4006    }
4007
4008    #[test]
4009    fn test_cargo_test_filter_pattern() {
4010        let _guard = test_guard!();
4011        let result = classify_command("cargo test test_classification");
4012        assert!(
4013            result.is_compilation,
4014            "cargo test with filter pattern should be classified"
4015        );
4016        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4017    }
4018
4019    #[test]
4020    fn test_cargo_test_exact() {
4021        let _guard = test_guard!();
4022        let result = classify_command("cargo test --exact my_test");
4023        assert!(
4024            result.is_compilation,
4025            "cargo test --exact should be classified"
4026        );
4027        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
4028    }
4029
4030    // =========================================================================
4031    // cargo-nextest tests (bead remote_compilation_helper-c7ky)
4032    // =========================================================================
4033
4034    #[test]
4035    fn test_nextest_keyword_detected() {
4036        let _guard = test_guard!();
4037        // Verify nextest command words trigger Tier 2 before subcommand checks.
4038        assert!(starts_with_compilation_command("cargo nextest run"));
4039        assert!(starts_with_compilation_command("cargo nextest list"));
4040    }
4041
4042    #[test]
4043    fn test_cargo_nextest_run_classification() {
4044        let _guard = test_guard!();
4045        // Basic command
4046        let result = classify_command("cargo nextest run");
4047        assert!(
4048            result.is_compilation,
4049            "cargo nextest run should be classified"
4050        );
4051        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4052        assert!((result.confidence - 0.95).abs() < 0.001);
4053    }
4054
4055    #[test]
4056    fn test_cargo_nextest_run_with_flags() {
4057        let _guard = test_guard!();
4058        // With release flag
4059        let result = classify_command("cargo nextest run --release");
4060        assert!(result.is_compilation);
4061        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4062
4063        // With profile flag
4064        let result = classify_command("cargo nextest run --cargo-profile ci");
4065        assert!(result.is_compilation);
4066        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4067
4068        // With workspace flag
4069        let result = classify_command("cargo nextest run --workspace");
4070        assert!(result.is_compilation);
4071        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4072
4073        // With package filter
4074        let result = classify_command("cargo nextest run -p rch-common");
4075        assert!(result.is_compilation);
4076        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4077
4078        // With test filter
4079        let result = classify_command("cargo nextest run test_classification");
4080        assert!(result.is_compilation);
4081        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4082
4083        // With multiple flags
4084        let result = classify_command("cargo nextest run --release --no-fail-fast -j 8");
4085        assert!(result.is_compilation);
4086        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4087    }
4088
4089    #[test]
4090    fn test_cargo_nextest_run_short_alias() {
4091        let _guard = test_guard!();
4092        // cargo nextest r is an alias for cargo nextest run
4093        let result = classify_command("cargo nextest r");
4094        assert!(
4095            result.is_compilation,
4096            "cargo nextest r should be classified"
4097        );
4098        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4099    }
4100
4101    #[test]
4102    fn test_cargo_nextest_list_not_intercepted() {
4103        let _guard = test_guard!();
4104        // cargo nextest list only shows tests, doesn't run them
4105        let result = classify_command("cargo nextest list");
4106        assert!(!result.is_compilation);
4107        assert!(result.reason.contains("never-intercept"));
4108    }
4109
4110    #[test]
4111    fn test_cargo_nextest_archive_not_intercepted() {
4112        let _guard = test_guard!();
4113        // cargo nextest archive creates archives
4114        let result = classify_command("cargo nextest archive");
4115        assert!(!result.is_compilation);
4116        assert!(result.reason.contains("never-intercept"));
4117    }
4118
4119    #[test]
4120    fn test_cargo_nextest_show_not_intercepted() {
4121        let _guard = test_guard!();
4122        // cargo nextest show displays config info
4123        let result = classify_command("cargo nextest show");
4124        assert!(!result.is_compilation);
4125        assert!(result.reason.contains("never-intercept"));
4126    }
4127
4128    #[test]
4129    fn test_bare_cargo_nextest_not_intercepted() {
4130        let _guard = test_guard!();
4131        // bare "cargo nextest" without subcommand
4132        let result = classify_command("cargo nextest");
4133        assert!(!result.is_compilation);
4134        assert!(result.reason.contains("without subcommand"));
4135    }
4136
4137    #[test]
4138    fn test_cargo_nextest_wrapped_commands() {
4139        let _guard = test_guard!();
4140        // Wrapped nextest commands should still be classified
4141        let result = classify_command("time cargo nextest run");
4142        assert!(
4143            result.is_compilation,
4144            "time cargo nextest run should be classified"
4145        );
4146        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4147
4148        let result = classify_command("RUST_BACKTRACE=1 cargo nextest run");
4149        assert!(
4150            result.is_compilation,
4151            "env-wrapped cargo nextest run should be classified"
4152        );
4153        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4154    }
4155
4156    #[test]
4157    fn test_cargo_nextest_compilation_kind_serde() {
4158        let _guard = test_guard!();
4159        // CargoNextest serialization
4160        let kind = CompilationKind::CargoNextest;
4161        let json = serde_json::to_string(&kind).unwrap();
4162        assert_eq!(json, "\"cargo_nextest\"");
4163        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4164        assert_eq!(parsed, CompilationKind::CargoNextest);
4165    }
4166
4167    #[test]
4168    fn test_cargo_nextest_vs_cargo_test_distinct() {
4169        let _guard = test_guard!();
4170        // CargoNextest and CargoTest are different
4171        assert_ne!(CompilationKind::CargoNextest, CompilationKind::CargoTest);
4172
4173        // Both should be classified but as different kinds
4174        let nextest_result = classify_command("cargo nextest run");
4175        let test_result = classify_command("cargo test");
4176        assert!(nextest_result.is_compilation);
4177        assert!(test_result.is_compilation);
4178        assert_eq!(nextest_result.kind, Some(CompilationKind::CargoNextest));
4179        assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4180    }
4181
4182    #[test]
4183    fn test_cargo_nextest_piped_to_pager_intercepted() {
4184        let _guard = test_guard!();
4185        // Issue #24: piping into a benign pager is offloaded.
4186        let result = classify_command("cargo nextest run | grep FAIL");
4187        assert!(result.is_compilation, "got: {:?}", result.reason);
4188        assert_eq!(result.kind, Some(CompilationKind::CargoNextest));
4189        assert_eq!(
4190            result.extracted_command.as_deref(),
4191            Some("cargo nextest run | grep FAIL")
4192        );
4193    }
4194
4195    #[test]
4196    fn test_cargo_nextest_redirected_intercepted() {
4197        let _guard = test_guard!();
4198        // Issue #24: a benign output redirect is offloaded.
4199        let result = classify_command("cargo nextest run > results.txt");
4200        assert!(result.is_compilation, "got: {:?}", result.reason);
4201        assert_eq!(
4202            result.extracted_command.as_deref(),
4203            Some("cargo nextest run > results.txt")
4204        );
4205    }
4206
4207    #[test]
4208    fn test_cargo_nextest_backgrounded_intercepted() {
4209        let _guard = test_guard!();
4210        // Issue #24: a trailing `&` is offloaded.
4211        let result = classify_command("cargo nextest run &");
4212        assert!(result.is_compilation, "got: {:?}", result.reason);
4213        assert_eq!(
4214            result.extracted_command.as_deref(),
4215            Some("cargo nextest run &")
4216        );
4217    }
4218
4219    // =========================================================================
4220    // cargo bench tests (bead remote_compilation_helper-8o52)
4221    // =========================================================================
4222
4223    #[test]
4224    fn test_cargo_bench_classification() {
4225        let _guard = test_guard!();
4226        let result = classify_command("cargo bench");
4227        assert!(result.is_compilation, "cargo bench should be classified");
4228        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4229        assert!((result.confidence - 0.90).abs() < 0.001);
4230    }
4231
4232    #[test]
4233    fn test_cargo_bench_with_filter() {
4234        let _guard = test_guard!();
4235        // Benchmarks with name filter
4236        let result = classify_command("cargo bench my_bench");
4237        assert!(result.is_compilation);
4238        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4239    }
4240
4241    #[test]
4242    fn test_cargo_bench_with_flags() {
4243        let _guard = test_guard!();
4244        // With release flag (benchmarks typically use release)
4245        let result = classify_command("cargo bench --release");
4246        assert!(result.is_compilation);
4247        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4248
4249        // With package flag
4250        let result = classify_command("cargo bench -p rch-common");
4251        assert!(result.is_compilation);
4252        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4253
4254        // With features
4255        let result = classify_command("cargo bench --features benchmarks");
4256        assert!(result.is_compilation);
4257        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4258
4259        // With target filter
4260        let result = classify_command("cargo bench --bench criterion_bench");
4261        assert!(result.is_compilation);
4262        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4263    }
4264
4265    #[test]
4266    fn test_cargo_bench_wrapped() {
4267        let _guard = test_guard!();
4268        // Wrapped with time
4269        let result = classify_command("time cargo bench");
4270        assert!(
4271            result.is_compilation,
4272            "time cargo bench should be classified"
4273        );
4274        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4275
4276        // With env var
4277        let result = classify_command("CARGO_INCREMENTAL=0 cargo bench");
4278        assert!(result.is_compilation);
4279        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4280    }
4281
4282    #[test]
4283    fn test_cargo_bench_serde() {
4284        let _guard = test_guard!();
4285        // CargoBench serialization
4286        let kind = CompilationKind::CargoBench;
4287        let json = serde_json::to_string(&kind).unwrap();
4288        assert_eq!(json, "\"cargo_bench\"");
4289        let parsed: CompilationKind = serde_json::from_str(&json).unwrap();
4290        assert_eq!(parsed, CompilationKind::CargoBench);
4291    }
4292
4293    #[test]
4294    fn test_cargo_bench_distinct_from_test() {
4295        let _guard = test_guard!();
4296        // CargoBench and CargoTest are different
4297        assert_ne!(CompilationKind::CargoBench, CompilationKind::CargoTest);
4298
4299        // Both should be classified but as different kinds
4300        let bench_result = classify_command("cargo bench");
4301        let test_result = classify_command("cargo test");
4302        assert!(bench_result.is_compilation);
4303        assert!(test_result.is_compilation);
4304        assert_eq!(bench_result.kind, Some(CompilationKind::CargoBench));
4305        assert_eq!(test_result.kind, Some(CompilationKind::CargoTest));
4306    }
4307
4308    #[test]
4309    fn test_cargo_bench_piped_to_pager_intercepted() {
4310        let _guard = test_guard!();
4311        // Issue #24: piping into a benign pager (tee) is offloaded.
4312        let result = classify_command("cargo bench | tee output.txt");
4313        assert!(result.is_compilation, "got: {:?}", result.reason);
4314        assert_eq!(result.kind, Some(CompilationKind::CargoBench));
4315        assert_eq!(
4316            result.extracted_command.as_deref(),
4317            Some("cargo bench | tee output.txt")
4318        );
4319    }
4320
4321    #[test]
4322
4323    fn test_normalize_path_and_wrapper_bug_repro() {
4324        let _guard = test_guard!();
4325        // Case: /usr/bin/time cargo build
4326
4327        // Current behavior: strips path -> "time cargo build", then returns.
4328
4329        // Desired behavior: strips path -> "time cargo build", then strips wrapper -> "cargo build".
4330
4331        // This test asserts the CURRENT BROKEN behavior.
4332
4333        let normalized = normalize_command("/usr/bin/time cargo build");
4334
4335        assert_eq!(normalized, "cargo build");
4336
4337        let result = classify_command("/usr/bin/time cargo build");
4338
4339        assert!(result.is_compilation);
4340    }
4341
4342    // ==========================================================================
4343    // Command Base Tests (bd-785w)
4344    // ==========================================================================
4345
4346    #[test]
4347    fn test_command_base_rust() {
4348        let _guard = test_guard!();
4349        assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
4350        assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
4351        assert_eq!(CompilationKind::CargoCheck.command_base(), "cargo");
4352        assert_eq!(CompilationKind::CargoClippy.command_base(), "cargo");
4353        assert_eq!(CompilationKind::CargoDoc.command_base(), "cargo");
4354        assert_eq!(CompilationKind::CargoBench.command_base(), "cargo");
4355        assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
4356        assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
4357    }
4358
4359    #[test]
4360    fn test_command_base_c_cpp() {
4361        let _guard = test_guard!();
4362        assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
4363        assert_eq!(CompilationKind::Gpp.command_base(), "g++");
4364        assert_eq!(CompilationKind::Clang.command_base(), "clang");
4365        assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
4366    }
4367
4368    #[test]
4369    fn test_command_base_build_systems() {
4370        let _guard = test_guard!();
4371        assert_eq!(CompilationKind::Make.command_base(), "make");
4372        assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
4373        assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
4374        assert_eq!(CompilationKind::Meson.command_base(), "meson");
4375    }
4376
4377    #[test]
4378    fn test_command_base_bun() {
4379        let _guard = test_guard!();
4380        assert_eq!(CompilationKind::BunTest.command_base(), "bun");
4381        assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
4382    }
4383
4384    // ==========================================================================
4385    // Proptest: Command Classification with Random Inputs (bd-2kdm)
4386    // ==========================================================================
4387
4388    mod proptest_classification {
4389        use super::*;
4390        use proptest::prelude::*;
4391
4392        // Strategy for arbitrary strings (pure random)
4393        fn arbitrary_string() -> impl Strategy<Value = String> {
4394            prop::string::string_regex(".{0,500}").unwrap()
4395        }
4396
4397        // Strategy for command-like strings with random suffixes
4398        fn command_like_string() -> impl Strategy<Value = String> {
4399            let prefixes = prop::sample::select(vec![
4400                "cargo", "rustc", "gcc", "g++", "clang", "make", "cmake", "ninja", "bun", "ls",
4401                "cd", "echo", "cat", "grep", "find", "rm", "mv", "cp", "mkdir",
4402            ]);
4403            (prefixes, "[ a-zA-Z0-9_.-]{0,200}")
4404                .prop_map(|(prefix, suffix)| format!("{}{}", prefix, suffix))
4405        }
4406
4407        // Strategy for known compilation commands with random flags
4408        fn known_command_with_flags() -> impl Strategy<Value = String> {
4409            let base_commands = prop::sample::select(vec![
4410                "cargo build",
4411                "cargo test",
4412                "cargo check",
4413                "cargo clippy",
4414                "cargo run",
4415                "rustc",
4416                "gcc",
4417                "g++",
4418                "make",
4419                "bun test",
4420                "bun typecheck",
4421            ]);
4422            let flags = prop::collection::vec(
4423                prop::sample::select(vec![
4424                    "--release",
4425                    "--verbose",
4426                    "-j8",
4427                    "-p",
4428                    "--all",
4429                    "--workspace",
4430                    "--lib",
4431                    "--bin",
4432                    "-o",
4433                    "-c",
4434                ]),
4435                0..5,
4436            );
4437            (base_commands, flags).prop_map(|(cmd, flags)| {
4438                if flags.is_empty() {
4439                    cmd.to_string()
4440                } else {
4441                    format!("{} {}", cmd, flags.join(" "))
4442                }
4443            })
4444        }
4445
4446        // Strategy for strings with unicode and control characters
4447        fn unicode_and_control_chars() -> impl Strategy<Value = String> {
4448            prop::string::string_regex(
4449                r"[\x00-\x1f\x80-\xff\u{100}-\u{10FFFF}a-zA-Z0-9 |>&;$()]{0,100}",
4450            )
4451            .unwrap()
4452        }
4453
4454        // Strategy for shell-like commands with special characters
4455        fn shell_special_commands() -> impl Strategy<Value = String> {
4456            let components = prop::sample::select(vec![
4457                "cargo build",
4458                "ls -la",
4459                "echo test",
4460                "cat file.txt",
4461                "grep pattern",
4462            ]);
4463            let operators = prop::sample::select(vec![
4464                " | ", " && ", " || ", " ; ", " > ", " < ", " & ", " 2>&1 ", " $(", " `",
4465            ]);
4466            prop::collection::vec((components, operators), 1..4).prop_map(|pairs| {
4467                let mut result = String::new();
4468                for (i, (comp, op)) in pairs.iter().enumerate() {
4469                    if i > 0 {
4470                        result.push_str(op);
4471                    }
4472                    result.push_str(comp);
4473                }
4474                result
4475            })
4476        }
4477
4478        // Strategy for wrapper-prefixed commands
4479        fn wrapped_commands() -> impl Strategy<Value = String> {
4480            let wrappers = prop::sample::select(vec![
4481                "sudo ",
4482                "env ",
4483                "time ",
4484                "nice ",
4485                "/usr/bin/time ",
4486                "RUST_BACKTRACE=1 ",
4487                "CC=clang ",
4488                "",
4489            ]);
4490            let commands = prop::sample::select(vec![
4491                "cargo build",
4492                "cargo test",
4493                "make",
4494                "gcc -c main.c",
4495                "ls -la",
4496            ]);
4497            (wrappers, commands).prop_map(|(wrapper, cmd)| format!("{}{}", wrapper, cmd))
4498        }
4499
4500        proptest! {
4501            // Configure proptest for high-volume testing
4502            #![proptest_config(ProptestConfig::with_cases(1000))]
4503
4504            // Test 1: Arbitrary strings never cause panics
4505            #[test]
4506            fn test_classify_arbitrary_no_panic(s in arbitrary_string()) {
4507                // Just call classify_command - if it panics, proptest will catch it
4508                let _ = classify_command(&s);
4509            }
4510
4511            // Test 2: Command-like strings never cause panics
4512            #[test]
4513            fn test_classify_command_like_no_panic(s in command_like_string()) {
4514                let _ = classify_command(&s);
4515            }
4516
4517            // Test 3: Known commands with flags produce valid classification
4518            #[test]
4519            fn test_classify_known_commands_valid(s in known_command_with_flags()) {
4520                let result = classify_command(&s);
4521                // Confidence should be in valid range
4522                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0,
4523                    "Confidence {} out of range for command: {}", result.confidence, s);
4524                // If is_compilation, must have a kind
4525                if result.is_compilation {
4526                    prop_assert!(result.kind.is_some(),
4527                        "is_compilation=true but kind=None for: {}", s);
4528                }
4529            }
4530
4531            // Test 4: Unicode and control characters never panic
4532            #[test]
4533            fn test_classify_unicode_no_panic(s in unicode_and_control_chars()) {
4534                let _ = classify_command(&s);
4535            }
4536
4537            // Test 5: Shell special commands are handled
4538            #[test]
4539            fn test_classify_shell_special(s in shell_special_commands()) {
4540                let result = classify_command(&s);
4541                // Commands with shell operators should typically NOT be intercepted
4542                // (pipes, redirects, backgrounding, chaining)
4543                if s.contains(" | ") || s.contains(" > ") || s.contains(" < ") || s.contains(" & ") {
4544                    // May or may not be classified depending on quote handling
4545                    let _ = result; // Just ensure no panic
4546                }
4547            }
4548
4549            // Test 6: Wrapped commands are handled
4550            #[test]
4551            fn test_classify_wrapped_commands(s in wrapped_commands()) {
4552                let result = classify_command(&s);
4553                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4554            }
4555
4556            // Test 7: Classification is deterministic
4557            #[test]
4558            fn test_classify_deterministic(s in arbitrary_string()) {
4559                let result1 = classify_command(&s);
4560                let result2 = classify_command(&s);
4561                prop_assert_eq!(result1, result2,
4562                    "Non-deterministic classification for: {}", s);
4563            }
4564
4565            // Test 8: Empty-ish strings handled correctly
4566            #[test]
4567            fn test_classify_whitespace_variants(s in "[ \t\n\r]{0,20}") {
4568        let _guard = test_guard!();
4569                let result = classify_command(&s);
4570                prop_assert!(!result.is_compilation,
4571                    "Whitespace-only command should not be classified as compilation: {:?}", s);
4572                prop_assert!(result.reason.contains("empty") || result.reason.contains("keyword"),
4573                    "Unexpected reason for whitespace: {}", result.reason);
4574            }
4575
4576            // Test 9: Very long commands don't panic
4577            #[test]
4578            fn test_classify_long_commands(
4579                prefix in "cargo (build|test|check)",
4580                suffix in "[a-zA-Z0-9_ -]{0,10000}"
4581            ) {
4582                let long_cmd = format!("{} {}", prefix, suffix);
4583                let result = classify_command(&long_cmd);
4584                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4585            }
4586
4587            // Test 10: Null bytes and special sequences
4588            #[test]
4589            fn test_classify_special_bytes(s in prop::collection::vec(any::<u8>(), 0..200)) {
4590                if let Ok(valid_str) = String::from_utf8(s.clone()) {
4591                    let _ = classify_command(&valid_str);
4592                }
4593                // Non-UTF8 sequences can't be tested with &str
4594            }
4595        }
4596
4597        // Additional targeted proptest tests
4598
4599        proptest! {
4600            #![proptest_config(ProptestConfig::with_cases(500))]
4601
4602            // Test: Cargo subcommands with arbitrary suffixes
4603            #[test]
4604            fn test_cargo_subcommand_robustness(
4605                subcommand in "(build|test|check|clippy|fmt|clean|run|doc|bench|nextest)",
4606                suffix in "[a-zA-Z0-9_ -]{0,50}"
4607            ) {
4608                let cmd = format!("cargo {} {}", subcommand, suffix);
4609                let result = classify_command(&cmd);
4610                // Ensure valid result structure
4611                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4612                // fmt and clean should NEVER be classified as compilation
4613                if subcommand.eq("fmt") || subcommand.eq("clean") {
4614                    prop_assert!(!result.is_compilation,
4615                        "cargo {} should not be compilation: {}", subcommand, cmd);
4616                }
4617            }
4618
4619            // Test: Bun commands robustness
4620            #[test]
4621            fn test_bun_command_robustness(
4622                subcommand in "(test|typecheck|install|add|remove|run|build|dev)",
4623                suffix in "[a-zA-Z0-9_ -]{0,30}"
4624            ) {
4625                let cmd = format!("bun {} {}", subcommand, suffix);
4626                let result = classify_command(&cmd);
4627                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4628                // install, add, remove, run, build, dev should NOT be compilation
4629                if matches!(subcommand.as_str(), "install" | "add" | "remove" | "run" | "build" | "dev") {
4630                    prop_assert!(!result.is_compilation,
4631                        "bun {} should not be compilation", subcommand);
4632                }
4633            }
4634
4635            // Test: GCC/Clang commands with file patterns
4636            #[test]
4637            fn test_c_compiler_robustness(
4638                compiler in "(gcc|g\\+\\+|clang|clang\\+\\+)",
4639                flags in "(-[cCoOgW][a-z0-9]* )*",
4640                file in "[a-zA-Z_][a-zA-Z0-9_]*\\.(c|cpp|cc|h|hpp)"
4641            ) {
4642                let cmd = format!("{} {}{}", compiler, flags, file);
4643                let result = classify_command(&cmd);
4644                prop_assert!(result.confidence >= 0.0 && result.confidence <= 1.0);
4645            }
4646
4647            // Test: Commands with quoted strings preserve correctness
4648            #[test]
4649            fn test_quoted_args_handling(
4650                base in "(cargo build|cargo test|gcc|make)",
4651                quoted_content in "[a-zA-Z0-9 _-]{0,30}"
4652            ) {
4653                // Single quotes
4654                let cmd_single = format!("{} '{}'", base, quoted_content);
4655                let _ = classify_command(&cmd_single);
4656
4657                // Double quotes
4658                let cmd_double = format!("{} \"{}\"", base, quoted_content);
4659                let _ = classify_command(&cmd_double);
4660            }
4661        }
4662
4663        // Regression test: ensure specific problematic inputs are handled
4664        #[test]
4665        fn test_known_edge_cases() {
4666            let _guard = test_guard!();
4667            // These are edge cases that might have caused issues
4668            let edge_cases = [
4669                "",
4670                " ",
4671                "\t",
4672                "\n",
4673                "cargo",
4674                "cargo ",
4675                "cargo  ",
4676                "cargo\tbuild",
4677                "cargo\nbuild",
4678                "cargo\rbuild",
4679                "cargo+nightly",
4680                "cargo +nightly",
4681                "cargo +nightly build",
4682                "  cargo build  ",
4683                "CARGO_TARGET_DIR=/tmp cargo build",
4684                "/usr/local/bin/cargo build",
4685                "~/.cargo/bin/cargo build",
4686                "./cargo build",
4687                "../cargo build",
4688                "cargo build 'test file.rs'",
4689                "cargo build \"test file.rs\"",
4690                "cargo build test\\ file.rs",
4691                "cargo build -- --test-threads=1",
4692                "cargo build --features \"feat1 feat2\"",
4693                "cargo build 2>&1",
4694                "cargo build 2>/dev/null",
4695                "gcc -DFOO=\"bar baz\" main.c",
4696                "make -j$(nproc)",
4697                "ninja -C build",
4698                "cmake --build . --target all",
4699                "bun test --timeout 5000 src/",
4700                "env -i PATH=/usr/bin cargo build",
4701            ];
4702
4703            for cmd in edge_cases {
4704                let result = classify_command(cmd);
4705                assert!(
4706                    result.confidence >= 0.0 && result.confidence <= 1.0,
4707                    "Invalid confidence for: {:?}",
4708                    cmd
4709                );
4710            }
4711        }
4712
4713        // Test that detailed classification matches simple classification
4714        #[test]
4715        fn test_detailed_matches_simple_proptest() {
4716            let _guard = test_guard!();
4717            let test_cases = [
4718                "cargo build --release",
4719                "cargo test",
4720                "make -j8",
4721                "ls -la",
4722                "echo hello",
4723            ];
4724
4725            for cmd in test_cases {
4726                let simple = classify_command(cmd);
4727                let detailed = classify_command_detailed(cmd);
4728                assert_eq!(simple, detailed.classification, "Mismatch for: {}", cmd);
4729            }
4730        }
4731
4732        // =================== split_shell_commands tests ===================
4733
4734        #[test]
4735        fn test_split_single_command() {
4736            let _guard = test_guard!();
4737            assert_eq!(split_shell_commands("cargo build"), vec!["cargo build"]);
4738        }
4739
4740        #[test]
4741        fn test_split_and_operator() {
4742            let _guard = test_guard!();
4743            assert_eq!(
4744                split_shell_commands("cargo fmt && cargo build"),
4745                vec!["cargo fmt", "cargo build"]
4746            );
4747        }
4748
4749        #[test]
4750        fn test_split_quoted_operator() {
4751            let _guard = test_guard!();
4752            assert_eq!(
4753                split_shell_commands("echo '&&' && cargo build"),
4754                vec!["echo '&&'", "cargo build"]
4755            );
4756        }
4757
4758        #[test]
4759        fn test_split_mixed_operators() {
4760            let _guard = test_guard!();
4761            assert_eq!(
4762                split_shell_commands("cd /tmp && make -j4 || echo fail"),
4763                vec!["cd /tmp", "make -j4", "echo fail"]
4764            );
4765        }
4766
4767        #[test]
4768        fn test_split_semicolons() {
4769            let _guard = test_guard!();
4770            assert_eq!(split_shell_commands("a ; b ; c"), vec!["a", "b", "c"]);
4771        }
4772
4773        #[test]
4774        fn test_split_quoted_semicolon() {
4775            let _guard = test_guard!();
4776            assert_eq!(
4777                split_shell_commands("echo 'hello;world' && make"),
4778                vec!["echo 'hello;world'", "make"]
4779            );
4780        }
4781
4782        #[test]
4783        fn test_split_pipe_preserved() {
4784            let _guard = test_guard!();
4785            // Single pipe is NOT a command separator
4786            assert_eq!(
4787                split_shell_commands("cargo build 2>&1 | tee log"),
4788                vec!["cargo build 2>&1 | tee log"]
4789            );
4790        }
4791
4792        #[test]
4793        fn test_split_double_quoted_operator() {
4794            let _guard = test_guard!();
4795            assert_eq!(
4796                split_shell_commands(r#"echo "&&" && cargo build"#),
4797                vec![r#"echo "&&""#, "cargo build"]
4798            );
4799        }
4800
4801        #[test]
4802        fn test_split_nested_quotes() {
4803            let _guard = test_guard!();
4804            assert_eq!(
4805                split_shell_commands("echo \"he said 'hello && bye'\" && cargo test"),
4806                vec!["echo \"he said 'hello && bye'\"", "cargo test"]
4807            );
4808        }
4809
4810        #[test]
4811        fn test_split_escaped_quote() {
4812            let _guard = test_guard!();
4813            // Escaped quotes don't toggle state
4814            assert_eq!(
4815                split_shell_commands(r"echo it\'s && cargo build"),
4816                vec![r"echo it\'s", "cargo build"]
4817            );
4818        }
4819
4820        #[test]
4821        fn test_split_backslash_literal_in_single_quotes() {
4822            let _guard = test_guard!();
4823            // POSIX: backslash is LITERAL inside single quotes, so 'a\' is the
4824            // string a\ and the closing quote ENDS the span — exposing the
4825            // following operator. Previously the \ swallowed the closing ',
4826            // kept the quote "open", and hid the && (bd-review-singlequote-escape).
4827            assert_eq!(
4828                split_shell_commands(r"cargo build 'a\' && rm -rf x"),
4829                vec![r"cargo build 'a\'", "rm -rf x"]
4830            );
4831        }
4832
4833        #[test]
4834        fn test_split_empty_string() {
4835            let _guard = test_guard!();
4836            assert!(split_shell_commands("").is_empty());
4837        }
4838
4839        #[test]
4840        fn test_split_only_whitespace() {
4841            let _guard = test_guard!();
4842            assert!(split_shell_commands("   ").is_empty());
4843        }
4844
4845        #[test]
4846        fn test_split_backtick_quoting() {
4847            let _guard = test_guard!();
4848            assert_eq!(
4849                split_shell_commands("echo `echo && fail` && cargo build"),
4850                vec!["echo `echo && fail`", "cargo build"]
4851            );
4852        }
4853
4854        #[test]
4855        fn test_split_trailing_operator() {
4856            let _guard = test_guard!();
4857            // Trailing && with nothing after should yield just the first segment
4858            assert_eq!(split_shell_commands("cargo build &&"), vec!["cargo build"]);
4859        }
4860
4861        // =================== fail-open safeguard tests (bd-16t3) ===================
4862
4863        #[test]
4864        fn test_split_unclosed_single_quote() {
4865            let _guard = test_guard!();
4866            // Unclosed quote: everything stays "inside quotes", no split found
4867            let result = split_shell_commands("echo 'hello && cargo build");
4868            assert_eq!(result, vec!["echo 'hello && cargo build"]);
4869        }
4870
4871        #[test]
4872        fn test_split_unclosed_double_quote() {
4873            let _guard = test_guard!();
4874            let result = split_shell_commands("echo \"hello && cargo build");
4875            assert_eq!(result, vec!["echo \"hello && cargo build"]);
4876        }
4877
4878        #[test]
4879        fn test_split_unclosed_backtick() {
4880            let _guard = test_guard!();
4881            let result = split_shell_commands("echo `hello && cargo build");
4882            assert_eq!(result, vec!["echo `hello && cargo build"]);
4883        }
4884
4885        #[test]
4886        fn test_split_embedded_nulls() {
4887            let _guard = test_guard!();
4888            // Embedded null bytes should not cause panic
4889            let input = "cargo build\0 && echo done";
4890            let result = split_shell_commands(input);
4891            assert_eq!(result.len(), 2);
4892        }
4893
4894        #[test]
4895        fn test_split_unicode_input() {
4896            let _guard = test_guard!();
4897            let result = split_shell_commands("echo 'こんにちは' && cargo build");
4898            assert_eq!(result, vec!["echo 'こんにちは'", "cargo build"]);
4899        }
4900
4901        #[test]
4902        fn test_split_extremely_long_input() {
4903            let _guard = test_guard!();
4904            // 20KB string should still work (split_shell_commands has no length limit)
4905            let long_cmd = format!("echo {} && cargo build", "x".repeat(20_000));
4906            let result = split_shell_commands(&long_cmd);
4907            assert_eq!(result.len(), 2);
4908        }
4909
4910        #[test]
4911        fn test_classify_long_input_skips_splitting() {
4912            let _guard = test_guard!();
4913            // Commands >10KB skip multi-command splitting in classify_command
4914            let long_cmd = format!("cargo build && echo {}", "x".repeat(11_000));
4915            let result = classify_command(&long_cmd);
4916            // Falls through to single-command classification which rejects at check_structure
4917            assert!(!result.is_compilation);
4918            assert!(
4919                result.reason.to_string().contains("chained"),
4920                "long input should be rejected by check_structure, not split"
4921            );
4922        }
4923
4924        #[test]
4925        fn test_split_only_operators() {
4926            let _guard = test_guard!();
4927            // Just operators with no commands
4928            let result = split_shell_commands("&& || ;");
4929            assert!(
4930                result.is_empty(),
4931                "only operators should yield empty result"
4932            );
4933        }
4934
4935        #[test]
4936        fn test_split_consecutive_operators() {
4937            let _guard = test_guard!();
4938            let result = split_shell_commands("cargo build && && echo done");
4939            // Middle empty segment is dropped, yielding 2 segments
4940            assert_eq!(result, vec!["cargo build", "echo done"]);
4941        }
4942
4943        #[test]
4944        fn test_classify_empty_after_split() {
4945            let _guard = test_guard!();
4946            // All sub-commands are non-compilation
4947            let result = classify_command("echo hello && ls -la || pwd");
4948            assert!(!result.is_compilation);
4949        }
4950
4951        // =================== comprehensive multi-command tests (bd-1q0e) ===================
4952
4953        // --- Split edge cases ---
4954
4955        #[test]
4956        fn test_split_three_segment_chain() {
4957            let _guard = test_guard!();
4958            assert_eq!(
4959                split_shell_commands("cd /proj && cmake .. && make"),
4960                vec!["cd /proj", "cmake ..", "make"]
4961            );
4962        }
4963
4964        #[test]
4965        fn test_split_single_with_flags() {
4966            let _guard = test_guard!();
4967            assert_eq!(
4968                split_shell_commands("cargo build --release"),
4969                vec!["cargo build --release"]
4970            );
4971        }
4972
4973        #[test]
4974        fn test_split_nested_quotes_semicolon() {
4975            let _guard = test_guard!();
4976            assert_eq!(
4977                split_shell_commands("echo \"it's && done\" && make"),
4978                vec!["echo \"it's && done\"", "make"]
4979            );
4980        }
4981
4982        #[test]
4983        fn test_split_pipe_then_and() {
4984            let _guard = test_guard!();
4985            // Single pipe within segment, && between segments
4986            assert_eq!(
4987                split_shell_commands("make 2>&1 | grep error && echo done"),
4988                vec!["make 2>&1 | grep error", "echo done"]
4989            );
4990        }
4991
4992        #[test]
4993        fn test_split_leading_operator() {
4994            let _guard = test_guard!();
4995            // Leading && with nothing before
4996            assert_eq!(split_shell_commands("&& cargo build"), vec!["cargo build"]);
4997        }
4998
4999        // --- Compound command classification tests ---
5000        // Compound && commands with compilation suffix are now ACCEPTED
5001        // This enables patterns like "cd /path && cargo build"
5002
5003        #[test]
5004        fn test_classify_cargo_fmt_and_build() {
5005            let _guard = test_guard!();
5006            // Last segment is "cargo build" (compilation) → accepted
5007            let result = classify_command("cargo fmt && cargo build");
5008            assert!(
5009                result.is_compilation,
5010                "compound command with compilation suffix should be accepted"
5011            );
5012            assert!(result.reason.contains("compound"));
5013        }
5014
5015        #[test]
5016        fn test_classify_cd_and_make() {
5017            let _guard = test_guard!();
5018            // Last segment is "make -j8" (compilation) → accepted
5019            let result = classify_command("cd /project && make -j8");
5020            assert!(
5021                result.is_compilation,
5022                "compound command with compilation suffix should be accepted"
5023            );
5024            assert!(result.reason.contains("compound"));
5025        }
5026        #[test]
5027        fn test_classify_export_and_cargo_build() {
5028            let _guard = test_guard!();
5029            // Last segment is "cargo build --release" (compilation) → accepted
5030            let result =
5031                classify_command("export RUSTFLAGS='-C opt-level=3' && cargo build --release");
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_mkdir_cmake_chain() {
5040            let _guard = test_guard!();
5041            // Last segment is "cmake --build build" (compilation) → accepted
5042            let result =
5043                classify_command("mkdir -p build && cmake -B build && cmake --build build");
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_echo_and_cargo_test() {
5052            let _guard = test_guard!();
5053            // Last segment is "cargo test" (compilation) → accepted
5054            let result = classify_command("echo 'Starting...' && cargo test");
5055            assert!(
5056                result.is_compilation,
5057                "compound command with compilation suffix should be accepted"
5058            );
5059            assert!(result.reason.contains("compound"));
5060        }
5061        #[test]
5062        fn test_classify_semicolon_chain_with_compilation() {
5063            let _guard = test_guard!();
5064            // Semicolon chains are NOT supported (only && is supported)
5065            let result = classify_command("cargo fmt; cargo build; cargo test");
5066            assert!(
5067                !result.is_compilation,
5068                "semicolon chained commands should be rejected"
5069            );
5070            assert!(result.reason.contains("chained"));
5071        }
5072        // --- Classification integration: should classify as NON-COMPILATION ---
5073
5074        #[test]
5075        fn test_classify_echo_chain_non_compilation() {
5076            let _guard = test_guard!();
5077            let result = classify_command("echo hello && echo world");
5078            assert!(!result.is_compilation);
5079        }
5080
5081        #[test]
5082        fn test_classify_ls_cat_non_compilation() {
5083            let _guard = test_guard!();
5084            let result = classify_command("ls -la && cat file.txt");
5085            assert!(!result.is_compilation);
5086        }
5087
5088        #[test]
5089        fn test_classify_git_chain_non_compilation() {
5090            let _guard = test_guard!();
5091            let result = classify_command("git status && git log");
5092            assert!(!result.is_compilation);
5093        }
5094
5095        // --- Performance test ---
5096
5097        #[test]
5098        fn test_classify_multi_command_performance() {
5099            let _guard = test_guard!();
5100            let start = std::time::Instant::now();
5101            for _ in 0..100 {
5102                let _ = classify_command("cargo fmt && cargo build && cargo test");
5103            }
5104            let elapsed = start.elapsed();
5105            let per_call_us = elapsed.as_micros() / 100;
5106            // Each classify_command call should be well under 5ms
5107            assert!(
5108                per_call_us < 5_000,
5109                "classify_command took {}us per call, should be <5000us",
5110                per_call_us
5111            );
5112            eprintln!(
5113                "[perf] classify_command multi-command: {}us avg per call",
5114                per_call_us
5115            );
5116        }
5117
5118        // --- Pipe preservation with split ---
5119
5120        #[test]
5121        fn test_classify_pipe_to_pager_preserved() {
5122            let _guard = test_guard!();
5123            // Issue #24: a compilation command piped into a benign pager is
5124            // offloaded with the pipe re-attached to the offloaded output.
5125            let result = classify_command("cargo build 2>&1 | tee log");
5126            assert!(result.is_compilation, "got: {:?}", result.reason);
5127            assert_eq!(
5128                result.extracted_command.as_deref(),
5129                Some("cargo build 2>&1 | tee log")
5130            );
5131        }
5132
5133        #[test]
5134        fn test_classify_pipe_segment_and_operator() {
5135            let _guard = test_guard!();
5136            // Pipe within first segment, && separates from second
5137            let result = classify_command("make 2>&1 | grep error && cargo build");
5138            // Should be rejected due to chaining (&&) AND piping (|)
5139            assert!(
5140                !result.is_compilation,
5141                "chained commands should be rejected"
5142            );
5143            // The reason will likely be "chained command (&&)" because check_structure checks separators first or pipes first?
5144            // Let's check check_structure impl: pipes are checked AFTER backgrounding, separators are after pipes?
5145            // Actually, check_structure checks pipes, then redirects, then chaining.
5146            // So "piped command" might be the reason. Either is fine.
5147            assert!(result.reason.contains("piped") || result.reason.contains("chained"));
5148        }
5149    }
5150
5151    // =========================================================================
5152    // WS2.4: Zero-allocation reject path tests (bd-3mog)
5153    //
5154    // Verify that the Cow<'static, str> conversion in Classification and
5155    // ClassificationTier produces Cow::Borrowed on the reject path (zero heap
5156    // allocations) and that serde roundtrips work correctly.
5157    // =========================================================================
5158
5159    /// Helper: assert that a Cow is the Borrowed variant (no heap allocation).
5160    /// We need the actual &Cow to distinguish Borrowed from Owned.
5161    #[allow(clippy::ptr_arg)]
5162    fn assert_cow_borrowed(cow: &Cow<'static, str>, context: &str) {
5163        assert!(
5164            matches!(cow, Cow::Borrowed(_)),
5165            "{context}: expected Cow::Borrowed, got Cow::Owned({:?})",
5166            cow,
5167        );
5168    }
5169
5170    // --- 1. Classification correctness after Cow conversion ---
5171
5172    #[test]
5173    fn test_cow_reject_empty_command_correctness() {
5174        let _guard = test_guard!();
5175        let result = classify_command("");
5176        assert!(!result.is_compilation);
5177        assert_eq!(result.confidence, 0.0);
5178        assert_eq!(result.kind, None);
5179        assert!(result.reason.contains("empty"), "reason: {}", result.reason);
5180    }
5181
5182    #[test]
5183    fn test_cow_reject_non_compilation_commands() {
5184        let _guard = test_guard!();
5185        let non_compilation = [
5186            "ls -la",
5187            "cat file.txt",
5188            "echo hello",
5189            "git status",
5190            "pwd",
5191            "cd /tmp",
5192        ];
5193        for cmd in non_compilation {
5194            let result = classify_command(cmd);
5195            assert!(
5196                !result.is_compilation,
5197                "'{cmd}' should NOT be classified as compilation"
5198            );
5199            assert_eq!(result.confidence, 0.0, "'{cmd}' should have 0.0 confidence");
5200            assert_eq!(result.kind, None, "'{cmd}' should have no CompilationKind");
5201            assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5202        }
5203    }
5204
5205    #[test]
5206    fn test_cow_accept_compilation_commands() {
5207        let _guard = test_guard!();
5208        let cases: &[(&str, CompilationKind)] = &[
5209            ("cargo build", CompilationKind::CargoBuild),
5210            ("cargo test", CompilationKind::CargoTest),
5211            ("cargo check", CompilationKind::CargoCheck),
5212            ("cargo clippy", CompilationKind::CargoClippy),
5213            ("gcc -o hello hello.c", CompilationKind::Gcc),
5214            ("make", CompilationKind::Make),
5215            ("bun test", CompilationKind::BunTest),
5216        ];
5217        for &(cmd, expected_kind) in cases {
5218            let result = classify_command(cmd);
5219            assert!(
5220                result.is_compilation,
5221                "'{cmd}' should be classified as compilation"
5222            );
5223            assert_eq!(
5224                result.kind,
5225                Some(expected_kind),
5226                "'{cmd}' should have kind {expected_kind:?}"
5227            );
5228            assert!(
5229                result.confidence > 0.0,
5230                "'{cmd}' should have positive confidence"
5231            );
5232            assert!(!result.reason.is_empty(), "'{cmd}' should have a reason");
5233        }
5234    }
5235
5236    // --- 2. Zero-allocation verification on reject path ---
5237
5238    #[test]
5239    fn test_cow_borrowed_on_tier0_reject() {
5240        let _guard = test_guard!();
5241        // Empty command -> Tier 0 instant reject
5242        let result = classify_command("");
5243        assert_cow_borrowed(&result.reason, "Tier 0 empty command reason");
5244    }
5245
5246    #[test]
5247    fn test_cow_borrowed_on_tier1_reject() {
5248        let _guard = test_guard!();
5249        // Pipe into a NON-benign command -> Tier 1 structure analysis reject
5250        // (benign pagers like tee/head are now offloaded per issue #24).
5251        let result = classify_command("cargo build | xargs rm");
5252        assert!(!result.is_compilation);
5253        assert_cow_borrowed(&result.reason, "Tier 1 piped command reason");
5254
5255        // Input redirect -> still rejected (transforms the command's INPUT)
5256        let result = classify_command("cargo build < input.txt");
5257        assert!(!result.is_compilation);
5258        assert_cow_borrowed(&result.reason, "Tier 1 input redirect reason");
5259
5260        // Subshell -> still rejected
5261        let result = classify_command("cargo build $(echo args)");
5262        assert!(!result.is_compilation);
5263        assert_cow_borrowed(&result.reason, "Tier 1 subshell reason");
5264    }
5265
5266    #[test]
5267    fn test_cow_borrowed_on_tier2_reject() {
5268        let _guard = test_guard!();
5269        // Commands with no compilation keyword -> Tier 2 keyword filter reject
5270        let non_keyword_commands = [
5271            "ls -la",
5272            "cat file.txt",
5273            "echo hello world",
5274            "git status",
5275            "pwd",
5276            "cd /tmp",
5277            "grep pattern file",
5278            "find . -name '*.rs'",
5279            "cp src dst",
5280            "rm file.txt",
5281        ];
5282        for cmd in non_keyword_commands {
5283            let result = classify_command(cmd);
5284            assert!(!result.is_compilation, "'{cmd}' should be rejected");
5285            assert_cow_borrowed(&result.reason, &format!("Tier 2 reject for '{cmd}'"));
5286        }
5287    }
5288
5289    #[test]
5290    fn test_cow_borrowed_on_tier3_reject() {
5291        let _guard = test_guard!();
5292        // Never-intercept commands now use static strings for performance (no format! allocation)
5293        let never_intercept = ["cargo fmt", "cargo install ripgrep", "cargo clean"];
5294        for cmd in never_intercept {
5295            let result = classify_command(cmd);
5296            assert!(!result.is_compilation, "'{cmd}' should be rejected");
5297            assert!(
5298                result.reason.contains("never-intercept"),
5299                "'{cmd}' reason should mention never-intercept: {}",
5300                result.reason
5301            );
5302            // Tier 3 now uses static string for performance -> Cow::Borrowed
5303            assert_cow_borrowed(&result.reason, &format!("Tier 3 reject for '{cmd}'"));
5304        }
5305    }
5306
5307    // --- 3. ClassificationDetails tier Cow verification ---
5308
5309    #[test]
5310    fn test_detailed_tiers_use_borrowed_names() {
5311        let _guard = test_guard!();
5312        // classify_command_detailed produces ClassificationTier entries
5313        // All tier names should be Cow::Borrowed (static constants)
5314        let details = classify_command_detailed("ls -la");
5315        for tier in &details.tiers {
5316            assert_cow_borrowed(&tier.name, &format!("Tier {} name", tier.tier));
5317        }
5318    }
5319
5320    #[test]
5321    fn test_detailed_tier_reasons_borrowed_on_reject() {
5322        let _guard = test_guard!();
5323        // Non-compilation commands should have Cow::Borrowed reasons on all tiers
5324        let details = classify_command_detailed("ls -la");
5325        assert!(!details.classification.is_compilation);
5326        for tier in &details.tiers {
5327            assert_cow_borrowed(
5328                &tier.reason,
5329                &format!("Tier {} reason for 'ls -la'", tier.tier),
5330            );
5331        }
5332    }
5333
5334    #[test]
5335    fn test_detailed_compilation_tier_names_borrowed() {
5336        let _guard = test_guard!();
5337        // Even for compilation commands, tier NAMES should always be Cow::Borrowed
5338        let details = classify_command_detailed("cargo build");
5339        assert!(details.classification.is_compilation);
5340        for tier in &details.tiers {
5341            assert_cow_borrowed(
5342                &tier.name,
5343                &format!("Tier {} name for 'cargo build'", tier.tier),
5344            );
5345        }
5346    }
5347
5348    // --- 4. Serde roundtrip tests for Cow fields ---
5349
5350    #[test]
5351    fn test_classification_serde_roundtrip_borrowed() {
5352        let _guard = test_guard!();
5353        let original = Classification::not_compilation("no compilation keyword");
5354        assert_cow_borrowed(&original.reason, "before serialize");
5355
5356        let json = serde_json::to_string(&original).expect("serialize");
5357        let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5358
5359        assert_eq!(deserialized.is_compilation, original.is_compilation);
5360        assert_eq!(deserialized.confidence, original.confidence);
5361        assert_eq!(deserialized.kind, original.kind);
5362        assert_eq!(deserialized.reason, original.reason);
5363        // Note: after deserialization, Cow will be Owned (serde deserializes into String)
5364        // This is correct behavior — only the pre-serialization path needs to be allocation-free
5365    }
5366
5367    #[test]
5368    fn test_classification_serde_roundtrip_compilation() {
5369        let _guard = test_guard!();
5370        let original =
5371            Classification::compilation(CompilationKind::CargoBuild, 0.95, "cargo build detected");
5372
5373        let json = serde_json::to_string(&original).expect("serialize");
5374        let deserialized: Classification = serde_json::from_str(&json).expect("deserialize");
5375
5376        assert_eq!(deserialized.is_compilation, original.is_compilation);
5377        assert_eq!(deserialized.confidence, original.confidence);
5378        assert_eq!(deserialized.kind, original.kind);
5379        assert_eq!(deserialized.reason.as_ref(), original.reason.as_ref());
5380    }
5381
5382    #[test]
5383    fn test_classification_tier_serde_roundtrip() {
5384        let _guard = test_guard!();
5385        let tier = ClassificationTier {
5386            tier: 2,
5387            name: Cow::Borrowed(TIER_KEYWORD_FILTER),
5388            decision: TierDecision::Reject,
5389            reason: Cow::Borrowed("no compilation keyword"),
5390        };
5391        assert_cow_borrowed(&tier.name, "tier name before serialize");
5392        assert_cow_borrowed(&tier.reason, "tier reason before serialize");
5393
5394        let json = serde_json::to_string(&tier).expect("serialize");
5395        let deserialized: ClassificationTier = serde_json::from_str(&json).expect("deserialize");
5396
5397        assert_eq!(deserialized.tier, tier.tier);
5398        assert_eq!(deserialized.name.as_ref(), tier.name.as_ref());
5399        assert_eq!(deserialized.decision, tier.decision);
5400        assert_eq!(deserialized.reason.as_ref(), tier.reason.as_ref());
5401    }
5402
5403    #[test]
5404    fn test_classification_details_serde_roundtrip() {
5405        let _guard = test_guard!();
5406        let details = classify_command_detailed("cargo build");
5407        assert!(details.classification.is_compilation);
5408
5409        let json = serde_json::to_string(&details).expect("serialize");
5410        let deserialized: ClassificationDetails = serde_json::from_str(&json).expect("deserialize");
5411
5412        assert_eq!(deserialized.original, details.original);
5413        assert_eq!(deserialized.normalized, details.normalized);
5414        assert_eq!(deserialized.tiers.len(), details.tiers.len());
5415        assert_eq!(
5416            deserialized.classification.is_compilation,
5417            details.classification.is_compilation
5418        );
5419        assert_eq!(
5420            deserialized.classification.kind,
5421            details.classification.kind
5422        );
5423    }
5424
5425    // --- 5. Cow display and comparison tests ---
5426
5427    #[test]
5428    fn test_cow_reason_display() {
5429        let _guard = test_guard!();
5430        let result = classify_command("ls");
5431        // Cow<str> should Display/Debug correctly
5432        let displayed = format!("{}", result.reason);
5433        assert!(!displayed.is_empty());
5434        let debugged = format!("{:?}", result.reason);
5435        assert!(!debugged.is_empty());
5436    }
5437
5438    #[test]
5439    fn test_cow_reason_comparison() {
5440        let _guard = test_guard!();
5441        // Cow::Borrowed and Cow::Owned with same content should be equal
5442        let borrowed: Cow<'static, str> = Cow::Borrowed("no compilation keyword");
5443        let owned: Cow<'static, str> = Cow::Owned("no compilation keyword".to_string());
5444        assert_eq!(borrowed, owned);
5445
5446        // Classification reasons should be comparable
5447        let r1 = classify_command("ls");
5448        let r2 = classify_command("pwd");
5449        // Both should be "no compilation keyword" (Tier 2 reject)
5450        assert_eq!(r1.reason, r2.reason);
5451    }
5452}
5453
5454#[cfg(test)]
5455mod tests_bun_whitespace {
5456    use super::*;
5457    use crate::test_guard;
5458
5459    #[test]
5460    fn test_bun_whitespace_resilience() {
5461        let _guard = test_guard!();
5462
5463        // Standard single space
5464        let result = classify_command("bun test");
5465        assert!(result.is_compilation, "bun test failed");
5466        assert_eq!(result.kind, Some(CompilationKind::BunTest));
5467
5468        // Multiple spaces
5469        let result = classify_command("bun  test");
5470        assert!(result.is_compilation, "bun  test failed");
5471        assert_eq!(result.kind, Some(CompilationKind::BunTest));
5472
5473        // Tab separation
5474        let result = classify_command("bun\ttest");
5475        assert!(result.is_compilation, "bun\\ttest failed");
5476        assert_eq!(result.kind, Some(CompilationKind::BunTest));
5477
5478        // Typecheck with extra spaces
5479        let result = classify_command("bun   typecheck   src/");
5480        assert!(result.is_compilation, "bun typecheck with spaces failed");
5481        assert_eq!(result.kind, Some(CompilationKind::BunTypecheck));
5482    }
5483
5484    #[test]
5485    fn test_bun_watch_with_whitespace() {
5486        let _guard = test_guard!();
5487
5488        // Watch with extra spaces
5489        let result = classify_command("bun  test  --watch");
5490        assert!(
5491            !result.is_compilation,
5492            "bun test --watch should be rejected"
5493        );
5494        assert!(result.reason.contains("interactive"));
5495
5496        // Watch with short flag and tabs
5497        let result = classify_command("bun\ttypecheck\t-w");
5498        assert!(
5499            !result.is_compilation,
5500            "bun typecheck -w should be rejected"
5501        );
5502        assert!(result.reason.contains("interactive"));
5503    }
5504
5505    #[test]
5506    fn test_bun_x_whitespace() {
5507        let _guard = test_guard!();
5508
5509        // bun x with extra spaces
5510        let result = classify_command("bun  x  vitest");
5511        assert!(!result.is_compilation, "bun x should be rejected");
5512        assert!(result.reason.contains("bun x"));
5513    }
5514}
5515
5516#[cfg(test)]
5517mod tests_normalize_whitespace {
5518    use super::*;
5519    use crate::test_guard;
5520
5521    #[test]
5522    fn test_wrapper_whitespace_resilience() {
5523        let _guard = test_guard!();
5524
5525        // Multiple spaces
5526        assert_eq!(normalize_command("sudo  cargo"), "cargo");
5527
5528        // Tabs
5529        assert_eq!(normalize_command("sudo\tcargo"), "cargo");
5530
5531        // Mixed wrappers with weird spacing
5532        assert_eq!(normalize_command("time\tsudo  cargo"), "cargo");
5533
5534        // Prefix matching safety
5535        assert_eq!(normalize_command("sudocargo"), "sudocargo");
5536
5537        // Bare wrapper (should become empty)
5538        assert_eq!(normalize_command("sudo"), "");
5539    }
5540
5541    #[test]
5542    fn wrapper_value_flags_consume_their_value_token() {
5543        // Regression (bd-review-wrapper-flag-eater): value-taking wrapper flags
5544        // must consume the VALUE token too, or it is left as the command word
5545        // and a real build silently runs local.
5546        let _guard = test_guard!();
5547        assert_eq!(normalize_command("nice -n 10 cargo build"), "cargo build");
5548        assert_eq!(
5549            normalize_command("taskset -c 0-3 cargo build"),
5550            "cargo build"
5551        );
5552        assert_eq!(normalize_command("ionice -c 2 cargo test"), "cargo test");
5553        assert_eq!(
5554            normalize_command("ionice -c 2 -n 4 cargo build"),
5555            "cargo build"
5556        );
5557        assert_eq!(normalize_command("chrt -r 10 cargo build"), "cargo build");
5558        assert_eq!(normalize_command("timeout 60 cargo build"), "cargo build");
5559        assert_eq!(
5560            normalize_command("timeout -s KILL 90 cargo build"),
5561            "cargo build"
5562        );
5563        assert_eq!(
5564            normalize_command("sudo nice -n 5 cargo build"),
5565            "cargo build"
5566        );
5567        // Attached / `=`-joined values carry their own value (no extra consume).
5568        assert_eq!(normalize_command("nice -n10 cargo build"), "cargo build");
5569        assert_eq!(normalize_command("ionice -c2 cargo build"), "cargo build");
5570        // Existing env -u behavior preserved.
5571        assert_eq!(
5572            normalize_command("env -u RUSTFLAGS cargo build"),
5573            "cargo build"
5574        );
5575    }
5576
5577    #[test]
5578    fn positional_wrapper_does_not_eat_a_non_numeric_command() {
5579        // Malformed `timeout cargo build` (no duration): the leading non-digit
5580        // token is the command and must NOT be skipped.
5581        let _guard = test_guard!();
5582        assert_eq!(normalize_command("timeout cargo build"), "cargo build");
5583    }
5584
5585    #[test]
5586    fn wrapped_cargo_build_is_still_classified_as_compilation() {
5587        let _guard = test_guard!();
5588        for cmd in [
5589            "nice -n 10 cargo build",
5590            "taskset -c 0-3 cargo build",
5591            "ionice -c 2 cargo build",
5592            "chrt -r 10 cargo build",
5593            "timeout 60 cargo build",
5594            "timeout -s KILL 90 cargo test",
5595            "time '-f%U' cargo build",
5596            "time \"-f%U\" cargo build",
5597            "nice '--adjustment=+5' cargo build",
5598            "nice '-n' '5' cargo build",
5599            "env '--unset' 'RUSTFLAGS' cargo build",
5600        ] {
5601            assert!(
5602                classify_command(cmd).is_compilation,
5603                "wrapper must not defeat offload: {cmd}"
5604            );
5605        }
5606        // A wrapper around a non-build must still pass through (not offloaded).
5607        assert!(!classify_command("nice -n 10 ls -la").is_compilation);
5608        assert!(!classify_command("timeout 60 echo hi").is_compilation);
5609        assert!(!classify_command("time '-f%U' echo cargo build").is_compilation);
5610        assert!(!classify_command("time '-f%U cargo build").is_compilation);
5611        assert!(!classify_command("time '-f%U'echo cargo build").is_compilation);
5612    }
5613
5614    #[test]
5615    fn test_env_var_whitespace() {
5616        let _guard = test_guard!();
5617
5618        for (cmd, normalized) in [
5619            // env with multiple spaces before VAR
5620            ("env  RUST_BACKTRACE=1 cargo", "cargo"),
5621            // env with tabs
5622            ("env\tRUST_BACKTRACE=1\tcargo", "cargo"),
5623            ("env -u RUST_LOG cargo test", "cargo test"),
5624            ("env --unset RUST_LOG cargo test", "cargo test"),
5625            ("env --unset=RUST_LOG cargo test", "cargo test"),
5626        ] {
5627            assert_eq!(normalize_command(cmd), normalized, "{cmd}");
5628        }
5629    }
5630}
5631
5632// ============================================================================
5633// REGRESSION SUITE: Command Classification (bd-vvmd.2.9)
5634//
5635// Table-driven regression tests protecting non-compilation UX. Every entry
5636// documents expected behavior with rationale so accidental interception of
5637// local-only commands is caught immediately.
5638// ============================================================================
5639
5640#[cfg(test)]
5641mod regression_classification {
5642    use super::*;
5643    use crate::test_guard;
5644
5645    fn assert_golden_json(
5646        actual: serde_json::Value,
5647        expected: &serde_json::Value,
5648        fixture_path: &str,
5649    ) {
5650        if &actual == expected {
5651            return;
5652        }
5653
5654        let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
5655        let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
5656        panic!(
5657            "golden mismatch in {fixture_path}\n\
5658             expected_blake3={}\n\
5659             actual_blake3={}\n\
5660             bless path: review the semantic diff, update the fixture expected_* field, \
5661             and record both hashes in the Beads closeout.\n\
5662             expected:\n{expected_json}\n\
5663             actual:\n{actual_json}",
5664            blake3::hash(expected_json.as_bytes()),
5665            blake3::hash(actual_json.as_bytes())
5666        );
5667    }
5668
5669    /// A single regression test case.
5670    struct Case {
5671        cmd: &'static str,
5672        expect_compilation: bool,
5673        expected_kind: Option<CompilationKind>,
5674        /// Substring that MUST appear in the classification reason.
5675        reason_contains: &'static str,
5676        /// Minimum confidence (only checked when expect_compilation is true).
5677        min_confidence: f64,
5678    }
5679
5680    fn run_cases(cases: &[Case]) {
5681        for (i, c) in cases.iter().enumerate() {
5682            let result = classify_command(c.cmd);
5683            assert_eq!(
5684                result.is_compilation, c.expect_compilation,
5685                "Case {i} ({:?}): expected is_compilation={}, got={}. reason={:?}",
5686                c.cmd, c.expect_compilation, result.is_compilation, result.reason
5687            );
5688            if c.expect_compilation {
5689                assert_eq!(
5690                    result.kind, c.expected_kind,
5691                    "Case {i} ({:?}): expected kind={:?}, got={:?}",
5692                    c.cmd, c.expected_kind, result.kind
5693                );
5694                assert!(
5695                    result.confidence >= c.min_confidence,
5696                    "Case {i} ({:?}): expected confidence >= {}, got={}",
5697                    c.cmd,
5698                    c.min_confidence,
5699                    result.confidence
5700                );
5701            }
5702            if !c.reason_contains.is_empty() {
5703                assert!(
5704                    result.reason.contains(c.reason_contains),
5705                    "Case {i} ({:?}): expected reason containing {:?}, got {:?}",
5706                    c.cmd,
5707                    c.reason_contains,
5708                    result.reason
5709                );
5710            }
5711        }
5712    }
5713
5714    // ------------------------------------------------------------------
5715    // 1. Cargo: Positive compilation commands
5716    // ------------------------------------------------------------------
5717
5718    #[test]
5719    fn regression_cargo_compilation_positive() {
5720        let _guard = test_guard!();
5721        let cases = [
5722            Case {
5723                cmd: "cargo build",
5724                expect_compilation: true,
5725                expected_kind: Some(CompilationKind::CargoBuild),
5726                reason_contains: "cargo build",
5727                min_confidence: 0.90,
5728            },
5729            Case {
5730                cmd: "cargo build --release",
5731                expect_compilation: true,
5732                expected_kind: Some(CompilationKind::CargoBuild),
5733                reason_contains: "cargo build",
5734                min_confidence: 0.90,
5735            },
5736            Case {
5737                cmd: "cargo build --workspace",
5738                expect_compilation: true,
5739                expected_kind: Some(CompilationKind::CargoBuild),
5740                reason_contains: "cargo build",
5741                min_confidence: 0.90,
5742            },
5743            Case {
5744                cmd: "cargo build -p my-crate",
5745                expect_compilation: true,
5746                expected_kind: Some(CompilationKind::CargoBuild),
5747                reason_contains: "cargo build",
5748                min_confidence: 0.90,
5749            },
5750            Case {
5751                cmd: "cargo b",
5752                expect_compilation: true,
5753                expected_kind: Some(CompilationKind::CargoBuild),
5754                reason_contains: "cargo build",
5755                min_confidence: 0.90,
5756            },
5757            Case {
5758                cmd: "cargo test",
5759                expect_compilation: true,
5760                expected_kind: Some(CompilationKind::CargoTest),
5761                reason_contains: "cargo test",
5762                min_confidence: 0.90,
5763            },
5764            Case {
5765                cmd: "cargo test --release",
5766                expect_compilation: true,
5767                expected_kind: Some(CompilationKind::CargoTest),
5768                reason_contains: "cargo test",
5769                min_confidence: 0.90,
5770            },
5771            Case {
5772                cmd: "cargo test my_test",
5773                expect_compilation: true,
5774                expected_kind: Some(CompilationKind::CargoTest),
5775                reason_contains: "cargo test",
5776                min_confidence: 0.90,
5777            },
5778            Case {
5779                cmd: "cargo test -- --nocapture",
5780                expect_compilation: true,
5781                expected_kind: Some(CompilationKind::CargoTest),
5782                reason_contains: "cargo test",
5783                min_confidence: 0.90,
5784            },
5785            Case {
5786                cmd: "cargo test --workspace",
5787                expect_compilation: true,
5788                expected_kind: Some(CompilationKind::CargoTest),
5789                reason_contains: "cargo test",
5790                min_confidence: 0.90,
5791            },
5792            Case {
5793                cmd: "cargo test -p rch-common",
5794                expect_compilation: true,
5795                expected_kind: Some(CompilationKind::CargoTest),
5796                reason_contains: "cargo test",
5797                min_confidence: 0.90,
5798            },
5799            Case {
5800                cmd: "cargo t",
5801                expect_compilation: true,
5802                expected_kind: Some(CompilationKind::CargoTest),
5803                reason_contains: "cargo test",
5804                min_confidence: 0.90,
5805            },
5806            Case {
5807                cmd: "cargo test --lib",
5808                expect_compilation: true,
5809                expected_kind: Some(CompilationKind::CargoTest),
5810                reason_contains: "cargo test",
5811                min_confidence: 0.90,
5812            },
5813            Case {
5814                cmd: "cargo test --bins",
5815                expect_compilation: true,
5816                expected_kind: Some(CompilationKind::CargoTest),
5817                reason_contains: "cargo test",
5818                min_confidence: 0.90,
5819            },
5820            Case {
5821                cmd: "cargo test --doc",
5822                expect_compilation: true,
5823                expected_kind: Some(CompilationKind::CargoTest),
5824                reason_contains: "cargo test",
5825                min_confidence: 0.90,
5826            },
5827            Case {
5828                cmd: "cargo test -- --test-threads=4",
5829                expect_compilation: true,
5830                expected_kind: Some(CompilationKind::CargoTest),
5831                reason_contains: "cargo test",
5832                min_confidence: 0.90,
5833            },
5834            Case {
5835                cmd: "cargo check",
5836                expect_compilation: true,
5837                expected_kind: Some(CompilationKind::CargoCheck),
5838                reason_contains: "cargo check",
5839                min_confidence: 0.85,
5840            },
5841            Case {
5842                cmd: "cargo check --workspace --all-targets",
5843                expect_compilation: true,
5844                expected_kind: Some(CompilationKind::CargoCheck),
5845                reason_contains: "cargo check",
5846                min_confidence: 0.85,
5847            },
5848            Case {
5849                cmd: "cargo c",
5850                expect_compilation: true,
5851                expected_kind: Some(CompilationKind::CargoCheck),
5852                reason_contains: "cargo check",
5853                min_confidence: 0.85,
5854            },
5855            Case {
5856                cmd: "cargo clippy",
5857                expect_compilation: true,
5858                expected_kind: Some(CompilationKind::CargoClippy),
5859                reason_contains: "cargo clippy",
5860                min_confidence: 0.85,
5861            },
5862            Case {
5863                cmd: "cargo clippy --workspace --all-targets -- -D warnings",
5864                expect_compilation: true,
5865                expected_kind: Some(CompilationKind::CargoClippy),
5866                reason_contains: "cargo clippy",
5867                min_confidence: 0.85,
5868            },
5869            Case {
5870                cmd: "cargo doc",
5871                expect_compilation: true,
5872                expected_kind: Some(CompilationKind::CargoDoc),
5873                reason_contains: "cargo doc",
5874                min_confidence: 0.80,
5875            },
5876            Case {
5877                cmd: "cargo doc --no-deps",
5878                expect_compilation: true,
5879                expected_kind: Some(CompilationKind::CargoDoc),
5880                reason_contains: "cargo doc",
5881                min_confidence: 0.80,
5882            },
5883            Case {
5884                cmd: "cargo run",
5885                expect_compilation: true,
5886                expected_kind: Some(CompilationKind::CargoBuild),
5887                reason_contains: "cargo run",
5888                min_confidence: 0.80,
5889            },
5890            Case {
5891                cmd: "cargo run --release",
5892                expect_compilation: true,
5893                expected_kind: Some(CompilationKind::CargoBuild),
5894                reason_contains: "cargo run",
5895                min_confidence: 0.80,
5896            },
5897            Case {
5898                cmd: "cargo r",
5899                expect_compilation: true,
5900                expected_kind: Some(CompilationKind::CargoBuild),
5901                reason_contains: "cargo run",
5902                min_confidence: 0.80,
5903            },
5904            Case {
5905                cmd: "cargo bench",
5906                expect_compilation: true,
5907                expected_kind: Some(CompilationKind::CargoBench),
5908                reason_contains: "cargo bench",
5909                min_confidence: 0.85,
5910            },
5911            Case {
5912                cmd: "cargo bench --bench my_bench",
5913                expect_compilation: true,
5914                expected_kind: Some(CompilationKind::CargoBench),
5915                reason_contains: "cargo bench",
5916                min_confidence: 0.85,
5917            },
5918            Case {
5919                cmd: "cargo nextest run",
5920                expect_compilation: true,
5921                expected_kind: Some(CompilationKind::CargoNextest),
5922                reason_contains: "cargo nextest run",
5923                min_confidence: 0.90,
5924            },
5925            Case {
5926                cmd: "cargo nextest run --workspace",
5927                expect_compilation: true,
5928                expected_kind: Some(CompilationKind::CargoNextest),
5929                reason_contains: "cargo nextest run",
5930                min_confidence: 0.90,
5931            },
5932            Case {
5933                cmd: "cargo nextest r",
5934                expect_compilation: true,
5935                expected_kind: Some(CompilationKind::CargoNextest),
5936                reason_contains: "cargo nextest run",
5937                min_confidence: 0.90,
5938            },
5939            // Toolchain override variants
5940            Case {
5941                cmd: "cargo +nightly build",
5942                expect_compilation: true,
5943                expected_kind: Some(CompilationKind::CargoBuild),
5944                reason_contains: "cargo build",
5945                min_confidence: 0.90,
5946            },
5947            Case {
5948                cmd: "cargo +1.80.0 test",
5949                expect_compilation: true,
5950                expected_kind: Some(CompilationKind::CargoTest),
5951                reason_contains: "cargo test",
5952                min_confidence: 0.90,
5953            },
5954            Case {
5955                cmd: "cargo +nightly nextest run",
5956                expect_compilation: true,
5957                expected_kind: Some(CompilationKind::CargoNextest),
5958                reason_contains: "cargo nextest run",
5959                min_confidence: 0.90,
5960            },
5961            // Leading global flags before the subcommand must NOT hide the build
5962            // (regression for the flag-read-as-subcommand misclassification).
5963            Case {
5964                cmd: "cargo --offline build",
5965                expect_compilation: true,
5966                expected_kind: Some(CompilationKind::CargoBuild),
5967                reason_contains: "cargo build",
5968                min_confidence: 0.90,
5969            },
5970            Case {
5971                cmd: "cargo -q test",
5972                expect_compilation: true,
5973                expected_kind: Some(CompilationKind::CargoTest),
5974                reason_contains: "cargo test",
5975                min_confidence: 0.90,
5976            },
5977            Case {
5978                cmd: "cargo --locked --frozen check",
5979                expect_compilation: true,
5980                expected_kind: Some(CompilationKind::CargoCheck),
5981                reason_contains: "cargo check",
5982                min_confidence: 0.85,
5983            },
5984            Case {
5985                cmd: "cargo --color never build",
5986                expect_compilation: true,
5987                expected_kind: Some(CompilationKind::CargoBuild),
5988                reason_contains: "cargo build",
5989                min_confidence: 0.90,
5990            },
5991            Case {
5992                cmd: "cargo +nightly --offline -Z unstable-options build",
5993                expect_compilation: true,
5994                expected_kind: Some(CompilationKind::CargoBuild),
5995                reason_contains: "cargo build",
5996                min_confidence: 0.90,
5997            },
5998        ];
5999        run_cases(&cases);
6000    }
6001
6002    #[test]
6003    fn regression_cargo_package_verification_is_compilation() {
6004        let _guard = test_guard!();
6005        for command in [
6006            "cargo package",
6007            "cargo package --workspace --locked -j2",
6008            "cargo package -p asupersync --features tls --target x86_64-unknown-linux-gnu",
6009            "cargo +nightly --offline package --allow-dirty --no-metadata",
6010            "cargo --color never package --target-dir=/data/tmp/package-check",
6011            "cargo publish --dry-run",
6012            "cargo publish -n --workspace --locked --keep-going -j 2",
6013            "cargo publish --registry crates-io -p asupersync --dry-run",
6014            "cargo +nightly --locked publish --dry-run --features=tls",
6015            "env CARGO_INCREMENTAL=0 cargo publish --dry-run --workspace",
6016        ] {
6017            let classification = classify_command(command);
6018            assert!(
6019                classification.is_compilation,
6020                "{command}: {classification:?}"
6021            );
6022            assert_eq!(classification.kind, Some(CompilationKind::CargoBuild));
6023            assert_eq!(
6024                classify_command_detailed(command).classification,
6025                classification,
6026                "diagnosis must agree with execution for {command}"
6027            );
6028        }
6029    }
6030
6031    #[test]
6032    fn regression_cargo_package_verification_declines_publication_and_metadata() {
6033        let _guard = test_guard!();
6034        for command in [
6035            "cargo publish",
6036            "cargo publish --workspace --locked",
6037            "cargo +nightly --offline publish",
6038            "cargo publish --no-verify",
6039            "cargo publish --dry-run --no-verify",
6040            "cargo publish --no-verify -n",
6041            "cargo publish -qn --no-verify",
6042            "cargo package --no-verify",
6043            "cargo package --list",
6044            "cargo package -l",
6045            "cargo package -vl",
6046            "cargo package --help",
6047            "cargo --help package",
6048            "cargo publish --dry-run -h",
6049            "cargo publish -- --dry-run",
6050            "cargo publish --dry-run -- --no-verify",
6051            "cargo publish --dry-run=false",
6052            "cargo publish --registry=--dry-run",
6053            "cargo publish -p--dry-run",
6054            "cargo publish --token=--dry-run",
6055            "cargo publish --config --dry-run",
6056            "cargo publish --config alias.note=--dry-run",
6057            "cargo publish --unknown-option --dry-run",
6058            "cargo package --target --no-verify",
6059            "cargo package --package --list",
6060            "cargo package --config",
6061            "cargo publish --config 'alias.note=\"--dry-run\"'",
6062            "cargo publish --registry 'example --dry-run registry'",
6063            "cargo publish --dry-run --config 'alias.note=\"--no-verify\"'",
6064            "cargo publish --dry-run $EXTRA_CARGO_OPTIONS",
6065        ] {
6066            let classification = classify_command(command);
6067            assert!(
6068                !classification.is_compilation,
6069                "{command}: {classification:?}"
6070            );
6071            let detailed = classify_command_detailed(command).classification;
6072            assert!(!detailed.is_compilation, "{command}: {detailed:?}");
6073            assert!(!is_cargo_package_verification(command), "{command}");
6074        }
6075    }
6076
6077    // ------------------------------------------------------------------
6078    // 2. Cargo: Negative (NEVER intercept — must stay local)
6079    // ------------------------------------------------------------------
6080
6081    #[test]
6082    fn regression_cargo_never_intercept() {
6083        let _guard = test_guard!();
6084        let cases = [
6085            Case {
6086                cmd: "cargo install ripgrep",
6087                expect_compilation: false,
6088                expected_kind: None,
6089                reason_contains: "never-intercept",
6090                min_confidence: 0.0,
6091            },
6092            Case {
6093                cmd: "cargo publish",
6094                expect_compilation: false,
6095                expected_kind: None,
6096                reason_contains: "never-intercept",
6097                min_confidence: 0.0,
6098            },
6099            Case {
6100                cmd: "cargo login",
6101                expect_compilation: false,
6102                expected_kind: None,
6103                reason_contains: "never-intercept",
6104                min_confidence: 0.0,
6105            },
6106            Case {
6107                cmd: "cargo fmt",
6108                expect_compilation: false,
6109                expected_kind: None,
6110                reason_contains: "never-intercept",
6111                min_confidence: 0.0,
6112            },
6113            Case {
6114                cmd: "cargo fmt --check",
6115                expect_compilation: false,
6116                expected_kind: None,
6117                reason_contains: "never-intercept",
6118                min_confidence: 0.0,
6119            },
6120            Case {
6121                cmd: "cargo fix",
6122                expect_compilation: false,
6123                expected_kind: None,
6124                reason_contains: "never-intercept",
6125                min_confidence: 0.0,
6126            },
6127            Case {
6128                cmd: "cargo clean",
6129                expect_compilation: false,
6130                expected_kind: None,
6131                reason_contains: "never-intercept",
6132                min_confidence: 0.0,
6133            },
6134            Case {
6135                cmd: "cargo new my_project",
6136                expect_compilation: false,
6137                expected_kind: None,
6138                reason_contains: "never-intercept",
6139                min_confidence: 0.0,
6140            },
6141            Case {
6142                cmd: "cargo init",
6143                expect_compilation: false,
6144                expected_kind: None,
6145                reason_contains: "never-intercept",
6146                min_confidence: 0.0,
6147            },
6148            Case {
6149                cmd: "cargo add serde",
6150                expect_compilation: false,
6151                expected_kind: None,
6152                reason_contains: "never-intercept",
6153                min_confidence: 0.0,
6154            },
6155            Case {
6156                cmd: "cargo remove serde",
6157                expect_compilation: false,
6158                expected_kind: None,
6159                reason_contains: "never-intercept",
6160                min_confidence: 0.0,
6161            },
6162            Case {
6163                cmd: "cargo update",
6164                expect_compilation: false,
6165                expected_kind: None,
6166                reason_contains: "never-intercept",
6167                min_confidence: 0.0,
6168            },
6169            Case {
6170                cmd: "cargo generate-lockfile",
6171                expect_compilation: false,
6172                expected_kind: None,
6173                reason_contains: "never-intercept",
6174                min_confidence: 0.0,
6175            },
6176            Case {
6177                cmd: "cargo watch -x test",
6178                expect_compilation: false,
6179                expected_kind: None,
6180                reason_contains: "never-intercept",
6181                min_confidence: 0.0,
6182            },
6183            Case {
6184                cmd: "cargo --version",
6185                expect_compilation: false,
6186                expected_kind: None,
6187                reason_contains: "never-intercept",
6188                min_confidence: 0.0,
6189            },
6190            Case {
6191                cmd: "cargo -V",
6192                expect_compilation: false,
6193                expected_kind: None,
6194                reason_contains: "never-intercept",
6195                min_confidence: 0.0,
6196            },
6197            // cargo nextest non-run subcommands
6198            Case {
6199                cmd: "cargo nextest list",
6200                expect_compilation: false,
6201                expected_kind: None,
6202                reason_contains: "never-intercept",
6203                min_confidence: 0.0,
6204            },
6205            Case {
6206                cmd: "cargo nextest archive",
6207                expect_compilation: false,
6208                expected_kind: None,
6209                reason_contains: "never-intercept",
6210                min_confidence: 0.0,
6211            },
6212            Case {
6213                cmd: "cargo nextest show",
6214                expect_compilation: false,
6215                expected_kind: None,
6216                reason_contains: "never-intercept",
6217                min_confidence: 0.0,
6218            },
6219            // Bare cargo / bare cargo nextest
6220            Case {
6221                cmd: "cargo",
6222                expect_compilation: false,
6223                expected_kind: None,
6224                reason_contains: "bare cargo",
6225                min_confidence: 0.0,
6226            },
6227            Case {
6228                cmd: "cargo nextest",
6229                expect_compilation: false,
6230                expected_kind: None,
6231                reason_contains: "bare cargo nextest",
6232                min_confidence: 0.0,
6233            },
6234            // Non-interceptable subcommands
6235            Case {
6236                cmd: "cargo tree",
6237                expect_compilation: false,
6238                expected_kind: None,
6239                reason_contains: "not interceptable",
6240                min_confidence: 0.0,
6241            },
6242            Case {
6243                cmd: "cargo metadata",
6244                expect_compilation: false,
6245                expected_kind: None,
6246                reason_contains: "not interceptable",
6247                min_confidence: 0.0,
6248            },
6249            Case {
6250                cmd: "cargo search serde",
6251                expect_compilation: false,
6252                expected_kind: None,
6253                reason_contains: "not interceptable",
6254                min_confidence: 0.0,
6255            },
6256            Case {
6257                cmd: "cargo vendor",
6258                expect_compilation: false,
6259                expected_kind: None,
6260                reason_contains: "not interceptable",
6261                min_confidence: 0.0,
6262            },
6263        ];
6264        run_cases(&cases);
6265    }
6266
6267    // ------------------------------------------------------------------
6268    // 3. Bun: Positive compilation (test, typecheck)
6269    // ------------------------------------------------------------------
6270
6271    #[test]
6272    fn regression_bun_compilation_positive() {
6273        let _guard = test_guard!();
6274        let cases = [
6275            Case {
6276                cmd: "bun test",
6277                expect_compilation: true,
6278                expected_kind: Some(CompilationKind::BunTest),
6279                reason_contains: "bun test",
6280                min_confidence: 0.90,
6281            },
6282            Case {
6283                cmd: "bun test src/",
6284                expect_compilation: true,
6285                expected_kind: Some(CompilationKind::BunTest),
6286                reason_contains: "bun test",
6287                min_confidence: 0.90,
6288            },
6289            Case {
6290                cmd: "bun test --bail",
6291                expect_compilation: true,
6292                expected_kind: Some(CompilationKind::BunTest),
6293                reason_contains: "bun test",
6294                min_confidence: 0.90,
6295            },
6296            Case {
6297                cmd: "bun test --timeout 5000",
6298                expect_compilation: true,
6299                expected_kind: Some(CompilationKind::BunTest),
6300                reason_contains: "bun test",
6301                min_confidence: 0.90,
6302            },
6303            Case {
6304                cmd: "bun typecheck",
6305                expect_compilation: true,
6306                expected_kind: Some(CompilationKind::BunTypecheck),
6307                reason_contains: "bun typecheck",
6308                min_confidence: 0.90,
6309            },
6310            Case {
6311                cmd: "bun typecheck src/",
6312                expect_compilation: true,
6313                expected_kind: Some(CompilationKind::BunTypecheck),
6314                reason_contains: "bun typecheck",
6315                min_confidence: 0.90,
6316            },
6317        ];
6318        run_cases(&cases);
6319    }
6320
6321    // ------------------------------------------------------------------
6322    // 4. Bun: Negative (package management, execution, interactive)
6323    // ------------------------------------------------------------------
6324
6325    #[test]
6326    fn regression_bun_never_intercept() {
6327        let _guard = test_guard!();
6328        let cases = [
6329            // Package management — modifies local node_modules
6330            Case {
6331                cmd: "bun install",
6332                expect_compilation: false,
6333                expected_kind: None,
6334                reason_contains: "never-intercept",
6335                min_confidence: 0.0,
6336            },
6337            Case {
6338                cmd: "bun add react",
6339                expect_compilation: false,
6340                expected_kind: None,
6341                reason_contains: "never-intercept",
6342                min_confidence: 0.0,
6343            },
6344            Case {
6345                cmd: "bun remove react",
6346                expect_compilation: false,
6347                expected_kind: None,
6348                reason_contains: "never-intercept",
6349                min_confidence: 0.0,
6350            },
6351            Case {
6352                cmd: "bun link",
6353                expect_compilation: false,
6354                expected_kind: None,
6355                reason_contains: "never-intercept",
6356                min_confidence: 0.0,
6357            },
6358            Case {
6359                cmd: "bun unlink",
6360                expect_compilation: false,
6361                expected_kind: None,
6362                reason_contains: "never-intercept",
6363                min_confidence: 0.0,
6364            },
6365            Case {
6366                cmd: "bun pm ls",
6367                expect_compilation: false,
6368                expected_kind: None,
6369                reason_contains: "never-intercept",
6370                min_confidence: 0.0,
6371            },
6372            Case {
6373                cmd: "bun init",
6374                expect_compilation: false,
6375                expected_kind: None,
6376                reason_contains: "never-intercept",
6377                min_confidence: 0.0,
6378            },
6379            Case {
6380                cmd: "bun create my-app",
6381                expect_compilation: false,
6382                expected_kind: None,
6383                reason_contains: "never-intercept",
6384                min_confidence: 0.0,
6385            },
6386            Case {
6387                cmd: "bun upgrade",
6388                expect_compilation: false,
6389                expected_kind: None,
6390                reason_contains: "never-intercept",
6391                min_confidence: 0.0,
6392            },
6393            // Execution — binds local ports or runs scripts
6394            Case {
6395                cmd: "bun run dev",
6396                expect_compilation: false,
6397                expected_kind: None,
6398                reason_contains: "never-intercept",
6399                min_confidence: 0.0,
6400            },
6401            Case {
6402                cmd: "bun build src/index.ts",
6403                expect_compilation: false,
6404                expected_kind: None,
6405                reason_contains: "never-intercept",
6406                min_confidence: 0.0,
6407            },
6408            Case {
6409                cmd: "bun dev",
6410                expect_compilation: false,
6411                expected_kind: None,
6412                reason_contains: "never-intercept",
6413                min_confidence: 0.0,
6414            },
6415            Case {
6416                cmd: "bun repl",
6417                expect_compilation: false,
6418                expected_kind: None,
6419                reason_contains: "never-intercept",
6420                min_confidence: 0.0,
6421            },
6422            // Package runner — arbitrary execution like npx
6423            Case {
6424                cmd: "bun x vitest",
6425                expect_compilation: false,
6426                expected_kind: None,
6427                reason_contains: "bun x",
6428                min_confidence: 0.0,
6429            },
6430            Case {
6431                cmd: "bun x tsc --noEmit",
6432                expect_compilation: false,
6433                expected_kind: None,
6434                reason_contains: "bun x",
6435                min_confidence: 0.0,
6436            },
6437            // Interactive watch modes — must run locally for interactivity
6438            Case {
6439                cmd: "bun test --watch",
6440                expect_compilation: false,
6441                expected_kind: None,
6442                reason_contains: "interactive",
6443                min_confidence: 0.0,
6444            },
6445            Case {
6446                cmd: "bun test -w",
6447                expect_compilation: false,
6448                expected_kind: None,
6449                reason_contains: "interactive",
6450                min_confidence: 0.0,
6451            },
6452            Case {
6453                cmd: "bun typecheck --watch",
6454                expect_compilation: false,
6455                expected_kind: None,
6456                reason_contains: "interactive",
6457                min_confidence: 0.0,
6458            },
6459            Case {
6460                cmd: "bun typecheck -w",
6461                expect_compilation: false,
6462                expected_kind: None,
6463                reason_contains: "interactive",
6464                min_confidence: 0.0,
6465            },
6466            // Version/help
6467            Case {
6468                cmd: "bun --version",
6469                expect_compilation: false,
6470                expected_kind: None,
6471                reason_contains: "never-intercept",
6472                min_confidence: 0.0,
6473            },
6474            Case {
6475                cmd: "bun -v",
6476                expect_compilation: false,
6477                expected_kind: None,
6478                reason_contains: "never-intercept",
6479                min_confidence: 0.0,
6480            },
6481            Case {
6482                cmd: "bun --help",
6483                expect_compilation: false,
6484                expected_kind: None,
6485                reason_contains: "never-intercept",
6486                min_confidence: 0.0,
6487            },
6488            Case {
6489                cmd: "bun -h",
6490                expect_compilation: false,
6491                expected_kind: None,
6492                reason_contains: "never-intercept",
6493                min_confidence: 0.0,
6494            },
6495            Case {
6496                cmd: "bun completions",
6497                expect_compilation: false,
6498                expected_kind: None,
6499                reason_contains: "never-intercept",
6500                min_confidence: 0.0,
6501            },
6502        ];
6503        run_cases(&cases);
6504    }
6505
6506    // ------------------------------------------------------------------
6507    // 5. C/C++ compilers: Positive compilation
6508    // ------------------------------------------------------------------
6509
6510    #[test]
6511    fn regression_c_cpp_compilation_positive() {
6512        let _guard = test_guard!();
6513        let cases = [
6514            Case {
6515                cmd: "gcc -c main.c -o main.o",
6516                expect_compilation: true,
6517                expected_kind: Some(CompilationKind::Gcc),
6518                reason_contains: "gcc",
6519                min_confidence: 0.85,
6520            },
6521            Case {
6522                cmd: "gcc main.c -o main",
6523                expect_compilation: true,
6524                expected_kind: Some(CompilationKind::Gcc),
6525                reason_contains: "gcc",
6526                min_confidence: 0.85,
6527            },
6528            Case {
6529                cmd: "g++ -c main.cpp -o main.o",
6530                expect_compilation: true,
6531                expected_kind: Some(CompilationKind::Gpp),
6532                reason_contains: "g++",
6533                min_confidence: 0.85,
6534            },
6535            Case {
6536                cmd: "g++ main.cc -o main",
6537                expect_compilation: true,
6538                expected_kind: Some(CompilationKind::Gpp),
6539                reason_contains: "g++",
6540                min_confidence: 0.85,
6541            },
6542            Case {
6543                cmd: "clang -c main.c -o main.o",
6544                expect_compilation: true,
6545                expected_kind: Some(CompilationKind::Clang),
6546                reason_contains: "clang",
6547                min_confidence: 0.85,
6548            },
6549            Case {
6550                cmd: "clang++ -c main.cpp -o main.o",
6551                expect_compilation: true,
6552                expected_kind: Some(CompilationKind::Clangpp),
6553                reason_contains: "clang++",
6554                min_confidence: 0.85,
6555            },
6556            Case {
6557                cmd: "clang++ main.cc -o main",
6558                expect_compilation: true,
6559                expected_kind: Some(CompilationKind::Clangpp),
6560                reason_contains: "clang++",
6561                min_confidence: 0.85,
6562            },
6563            Case {
6564                cmd: "cc -c main.c -o main.o",
6565                expect_compilation: true,
6566                expected_kind: Some(CompilationKind::Gcc),
6567                reason_contains: "cc",
6568                min_confidence: 0.80,
6569            },
6570            Case {
6571                cmd: "c++ -c main.cpp -o main.o",
6572                expect_compilation: true,
6573                expected_kind: Some(CompilationKind::Gpp),
6574                reason_contains: "c++",
6575                min_confidence: 0.80,
6576            },
6577        ];
6578        run_cases(&cases);
6579    }
6580
6581    // ------------------------------------------------------------------
6582    // 6. C/C++ compilers: Version checks (NEVER intercept)
6583    // ------------------------------------------------------------------
6584
6585    #[test]
6586    fn regression_c_cpp_version_checks_not_intercepted() {
6587        let _guard = test_guard!();
6588        let cases = [
6589            Case {
6590                cmd: "rustc --version",
6591                expect_compilation: false,
6592                expected_kind: None,
6593                reason_contains: "never-intercept",
6594                min_confidence: 0.0,
6595            },
6596            Case {
6597                cmd: "rustc -V",
6598                expect_compilation: false,
6599                expected_kind: None,
6600                reason_contains: "never-intercept",
6601                min_confidence: 0.0,
6602            },
6603            Case {
6604                cmd: "gcc --version",
6605                expect_compilation: false,
6606                expected_kind: None,
6607                reason_contains: "never-intercept",
6608                min_confidence: 0.0,
6609            },
6610            Case {
6611                cmd: "gcc -v",
6612                expect_compilation: false,
6613                expected_kind: None,
6614                reason_contains: "never-intercept",
6615                min_confidence: 0.0,
6616            },
6617            Case {
6618                cmd: "clang --version",
6619                expect_compilation: false,
6620                expected_kind: None,
6621                reason_contains: "never-intercept",
6622                min_confidence: 0.0,
6623            },
6624            Case {
6625                cmd: "clang -v",
6626                expect_compilation: false,
6627                expected_kind: None,
6628                reason_contains: "never-intercept",
6629                min_confidence: 0.0,
6630            },
6631            Case {
6632                cmd: "make --version",
6633                expect_compilation: false,
6634                expected_kind: None,
6635                reason_contains: "never-intercept",
6636                min_confidence: 0.0,
6637            },
6638            Case {
6639                cmd: "make -v",
6640                expect_compilation: false,
6641                expected_kind: None,
6642                reason_contains: "never-intercept",
6643                min_confidence: 0.0,
6644            },
6645            Case {
6646                cmd: "cmake --version",
6647                expect_compilation: false,
6648                expected_kind: None,
6649                reason_contains: "never-intercept",
6650                min_confidence: 0.0,
6651            },
6652        ];
6653        run_cases(&cases);
6654    }
6655
6656    // ------------------------------------------------------------------
6657    // 7. Build systems: Positive compilation
6658    // ------------------------------------------------------------------
6659
6660    #[test]
6661    fn regression_build_systems_positive() {
6662        let _guard = test_guard!();
6663        let cases = [
6664            Case {
6665                cmd: "make",
6666                expect_compilation: true,
6667                expected_kind: Some(CompilationKind::Make),
6668                reason_contains: "make build",
6669                min_confidence: 0.80,
6670            },
6671            Case {
6672                cmd: "make -j8",
6673                expect_compilation: true,
6674                expected_kind: Some(CompilationKind::Make),
6675                reason_contains: "make build",
6676                min_confidence: 0.80,
6677            },
6678            Case {
6679                cmd: "make all",
6680                expect_compilation: true,
6681                expected_kind: Some(CompilationKind::Make),
6682                reason_contains: "make build",
6683                min_confidence: 0.80,
6684            },
6685            Case {
6686                cmd: "cmake --build build",
6687                expect_compilation: true,
6688                expected_kind: Some(CompilationKind::CmakeBuild),
6689                reason_contains: "cmake --build",
6690                min_confidence: 0.85,
6691            },
6692            Case {
6693                cmd: "cmake --build=build",
6694                expect_compilation: true,
6695                expected_kind: Some(CompilationKind::CmakeBuild),
6696                reason_contains: "cmake --build",
6697                min_confidence: 0.85,
6698            },
6699            Case {
6700                cmd: "ninja",
6701                expect_compilation: true,
6702                expected_kind: Some(CompilationKind::Ninja),
6703                reason_contains: "ninja build",
6704                min_confidence: 0.85,
6705            },
6706            Case {
6707                cmd: "ninja -j4",
6708                expect_compilation: true,
6709                expected_kind: Some(CompilationKind::Ninja),
6710                reason_contains: "ninja build",
6711                min_confidence: 0.85,
6712            },
6713            Case {
6714                cmd: "meson compile",
6715                expect_compilation: true,
6716                expected_kind: Some(CompilationKind::Meson),
6717                reason_contains: "meson compile",
6718                min_confidence: 0.80,
6719            },
6720        ];
6721        run_cases(&cases);
6722    }
6723
6724    // ------------------------------------------------------------------
6725    // 8. Build systems: Negative (clean, install, version)
6726    // ------------------------------------------------------------------
6727
6728    #[test]
6729    fn regression_build_systems_not_intercepted() {
6730        let _guard = test_guard!();
6731        let cases = [
6732            Case {
6733                cmd: "make clean",
6734                expect_compilation: false,
6735                expected_kind: None,
6736                reason_contains: "make maintenance",
6737                min_confidence: 0.0,
6738            },
6739            Case {
6740                cmd: "make install",
6741                expect_compilation: false,
6742                expected_kind: None,
6743                reason_contains: "make maintenance",
6744                min_confidence: 0.0,
6745            },
6746            Case {
6747                cmd: "make distclean",
6748                expect_compilation: false,
6749                expected_kind: None,
6750                reason_contains: "make maintenance",
6751                min_confidence: 0.0,
6752            },
6753            Case {
6754                cmd: "ninja -t clean",
6755                expect_compilation: false,
6756                expected_kind: None,
6757                reason_contains: "ninja clean",
6758                min_confidence: 0.0,
6759            },
6760            Case {
6761                cmd: "ninja clean",
6762                expect_compilation: false,
6763                expected_kind: None,
6764                reason_contains: "ninja clean",
6765                min_confidence: 0.0,
6766            },
6767            // meson without compile subcommand
6768            Case {
6769                cmd: "meson setup build",
6770                expect_compilation: false,
6771                expected_kind: None,
6772                reason_contains: "no matching pattern",
6773                min_confidence: 0.0,
6774            },
6775            Case {
6776                cmd: "meson configure",
6777                expect_compilation: false,
6778                expected_kind: None,
6779                reason_contains: "no matching pattern",
6780                min_confidence: 0.0,
6781            },
6782            // cmake without --build
6783            Case {
6784                cmd: "cmake .",
6785                expect_compilation: false,
6786                expected_kind: None,
6787                reason_contains: "no matching pattern",
6788                min_confidence: 0.0,
6789            },
6790            Case {
6791                cmd: "cmake -DCMAKE_BUILD_TYPE=Release ..",
6792                expect_compilation: false,
6793                expected_kind: None,
6794                reason_contains: "no matching pattern",
6795                min_confidence: 0.0,
6796            },
6797        ];
6798        run_cases(&cases);
6799    }
6800
6801    // ------------------------------------------------------------------
6802    // 9. Rustc: Direct invocation
6803    // ------------------------------------------------------------------
6804
6805    #[test]
6806    fn regression_rustc_positive() {
6807        let _guard = test_guard!();
6808        let cases = [
6809            Case {
6810                cmd: "rustc main.rs",
6811                expect_compilation: true,
6812                expected_kind: Some(CompilationKind::Rustc),
6813                reason_contains: "rustc",
6814                min_confidence: 0.90,
6815            },
6816            Case {
6817                cmd: "rustc main.rs -o main",
6818                expect_compilation: true,
6819                expected_kind: Some(CompilationKind::Rustc),
6820                reason_contains: "rustc",
6821                min_confidence: 0.90,
6822            },
6823            Case {
6824                cmd: "rustc --edition 2021 main.rs",
6825                expect_compilation: true,
6826                expected_kind: Some(CompilationKind::Rustc),
6827                reason_contains: "rustc",
6828                min_confidence: 0.90,
6829            },
6830        ];
6831        run_cases(&cases);
6832    }
6833
6834    // ------------------------------------------------------------------
6835    // 10. Shell structure exclusions (pipes, redirects, bg, chains)
6836    // ------------------------------------------------------------------
6837
6838    #[test]
6839    fn regression_shell_structure_exclusions() {
6840        let _guard = test_guard!();
6841        let cases = [
6842            // Pipe into a NON-benign command -> still rejected (issue #24 only
6843            // offloads pipes into benign pagers like tee/head/grep).
6844            Case {
6845                cmd: "cargo build 2>&1 | xargs rm",
6846                expect_compilation: false,
6847                expected_kind: None,
6848                reason_contains: "piped",
6849                min_confidence: 0.0,
6850            },
6851            // Input redirect
6852            Case {
6853                cmd: "cargo build < input.txt",
6854                expect_compilation: false,
6855                expected_kind: None,
6856                reason_contains: "input redirect",
6857                min_confidence: 0.0,
6858            },
6859            // Subshell
6860            Case {
6861                cmd: "(cargo build)",
6862                expect_compilation: false,
6863                expected_kind: None,
6864                reason_contains: "subshell",
6865                min_confidence: 0.0,
6866            },
6867            Case {
6868                cmd: "$(cargo build)",
6869                expect_compilation: false,
6870                expected_kind: None,
6871                reason_contains: "subshell",
6872                min_confidence: 0.0,
6873            },
6874            Case {
6875                cmd: "`cargo build`",
6876                expect_compilation: false,
6877                expected_kind: None,
6878                reason_contains: "subshell capture",
6879                min_confidence: 0.0,
6880            },
6881            // Process substitution
6882            Case {
6883                cmd: "cargo build --config <(echo ...)",
6884                expect_compilation: false,
6885                expected_kind: None,
6886                reason_contains: "subshell",
6887                min_confidence: 0.0,
6888            },
6889            // Semicolon chains
6890            Case {
6891                cmd: "cargo build; cargo test",
6892                expect_compilation: false,
6893                expected_kind: None,
6894                reason_contains: "chained",
6895                min_confidence: 0.0,
6896            },
6897            // || chains
6898            Case {
6899                cmd: "cargo build || echo failed",
6900                expect_compilation: false,
6901                expected_kind: None,
6902                reason_contains: "chained",
6903                min_confidence: 0.0,
6904            },
6905            // Newline injection (security)
6906            Case {
6907                cmd: "cargo build\nrm -rf /",
6908                expect_compilation: false,
6909                expected_kind: None,
6910                reason_contains: "newline",
6911                min_confidence: 0.0,
6912            },
6913            Case {
6914                cmd: "cargo build\r\nevil",
6915                expect_compilation: false,
6916                expected_kind: None,
6917                reason_contains: "newline",
6918                min_confidence: 0.0,
6919            },
6920        ];
6921        run_cases(&cases);
6922    }
6923
6924    // ------------------------------------------------------------------
6925    // 10a. Every compilation segment of an `&&` chain is offloaded (issue #50).
6926    //
6927    // `cargo build --release && cargo test` used to rewrite only the final
6928    // segment; the first build ran locally on the dispatcher with no summary
6929    // line. Now every segment that classifies as a compilation is wrapped.
6930    // ------------------------------------------------------------------
6931
6932    #[test]
6933    fn regression_compound_chain_offloads_every_compilation_segment() {
6934        let _guard = test_guard!();
6935
6936        let result = classify_command("cargo build --release && cargo test");
6937        assert!(result.is_compilation, "got: {:?}", result.reason);
6938        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
6939        assert_eq!(
6940            result.command_prefix.as_deref(),
6941            Some("rch exec -- cargo build --release && ")
6942        );
6943        assert_eq!(result.extracted_command.as_deref(), Some("cargo test"));
6944
6945        // Three segments, non-compilation head: only the builds are wrapped.
6946        let result = classify_command("touch marker && cargo build && cargo test --workspace");
6947        assert_eq!(
6948            result.command_prefix.as_deref(),
6949            Some("touch marker && rch exec -- cargo build && ")
6950        );
6951        assert_eq!(
6952            result.extracted_command.as_deref(),
6953            Some("cargo test --workspace")
6954        );
6955
6956        // Mixed tool families are each wrapped.
6957        let result = classify_command("go vet ./... && cargo check");
6958        assert_eq!(
6959            result.command_prefix.as_deref(),
6960            Some("rch exec -- go vet ./... && ")
6961        );
6962
6963        // Existing contract: a plain `cd` prefix is untouched.
6964        let result = classify_command("cd /repo && cargo build");
6965        assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
6966
6967        // A build-looking segment that must stay local is left verbatim ...
6968        let result = classify_command("go build -o app ./cmd && cargo test");
6969        assert!(result.is_compilation);
6970        assert_eq!(
6971            result.command_prefix.as_deref(),
6972            Some("go build -o app ./cmd && ")
6973        );
6974        // ... and reported, so the hook can announce it instead of staying silent.
6975        assert_eq!(
6976            compound_local_compilation_segments("go build -o app ./cmd && cargo test"),
6977            vec!["go build -o app ./cmd".to_string()]
6978        );
6979        assert!(
6980            compound_local_compilation_segments("cargo build --release && cargo test").is_empty()
6981        );
6982        assert!(compound_local_compilation_segments("cd /repo && cargo build").is_empty());
6983        assert!(compound_local_compilation_segments("cargo test").is_empty());
6984        assert!(
6985            compound_local_compilation_segments("bash -c \"cargo build && cargo test\"").is_empty(),
6986            "quoted operators are not chain separators"
6987        );
6988
6989        // `;` / `||` chains and a chain whose final segment is not a build stay
6990        // declined, and each carries a structure reason the hook prints.
6991        assert!(!classify_command("cargo build ; cargo test").is_compilation);
6992        assert!(declined_compilation_due_to_structure("cargo build ; cargo test").is_some());
6993        assert!(declined_compilation_due_to_structure("cargo build || echo failed").is_some());
6994        assert!(declined_compilation_due_to_structure("cargo build && ./run").is_some());
6995    }
6996
6997    // ------------------------------------------------------------------
6998    // 10b. Benign trailing structure IS offloaded (issue #24).
6999    //
7000    // A compilation command followed only by a benign stdout/stderr redirect,
7001    // a pipe into a benign pager (tee/head/cat/grep/less/...), or backgrounding
7002    // is now extracted and offloaded, with the trailing structure re-attached
7003    // to the offloaded command's output.
7004    // ------------------------------------------------------------------
7005
7006    #[test]
7007    fn regression_benign_trailing_structure_offloaded() {
7008        let _guard = test_guard!();
7009        let expectations: &[(&str, CompilationKind, &str)] = &[
7010            // Output redirects
7011            (
7012                "cargo build > log.txt",
7013                CompilationKind::CargoBuild,
7014                "cargo build > log.txt",
7015            ),
7016            (
7017                "cargo build >> log.txt",
7018                CompilationKind::CargoBuild,
7019                "cargo build >> log.txt",
7020            ),
7021            (
7022                "cargo test --workspace > out.log 2>&1",
7023                CompilationKind::CargoTest,
7024                "cargo test --workspace > out.log 2>&1",
7025            ),
7026            // Pipes into benign pagers
7027            (
7028                "cargo build 2>&1 | grep error",
7029                CompilationKind::CargoBuild,
7030                "cargo build 2>&1 | grep error",
7031            ),
7032            (
7033                "cargo test | tee output.log",
7034                CompilationKind::CargoTest,
7035                "cargo test | tee output.log",
7036            ),
7037            (
7038                "cargo check --lib 2>&1 | head",
7039                CompilationKind::CargoCheck,
7040                "cargo check --lib 2>&1 | head",
7041            ),
7042            // Backgrounding
7043            (
7044                "cargo build &",
7045                CompilationKind::CargoBuild,
7046                "cargo build &",
7047            ),
7048        ];
7049        for (cmd, kind, extracted) in expectations {
7050            let result = classify_command(cmd);
7051            assert!(
7052                result.is_compilation,
7053                "'{cmd}' should be offloaded, got: {:?}",
7054                result.reason
7055            );
7056            assert_eq!(result.kind, Some(*kind), "kind mismatch for '{cmd}'");
7057            assert_eq!(
7058                result.extracted_command.as_deref(),
7059                Some(*extracted),
7060                "extracted command mismatch for '{cmd}'"
7061            );
7062            // command_prefix is empty for benign-suffix (no leading prefix).
7063            assert_eq!(result.command_prefix.as_deref(), Some(""));
7064        }
7065    }
7066
7067    #[test]
7068    fn regression_unsafe_trailing_structure_still_rejected() {
7069        let _guard = test_guard!();
7070        // Stay conservative: these forms transform input / chain a build / pipe
7071        // into a non-pager, and must NOT be offloaded.
7072        let rejected = [
7073            "cargo build < input.txt",           // input redirect
7074            "cargo build | xargs rm",            // non-benign pipe target
7075            "cargo build 2>&1 | grep e | sh",    // multi-stage pipe
7076            "cargo test | tee log && cargo run", // chained build
7077            "cargo build > a.log < in.txt",      // input redirect present
7078        ];
7079        for cmd in rejected {
7080            let result = classify_command(cmd);
7081            assert!(
7082                !result.is_compilation,
7083                "'{cmd}' should NOT be offloaded, got: {:?}",
7084                result
7085            );
7086        }
7087    }
7088
7089    #[test]
7090    fn regression_shell_dash_c_wrapper_offloaded() {
7091        let _guard = test_guard!();
7092        // Issue #24 item 2: bash -c / sh -c wrappers extract the inner command.
7093        let result = classify_command("bash -c \"cargo test --workspace\"");
7094        assert!(result.is_compilation, "got: {:?}", result.reason);
7095        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7096        assert_eq!(
7097            result.extracted_command.as_deref(),
7098            Some("cargo test --workspace")
7099        );
7100
7101        let result = classify_command("sh -c 'cargo build --release'");
7102        assert!(result.is_compilation, "got: {:?}", result.reason);
7103        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7104        assert_eq!(
7105            result.extracted_command.as_deref(),
7106            Some("cargo build --release")
7107        );
7108
7109        // Absolute-path shell.
7110        let result = classify_command("/bin/bash -c \"cargo check\"");
7111        assert!(result.is_compilation, "got: {:?}", result.reason);
7112
7113        // Inner compound is preserved (cd prefix kept).
7114        let result = classify_command("bash -c \"cd /repo && cargo build\"");
7115        assert!(result.is_compilation, "got: {:?}", result.reason);
7116        assert_eq!(
7117            result.extracted_command.as_deref(),
7118            Some("cargo build"),
7119            "inner compound should keep extracted command"
7120        );
7121        assert_eq!(result.command_prefix.as_deref(), Some("cd /repo && "));
7122
7123        // Inner benign suffix is preserved.
7124        let result = classify_command("bash -c \"cargo test > log 2>&1\"");
7125        assert!(result.is_compilation, "got: {:?}", result.reason);
7126        assert_eq!(
7127            result.extracted_command.as_deref(),
7128            Some("cargo test > log 2>&1")
7129        );
7130
7131        // Non-compilation inner -> not intercepted.
7132        let result = classify_command("bash -c \"ls -la\"");
7133        assert!(!result.is_compilation);
7134
7135        // Inner command that is itself a never-intercept stays declined.
7136        let result = classify_command("bash -c \"cargo fmt\"");
7137        assert!(!result.is_compilation);
7138    }
7139
7140    // ------------------------------------------------------------------
7141    // 11. fd-to-fd redirects are SAFE and should NOT block interception
7142    // ------------------------------------------------------------------
7143
7144    #[test]
7145    fn regression_fd_redirect_safe() {
7146        let _guard = test_guard!();
7147        // Compound: "cd /path && cargo build 2>&1" should detect cargo build
7148        // via compound command handling (depth-0 splits on &&, depth-1 checks segment)
7149        // Simple fd-to-fd redirect like "cargo build 2>&1" is handled by check_structure
7150        // which explicitly skips fd-to-fd redirects. So "cargo build 2>&1" should be
7151        // classified as compilation.
7152        let result = classify_command("cargo build 2>&1");
7153        assert!(
7154            result.is_compilation,
7155            "cargo build 2>&1 should be classified as compilation (fd redirect is safe), got: {:?}",
7156            result.reason
7157        );
7158        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7159    }
7160
7161    // ------------------------------------------------------------------
7162    // 12. Compound commands: && chains with compilation suffix
7163    // ------------------------------------------------------------------
7164
7165    #[test]
7166    fn regression_compound_commands() {
7167        let _guard = test_guard!();
7168        // cd && cargo build should be detected as compilation
7169        let result = classify_command("cd /path && cargo build");
7170        assert!(
7171            result.is_compilation,
7172            "cd && cargo build should compile, got: {:?}",
7173            result.reason
7174        );
7175        assert_eq!(result.kind, Some(CompilationKind::CargoBuild));
7176        assert!(result.command_prefix.is_some(), "should have prefix");
7177        assert!(
7178            result.extracted_command.is_some(),
7179            "should have extracted command"
7180        );
7181
7182        let result = classify_command("cd /path && cargo test --workspace");
7183        assert!(
7184            result.is_compilation,
7185            "cd && cargo test should compile, got: {:?}",
7186            result.reason
7187        );
7188        assert_eq!(result.kind, Some(CompilationKind::CargoTest));
7189
7190        // Non-compilation suffix should not match
7191        let result = classify_command("cd /path && cargo fmt");
7192        assert!(!result.is_compilation, "cd && cargo fmt should NOT compile");
7193
7194        let result = classify_command("cd /path && ls -la");
7195        assert!(!result.is_compilation, "cd && ls should NOT compile");
7196    }
7197
7198    // ------------------------------------------------------------------
7199    // 13. Wrapper normalization: env, sudo, time, etc.
7200    // ------------------------------------------------------------------
7201
7202    #[test]
7203    fn regression_wrapper_normalization() {
7204        let _guard = test_guard!();
7205        let cases = [
7206            Case {
7207                cmd: "sudo cargo build",
7208                expect_compilation: true,
7209                expected_kind: Some(CompilationKind::CargoBuild),
7210                reason_contains: "cargo build",
7211                min_confidence: 0.90,
7212            },
7213            Case {
7214                cmd: "time cargo test",
7215                expect_compilation: true,
7216                expected_kind: Some(CompilationKind::CargoTest),
7217                reason_contains: "cargo test",
7218                min_confidence: 0.90,
7219            },
7220            Case {
7221                cmd: "env RUST_BACKTRACE=1 cargo test",
7222                expect_compilation: true,
7223                expected_kind: Some(CompilationKind::CargoTest),
7224                reason_contains: "cargo test",
7225                min_confidence: 0.90,
7226            },
7227            Case {
7228                cmd: "env -u RUST_LOG cargo test",
7229                expect_compilation: true,
7230                expected_kind: Some(CompilationKind::CargoTest),
7231                reason_contains: "cargo test",
7232                min_confidence: 0.90,
7233            },
7234            Case {
7235                cmd: "RUST_BACKTRACE=1 cargo test",
7236                expect_compilation: true,
7237                expected_kind: Some(CompilationKind::CargoTest),
7238                reason_contains: "cargo test",
7239                min_confidence: 0.90,
7240            },
7241            Case {
7242                cmd: "nice cargo build --release",
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: "/usr/bin/cargo build",
7250                expect_compilation: true,
7251                expected_kind: Some(CompilationKind::CargoBuild),
7252                reason_contains: "cargo build",
7253                min_confidence: 0.90,
7254            },
7255            Case {
7256                cmd: "sudo env RUSTFLAGS=-Dwarnings cargo clippy",
7257                expect_compilation: true,
7258                expected_kind: Some(CompilationKind::CargoClippy),
7259                reason_contains: "cargo clippy",
7260                min_confidence: 0.85,
7261            },
7262        ];
7263        run_cases(&cases);
7264    }
7265
7266    // ------------------------------------------------------------------
7267    // 14. Non-compilation commands (must never trigger)
7268    // ------------------------------------------------------------------
7269
7270    #[test]
7271    fn regression_non_compilation_passthrough() {
7272        let _guard = test_guard!();
7273        let cases = [
7274            Case {
7275                cmd: "ls -la",
7276                expect_compilation: false,
7277                expected_kind: None,
7278                reason_contains: "no compilation keyword",
7279                min_confidence: 0.0,
7280            },
7281            Case {
7282                cmd: "pwd",
7283                expect_compilation: false,
7284                expected_kind: None,
7285                reason_contains: "no compilation keyword",
7286                min_confidence: 0.0,
7287            },
7288            Case {
7289                cmd: "echo hello",
7290                expect_compilation: false,
7291                expected_kind: None,
7292                reason_contains: "no compilation keyword",
7293                min_confidence: 0.0,
7294            },
7295            Case {
7296                cmd: "cat Cargo.toml",
7297                expect_compilation: false,
7298                expected_kind: None,
7299                reason_contains: "no compilation keyword",
7300                min_confidence: 0.0,
7301            },
7302            Case {
7303                cmd: "git status",
7304                expect_compilation: false,
7305                expected_kind: None,
7306                reason_contains: "no compilation keyword",
7307                min_confidence: 0.0,
7308            },
7309            Case {
7310                cmd: "git commit -m 'fix'",
7311                expect_compilation: false,
7312                expected_kind: None,
7313                reason_contains: "no compilation keyword",
7314                min_confidence: 0.0,
7315            },
7316            Case {
7317                cmd: "npm install",
7318                expect_compilation: false,
7319                expected_kind: None,
7320                reason_contains: "no compilation keyword",
7321                min_confidence: 0.0,
7322            },
7323            Case {
7324                cmd: "yarn build",
7325                expect_compilation: false,
7326                expected_kind: None,
7327                reason_contains: "no compilation keyword",
7328                min_confidence: 0.0,
7329            },
7330            Case {
7331                cmd: "python main.py",
7332                expect_compilation: false,
7333                expected_kind: None,
7334                reason_contains: "no compilation keyword",
7335                min_confidence: 0.0,
7336            },
7337            // Go is now a first-class offload target (previously this asserted
7338            // "no compilation keyword", i.e. that `go build` was NOT intercepted
7339            // and therefore ran locally on the orchestrator).
7340            Case {
7341                cmd: "go build .",
7342                expect_compilation: true,
7343                expected_kind: Some(CompilationKind::GoBuild),
7344                reason_contains: "go build command",
7345                min_confidence: 0.9,
7346            },
7347            // …but an emitting `go build -o` still runs locally: Go kinds are
7348            // stream-only, so offloading it would leave no binary behind.
7349            Case {
7350                cmd: "go build -o bin/app ./cmd/app",
7351                expect_compilation: false,
7352                expected_kind: None,
7353                reason_contains: "emits a local binary",
7354                min_confidence: 0.0,
7355            },
7356            Case {
7357                cmd: "docker build -t myapp .",
7358                expect_compilation: false,
7359                expected_kind: None,
7360                reason_contains: "no compilation keyword",
7361                min_confidence: 0.0,
7362            },
7363            Case {
7364                cmd: "pip install -r requirements.txt",
7365                expect_compilation: false,
7366                expected_kind: None,
7367                reason_contains: "no compilation keyword",
7368                min_confidence: 0.0,
7369            },
7370            Case {
7371                cmd: "curl https://example.com",
7372                expect_compilation: false,
7373                expected_kind: None,
7374                reason_contains: "no compilation keyword",
7375                min_confidence: 0.0,
7376            },
7377            Case {
7378                cmd: "mkdir -p build",
7379                expect_compilation: false,
7380                expected_kind: None,
7381                reason_contains: "no compilation keyword",
7382                min_confidence: 0.0,
7383            },
7384            Case {
7385                cmd: "rm -rf target/",
7386                expect_compilation: false,
7387                expected_kind: None,
7388                reason_contains: "no compilation keyword",
7389                min_confidence: 0.0,
7390            },
7391            Case {
7392                cmd: "cp -r src/ backup/",
7393                expect_compilation: false,
7394                expected_kind: None,
7395                reason_contains: "no compilation keyword",
7396                min_confidence: 0.0,
7397            },
7398            Case {
7399                cmd: "",
7400                expect_compilation: false,
7401                expected_kind: None,
7402                reason_contains: "empty command",
7403                min_confidence: 0.0,
7404            },
7405            Case {
7406                cmd: "   ",
7407                expect_compilation: false,
7408                expected_kind: None,
7409                reason_contains: "empty command",
7410                min_confidence: 0.0,
7411            },
7412        ];
7413        run_cases(&cases);
7414    }
7415
7416    // ------------------------------------------------------------------
7417    // 15. ClassificationDetails: structured log field verification
7418    // ------------------------------------------------------------------
7419
7420    #[test]
7421    fn regression_classification_details_fields() {
7422        let _guard = test_guard!();
7423
7424        // Compilation: verify all tiers pass and final has kind + confidence
7425        let details = classify_command_detailed("cargo build --release");
7426        assert_eq!(details.original, "cargo build --release");
7427        assert!(details.classification.is_compilation);
7428        assert_eq!(
7429            details.classification.kind,
7430            Some(CompilationKind::CargoBuild)
7431        );
7432        assert!(details.classification.confidence >= 0.90);
7433        // Should have 5 tiers: 0-4
7434        assert_eq!(details.tiers.len(), 5, "Should have 5 tier decisions");
7435        for tier in &details.tiers[..4] {
7436            assert_eq!(tier.decision, TierDecision::Pass);
7437        }
7438        // Tier 4 should also pass (compilation detected)
7439        assert_eq!(details.tiers[4].decision, TierDecision::Pass);
7440        assert_eq!(details.tiers[4].tier, 4);
7441
7442        // Non-compilation: should reject early at Tier 2 (keyword filter)
7443        let details = classify_command_detailed("ls -la");
7444        assert!(!details.classification.is_compilation);
7445        assert!(
7446            details.tiers.len() >= 3,
7447            "Should have at least 3 tiers for keyword rejection"
7448        );
7449        assert_eq!(details.tiers.last().unwrap().decision, TierDecision::Reject);
7450
7451        // Never-intercept: should reject at Tier 3
7452        let details = classify_command_detailed("cargo fmt --check");
7453        assert!(!details.classification.is_compilation);
7454        assert!(
7455            details.tiers.len() >= 4,
7456            "Should have at least 4 tiers for never-intercept rejection"
7457        );
7458        // Find tier 3
7459        let tier3 = details.tiers.iter().find(|t| t.tier == 3).unwrap();
7460        assert_eq!(tier3.decision, TierDecision::Reject);
7461        assert!(
7462            tier3.reason.contains("never-intercept"),
7463            "Tier 3 reason should mention never-intercept, got: {:?}",
7464            tier3.reason
7465        );
7466
7467        // Pipe into a NON-benign command: rejected at Tier 1 (structure
7468        // analysis). A pipe into a benign pager (grep/tee/head) is offloaded
7469        // instead (issue #24), so use a non-pager target here.
7470        let details = classify_command_detailed("cargo build | xargs rm");
7471        assert!(!details.classification.is_compilation);
7472        let tier1 = details.tiers.iter().find(|t| t.tier == 1).unwrap();
7473        assert_eq!(tier1.decision, TierDecision::Reject);
7474        assert!(tier1.reason.contains("piped"));
7475
7476        // Empty command: should reject at Tier 0
7477        let details = classify_command_detailed("");
7478        assert!(!details.classification.is_compilation);
7479        assert_eq!(details.tiers[0].tier, 0);
7480        assert_eq!(details.tiers[0].decision, TierDecision::Reject);
7481    }
7482
7483    // ------------------------------------------------------------------
7484    // 16. Decision path completeness: every positive case has reason code
7485    // ------------------------------------------------------------------
7486
7487    #[test]
7488    fn regression_every_compilation_has_reason_and_confidence() {
7489        let _guard = test_guard!();
7490        let compilation_cmds = [
7491            "cargo build",
7492            "cargo test",
7493            "cargo check",
7494            "cargo clippy",
7495            "cargo doc",
7496            "cargo run",
7497            "cargo bench",
7498            "cargo nextest run",
7499            "rustc main.rs",
7500            "gcc -c main.c -o main.o",
7501            "g++ -c main.cpp -o main.o",
7502            "clang -c main.c -o main.o",
7503            "clang++ -c main.cpp -o main.o",
7504            "make",
7505            "cmake --build build",
7506            "ninja",
7507            "meson compile",
7508            "bun test",
7509            "bun typecheck",
7510        ];
7511        for cmd in compilation_cmds {
7512            let result = classify_command(cmd);
7513            assert!(result.is_compilation, "{cmd:?} should be compilation");
7514            assert!(
7515                result.kind.is_some(),
7516                "{cmd:?} should have a CompilationKind"
7517            );
7518            assert!(
7519                result.confidence > 0.0,
7520                "{cmd:?} should have non-zero confidence"
7521            );
7522            assert!(
7523                !result.reason.is_empty(),
7524                "{cmd:?} should have a non-empty reason"
7525            );
7526        }
7527    }
7528
7529    // ------------------------------------------------------------------
7530    // 17. Decision path completeness: every negative case has reason code
7531    // ------------------------------------------------------------------
7532
7533    #[test]
7534    fn regression_every_rejection_has_reason() {
7535        let _guard = test_guard!();
7536        let non_compilation_cmds = [
7537            "",
7538            "ls",
7539            "pwd",
7540            "git status",
7541            "cargo fmt",
7542            "cargo install ripgrep",
7543            "cargo clean",
7544            "bun install",
7545            "bun add react",
7546            "bun run dev",
7547            "bun dev",
7548            "bun repl",
7549            "bun x vitest",
7550            "bun test --watch",
7551            "bun typecheck -w",
7552            "make clean",
7553            "ninja clean",
7554            "gcc --version",
7555            "rustc --version",
7556            "cargo build | xargs rm",
7557            "cargo build < input.txt",
7558            "cargo build || echo failed",
7559            "(cargo build)",
7560        ];
7561        for cmd in non_compilation_cmds {
7562            let result = classify_command(cmd);
7563            assert!(
7564                !result.is_compilation,
7565                "{cmd:?} should NOT be compilation, got kind={:?}",
7566                result.kind
7567            );
7568            assert!(
7569                !result.reason.is_empty(),
7570                "{cmd:?} should have a non-empty rejection reason"
7571            );
7572            assert_eq!(result.kind, None, "{cmd:?} should have no CompilationKind");
7573            assert_eq!(
7574                result.confidence, 0.0,
7575                "{cmd:?} should have zero confidence"
7576            );
7577        }
7578    }
7579
7580    // ------------------------------------------------------------------
7581    // 18. Representative real-world agent workflow commands
7582    // ------------------------------------------------------------------
7583
7584    #[test]
7585    fn regression_real_world_agent_workflows() {
7586        let _guard = test_guard!();
7587
7588        // Typical Claude Code agent workflow: navigation + build
7589        let cd_build = classify_command(
7590            "cd /data/projects/remote_compilation_helper && cargo build --workspace --all-targets",
7591        );
7592        assert!(
7593            cd_build.is_compilation,
7594            "agent cd+build workflow should be intercepted"
7595        );
7596
7597        // Agent running tests after code change
7598        let test_ws = classify_command("cargo test --workspace -- --nocapture");
7599        assert!(test_ws.is_compilation);
7600        assert_eq!(test_ws.kind, Some(CompilationKind::CargoTest));
7601
7602        // Agent running clippy check
7603        let clippy = classify_command("cargo clippy --workspace --all-targets -- -D warnings");
7604        assert!(clippy.is_compilation);
7605        assert_eq!(clippy.kind, Some(CompilationKind::CargoClippy));
7606
7607        // Agent checking format (should NOT intercept — modifies source)
7608        let fmt = classify_command("cargo fmt --check");
7609        assert!(!fmt.is_compilation);
7610
7611        // Agent reading Cargo.toml (should NOT intercept)
7612        let cat = classify_command("cat Cargo.toml");
7613        assert!(!cat.is_compilation);
7614
7615        // Agent checking git status (should NOT intercept)
7616        let gs = classify_command("git status");
7617        assert!(!gs.is_compilation);
7618
7619        // Agent installing a tool (should NOT intercept)
7620        let install = classify_command("cargo install cargo-nextest");
7621        assert!(!install.is_compilation);
7622
7623        // Agent running specific package tests
7624        let pkg_test = classify_command("cargo test -p rch-common");
7625        assert!(pkg_test.is_compilation);
7626        assert_eq!(pkg_test.kind, Some(CompilationKind::CargoTest));
7627
7628        // Agent running with environment variable
7629        let env_test = classify_command("RUST_BACKTRACE=1 cargo test --workspace");
7630        assert!(env_test.is_compilation);
7631        assert_eq!(env_test.kind, Some(CompilationKind::CargoTest));
7632
7633        // Agent running nextest
7634        let nxt = classify_command("cargo nextest run --workspace --no-fail-fast");
7635        assert!(nxt.is_compilation);
7636        assert_eq!(nxt.kind, Some(CompilationKind::CargoNextest));
7637    }
7638
7639    // ------------------------------------------------------------------
7640    // 19. Boundary: commands that contain keywords but are NOT compilation
7641    // ------------------------------------------------------------------
7642
7643    #[test]
7644    fn regression_keyword_in_non_compilation_context() {
7645        let _guard = test_guard!();
7646        // "cargo" appears as substring in path or argument, not as command
7647        let result = classify_command("echo cargo build");
7648        // "echo" has no compilation keyword... wait, "cargo" IS a keyword
7649        // but the command starts with "echo", which isn't a compilation command.
7650        // After normalization, it should still start with "echo".
7651        assert!(
7652            !result.is_compilation,
7653            "echo cargo should not compile, got: {:?}",
7654            result.reason
7655        );
7656
7657        // grep for "make" in a log file - keyword present but not a build command
7658        let result = classify_command("grep make Makefile");
7659        // "make" is a keyword so passes Tier 2, but starts with "grep", not "make"
7660        assert!(
7661            !result.is_compilation,
7662            "grep make should not compile, got: {:?}",
7663            result.reason
7664        );
7665
7666        // cat a file with "gcc" in its name
7667        let result = classify_command("cat gcc_output.log");
7668        // "gcc" keyword passes Tier 2, but the command is "cat"
7669        assert!(
7670            !result.is_compilation,
7671            "cat gcc_output should not compile, got: {:?}",
7672            result.reason
7673        );
7674    }
7675
7676    #[test]
7677    fn regression_build_words_in_non_build_arguments_are_not_compilation() {
7678        let _guard = test_guard!();
7679        let cases = [
7680            r#"br comments add ft-4tp7g.1 "remote proof blocked: cargo test -p rchd --lib""#,
7681            r#"AGENT_NAME=Codex br comments add ft-4tp7g.1 "proof lane: cargo clippy --workspace""#,
7682            r#"printf "cargo build --release""#,
7683            r#"echo 'cargo test -p rchd -- --nocapture'"#,
7684            r#"grep "cmake --build" docs/proof-notes.md"#,
7685        ];
7686
7687        for cmd in cases {
7688            let result = classify_command(cmd);
7689            assert!(
7690                !result.is_compilation,
7691                "embedded build text should not compile for {cmd:?}: {:?}",
7692                result
7693            );
7694            assert_eq!(result.reason.as_ref(), "no compilation keyword");
7695        }
7696    }
7697
7698    #[test]
7699    fn golden_non_build_arguments_with_build_words_stay_non_compilation() {
7700        let _guard = test_guard!();
7701        const FIXTURE_PATH: &str =
7702            "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json";
7703
7704        #[derive(serde::Deserialize)]
7705        struct Golden {
7706            schema_version: String,
7707            cases: Vec<Case>,
7708            expected_results: serde_json::Value,
7709        }
7710
7711        #[derive(serde::Deserialize)]
7712        struct Case {
7713            name: String,
7714            command: String,
7715        }
7716
7717        let golden: Golden = serde_json::from_str(include_str!(
7718            "../../tests/goldens/ft_4tp7g/classification_non_build_arguments.json"
7719        ))
7720        .expect("classification golden fixture parses");
7721        assert_eq!(
7722            golden.schema_version,
7723            "rch.golden.classification_non_build_arguments.v1"
7724        );
7725
7726        let mut actual_results = Vec::with_capacity(golden.cases.len());
7727        for case in golden.cases {
7728            let result = classify_command(&case.command);
7729            actual_results.push(serde_json::json!({
7730                "name": case.name,
7731                "is_compilation": result.is_compilation,
7732                "kind": result.kind,
7733                "reason": result.reason.as_ref(),
7734            }));
7735        }
7736        assert_golden_json(
7737            serde_json::Value::Array(actual_results),
7738            &golden.expected_results,
7739            FIXTURE_PATH,
7740        );
7741    }
7742
7743    #[test]
7744    fn regression_keyword_filter_still_accepts_normalized_build_invocations() {
7745        let _guard = test_guard!();
7746        let cases = [
7747            ("RUST_BACKTRACE=1 cargo test", CompilationKind::CargoTest),
7748            (
7749                "env 'CARGO_TARGET_DIR=/data/tmp/rch-target' cargo build",
7750                CompilationKind::CargoBuild,
7751            ),
7752            ("sudo -E cargo check", CompilationKind::CargoCheck),
7753            ("/usr/bin/time cargo build", CompilationKind::CargoBuild),
7754        ];
7755
7756        for (cmd, expected_kind) in cases {
7757            let result = classify_command(cmd);
7758            assert!(
7759                result.is_compilation,
7760                "normalized build invocation should compile for {cmd:?}: {:?}",
7761                result
7762            );
7763            assert_eq!(result.kind, Some(expected_kind));
7764        }
7765    }
7766
7767    // ------------------------------------------------------------------
7768    // 20. CompilationKind properties: is_test_command, command_base
7769    // ------------------------------------------------------------------
7770
7771    #[test]
7772    fn regression_compilation_kind_properties() {
7773        let _guard = test_guard!();
7774
7775        // Test commands
7776        assert!(CompilationKind::CargoTest.is_test_command());
7777        assert!(CompilationKind::CargoNextest.is_test_command());
7778        assert!(CompilationKind::CargoBench.is_test_command());
7779        assert!(CompilationKind::BunTest.is_test_command());
7780
7781        // Non-test commands
7782        assert!(!CompilationKind::CargoBuild.is_test_command());
7783        assert!(!CompilationKind::CargoCheck.is_test_command());
7784        assert!(!CompilationKind::CargoClippy.is_test_command());
7785        assert!(!CompilationKind::CargoDoc.is_test_command());
7786        assert!(!CompilationKind::BunTypecheck.is_test_command());
7787        assert!(!CompilationKind::Gcc.is_test_command());
7788        assert!(!CompilationKind::Gpp.is_test_command());
7789        assert!(!CompilationKind::Clang.is_test_command());
7790        assert!(!CompilationKind::Clangpp.is_test_command());
7791        assert!(!CompilationKind::Make.is_test_command());
7792        assert!(!CompilationKind::CmakeBuild.is_test_command());
7793        assert!(!CompilationKind::Ninja.is_test_command());
7794        assert!(!CompilationKind::Meson.is_test_command());
7795        assert!(!CompilationKind::Rustc.is_test_command());
7796
7797        // command_base
7798        assert_eq!(CompilationKind::CargoBuild.command_base(), "cargo");
7799        assert_eq!(CompilationKind::CargoTest.command_base(), "cargo");
7800        assert_eq!(CompilationKind::CargoNextest.command_base(), "cargo");
7801        assert_eq!(CompilationKind::Rustc.command_base(), "rustc");
7802        assert_eq!(CompilationKind::Gcc.command_base(), "gcc");
7803        assert_eq!(CompilationKind::Gpp.command_base(), "g++");
7804        assert_eq!(CompilationKind::Clang.command_base(), "clang");
7805        assert_eq!(CompilationKind::Clangpp.command_base(), "clang++");
7806        assert_eq!(CompilationKind::Make.command_base(), "make");
7807        assert_eq!(CompilationKind::CmakeBuild.command_base(), "cmake");
7808        assert_eq!(CompilationKind::Ninja.command_base(), "ninja");
7809        assert_eq!(CompilationKind::Meson.command_base(), "meson");
7810        assert_eq!(CompilationKind::BunTest.command_base(), "bun");
7811        assert_eq!(CompilationKind::BunTypecheck.command_base(), "bun");
7812    }
7813}
7814
7815#[cfg(test)]
7816mod tests_go_and_typescript {
7817    use super::*;
7818
7819    fn kind_of(cmd: &str) -> Option<CompilationKind> {
7820        classify_command(cmd).kind
7821    }
7822
7823    // --- Go: the offloadable forms ---
7824
7825    #[test]
7826    fn go_build_test_vet_are_classified() {
7827        assert_eq!(kind_of("go build ./..."), Some(CompilationKind::GoBuild));
7828        assert_eq!(kind_of("go test ./..."), Some(CompilationKind::GoTest));
7829        assert_eq!(kind_of("go vet ./..."), Some(CompilationKind::GoVet));
7830        // Real commands observed hammering the orchestrator.
7831        assert_eq!(
7832            kind_of("go test -tags=e2e ./e2e -run ^TestE2EAtomicAssignment$"),
7833            Some(CompilationKind::GoTest)
7834        );
7835        assert_eq!(
7836            kind_of("go test ./internal/robot ./internal/cli -run TestExitCode"),
7837            Some(CompilationKind::GoTest)
7838        );
7839    }
7840
7841    #[test]
7842    fn go_commands_clear_the_confidence_threshold() {
7843        // Default confidence_threshold is 0.85; anything at or below it is never
7844        // intercepted, which would silently keep these local.
7845        for cmd in ["go build ./...", "go test ./...", "go vet ./..."] {
7846            let c = classify_command(cmd);
7847            assert!(
7848                c.confidence > 0.85,
7849                "{cmd} scored {} which does not clear the 0.85 threshold",
7850                c.confidence
7851            );
7852        }
7853    }
7854
7855    #[test]
7856    fn go_test_is_a_test_command() {
7857        // Drives cache affinity, the test-slot bucket, and the test timeout.
7858        assert!(CompilationKind::GoTest.is_test_command());
7859        assert!(!CompilationKind::GoBuild.is_test_command());
7860        assert!(!CompilationKind::GoVet.is_test_command());
7861    }
7862
7863    // --- Go: forms that MUST stay local (would otherwise lose artifacts) ---
7864
7865    #[test]
7866    fn go_build_with_output_flag_is_not_offloaded() {
7867        // Go kinds are stream-only (no artifact sync-back). Offloading an
7868        // emitting build would report success and leave no binary behind.
7869        assert_eq!(kind_of("go build -o bin/app ./cmd/app"), None);
7870        assert_eq!(kind_of("go build -o=bin/app ./cmd/app"), None);
7871    }
7872
7873    #[test]
7874    fn go_test_c_and_exec_stay_local() {
7875        assert_eq!(kind_of("go test -c ./pkg"), None);
7876        assert_eq!(kind_of("go test -exec sudo ./pkg"), None);
7877    }
7878
7879    #[test]
7880    fn go_state_mutating_subcommands_are_never_intercepted() {
7881        for cmd in [
7882            "go get github.com/foo/bar",
7883            "go mod tidy",
7884            "go mod download",
7885            "go install ./cmd/tool",
7886            "go run main.go",
7887            "go generate ./...",
7888            "go fmt ./...",
7889            "go clean -cache",
7890            "go work sync",
7891            "go env GOPATH",
7892            "go version",
7893            "go list ./...",
7894            "go tool pprof",
7895        ] {
7896            assert_eq!(kind_of(cmd), None, "{cmd} must never be intercepted");
7897        }
7898    }
7899
7900    #[test]
7901    fn go_prefixed_binaries_are_not_matched() {
7902        // Keyword matching is whole-word, so these must not be treated as `go`.
7903        assert_eq!(kind_of("gofmt -l ."), None);
7904        assert_eq!(kind_of("golangci-lint run"), None);
7905    }
7906
7907    // --- TypeScript ---
7908
7909    #[test]
7910    fn tsc_noemit_is_classified() {
7911        assert_eq!(kind_of("tsc --noEmit"), Some(CompilationKind::Tsc));
7912        assert_eq!(kind_of("npx tsc --noEmit"), Some(CompilationKind::Tsc));
7913        // Observed in the wild.
7914        assert_eq!(
7915            kind_of("npx tsc --noEmit --incremental false"),
7916            Some(CompilationKind::Tsc)
7917        );
7918        // tsc flags are case-insensitive.
7919        assert_eq!(kind_of("tsc --noemit"), Some(CompilationKind::Tsc));
7920    }
7921
7922    #[test]
7923    fn emitting_tsc_stays_local() {
7924        // Without --noEmit, tsc writes .js/.d.ts into the local tree. Tsc is
7925        // stream-only, so offloading it would silently produce no output.
7926        assert_eq!(kind_of("tsc"), None);
7927        assert_eq!(kind_of("tsc -p tsconfig.json"), None);
7928        assert_eq!(kind_of("npx tsc --outDir dist"), None);
7929    }
7930
7931    #[test]
7932    fn tsc_watch_is_not_intercepted() {
7933        assert_eq!(kind_of("tsc --noEmit --watch"), None);
7934        assert_eq!(kind_of("tsc --noEmit -w"), None);
7935    }
7936
7937    #[test]
7938    fn tsc_trivial_forms_are_not_intercepted() {
7939        assert_eq!(kind_of("tsc --version"), None);
7940        assert_eq!(kind_of("tsc --init"), None);
7941    }
7942
7943    #[test]
7944    fn npx_non_tsc_is_not_intercepted() {
7945        // "npx" is a compilation keyword only so `npx tsc` reaches classify_full;
7946        // every other npx invocation must fall through.
7947        assert_eq!(kind_of("npx jest"), None);
7948        assert_eq!(kind_of("npx prettier --write ."), None);
7949    }
7950
7951    // --- command_base drives the execution allowlist ---
7952
7953    #[test]
7954    fn command_base_is_allowlisted() {
7955        use crate::types::ExecutionConfig;
7956        let exec = ExecutionConfig::default();
7957        for kind in [
7958            CompilationKind::GoBuild,
7959            CompilationKind::GoTest,
7960            CompilationKind::GoVet,
7961            CompilationKind::Tsc,
7962        ] {
7963            let base = kind.command_base();
7964            assert!(
7965                exec.is_allowed(base),
7966                "{base} missing from the execution allowlist — the hook would fail \
7967                 open to LOCAL execution, silently losing offload"
7968            );
7969        }
7970        assert_eq!(CompilationKind::GoBuild.command_base(), "go");
7971        assert_eq!(CompilationKind::Tsc.command_base(), "tsc");
7972    }
7973}