Skip to main content

bot_forge/planning/
resolver.rs

1//! Profile, dependency, provider, variant, and resource resolution.
2
3use 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
20/// Borrowed inputs required to build a plan for one profile and target.
21pub struct PlanRequest<'a> {
22    /// Canonical configuration to resolve.
23    pub document: &'a ConfigDocument,
24    /// Configuration provenance copied into the plan.
25    pub origins: &'a OriginMap,
26    /// Profile identifier to expand.
27    pub profile: &'a str,
28    /// Target used for platform and variant selection.
29    pub target: TargetPlatform,
30    /// Optional inclusive component filters.
31    pub only: &'a [String],
32    /// Component filters removed after profile expansion.
33    pub exclude: &'a [String],
34    /// Base directory used to resolve local sources.
35    pub source_root: Option<&'a Path>,
36}
37
38/// Resolve a validated configuration into an immutable execution plan.
39///
40/// # Errors
41///
42/// Returns [`ForgeError`] for unsupported targets, missing profiles or components, dependency
43/// cycles, conflicts, ambiguous providers, invalid filters, or serialization failures while
44/// computing plan hashes.
45pub 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}