Skip to main content

rch_common/
admit_preflight.rs

1//! `rch admit` preflight core (bd-session-history-remediation-ocv9i.6.1).
2//!
3//! `rch admit -- <command>` is a fast, read-only preflight an agent runs before
4//! expensive work: it classifies the command, determines its family and the
5//! capabilities a worker must have to run it, and — given the daemon's
6//! candidate/rejection data — returns a decisive Offload / Local / Queue / Defer
7//! answer. This module is the **pure** heart of that: classification and
8//! capability derivation ([`preflight`]) plus the decision rule that folds in
9//! aggregated candidate rejections ([`refine_recommendation`]).
10//!
11//! It reuses the existing classifier ([`crate::patterns`]), the capability
12//! requirement contract ([`crate::capability_probe::CapabilityRequirement`]),
13//! and the admission-rejection vocabulary ([`crate::admission_rejection`]) rather
14//! than inventing a parallel path. The clap `rch admit` subcommand and the
15//! optional daemon candidate query are the CLI/daemon surface over this contract.
16
17use serde::{Deserialize, Serialize};
18
19use crate::admission_rejection::{AdmissionRejectionSummary, RejectionClass};
20use crate::capability_probe::CapabilityRequirement;
21use crate::patterns::{
22    CompilationKind, cargo_xwin_compilation_kind, classify_command, classify_command_detailed,
23    normalize_command, split_shell_commands, split_wrapper_word, unquote_literal_option_word,
24};
25
26/// The decisive recommendation `rch admit` returns.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AdmitRecommendation {
30    /// Offload to a remote worker.
31    Offload,
32    /// Run locally (not a compilation, or nothing to gain from offload).
33    Local,
34    /// Offload-eligible but the fleet is transiently busy/unhealthy — queue.
35    Queue,
36    /// No worker can run this command (structural capability gap) — defer until
37    /// the fleet gains the capability rather than waiting on a slot.
38    Defer,
39}
40
41impl AdmitRecommendation {
42    /// Stable lowercase token.
43    #[must_use]
44    pub fn as_str(self) -> &'static str {
45        match self {
46            AdmitRecommendation::Offload => "offload",
47            AdmitRecommendation::Local => "local",
48            AdmitRecommendation::Queue => "queue",
49            AdmitRecommendation::Defer => "defer",
50        }
51    }
52}
53
54/// The capabilities a command needs from a worker, as a serializable summary.
55/// Mirrors (and converts to) [`CapabilityRequirement`].
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct RequiredCapabilities {
58    pub needs_cargo: bool,
59    pub needs_bun: bool,
60    /// Whether the command requires nix (binary + populated `/nix/store`).
61    pub needs_nix: bool,
62    /// Whether the command requires the Go toolchain.
63    pub needs_go: bool,
64    /// Whether the command requires Node.js (e.g. `tsc` / `npx tsc`).
65    pub needs_node: bool,
66    /// Whether the command requires `zig` + `cargo-zigbuild` (a zig cross-build).
67    /// This is on TOP of `needs_cargo` and the requested `needs_targets`.
68    pub needs_zig: bool,
69    /// rustup targets the command explicitly requests (`--target <triple>`).
70    pub needs_targets: Vec<String>,
71    /// Explicit toolchain overrides (`cargo +nightly-…`).
72    pub needs_toolchains: Vec<String>,
73    /// Host OS the worker must run, when a native tool or requested `--target`
74    /// triple implies one (e.g. `xcodebuild` → `darwin`). `None` for the ordinary
75    /// native build, which stays schedulable on any worker.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub needs_os: Option<String>,
78    /// Operator-declared named tools the caller required (`--require-tool`).
79    ///
80    /// Job mode only, and never derived from the command: RCH cannot know that
81    /// `./run_shards.sh` shells out to clang, so the requirement is something
82    /// the caller states, not something a classifier guesses.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub needs_tools: Vec<String>,
85}
86
87impl RequiredCapabilities {
88    /// Convert to the admission-gate [`CapabilityRequirement`] for a given wire
89    /// protocol.
90    #[must_use]
91    pub fn to_requirement(&self, min_protocol: u32) -> CapabilityRequirement {
92        CapabilityRequirement {
93            needs_targets: self.needs_targets.clone(),
94            min_protocol,
95            needs_cargo: self.needs_cargo,
96            needs_bun: self.needs_bun,
97            needs_nix: self.needs_nix,
98            needs_go: self.needs_go,
99            needs_node: self.needs_node,
100            needs_zig: self.needs_zig,
101            needs_toolchains: self.needs_toolchains.clone(),
102            needs_os: self.needs_os.clone(),
103            needs_tools: self.needs_tools.clone(),
104            ..CapabilityRequirement::default()
105        }
106    }
107}
108
109/// The result of a read-only admit preflight.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AdmitPreflight {
112    /// The command as given.
113    pub command: String,
114    /// Sub-commands when the input is a compound shell form (`a && b`); a single
115    /// command yields one element.
116    pub compound: Vec<String>,
117    /// Whether the (whole) command is a compilation worth offloading.
118    pub is_compilation: bool,
119    /// The compilation family, when classified (e.g. `cargo_build`, `bun_test`).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub family: Option<String>,
122    /// Capabilities a worker must have to run it.
123    pub required: RequiredCapabilities,
124    /// Whether proof/strict-remote policy is in force (caller-supplied).
125    pub proof_policy: bool,
126    /// Whether this was preflighted as a job-mode admission (`--job`), where
127    /// the compilation classifier is bypassed rather than consulted.
128    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
129    pub job_mode: bool,
130    /// The decision before daemon candidate data is folded in.
131    pub base_recommendation: AdmitRecommendation,
132    /// Operator-facing explanation.
133    pub detail: String,
134}
135
136/// Snake_case family token for a compilation kind.
137fn family_token(kind: CompilationKind) -> &'static str {
138    match kind {
139        CompilationKind::CargoBuild => "cargo_build",
140        CompilationKind::CargoTest => "cargo_test",
141        CompilationKind::CargoCheck => "cargo_check",
142        CompilationKind::CargoClippy => "cargo_clippy",
143        CompilationKind::CargoDoc => "cargo_doc",
144        CompilationKind::CargoNextest => "cargo_nextest",
145        CompilationKind::CargoBench => "cargo_bench",
146        CompilationKind::CargoZigbuild => "cargo_zigbuild",
147        CompilationKind::Rustc => "rustc",
148        CompilationKind::Gcc => "gcc",
149        CompilationKind::Gpp => "gpp",
150        CompilationKind::Clang => "clang",
151        CompilationKind::Clangpp => "clangpp",
152        CompilationKind::Make => "make",
153        CompilationKind::CmakeBuild => "cmake",
154        CompilationKind::Ninja => "ninja",
155        CompilationKind::Meson => "meson",
156        CompilationKind::BunTest => "bun_test",
157        CompilationKind::BunTypecheck => "bun_typecheck",
158        CompilationKind::NixBuild => "nix_build",
159        CompilationKind::GoBuild => "go_build",
160        CompilationKind::GoTest => "go_test",
161        CompilationKind::GoVet => "go_vet",
162        CompilationKind::Tsc => "tsc",
163        CompilationKind::Job => "job",
164    }
165}
166
167/// Whether a kind is part of the Rust/cargo family (needs cargo).
168fn is_rust_kind(kind: CompilationKind) -> bool {
169    matches!(
170        kind,
171        CompilationKind::CargoBuild
172            | CompilationKind::CargoTest
173            | CompilationKind::CargoCheck
174            | CompilationKind::CargoClippy
175            | CompilationKind::CargoDoc
176            | CompilationKind::CargoNextest
177            | CompilationKind::CargoBench
178            // A zig cross-build runs through cargo, so it needs the cargo toolchain
179            // too (plus zig — see `needs_zig` in derive_capabilities).
180            | CompilationKind::CargoZigbuild
181            | CompilationKind::Rustc
182    )
183}
184
185/// Derive required capabilities from a command string + its classified kind.
186fn derive_capabilities(command: &str, kind: Option<CompilationKind>) -> RequiredCapabilities {
187    let mut req = RequiredCapabilities::default();
188    if let Some(kind) = kind {
189        req.needs_cargo = is_rust_kind(kind);
190        req.needs_bun = matches!(
191            kind,
192            CompilationKind::BunTest | CompilationKind::BunTypecheck
193        );
194        req.needs_nix = matches!(kind, CompilationKind::NixBuild);
195        req.needs_go = matches!(
196            kind,
197            CompilationKind::GoBuild | CompilationKind::GoTest | CompilationKind::GoVet
198        );
199        req.needs_node = matches!(kind, CompilationKind::Tsc);
200        // A zig cross-build additionally needs zig + cargo-zigbuild on the worker.
201        // The requested `--target` std is captured by the needs_targets scan below,
202        // exactly as for a plain `cargo build --target ...`.
203        req.needs_zig = matches!(kind, CompilationKind::CargoZigbuild);
204    }
205    // The xwin matcher validates the entire normalized suffix before admitting
206    // quoted option words. Scan that same suffix so an env/wrapper payload cannot
207    // introduce a target and a quoted --target= option cannot lose its requirement.
208    let xwin = cargo_xwin_compilation_kind(command).is_some();
209    let normalized = xwin.then(|| normalize_command(command));
210    let token_command = normalized.as_deref().unwrap_or(command);
211    // Scan tokens for `--target <triple>` / `--target=<triple>` and `+toolchain`.
212    let tokens: Vec<&str> = token_command
213        .split_whitespace()
214        .map(|token| {
215            if xwin {
216                unquote_literal_option_word(token)
217            } else {
218                token
219            }
220        })
221        .collect();
222    let mut i = 0;
223    while i < tokens.len() {
224        let tok = tokens[i];
225        if let Some(triple) = tok.strip_prefix("--target=") {
226            if !triple.is_empty() {
227                req.needs_targets.push(triple.to_string());
228            }
229        } else if tok == "--target" {
230            if let Some(triple) = tokens.get(i + 1) {
231                req.needs_targets.push((*triple).to_string());
232                i += 1;
233            }
234        } else if let Some(toolchain) = tok.strip_prefix('+') {
235            // A cargo toolchain override token, e.g. `cargo +nightly-2025-11-01 build`.
236            if !toolchain.is_empty() {
237                req.needs_toolchains.push(toolchain.to_string());
238            }
239        }
240        i += 1;
241    }
242    req.needs_targets.sort();
243    req.needs_targets.dedup();
244    req.needs_toolchains.sort();
245    req.needs_toolchains.dedup();
246    req.needs_os = os_from_target_triples(&host_bound_targets(command, &req.needs_targets));
247    req
248}
249
250/// Explicit cross-build tools supply their linker on the build host. Darwin
251/// zigbuild still needs the Apple SDK, Zig, and the requested Rust target, but
252/// the SDK can reside on Linux. Keep native constraints for ordinary Cargo and
253/// for targets the selected cross-build tool does not support here.
254fn host_bound_targets(command: &str, targets: &[String]) -> Vec<String> {
255    let cross_msvc = cargo_xwin_compilation_kind(command).is_some();
256    let cross_darwin = classify_command(command).kind == Some(CompilationKind::CargoZigbuild);
257    targets
258        .iter()
259        .filter(|target| {
260            let target = target.to_ascii_lowercase();
261            !(cross_msvc && target.contains("-windows-msvc")
262                || cross_darwin && target.ends_with("-apple-darwin"))
263        })
264        .cloned()
265        .collect()
266}
267
268/// The host OS a command demands of a worker, if any.
269///
270/// Exposed for the daemon's selection path, which gates on the command string it
271/// already carries rather than on a new wire field — keeping this usable across
272/// a fleet whose `rch` and `rchd` binaries are at mixed versions.
273#[must_use]
274pub fn required_os_for_command(command: &str) -> Option<String> {
275    let mut triples: Vec<String> = Vec::new();
276    for part in split_shell_commands(command) {
277        let detail = classify_command_detailed(part);
278        if detail.classification.is_compilation {
279            let required = derive_capabilities(part, detail.classification.kind);
280            triples.extend(host_bound_targets(part, &required.needs_targets));
281        }
282    }
283    os_from_command_and_targets(command, &triples)
284}
285
286/// Native Apple tools need a Darwin host even in explicit job mode, where the
287/// compilation classifier deliberately does not recognize them. Only inspect
288/// the invoked command word, never an argument mentioning a tool. Opaque shell
289/// scripts are not inferred: callers must invoke the Apple tool directly.
290fn os_from_command_and_targets(command: &str, triples: &[String]) -> Option<String> {
291    let native_hosts = split_shell_commands(command)
292        .into_iter()
293        .filter_map(|part| {
294            let normalized = normalize_command(part);
295            let (word, _) = split_wrapper_word(&normalized)?;
296            match word.rsplit('/').next()? {
297                "xcodebuild" | "xcrun" => Some("darwin"),
298                _ => None,
299            }
300        });
301    unique_host_os(
302        triples
303            .iter()
304            .filter_map(|triple| target_host_os(triple))
305            .chain(native_hosts),
306    )
307}
308
309/// The host OS a set of `--target` triples demands, when they agree on one.
310///
311/// Only a triple naming an OS the worker must *be* counts. `x86_64-pc-windows-msvc`
312/// ordinarily links against the native MSVC toolchain, so it yields `windows`;
313/// explicit cargo-xwin compilations remove their MSVC targets before this step.
314/// Every `*-apple-*` target (darwin, ios, tvos, watchos, visionos)
315/// needs the macOS SDK, so it yields `darwin`. `x86_64-pc-windows-gnu` and
316/// `wasm32-unknown-unknown` cross-compile happily from Linux and yield nothing.
317///
318/// Mixed or absent requirements yield `None`, leaving the command schedulable
319/// anywhere — the historical behaviour. A compound command spanning two host
320/// OSes cannot succeed on any single worker regardless, and returning `None`
321/// keeps that no worse than it is today rather than inventing a new refusal.
322fn os_from_target_triples(triples: &[String]) -> Option<String> {
323    unique_host_os(triples.iter().filter_map(|triple| target_host_os(triple)))
324}
325
326fn target_host_os(triple: &str) -> Option<&'static str> {
327    let lower = triple.to_ascii_lowercase();
328    if lower.contains("-windows-msvc") {
329        Some("windows")
330    } else if lower.contains("-apple-") {
331        Some("darwin")
332    } else {
333        None
334    }
335}
336
337fn unique_host_os(hosts: impl IntoIterator<Item = &'static str>) -> Option<String> {
338    let mut required: Option<String> = None;
339    for os in hosts {
340        match &required {
341            Some(existing) if existing != os => return None,
342            Some(_) => {}
343            None => required = Some(os.to_string()),
344        }
345    }
346    required
347}
348
349/// Run the read-only preflight for a command. `proof_policy` reflects the
350/// caller's strict/proof-remote setting (from env/flags). Pure.
351#[must_use]
352pub fn preflight(command: &str, proof_policy: bool) -> AdmitPreflight {
353    let compound: Vec<String> = split_shell_commands(command)
354        .into_iter()
355        .map(Into::into)
356        .collect();
357    let compound = if compound.is_empty() {
358        vec![command.to_string()]
359    } else {
360        compound
361    };
362
363    // The whole command is a compilation if any sub-command classifies as one;
364    // capabilities are the union across compilation sub-commands.
365    let mut is_compilation = false;
366    let mut family: Option<String> = None;
367    let mut required = RequiredCapabilities::default();
368    let mut host_targets = Vec::new();
369    for part in &compound {
370        let detail = classify_command_detailed(part);
371        let c = &detail.classification;
372        if c.is_compilation {
373            is_compilation = true;
374            if let Some(kind) = c.kind
375                && family.is_none()
376            {
377                family = Some(family_token(kind).to_string());
378            }
379            let part_req = derive_capabilities(part, c.kind);
380            host_targets.extend(host_bound_targets(part, &part_req.needs_targets));
381            required.needs_cargo |= part_req.needs_cargo;
382            required.needs_zig |= part_req.needs_zig;
383            required.needs_bun |= part_req.needs_bun;
384            required.needs_nix |= part_req.needs_nix;
385            required.needs_go |= part_req.needs_go;
386            required.needs_node |= part_req.needs_node;
387            required.needs_targets.extend(part_req.needs_targets);
388            required.needs_toolchains.extend(part_req.needs_toolchains);
389        }
390    }
391    required.needs_targets.sort();
392    required.needs_targets.dedup();
393    required.needs_toolchains.sort();
394    required.needs_toolchains.dedup();
395    // Recomputed over the merged target set: a compound command whose parts
396    // disagree on the required OS must not inherit one part's answer.
397    required.needs_os = os_from_command_and_targets(command, &host_targets);
398
399    let base_recommendation = if is_compilation {
400        AdmitRecommendation::Offload
401    } else {
402        AdmitRecommendation::Local
403    };
404
405    let detail = if !is_compilation {
406        "not a compilation command; run locally".to_string()
407    } else if compound.len() > 1 {
408        format!(
409            "compound command with {} parts; offload-eligible (family={})",
410            compound.len(),
411            family.as_deref().unwrap_or("unknown")
412        )
413    } else {
414        format!(
415            "offload-eligible (family={})",
416            family.as_deref().unwrap_or("unknown")
417        )
418    };
419
420    AdmitPreflight {
421        command: command.to_string(),
422        compound,
423        is_compilation,
424        family,
425        required,
426        proof_policy,
427        job_mode: false,
428        base_recommendation,
429        detail,
430    }
431}
432
433impl AdmitPreflight {
434    /// Add explicit compiler capabilities without bypassing classification or
435    /// replacing the command's existing Rust/target/toolchain requirements.
436    #[must_use]
437    pub fn with_required_tools(mut self, required_tools: Vec<String>) -> Self {
438        self.required.needs_tools.extend(required_tools);
439        self.required.needs_tools.sort();
440        self.required.needs_tools.dedup();
441        self
442    }
443
444    /// Re-frame a preflight as a JOB-mode admission (`rch exec --job`).
445    ///
446    /// Job mode bypasses the compilation classifier rather than consulting it,
447    /// so a job is offload-eligible whatever the classifier thought: `./fuzz.sh`
448    /// is not a compilation and must still preflight as `offload`, otherwise
449    /// this command would answer `local` for every workload job mode exists to
450    /// serve.
451    ///
452    /// The classification facts are KEPT rather than cleared. A caller that
453    /// passes `cargo test` to `--job` should still be able to see that it would
454    /// have classified as a compilation — that is usually a mistake worth
455    /// noticing, and erasing the evidence would hide it.
456    ///
457    /// Required tools are caller-stated, never derived: RCH cannot know that a
458    /// shell script shells out to clang.
459    #[must_use]
460    pub fn as_job(mut self, required_tools: Vec<String>) -> Self {
461        self.job_mode = true;
462        self.required.needs_tools = required_tools;
463        self.required.needs_tools.sort();
464        self.required.needs_tools.dedup();
465        self.base_recommendation = AdmitRecommendation::Offload;
466        let tools = if self.required.needs_tools.is_empty() {
467            String::new()
468        } else {
469            format!(
470                "; requires verified tool(s) {}",
471                self.required.needs_tools.join(", ")
472            )
473        };
474        self.detail = format!(
475            "job-mode admission: classifier bypassed, offload-eligible (classified as {}{})",
476            if self.is_compilation {
477                self.family.as_deref().unwrap_or("a compilation")
478            } else {
479                "non-compilation"
480            },
481            tools
482        );
483        self
484    }
485}
486
487/// Fold aggregated candidate-rejection data into a final recommendation.
488///
489/// A non-offload base stays as-is. For an offload-eligible command: if every
490/// candidate was rejected, distinguish a *structural* gap (command-admissibility
491/// rejections dominate — no worker can run it, so `Defer`) from a *transient*
492/// one (worker-health rejections dominate — busy/unhealthy, so `Queue`). If some
493/// candidate remained admissible, `Offload`.
494#[must_use]
495pub fn refine_recommendation(
496    base: AdmitRecommendation,
497    summary: &AdmissionRejectionSummary,
498) -> AdmitRecommendation {
499    if base != AdmitRecommendation::Offload {
500        return base;
501    }
502    if !summary.all_rejected() {
503        return AdmitRecommendation::Offload;
504    }
505    let command_specific = summary.class_count(RejectionClass::CommandAdmissibility)
506        + summary.class_count(RejectionClass::ProjectPolicy);
507    let health = summary.class_count(RejectionClass::WorkerHealth);
508    if command_specific >= health {
509        AdmitRecommendation::Defer
510    } else {
511        AdmitRecommendation::Queue
512    }
513}
514
515/// Convenience: whether `command` would offload at all (cheap classification).
516#[must_use]
517pub fn is_offloadable(command: &str) -> bool {
518    classify_command(command).is_compilation
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::admission_rejection::{
525        AdmissionRejectionCategory, CandidateRejection, aggregate_rejections,
526    };
527
528    #[test]
529    fn compiler_tool_requirements_preserve_rust_target_and_classification() {
530        let original = preflight(
531            "cargo build --target x86_64-pc-windows-msvc --release",
532            true,
533        );
534        let observed = original.clone().with_required_tools(vec![
535            "cargo-xwin".into(),
536            "llvm-ar".into(),
537            "cargo-xwin".into(),
538        ]);
539        assert_eq!(observed.is_compilation, original.is_compilation);
540        assert_eq!(observed.family, original.family);
541        assert_eq!(observed.base_recommendation, original.base_recommendation);
542        assert_eq!(
543            observed.required.needs_targets,
544            original.required.needs_targets
545        );
546        assert_eq!(
547            observed.required.needs_toolchains,
548            original.required.needs_toolchains
549        );
550        assert!(observed.required.needs_cargo);
551        assert!(!observed.job_mode);
552        assert!(observed.proof_policy);
553        assert_eq!(observed.required.needs_tools, vec!["cargo-xwin", "llvm-ar"]);
554    }
555
556    #[test]
557    fn job_mode_preflight_is_offload_eligible_whatever_the_classifier_said() {
558        // THE point of the job-mode preflight: `exec --job` bypasses the
559        // classifier, so a preflight that consulted it would answer `local`
560        // for every workload job mode exists to serve.
561        let script = preflight("./run_shards.sh --shard 3", false);
562        assert!(!script.is_compilation);
563        assert_eq!(script.base_recommendation, AdmitRecommendation::Local);
564
565        let job = script.clone().as_job(Vec::new());
566        assert!(job.job_mode);
567        assert_eq!(job.base_recommendation, AdmitRecommendation::Offload);
568        assert!(job.detail.contains("classifier bypassed"), "{}", job.detail);
569        // The classification facts are KEPT, not cleared — they are still the
570        // truth about the command, and erasing them would hide a mistake.
571        assert!(!job.is_compilation);
572        assert_eq!(job.command, script.command);
573        assert_eq!(job.compound, script.compound);
574
575        // A compilation passed to --job keeps its family visible, precisely so
576        // a caller can notice they probably did not mean `--job`.
577        let compile = preflight("cargo test --workspace", false).as_job(Vec::new());
578        assert!(compile.is_compilation);
579        assert_eq!(compile.family.as_deref(), Some("cargo_test"));
580        assert_eq!(compile.base_recommendation, AdmitRecommendation::Offload);
581        assert!(compile.detail.contains("cargo_test"), "{}", compile.detail);
582    }
583
584    #[test]
585    fn job_mode_required_tools_are_caller_stated_normalized_and_gate_ready() {
586        // Never derived: nothing can know that a shell script shells out to
587        // clang, so the requirement only ever comes from the caller.
588        assert!(
589            preflight("./fuzz.sh", false)
590                .as_job(Vec::new())
591                .required
592                .needs_tools
593                .is_empty()
594        );
595
596        let job = preflight("./fuzz.sh", false).as_job(vec![
597            "clang".to_string(),
598            "ld.lld".to_string(),
599            "clang".to_string(),
600        ]);
601        assert_eq!(
602            job.required.needs_tools,
603            vec!["clang".to_string(), "ld.lld".to_string()],
604            "sorted and deduped so the same request preflights identically"
605        );
606        assert!(job.detail.contains("clang, ld.lld"), "{}", job.detail);
607
608        // And it converts into the SAME requirement the selection gate uses,
609        // so the preflight cannot drift from the admission it predicts.
610        let requirement = job.required.to_requirement(1);
611        assert_eq!(
612            requirement.needs_tools,
613            vec!["clang".to_string(), "ld.lld".to_string()]
614        );
615    }
616
617    #[test]
618    fn msvc_target_requires_a_windows_host() {
619        // Ordinary Cargo builds use the native Microsoft toolchain. Only an
620        // explicit cross-build entry point can supply that SDK on another OS.
621        assert_eq!(
622            required_os_for_command("cargo build --target x86_64-pc-windows-msvc"),
623            Some("windows".to_string())
624        );
625        assert_eq!(
626            required_os_for_command("cargo build --target=aarch64-pc-windows-msvc --release"),
627            Some("windows".to_string())
628        );
629    }
630
631    #[test]
632    fn cargo_xwin_keeps_rust_requirements_without_a_native_windows_gate() {
633        for (subcommand, family) in [
634            ("build", "cargo_build"),
635            ("check", "cargo_check"),
636            ("clippy", "cargo_clippy"),
637        ] {
638            for (prefix, toolchain) in [
639                ("cargo xwin", None),
640                ("cargo +nightly-2026-08-31 xwin", Some("nightly-2026-08-31")),
641                ("cargo-xwin", None),
642                ("cargo-xwin xwin", None),
643            ] {
644                for (target_argument, target) in [
645                    ("--target x86_64-pc-windows-msvc", "x86_64-pc-windows-msvc"),
646                    (
647                        "--target=aarch64-pc-windows-msvc",
648                        "aarch64-pc-windows-msvc",
649                    ),
650                ] {
651                    let passthrough = if subcommand == "clippy" {
652                        " -- -D warnings"
653                    } else {
654                        ""
655                    };
656                    let command = format!(
657                        "{prefix} {subcommand} --locked --all-targets {target_argument}{passthrough}"
658                    );
659                    let result = preflight(&command, true);
660                    let expected_toolchains: Vec<String> = toolchain
661                        .into_iter()
662                        .map(|value| value.to_string())
663                        .collect();
664                    assert!(result.is_compilation, "{command}");
665                    assert!(result.required.needs_cargo, "{command}");
666                    assert_eq!(result.family.as_deref(), Some(family), "{command}");
667                    assert_eq!(
668                        result.required.needs_targets,
669                        vec![target.to_string()],
670                        "{command}"
671                    );
672                    assert_eq!(
673                        result.required.needs_toolchains, expected_toolchains,
674                        "{command}"
675                    );
676                    assert_eq!(result.required.needs_os, None, "{command}");
677                    assert_eq!(required_os_for_command(&command), None, "{command}");
678                    let requirement = result.required.to_requirement(1);
679                    assert!(requirement.needs_cargo, "{command}");
680                    assert_eq!(requirement.min_protocol, 1, "{command}");
681                    assert_eq!(
682                        requirement.needs_targets, result.required.needs_targets,
683                        "{command}"
684                    );
685                    assert_eq!(
686                        requirement.needs_toolchains, result.required.needs_toolchains,
687                        "{command}"
688                    );
689                    assert_eq!(requirement.needs_os, None, "{command}");
690                }
691            }
692        }
693    }
694
695    #[test]
696    fn cargo_xwin_does_not_relax_other_commands_or_apple_targets() {
697        for command in [
698            "cargo check --target x86_64-pc-windows-msvc",
699            "cargo clippy --target x86_64-pc-windows-msvc -- -D warnings",
700            "cargo xwin build --target x86_64-pc-windows-msvc && cargo build --target x86_64-pc-windows-msvc",
701            "cargo xwin check --target x86_64-pc-windows-msvc && cargo check --target x86_64-pc-windows-msvc",
702            "cargo xwin clippy --target x86_64-pc-windows-msvc && cargo clippy --target x86_64-pc-windows-msvc",
703            "cargo --config xwin build --target x86_64-pc-windows-msvc",
704            "cargo --config xwin check --target x86_64-pc-windows-msvc",
705            "cargo --config xwin clippy --target x86_64-pc-windows-msvc",
706            "cargo build --manifest-path cargo-xwin --target x86_64-pc-windows-msvc",
707            "cargo check --manifest-path 'cargo xwin check' --target x86_64-pc-windows-msvc",
708            "cargo clippy --config 'alias.cross=xwin' --target x86_64-pc-windows-msvc",
709        ] {
710            assert_eq!(
711                required_os_for_command(command).as_deref(),
712                Some("windows"),
713                "{command}"
714            );
715            assert_eq!(
716                preflight(command, true).required.needs_os.as_deref(),
717                Some("windows"),
718                "{command}"
719            );
720        }
721        for command in [
722            "cargo xwin build --target aarch64-apple-darwin",
723            "cargo xwin check --target aarch64-apple-darwin",
724            "cargo xwin clippy --target aarch64-apple-darwin",
725            "cargo-xwin check --target aarch64-apple-darwin",
726            "cargo-xwin xwin clippy --target aarch64-apple-darwin",
727            "cargo xwin build --target x86_64-pc-windows-msvc && cargo build --target aarch64-apple-darwin",
728            "cargo xwin check --target x86_64-pc-windows-msvc && cargo check --target aarch64-apple-darwin",
729        ] {
730            assert_eq!(
731                required_os_for_command(command).as_deref(),
732                Some("darwin"),
733                "{command}"
734            );
735            assert_eq!(
736                preflight(command, true).required.needs_os.as_deref(),
737                Some("darwin"),
738                "{command}"
739            );
740        }
741    }
742
743    #[test]
744    fn darwin_zigbuild_keeps_linker_and_target_requirements_without_native_host_gate() {
745        for command in [
746            "cargo zigbuild --target aarch64-apple-darwin",
747            "cargo +nightly zigbuild --target=x86_64-apple-darwin",
748            "cargo-zigbuild zigbuild --target aarch64-apple-darwin",
749            "env SDKROOT=/opt/MacOSX.sdk cargo zigbuild --target aarch64-apple-darwin",
750        ] {
751            let result = preflight(command, true);
752            assert!(result.is_compilation, "{command}");
753            assert!(result.required.needs_cargo, "{command}");
754            assert!(result.required.needs_zig, "{command}");
755            assert_eq!(result.family.as_deref(), Some("cargo_zigbuild"));
756            assert_eq!(result.required.needs_targets.len(), 1);
757            assert!(result.required.needs_targets[0].ends_with("-apple-darwin"));
758            assert_eq!(result.required.needs_os, None);
759            assert_eq!(required_os_for_command(command), None);
760        }
761    }
762
763    #[test]
764    fn darwin_zigbuild_does_not_relax_native_commands_or_other_apple_targets() {
765        for command in [
766            "cargo build --target aarch64-apple-darwin",
767            "cargo build --manifest-path cargo-zigbuild --target aarch64-apple-darwin",
768            "cargo --config zigbuild build --target aarch64-apple-darwin",
769            "cargo zigbuild --target aarch64-apple-ios",
770            "cargo zigbuild --target aarch64-apple-darwin && cargo build --target aarch64-apple-darwin",
771        ] {
772            assert_eq!(
773                required_os_for_command(command).as_deref(),
774                Some("darwin"),
775                "{command}"
776            );
777            assert_eq!(
778                preflight(command, true).required.needs_os.as_deref(),
779                Some("darwin"),
780                "{command}"
781            );
782        }
783        let command = "cargo zigbuild --target x86_64-pc-windows-msvc";
784        assert_eq!(required_os_for_command(command).as_deref(), Some("windows"));
785        assert_eq!(
786            preflight(command, true).required.needs_os.as_deref(),
787            Some("windows")
788        );
789    }
790
791    #[test]
792    fn cross_compilable_targets_stay_unconstrained() {
793        // mingw and wasm cross-compile from Linux, so constraining them would
794        // strand work that the existing fleet handles today.
795        assert_eq!(
796            required_os_for_command("cargo build --target x86_64-pc-windows-gnu"),
797            None
798        );
799        assert_eq!(
800            required_os_for_command("cargo build --target wasm32-unknown-unknown"),
801            None
802        );
803        assert_eq!(required_os_for_command("cargo check --workspace"), None);
804        assert_eq!(required_os_for_command("cargo test"), None);
805    }
806
807    #[test]
808    fn apple_target_requires_a_darwin_host() {
809        // Every Apple target needs the macOS SDK, not just apple-darwin.
810        for triple in [
811            "aarch64-apple-darwin",
812            "x86_64-apple-darwin",
813            "aarch64-apple-ios",
814            "aarch64-apple-tvos",
815            "arm64_32-apple-watchos",
816        ] {
817            assert_eq!(
818                required_os_for_command(&format!("cargo build --target {triple}")),
819                Some("darwin".to_string()),
820                "triple={triple}"
821            );
822        }
823    }
824
825    #[test]
826    fn native_apple_jobs_require_darwin_without_rust_target_flags() {
827        for command in [
828            "xcodebuild -version",
829            "/usr/bin/xcodebuild -project ios/App.xcodeproj -scheme App build",
830            "'xcodebuild' -scheme App test",
831            "'/Applications/Xcode Beta.app/Contents/Developer/usr/bin/xcodebuild' -version",
832            "env DEVELOPER_DIR='/Applications/Xcode Beta.app' xcodebuild -jobs 1 test",
833            "timeout 1200 xcodebuild -destination 'platform=macOS,variant=Mac Catalyst' test",
834            "xcrun --sdk iphoneos xcodebuild -scheme App build",
835            "/usr/bin/xcrun --find swift",
836            "cd ios && xcodebuild -scheme App build",
837        ] {
838            assert_eq!(
839                required_os_for_command(command).as_deref(),
840                Some("darwin"),
841                "{command}"
842            );
843            let job = preflight(command, true).as_job(Vec::new());
844            assert_eq!(
845                job.required.needs_os.as_deref(),
846                Some("darwin"),
847                "{command}"
848            );
849            assert!(
850                job.required.needs_targets.is_empty(),
851                "not a Rust target: {command}"
852            );
853            assert!(!job.required.needs_cargo, "not a Cargo job: {command}");
854        }
855    }
856
857    #[test]
858    fn native_apple_job_inference_does_not_claim_mentions_or_opaque_scripts() {
859        for command in [
860            "echo xcodebuild -version",
861            "printf '%s' 'xcrun --sdk macosx'",
862            "br comments add bd-example 'xcodebuild -scheme App build'",
863            "env NOTE=xcodebuild bun test",
864            "env -u xcodebuild bun test",
865            "xcodebuild-wrapper -scheme App build",
866            "./check.sh --target aarch64-apple-darwin",
867            "bash -c 'echo xcodebuild'",
868            "bash -c 'xcodebuild -version'",
869            "'xcodebuild -version'",
870            "\"${TOOL}/xcodebuild\" -version",
871            "'unterminated/xcodebuild -version",
872        ] {
873            assert_eq!(required_os_for_command(command), None, "{command}");
874            assert_eq!(
875                preflight(command, true)
876                    .as_job(Vec::new())
877                    .required
878                    .needs_os,
879                None,
880                "{command}"
881            );
882        }
883        assert_eq!(
884            required_os_for_command(
885                "xcodebuild build && cargo build --target x86_64-pc-windows-msvc"
886            ),
887            None,
888            "mixed native host requirements must not inherit just the Rust half"
889        );
890    }
891
892    #[test]
893    fn conflicting_targets_constrain_nothing() {
894        // A compound command spanning two host OSes has no single answer; it must
895        // stay schedulable rather than inherit one half's requirement.
896        assert_eq!(
897            required_os_for_command(
898                "cargo build --target x86_64-pc-windows-msvc && cargo build --target aarch64-apple-darwin"
899            ),
900            None
901        );
902    }
903
904    #[test]
905    fn preflight_surfaces_the_required_os() {
906        let p = preflight("cargo build --target x86_64-pc-windows-msvc", false);
907        assert_eq!(p.required.needs_os.as_deref(), Some("windows"));
908        assert_eq!(
909            p.required.to_requirement(0).needs_os.as_deref(),
910            Some("windows")
911        );
912
913        let plain = preflight("cargo build --release", false);
914        assert_eq!(plain.required.needs_os, None);
915        assert_eq!(plain.required.to_requirement(0).needs_os, None);
916    }
917
918    #[test]
919    fn common_cargo_build_offloads_needing_cargo() {
920        let p = preflight("cargo build --release", false);
921        assert!(p.is_compilation);
922        assert_eq!(p.family.as_deref(), Some("cargo_build"));
923        assert!(p.required.needs_cargo);
924        assert!(!p.required.needs_bun);
925        assert_eq!(p.base_recommendation, AdmitRecommendation::Offload);
926    }
927
928    #[test]
929    fn wasm_target_is_captured_as_required_capability() {
930        let p = preflight("cargo build --target wasm32-unknown-unknown", false);
931        assert!(p.required.needs_cargo);
932        assert_eq!(p.required.needs_targets, vec!["wasm32-unknown-unknown"]);
933        // `--target=triple` form too.
934        let p2 = preflight("cargo test --target=aarch64-apple-darwin", false);
935        assert_eq!(p2.required.needs_targets, vec!["aarch64-apple-darwin"]);
936    }
937
938    #[test]
939    fn explicit_toolchain_override_is_captured() {
940        let p = preflight("cargo +nightly-2025-11-01 build", false);
941        assert_eq!(p.required.needs_toolchains, vec!["nightly-2025-11-01"]);
942    }
943
944    #[test]
945    fn bun_command_needs_bun() {
946        let p = preflight("bun test", false);
947        if p.is_compilation {
948            assert!(p.required.needs_bun);
949            assert!(!p.required.needs_cargo);
950            assert_eq!(p.base_recommendation, AdmitRecommendation::Offload);
951        } else {
952            // If the classifier doesn't treat this bun form as compilation, the
953            // preflight must still be decisive (local), never ambiguous.
954            assert_eq!(p.base_recommendation, AdmitRecommendation::Local);
955        }
956    }
957
958    #[test]
959    fn non_compilation_runs_local() {
960        let p = preflight("ls -la", false);
961        assert!(!p.is_compilation);
962        assert_eq!(p.base_recommendation, AdmitRecommendation::Local);
963        assert!(!p.required.needs_cargo && !p.required.needs_bun);
964        assert!(p.detail.contains("locally"));
965    }
966
967    #[test]
968    fn quoted_exec_form_is_classified_decisively() {
969        // A quoted exec wrapper around a build still resolves to a definite
970        // recommendation (never ambiguous).
971        let p = preflight("sh -c 'cargo build'", false);
972        assert!(matches!(
973            p.base_recommendation,
974            AdmitRecommendation::Offload | AdmitRecommendation::Local
975        ));
976    }
977
978    #[test]
979    fn compound_shell_form_is_split_and_unioned() {
980        let p = preflight("cargo build && bun test", false);
981        assert!(
982            p.compound.len() >= 2,
983            "compound must be split: {:?}",
984            p.compound
985        );
986        // It is compilation if either part is, and capabilities union.
987        if p.is_compilation {
988            assert!(p.required.needs_cargo || p.required.needs_bun);
989        }
990    }
991
992    #[test]
993    fn refine_offload_when_some_candidate_admissible() {
994        // 1 of 3 rejected => others admissible => Offload.
995        let summary = aggregate_rejections(
996            3,
997            &[CandidateRejection {
998                worker_id: "a".into(),
999                category: AdmissionRejectionCategory::InsufficientSlots,
1000            }],
1001        );
1002        assert_eq!(
1003            refine_recommendation(AdmitRecommendation::Offload, &summary),
1004            AdmitRecommendation::Offload
1005        );
1006    }
1007
1008    #[test]
1009    fn refine_queue_when_all_rejected_for_health() {
1010        // All rejected, dominated by worker-health (busy/unhealthy) => Queue.
1011        let summary = aggregate_rejections(
1012            2,
1013            &[
1014                CandidateRejection {
1015                    worker_id: "a".into(),
1016                    category: AdmissionRejectionCategory::InsufficientSlots,
1017                },
1018                CandidateRejection {
1019                    worker_id: "b".into(),
1020                    category: AdmissionRejectionCategory::CircuitOpen,
1021                },
1022            ],
1023        );
1024        assert_eq!(
1025            refine_recommendation(AdmitRecommendation::Offload, &summary),
1026            AdmitRecommendation::Queue
1027        );
1028    }
1029
1030    #[test]
1031    fn refine_defer_when_all_rejected_for_capability() {
1032        // All rejected for command-specific capability => no worker can ever run
1033        // it => Defer (waiting on a slot is pointless).
1034        let summary = aggregate_rejections(
1035            2,
1036            &[
1037                CandidateRejection {
1038                    worker_id: "a".into(),
1039                    category: AdmissionRejectionCategory::MissingRustTarget,
1040                },
1041                CandidateRejection {
1042                    worker_id: "b".into(),
1043                    category: AdmissionRejectionCategory::OsArchMismatch,
1044                },
1045            ],
1046        );
1047        assert_eq!(
1048            refine_recommendation(AdmitRecommendation::Offload, &summary),
1049            AdmitRecommendation::Defer
1050        );
1051    }
1052
1053    #[test]
1054    fn refine_keeps_local_recommendation() {
1055        let summary = aggregate_rejections(0, &[]);
1056        assert_eq!(
1057            refine_recommendation(AdmitRecommendation::Local, &summary),
1058            AdmitRecommendation::Local
1059        );
1060    }
1061
1062    #[test]
1063    fn required_capabilities_convert_to_requirement() {
1064        let p = preflight("cargo build --target wasm32-unknown-unknown", false);
1065        let req = p.required.to_requirement(3);
1066        assert!(req.needs_cargo);
1067        assert_eq!(req.min_protocol, 3);
1068        assert_eq!(
1069            req.needs_targets,
1070            vec!["wasm32-unknown-unknown".to_string()]
1071        );
1072    }
1073
1074    #[test]
1075    fn preflight_serializes_with_stable_tokens() {
1076        let p = preflight("cargo build", false);
1077        let value = serde_json::to_value(&p).unwrap();
1078        assert_eq!(value["base_recommendation"], "offload");
1079        assert_eq!(value["is_compilation"], true);
1080        let back: AdmitPreflight = serde_json::from_value(value).unwrap();
1081        assert_eq!(back, p);
1082    }
1083}