Skip to main content

rch_common/
patterns.rs

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