1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AdmitRecommendation {
30 Offload,
32 Local,
34 Queue,
36 Defer,
39}
40
41impl AdmitRecommendation {
42 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct RequiredCapabilities {
58 pub needs_cargo: bool,
59 pub needs_bun: bool,
60 pub needs_nix: bool,
62 pub needs_go: bool,
64 pub needs_node: bool,
66 pub needs_zig: bool,
69 pub needs_targets: Vec<String>,
71 pub needs_toolchains: Vec<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub needs_os: Option<String>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub needs_tools: Vec<String>,
85}
86
87impl RequiredCapabilities {
88 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AdmitPreflight {
112 pub command: String,
114 pub compound: Vec<String>,
117 pub is_compilation: bool,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub family: Option<String>,
122 pub required: RequiredCapabilities,
124 pub proof_policy: bool,
126 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
129 pub job_mode: bool,
130 pub base_recommendation: AdmitRecommendation,
132 pub detail: String,
134}
135
136fn 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
167fn 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 | CompilationKind::CargoZigbuild
181 | CompilationKind::Rustc
182 )
183}
184
185fn 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 req.needs_zig = matches!(kind, CompilationKind::CargoZigbuild);
204 }
205 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 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 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
250fn 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#[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
286fn 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
309fn 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#[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 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 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 #[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 #[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#[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#[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 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 assert!(!job.is_compilation);
572 assert_eq!(job.command, script.command);
573 assert_eq!(job.compound, script.compound);
574
575 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 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 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 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 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 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 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 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 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 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 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 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 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 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}