1use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6use serde::Serialize;
7use sha2::{Digest, Sha256};
8
9use crate::config::schema::{
10 CargoInstall, ComponentDef, ConfigDocument, EnvironmentMutation, InstallSpec, OriginMap,
11 VariantDef,
12};
13use crate::error::ForgeError;
14use crate::model::InstallKind;
15use crate::planning::plan::{
16 ExecutionNode, ExecutionPlan, NodeKind, PlanPolicy, ResolvedComponent, ResourceClaim,
17 TargetPlatform,
18};
19
20pub struct PlanRequest<'a> {
22 pub document: &'a ConfigDocument,
24 pub origins: &'a OriginMap,
26 pub profile: &'a str,
28 pub target: TargetPlatform,
30 pub only: &'a [String],
32 pub exclude: &'a [String],
34 pub source_root: Option<&'a Path>,
36}
37
38pub fn build_plan(request: PlanRequest<'_>) -> Result<ExecutionPlan, ForgeError> {
46 request.target.validate_supported()?;
47 let mut resolver = Resolver::new(&request);
48 resolver.resolve_profile(request.profile)?;
49 resolver.apply_filters()?;
50 let (components, unsupported_components) = resolver.finish()?;
51 let environment = request
52 .document
53 .environment
54 .mutations
55 .iter()
56 .filter(|mutation| {
57 mutation
58 .platforms()
59 .iter()
60 .any(|selector| request.target.matches(selector))
61 })
62 .cloned()
63 .collect::<Vec<_>>();
64 let nodes = compile_nodes(&components, &environment);
65 let config_hash = digest(request.document)?;
66 let mut plan = ExecutionPlan {
67 profile: request.profile.to_string(),
68 target: request.target.clone(),
69 config_hash,
70 plan_hash: String::new(),
71 policy: PlanPolicy {
72 network: request.document.policy.network,
73 max_parallel: request.document.policy.max_parallel,
74 max_downloads: request.document.policy.max_downloads,
75 max_memory_mib: request.document.policy.max_memory_mib,
76 },
77 certificate_preflight: request
78 .document
79 .preflight
80 .certificate
81 .as_ref()
82 .filter(|certificate| {
83 certificate
84 .platforms
85 .iter()
86 .any(|selector| request.target.matches(selector))
87 })
88 .cloned(),
89 components,
90 unsupported_components,
91 nodes,
92 environment,
93 apt_mirror: request.document.apt_mirror.clone(),
94 origins: request.origins.fields.clone(),
95 };
96 plan.plan_hash = digest(&plan)?;
97 Ok(plan)
98}
99
100struct Resolver<'a> {
101 request: &'a PlanRequest<'a>,
102 component_index: BTreeMap<&'a str, usize>,
103 selected: BTreeMap<String, ResolvedComponent>,
104 resolving: Vec<String>,
105 requested: BTreeSet<String>,
106 has_cargo_provider: bool,
107 has_rustup_provider: bool,
108 unsupported: BTreeMap<String, crate::planning::plan::UnsupportedComponent>,
109}
110
111impl<'a> Resolver<'a> {
112 fn new(request: &'a PlanRequest<'a>) -> Self {
113 let component_index = request
114 .document
115 .components
116 .iter()
117 .enumerate()
118 .map(|(index, component)| (component.id.as_str(), index))
119 .collect();
120 let mut resolver = Self {
121 request,
122 component_index,
123 selected: BTreeMap::new(),
124 resolving: Vec::new(),
125 requested: BTreeSet::new(),
126 has_cargo_provider: false,
127 has_rustup_provider: false,
128 unsupported: BTreeMap::new(),
129 };
130 resolver.has_cargo_provider = request
131 .document
132 .components
133 .iter()
134 .any(|component| resolver.component_provides(component, "cargo"));
135 resolver.has_rustup_provider = request
136 .document
137 .components
138 .iter()
139 .any(|component| resolver.component_provides(component, "rustup"));
140 resolver
141 }
142
143 fn resolve_profile(&mut self, profile: &str) -> Result<(), ForgeError> {
144 let mut entries = Vec::new();
145 self.expand_profile(
146 profile,
147 &mut BTreeSet::new(),
148 &mut BTreeSet::new(),
149 &mut entries,
150 )?;
151 for reference in entries {
152 let direct_id = reference.strip_prefix("component:").unwrap_or(&reference);
153 if let Some(definition) = self
154 .component_index
155 .get(direct_id)
156 .map(|index| &self.request.document.components[*index])
157 {
158 self.requested.insert(direct_id.to_string());
159 if !self.component_supports_target(definition) {
160 self.unsupported
161 .entry(direct_id.to_string())
162 .or_insert_with(|| crate::planning::plan::UnsupportedComponent {
163 id: definition.id.clone(),
164 display_name: definition.display_name.clone(),
165 kind: definition.kind,
166 optional: definition.optional,
167 });
168 continue;
169 }
170 }
171 let id = self.resolve_reference(&reference, &format!("profile:{profile}"))?;
172 self.requested.insert(id);
173 }
174 Ok(())
175 }
176
177 fn component_supports_target(&self, component: &ComponentDef) -> bool {
178 component
179 .platforms
180 .iter()
181 .any(|selector| self.request.target.matches(selector))
182 || component.variants.iter().any(|variant| {
183 variant
184 .platforms
185 .iter()
186 .any(|selector| self.request.target.matches(selector))
187 })
188 }
189
190 fn expand_profile(
191 &self,
192 name: &str,
193 visiting: &mut BTreeSet<String>,
194 seen: &mut BTreeSet<String>,
195 output: &mut Vec<String>,
196 ) -> Result<(), ForgeError> {
197 let profile = self
198 .request
199 .document
200 .profiles
201 .get(name)
202 .ok_or_else(|| ForgeError::Config(format!("profile does not exist: {name}")))?;
203 if !visiting.insert(name.to_string()) {
204 return Err(ForgeError::Config(format!(
205 "profile inheritance cycle: {name}"
206 )));
207 }
208 for parent in &profile.inherits {
209 self.expand_profile(parent, visiting, seen, output)?;
210 }
211 for component in &profile.components {
212 for expanded in self.request.document.expand_profile_entry(component)? {
213 if seen.insert(expanded.clone()) {
214 output.push(expanded);
215 }
216 }
217 }
218 visiting.remove(name);
219 Ok(())
220 }
221
222 fn resolve_reference(&mut self, reference: &str, reason: &str) -> Result<String, ForgeError> {
223 let id = if let Some(capability) = reference.strip_prefix("capability:") {
224 self.provider_for(capability)?
225 } else {
226 reference
227 .strip_prefix("component:")
228 .unwrap_or(reference)
229 .to_string()
230 };
231 self.resolve_component(&id, reason)?;
232 Ok(id)
233 }
234
235 fn provider_for(&self, capability: &str) -> Result<String, ForgeError> {
236 let mut providers = self
237 .request
238 .document
239 .components
240 .iter()
241 .filter(|component| self.component_provides(component, capability))
242 .map(|component| component.id.clone())
243 .collect::<Vec<_>>();
244 providers.sort();
245 match providers.as_slice() {
246 [] => Err(ForgeError::Config(format!(
247 "capability has no provider for the current platform: {capability}"
248 ))),
249 [provider] => Ok(provider.clone()),
250 _ => Err(ForgeError::Config(format!(
251 "capability has multiple providers on the current platform; resolve the ambiguity: {capability} -> {}",
252 providers.join(", ")
253 ))),
254 }
255 }
256
257 fn component_provides(&self, component: &ComponentDef, capability: &str) -> bool {
258 let base_matches = component.provides.iter().any(|value| value == capability)
259 && component
260 .platforms
261 .iter()
262 .any(|selector| self.request.target.matches(selector));
263 let variant_matches = component.variants.iter().any(|variant| {
264 variant.provides.iter().any(|value| value == capability)
265 && variant
266 .platforms
267 .iter()
268 .any(|selector| self.request.target.matches(selector))
269 });
270 (base_matches || variant_matches) && self.component_has_platform_contract(component)
271 }
272
273 fn component_has_platform_contract(&self, component: &ComponentDef) -> bool {
274 let variant = component.variants.iter().find(|variant| {
275 variant
276 .platforms
277 .iter()
278 .any(|selector| self.request.target.matches(selector))
279 });
280 let install = variant
281 .and_then(|variant| variant.install.as_ref())
282 .or(component.install.as_ref());
283 !matches!(
284 install,
285 Some(InstallSpec::Rustup(rustup))
286 if rustup.bootstrap.as_ref().is_some_and(|bootstrap| {
287 !bootstrap.sha256.contains_key(&self.request.target.selector())
288 })
289 )
290 }
291
292 fn resolve_component(&mut self, id: &str, reason: &str) -> Result<(), ForgeError> {
293 if let Some(component) = self.selected.get_mut(id) {
294 if !component.requested_by.iter().any(|value| value == reason) {
295 component.requested_by.push(reason.to_string());
296 }
297 return Ok(());
298 }
299 if self.resolving.iter().any(|value| value == id) {
300 return Err(ForgeError::Config(format!(
301 "component dependency cycle: {} -> {id}",
302 self.resolving.join(" -> ")
303 )));
304 }
305 let definition = self
306 .component_index
307 .get(id)
308 .map(|index| &self.request.document.components[*index])
309 .ok_or_else(|| ForgeError::Config(format!("component does not exist: {id}")))?
310 .clone();
311 let variant = select_variant(&definition, &self.request.target)?.cloned();
312 if variant.is_none()
313 && !definition
314 .platforms
315 .iter()
316 .any(|selector| self.request.target.matches(selector))
317 {
318 return Err(ForgeError::Config(format!(
319 "component {id} does not support platform {}",
320 self.request.target.selector()
321 )));
322 }
323 self.resolving.push(id.to_string());
324 let install = variant
325 .as_ref()
326 .and_then(|value| value.install.clone())
327 .or_else(|| definition.install.clone());
328 let mut requires = definition
329 .requires
330 .iter()
331 .chain(variant.iter().flat_map(|value| value.requires.iter()))
332 .cloned()
333 .collect::<BTreeSet<_>>();
334 let provides = definition
335 .provides
336 .iter()
337 .chain(variant.iter().flat_map(|value| value.provides.iter()))
338 .cloned()
339 .collect::<BTreeSet<_>>();
340 match &install {
341 Some(InstallSpec::Cargo(_))
342 if !provides.contains("cargo") && self.has_cargo_provider =>
343 {
344 requires.insert("capability:cargo".into());
345 }
346 Some(InstallSpec::Rustup(rustup))
347 if rustup.bootstrap.is_none()
348 && !provides.contains("rustup")
349 && self.has_rustup_provider =>
350 {
351 requires.insert("capability:rustup".into());
352 }
353 _ => {}
354 }
355 let mut dependencies = Vec::new();
356 for reference in &requires {
357 let dependency = self.resolve_reference(reference, id)?;
358 if !dependencies.contains(&dependency) {
359 dependencies.push(dependency);
360 }
361 }
362 self.resolving.pop();
363 if let Some(InstallSpec::Rustup(rustup)) = &install
364 && let Some(bootstrap) = &rustup.bootstrap
365 {
366 let target = self.request.target.selector();
367 if !bootstrap.sha256.contains_key(&target) {
368 return Err(ForgeError::Config(format!(
369 "component {id} rustup bootstrap is missing a digest for target: {target}"
370 )));
371 }
372 }
373 let resolved = ResolvedComponent {
374 id: id.to_string(),
375 display_name: definition.display_name,
376 version: variant
377 .as_ref()
378 .and_then(|value| value.version.clone())
379 .or(definition.version),
380 optional: definition.optional,
381 allow_insecure_hosts: definition.allow_insecure_hosts,
382 kind: definition.kind,
383 variant: variant.as_ref().map(|value| value.id.clone()),
384 requested_by: vec![reason.to_string()],
385 dependencies,
386 provides: provides.into_iter().collect(),
387 conflicts: definition
388 .conflicts
389 .iter()
390 .chain(variant.iter().flat_map(|value| value.conflicts.iter()))
391 .cloned()
392 .collect::<BTreeSet<_>>()
393 .into_iter()
394 .collect(),
395 detect: variant
396 .as_ref()
397 .and_then(|value| value.detect.clone())
398 .or(definition.detect),
399 install,
400 verify: variant
401 .as_ref()
402 .and_then(|value| value.verify.clone())
403 .or(definition.verify),
404 source: definition.source.map(|source| {
405 let path = Path::new(&source);
406 if path.is_relative() {
407 self.request
408 .source_root
409 .map(|root| root.join(path).display().to_string())
410 .unwrap_or(source)
411 } else {
412 source
413 }
414 }),
415 revision: definition.revision,
416 agents: definition.agents,
417 };
418 self.selected.insert(id.to_string(), resolved);
419 Ok(())
420 }
421
422 fn apply_filters(&mut self) -> Result<(), ForgeError> {
423 let only = self.request.only.iter().cloned().collect::<BTreeSet<_>>();
424 let exclude = self
425 .request
426 .exclude
427 .iter()
428 .cloned()
429 .collect::<BTreeSet<_>>();
430 for id in only.iter().chain(exclude.iter()) {
431 if !self.requested.contains(id) {
432 return Err(ForgeError::Config(format!(
433 "selection is not a direct component of the profile: {id}"
434 )));
435 }
436 }
437 if !only.is_empty() {
438 let unsupported = only
439 .iter()
440 .filter(|id| !self.selected.contains_key(*id))
441 .cloned()
442 .collect::<Vec<_>>();
443 if !unsupported.is_empty() {
444 return Err(ForgeError::Config(format!(
445 "component selected by --only does not support the current platform: {}",
446 unsupported.join(", ")
447 )));
448 }
449 let roots = self
450 .requested
451 .iter()
452 .filter(|id| only.contains(*id))
453 .cloned()
454 .collect::<Vec<_>>();
455 self.retain_dependency_closure(&roots);
456 self.unsupported.retain(|id, _| only.contains(id));
457 }
458 if !exclude.is_empty() {
459 let blocked = self
460 .selected
461 .values()
462 .filter(|component| {
463 component
464 .dependencies
465 .iter()
466 .any(|dependency| exclude.contains(dependency))
467 })
468 .map(|component| component.id.clone())
469 .collect::<Vec<_>>();
470 if !blocked.is_empty() {
471 return Err(ForgeError::Config(format!(
472 "cannot exclude a component that is still required: {}",
473 blocked.join(", ")
474 )));
475 }
476 self.selected.retain(|id, _| !exclude.contains(id));
477 self.unsupported.retain(|id, _| !exclude.contains(id));
478 }
479 Ok(())
480 }
481
482 fn retain_dependency_closure(&mut self, roots: &[String]) {
483 let mut keep = BTreeSet::new();
484 let mut pending = roots.to_vec();
485 while let Some(id) = pending.pop() {
486 if keep.insert(id.clone())
487 && let Some(component) = self.selected.get(&id)
488 {
489 pending.extend(component.dependencies.iter().cloned());
490 }
491 }
492 self.selected.retain(|id, _| keep.contains(id));
493 }
494
495 fn finish(
496 self,
497 ) -> Result<
498 (
499 Vec<ResolvedComponent>,
500 Vec<crate::planning::plan::UnsupportedComponent>,
501 ),
502 ForgeError,
503 > {
504 for component in self.selected.values() {
505 for conflict in &component.conflicts {
506 let conflicting = if let Some(capability) = conflict.strip_prefix("capability:") {
507 self.selected.values().find(|candidate| {
508 candidate.id != component.id
509 && candidate.provides.iter().any(|value| value == capability)
510 })
511 } else {
512 let id = conflict.strip_prefix("component:").unwrap_or(conflict);
513 self.selected.get(id)
514 };
515 if let Some(conflicting) = conflicting {
516 return Err(ForgeError::Config(format!(
517 "component conflict: {} <-> {}",
518 component.id, conflicting.id
519 )));
520 }
521 }
522 }
523 let unsupported = self.unsupported.into_values().collect();
524 Ok((topological(self.selected)?, unsupported))
525 }
526}
527
528fn select_variant<'a>(
529 component: &'a ComponentDef,
530 target: &TargetPlatform,
531) -> Result<Option<&'a VariantDef>, ForgeError> {
532 let matches = component
533 .variants
534 .iter()
535 .filter(|variant| {
536 variant
537 .platforms
538 .iter()
539 .any(|selector| target.matches(selector))
540 })
541 .collect::<Vec<_>>();
542 if matches.len() > 1 {
543 return Err(ForgeError::Config(format!(
544 "component {} matches multiple variants on platform {}: {}",
545 component.id,
546 target.selector(),
547 matches
548 .iter()
549 .map(|variant| variant.id.as_str())
550 .collect::<Vec<_>>()
551 .join(", ")
552 )));
553 }
554 Ok(matches.into_iter().next())
555}
556
557fn topological(
558 mut components: BTreeMap<String, ResolvedComponent>,
559) -> Result<Vec<ResolvedComponent>, ForgeError> {
560 let mut indegree = components
561 .iter()
562 .map(|(id, component)| (id.clone(), component.dependencies.len()))
563 .collect::<BTreeMap<_, _>>();
564 let mut dependents = BTreeMap::<String, Vec<String>>::new();
565 for (id, component) in &components {
566 for dependency in &component.dependencies {
567 dependents
568 .entry(dependency.clone())
569 .or_default()
570 .push(id.clone());
571 }
572 }
573 let mut ready = indegree
574 .iter()
575 .filter(|(_, count)| **count == 0)
576 .map(|(id, _)| id.clone())
577 .collect::<BTreeSet<_>>();
578 let mut output = Vec::new();
579 while let Some(id) = ready.pop_first() {
580 let component = components.remove(&id).ok_or_else(|| {
581 ForgeError::Config(format!("component graph is missing a topology node: {id}"))
582 })?;
583 output.push(component);
584 for dependent in dependents.get(&id).into_iter().flatten() {
585 let count = indegree.get_mut(dependent).ok_or_else(|| {
586 ForgeError::Config(format!(
587 "component graph is missing an indegree node: {dependent}"
588 ))
589 })?;
590 *count -= 1;
591 if *count == 0 {
592 ready.insert(dependent.clone());
593 }
594 }
595 }
596 if !components.is_empty() {
597 return Err(ForgeError::Config(
598 "component graph cannot be topologically sorted".to_string(),
599 ));
600 }
601 Ok(output)
602}
603
604fn compile_nodes(
605 components: &[ResolvedComponent],
606 environment: &[EnvironmentMutation],
607) -> Vec<ExecutionNode> {
608 let environment_node = (!environment.is_empty()).then(|| "environment:apply".to_string());
609 let terminal = components
610 .iter()
611 .map(|component| (component.id.clone(), format!("{}:acquire", component.id)))
612 .collect::<BTreeMap<_, _>>();
613 let mut nodes = Vec::new();
614 if let Some(id) = &environment_node {
615 nodes.push(node(
616 id,
617 "environment",
618 NodeKind::Environment,
619 Vec::new(),
620 vec![lock("environment-write")],
621 ));
622 }
623 for component in components {
624 let mut prerequisites = component
625 .dependencies
626 .iter()
627 .filter_map(|dependency| terminal.get(dependency).cloned())
628 .collect::<Vec<_>>();
629 if let Some(environment) = &environment_node {
630 prerequisites.push(environment.clone());
631 }
632 let resources = match &component.install {
633 Some(InstallSpec::Cargo(cargo)) => cargo_pipeline_resources(component, cargo),
634 Some(_) => backend_resources(component.install.as_ref()),
635 None if component.kind == InstallKind::Skill => {
636 vec![
637 network(),
638 disk(),
639 lock("registry-write"),
640 lock(&format!("skill:{}", component.id)),
641 ]
642 }
643 None => vec![lock("registry-write")],
644 };
645 nodes.push(node(
646 &format!("{}:acquire", component.id),
647 &component.id,
648 NodeKind::Acquire,
649 prerequisites,
650 resources,
651 ));
652 }
653 nodes
654}
655
656fn node(
657 id: &str,
658 component: &str,
659 kind: NodeKind,
660 dependencies: Vec<String>,
661 resources: Vec<ResourceClaim>,
662) -> ExecutionNode {
663 ExecutionNode {
664 id: id.to_string(),
665 component: component.to_string(),
666 kind,
667 dependencies,
668 resources,
669 }
670}
671
672fn backend_resources(install: Option<&InstallSpec>) -> Vec<ResourceClaim> {
673 match install {
674 Some(InstallSpec::Apt(_)) => vec![lock("package-manager:apt")],
675 Some(InstallSpec::Brew(_)) => vec![network(), lock("package-manager:brew")],
676 Some(InstallSpec::Rustup(_)) => vec![lock("package-manager:rustup")],
677 Some(InstallSpec::Npm(_)) => vec![network(), lock("package-manager:npm")],
678 Some(InstallSpec::Pip(_)) => vec![network(), lock("package-manager:pip")],
679 Some(InstallSpec::UvTool(uv)) => {
680 let mut resources = vec![network(), lock("package-manager:uv")];
681 resources.extend(bin_locks(&uv.bins));
682 resources
683 }
684 Some(InstallSpec::Winget(_)) => vec![network(), lock("package-manager:winget")],
685 Some(InstallSpec::Git(git)) => {
686 let mut resources = vec![network(), cpu(), disk(), lock("registry-write")];
687 resources.extend(bin_locks(&git.bins.keys().cloned().collect::<Vec<_>>()));
688 resources
689 }
690 Some(InstallSpec::Shell(shell)) => shell.resources.iter().map(|key| lock(key)).collect(),
691 _ => Vec::new(),
692 }
693}
694
695fn cargo_pipeline_resources(
696 _component: &ResolvedComponent,
697 cargo: &CargoInstall,
698) -> Vec<ResourceClaim> {
699 let mut resources = vec![network(), cpu(), memory(), disk()];
700 let source_digest = cargo.source_digest();
701 let fingerprint = cargo.fingerprint(&source_digest, &cargo.lock_digest());
702 resources.push(lock(&format!("cargo-build:{fingerprint}")));
703 resources.push(lock(&format!("artifact:{fingerprint}")));
704 resources.extend(bin_locks(&cargo.bins));
705 resources
706}
707
708fn bin_locks(bins: &[String]) -> Vec<ResourceClaim> {
709 let mut bins = bins.to_vec();
710 bins.sort();
711 bins.into_iter()
712 .map(|bin| lock(&format!("bin:{bin}")))
713 .collect()
714}
715
716fn network() -> ResourceClaim {
717 ResourceClaim {
718 key: "network".to_string(),
719 units: 1,
720 }
721}
722
723fn cpu() -> ResourceClaim {
724 ResourceClaim {
725 key: "cpu".to_string(),
726 units: 1,
727 }
728}
729
730fn memory() -> ResourceClaim {
731 ResourceClaim {
732 key: "memory-mib".to_string(),
733 units: 512,
734 }
735}
736
737fn disk() -> ResourceClaim {
738 ResourceClaim {
739 key: "disk-io".to_string(),
740 units: 1,
741 }
742}
743
744fn lock(key: &str) -> ResourceClaim {
745 ResourceClaim {
746 key: key.to_string(),
747 units: 1,
748 }
749}
750
751fn digest(value: &impl Serialize) -> Result<String, ForgeError> {
752 let bytes = serde_json::to_vec(value)
753 .map_err(|error| ForgeError::Config(format!("failed to compute plan hash: {error}")))?;
754 Ok(Sha256::digest(bytes)
755 .iter()
756 .map(|byte| format!("{byte:02x}"))
757 .collect())
758}
759
760#[cfg(test)]
761mod tests {
762 use crate::config::schema::{
763 CargoInstall, CertificatePreflightDef, CheckSpec, ComponentDef, EnvironmentMutation,
764 MutationScope, Policy, ProfileDef, ShellInstall, VariantDef,
765 };
766 use crate::config::schema::{ConfigDocument, InstallSpec, OriginMap};
767 use crate::model::InstallKind;
768 use crate::planning::{NodeKind, PlanRequest, TargetPlatform, build_plan};
769
770 fn cargo_component(id: &str) -> ComponentDef {
771 ComponentDef {
772 id: id.to_string(),
773 kind: InstallKind::Tool,
774 display_name: None,
775 version: None,
776 requires: Vec::new(),
777 provides: vec!["cargo".into()],
778 conflicts: Vec::new(),
779 optional: false,
780 allow_insecure_hosts: Vec::new(),
781 platforms: vec!["*".to_string()],
782 detect: Some(CheckSpec::Command {
783 program: id.to_string(),
784 args: vec!["--version".to_string()],
785 stdout_contains: None,
786 success_codes: vec![0],
787 timeout_secs: Some(10),
788 }),
789 install: Some(InstallSpec::Cargo(CargoInstall {
790 crate_name: id.to_string(),
791 version: "=1.0.0".to_string(),
792 source: None,
793 revision: None,
794 locked: true,
795 features: Vec::new(),
796 bins: vec![id.to_string()],
797 target: None,
798 toolchain: None,
799 profile: "release".to_string(),
800 build_env_allow: Vec::new(),
801 })),
802 verify: None,
803 variants: Vec::new(),
804 source: None,
805 revision: None,
806 agents: Vec::new(),
807 }
808 }
809
810 #[test]
811 fn cargo_compiles_to_one_transaction_with_stage_lock_metadata() {
812 let mut document = ConfigDocument {
813 policy: Policy::default(),
814 ..ConfigDocument::default()
815 };
816 document.profiles.insert(
817 "default".to_string(),
818 ProfileDef {
819 inherits: Vec::new(),
820 components: vec!["cargo-audit".to_string()],
821 ..ProfileDef::default()
822 },
823 );
824 document.components.push(cargo_component("cargo-audit"));
825 let plan = build_plan(PlanRequest {
826 document: &document,
827 origins: &OriginMap::default(),
828 profile: "default",
829 target: TargetPlatform::host(),
830 only: &[],
831 exclude: &[],
832 source_root: None,
833 })
834 .unwrap();
835 let kinds = plan.nodes.iter().map(|node| node.kind).collect::<Vec<_>>();
836 assert_eq!(kinds, vec![NodeKind::Acquire]);
837 let transaction = &plan.nodes[0];
838 assert!(
839 !transaction
840 .resources
841 .iter()
842 .any(|claim| claim.key == "registry-write")
843 );
844 assert!(
845 !transaction
846 .resources
847 .iter()
848 .any(|claim| claim.key.starts_with("cargo-source:"))
849 );
850 assert!(
851 transaction
852 .resources
853 .iter()
854 .any(|claim| claim.key.starts_with("cargo-build:"))
855 );
856 assert_eq!(plan.plan_hash.len(), 64);
857 assert!(
858 !serde_json::to_string(&plan)
859 .unwrap()
860 .contains("schema_version")
861 );
862 }
863
864 #[test]
865 fn certificate_preflight_is_frozen_only_for_matching_targets() {
866 let mut document = ConfigDocument::default();
867 document
868 .profiles
869 .insert("default".into(), ProfileDef::default());
870 document.policy.allow_shell = true;
871 document.preflight.certificate = Some(CertificatePreflightDef {
872 platforms: vec!["linux-*".into()],
873 detect: CheckSpec::Path {
874 path: "/tmp/company.crt".into(),
875 },
876 install: InstallSpec::Shell(ShellInstall {
877 command: "install-company-certificate".into(),
878 resources: vec!["network".into()],
879 rollback: None,
880 timeout_secs: Some(30),
881 inactivity_timeout_secs: None,
882 }),
883 verify: None,
884 });
885 document.validate().unwrap();
886 let origins = OriginMap::default();
887 let linux = build_plan(PlanRequest {
888 document: &document,
889 origins: &origins,
890 profile: "default",
891 target: TargetPlatform {
892 os: "linux".into(),
893 arch: "x86_64".into(),
894 abi: "gnu".into(),
895 },
896 only: &[],
897 exclude: &[],
898 source_root: None,
899 })
900 .unwrap();
901 assert!(linux.certificate_preflight.is_some());
902 let windows = build_plan(PlanRequest {
903 document: &document,
904 origins: &origins,
905 profile: "default",
906 target: TargetPlatform {
907 os: "windows".into(),
908 arch: "x86_64".into(),
909 abi: "msvc".into(),
910 },
911 only: &[],
912 exclude: &[],
913 source_root: None,
914 })
915 .unwrap();
916 assert!(windows.certificate_preflight.is_none());
917 }
918
919 #[test]
920 fn unsupported_profile_components_are_retained_as_display_metadata() {
921 let mut document = ConfigDocument::default();
922 document.profiles.insert(
923 "default".into(),
924 ProfileDef {
925 components: vec!["linux-only".into(), "portable".into()],
926 ..ProfileDef::default()
927 },
928 );
929 let mut linux_only = cargo_component("linux-only");
930 linux_only.platforms = vec!["linux-*".into()];
931 document.components.push(linux_only);
932 document.components.push(cargo_component("portable"));
933 let plan = build_plan(request(&document, &OriginMap::default())).unwrap();
934 assert_eq!(
935 plan.components
936 .iter()
937 .map(|c| c.id.as_str())
938 .collect::<Vec<_>>(),
939 ["portable"]
940 );
941 assert_eq!(plan.unsupported_components[0].id, "linux-only");
942 assert!(
943 !serde_json::to_string(&plan)
944 .unwrap()
945 .contains("unsupported_components")
946 );
947 }
948
949 #[test]
950 fn rejects_ambiguous_capability_provider_on_target() {
951 let mut document = ConfigDocument::default();
952 document.profiles.insert(
953 "default".into(),
954 ProfileDef {
955 inherits: Vec::new(),
956 components: vec!["consumer".into()],
957 ..ProfileDef::default()
958 },
959 );
960 let mut first = cargo_component("first");
961 first.provides = vec!["compiler".into()];
962 let mut second = cargo_component("second");
963 second.provides = vec!["compiler".into()];
964 let mut consumer = cargo_component("consumer");
965 consumer.requires = vec!["capability:compiler".into()];
966 document.components = vec![first, second, consumer];
967 let origins = OriginMap::default();
968 let error = build_plan(request(&document, &origins))
969 .unwrap_err()
970 .to_string();
971 assert!(error.contains("multiple providers"), "{error}");
972 }
973
974 #[test]
975 fn variant_adds_dependencies_and_conflicts_to_component_contract() {
976 let mut document = ConfigDocument::default();
977 document.profiles.insert(
978 "default".into(),
979 ProfileDef {
980 inherits: Vec::new(),
981 components: vec!["tool".into(), "blocked".into()],
982 ..ProfileDef::default()
983 },
984 );
985 let mut tool = cargo_component("tool");
986 tool.requires = vec!["base".into()];
987 tool.variants = vec![VariantDef {
988 id: "host".into(),
989 version: None,
990 requires: vec!["extra".into()],
991 provides: vec!["selected-tool".into()],
992 conflicts: vec!["blocked".into()],
993 platforms: vec!["*".into()],
994 detect: None,
995 install: None,
996 verify: None,
997 }];
998 document.components = vec![
999 cargo_component("base"),
1000 cargo_component("extra"),
1001 tool,
1002 cargo_component("blocked"),
1003 ];
1004 let origins = OriginMap::default();
1005 let error = build_plan(request(&document, &origins))
1006 .unwrap_err()
1007 .to_string();
1008 assert!(error.contains("tool <-> blocked"), "{error}");
1009 document
1010 .profiles
1011 .get_mut("default")
1012 .unwrap()
1013 .components
1014 .pop();
1015 let plan = build_plan(request(&document, &origins)).unwrap();
1016 let tool = plan
1017 .components
1018 .iter()
1019 .find(|item| item.id == "tool")
1020 .unwrap();
1021 assert_eq!(tool.dependencies, vec!["base", "extra"]);
1022 assert_eq!(tool.provides, vec!["cargo", "selected-tool"]);
1023 assert_eq!(tool.requested_by, vec!["profile:default"]);
1024 }
1025
1026 #[test]
1027 fn environment_mutations_form_one_real_prerequisite_node() {
1028 let mut document = ConfigDocument::default();
1029 document.profiles.insert(
1030 "default".into(),
1031 ProfileDef {
1032 inherits: Vec::new(),
1033 components: vec!["tool".into()],
1034 ..ProfileDef::default()
1035 },
1036 );
1037 document.components.push(cargo_component("tool"));
1038 document
1039 .environment
1040 .mutations
1041 .push(EnvironmentMutation::PathPrepend {
1042 id: "managed-bin".into(),
1043 value: "$BOT_FORGE_HOME/bin".into(),
1044 scope: MutationScope::User,
1045 platforms: vec!["*".into()],
1046 });
1047 let origins = OriginMap::default();
1048 let plan = build_plan(request(&document, &origins)).unwrap();
1049 let environment = plan.nodes.first().unwrap();
1050 assert_eq!(environment.id, "environment:apply");
1051 assert_eq!(environment.kind, NodeKind::Environment);
1052 assert!(environment.dependencies.is_empty());
1053 assert_eq!(plan.nodes[1].dependencies, vec!["environment:apply"]);
1054 }
1055
1056 #[test]
1057 fn shell_execution_contract_reaches_the_plan() {
1058 let mut document = ConfigDocument::parse(
1059 r#"catalog = "rust-dev"
1060[policy]
1061allow_shell = true
1062[profiles.test]
1063components = ["shell-tool"]
1064[[components]]
1065id = "shell-tool"
1066[components.install]
1067backend = "shell"
1068command = "install-demo"
1069resources = ["external-state"]
1070rollback = "remove-demo"
1071timeout_secs = 30
1072inactivity_timeout_secs = 5
1073"#,
1074 )
1075 .unwrap();
1076 document.resolve_version_references().unwrap();
1077 let origins = OriginMap::default();
1078 let plan = build_plan(PlanRequest {
1079 document: &document,
1080 origins: &origins,
1081 profile: "test",
1082 target: TargetPlatform::host(),
1083 only: &[],
1084 exclude: &[],
1085 source_root: None,
1086 })
1087 .unwrap();
1088 let acquire = plan
1089 .nodes
1090 .iter()
1091 .find(|node| node.id == "shell-tool:acquire")
1092 .unwrap();
1093 assert!(
1094 acquire
1095 .resources
1096 .iter()
1097 .any(|claim| claim.key == "external-state")
1098 );
1099 }
1100
1101 #[test]
1102 fn same_binary_activations_share_a_scheduler_lock() {
1103 let mut document = ConfigDocument::default();
1104 document.profiles.insert(
1105 "default".into(),
1106 ProfileDef {
1107 inherits: Vec::new(),
1108 components: vec!["first".into(), "second".into()],
1109 ..ProfileDef::default()
1110 },
1111 );
1112 let mut first = cargo_component("first");
1113 let mut second = cargo_component("second");
1114 for component in [&mut first, &mut second] {
1115 let Some(InstallSpec::Cargo(cargo)) = component.install.as_mut() else {
1116 unreachable!()
1117 };
1118 cargo.bins = vec!["shared-bin".into()];
1119 }
1120 document.components = vec![first, second];
1121 let origins = OriginMap::default();
1122 let plan = build_plan(request(&document, &origins)).unwrap();
1123 for component in ["first", "second"] {
1124 let transaction = plan
1125 .nodes
1126 .iter()
1127 .find(|node| node.id == format!("{component}:acquire"))
1128 .unwrap();
1129 assert!(
1130 transaction
1131 .resources
1132 .iter()
1133 .any(|claim| claim.key == "bin:shared-bin")
1134 );
1135 }
1136 }
1137
1138 fn request<'a>(document: &'a ConfigDocument, origins: &'a OriginMap) -> PlanRequest<'a> {
1139 PlanRequest {
1140 document,
1141 origins,
1142 profile: "default",
1143 target: TargetPlatform::host(),
1144 only: &[],
1145 exclude: &[],
1146 source_root: None,
1147 }
1148 }
1149}