use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::config::schema::{
CargoInstall, ComponentDef, ConfigDocument, EnvironmentMutation, InstallSpec, OriginMap,
VariantDef,
};
use crate::error::ForgeError;
use crate::model::InstallKind;
use crate::planning::plan::{
ExecutionNode, ExecutionPlan, NodeKind, PlanPolicy, ResolvedComponent, ResourceClaim,
TargetPlatform,
};
pub struct PlanRequest<'a> {
pub document: &'a ConfigDocument,
pub origins: &'a OriginMap,
pub profile: &'a str,
pub target: TargetPlatform,
pub only: &'a [String],
pub exclude: &'a [String],
pub source_root: Option<&'a Path>,
}
pub fn build_plan(request: PlanRequest<'_>) -> Result<ExecutionPlan, ForgeError> {
request.target.validate_supported()?;
let mut resolver = Resolver::new(&request);
resolver.resolve_profile(request.profile)?;
resolver.apply_filters()?;
let (components, unsupported_components) = resolver.finish()?;
let environment = request
.document
.environment
.mutations
.iter()
.filter(|mutation| {
mutation
.platforms()
.iter()
.any(|selector| request.target.matches(selector))
})
.cloned()
.collect::<Vec<_>>();
let nodes = compile_nodes(&components, &environment);
let config_hash = digest(request.document)?;
let mut plan = ExecutionPlan {
profile: request.profile.to_string(),
target: request.target.clone(),
config_hash,
plan_hash: String::new(),
policy: PlanPolicy {
network: request.document.policy.network,
max_parallel: request.document.policy.max_parallel,
max_downloads: request.document.policy.max_downloads,
max_memory_mib: request.document.policy.max_memory_mib,
},
certificate_preflight: request
.document
.preflight
.certificate
.as_ref()
.filter(|certificate| {
certificate
.platforms
.iter()
.any(|selector| request.target.matches(selector))
})
.cloned(),
components,
unsupported_components,
nodes,
environment,
apt_mirror: request.document.apt_mirror.clone(),
origins: request.origins.fields.clone(),
};
plan.plan_hash = digest(&plan)?;
Ok(plan)
}
struct Resolver<'a> {
request: &'a PlanRequest<'a>,
component_index: BTreeMap<&'a str, usize>,
selected: BTreeMap<String, ResolvedComponent>,
resolving: Vec<String>,
requested: BTreeSet<String>,
has_cargo_provider: bool,
has_rustup_provider: bool,
unsupported: BTreeMap<String, crate::planning::plan::UnsupportedComponent>,
}
impl<'a> Resolver<'a> {
fn new(request: &'a PlanRequest<'a>) -> Self {
let component_index = request
.document
.components
.iter()
.enumerate()
.map(|(index, component)| (component.id.as_str(), index))
.collect();
let mut resolver = Self {
request,
component_index,
selected: BTreeMap::new(),
resolving: Vec::new(),
requested: BTreeSet::new(),
has_cargo_provider: false,
has_rustup_provider: false,
unsupported: BTreeMap::new(),
};
resolver.has_cargo_provider = request
.document
.components
.iter()
.any(|component| resolver.component_provides(component, "cargo"));
resolver.has_rustup_provider = request
.document
.components
.iter()
.any(|component| resolver.component_provides(component, "rustup"));
resolver
}
fn resolve_profile(&mut self, profile: &str) -> Result<(), ForgeError> {
let mut entries = Vec::new();
self.expand_profile(
profile,
&mut BTreeSet::new(),
&mut BTreeSet::new(),
&mut entries,
)?;
for reference in entries {
let direct_id = reference.strip_prefix("component:").unwrap_or(&reference);
if let Some(definition) = self
.component_index
.get(direct_id)
.map(|index| &self.request.document.components[*index])
{
self.requested.insert(direct_id.to_string());
if !self.component_supports_target(definition) {
self.unsupported
.entry(direct_id.to_string())
.or_insert_with(|| crate::planning::plan::UnsupportedComponent {
id: definition.id.clone(),
display_name: definition.display_name.clone(),
kind: definition.kind,
optional: definition.optional,
});
continue;
}
}
let id = self.resolve_reference(&reference, &format!("profile:{profile}"))?;
self.requested.insert(id);
}
Ok(())
}
fn component_supports_target(&self, component: &ComponentDef) -> bool {
component
.platforms
.iter()
.any(|selector| self.request.target.matches(selector))
|| component.variants.iter().any(|variant| {
variant
.platforms
.iter()
.any(|selector| self.request.target.matches(selector))
})
}
fn expand_profile(
&self,
name: &str,
visiting: &mut BTreeSet<String>,
seen: &mut BTreeSet<String>,
output: &mut Vec<String>,
) -> Result<(), ForgeError> {
let profile = self
.request
.document
.profiles
.get(name)
.ok_or_else(|| ForgeError::Config(format!("profile does not exist: {name}")))?;
if !visiting.insert(name.to_string()) {
return Err(ForgeError::Config(format!(
"profile inheritance cycle: {name}"
)));
}
for parent in &profile.inherits {
self.expand_profile(parent, visiting, seen, output)?;
}
for component in &profile.components {
for expanded in self.request.document.expand_profile_entry(component)? {
if seen.insert(expanded.clone()) {
output.push(expanded);
}
}
}
visiting.remove(name);
Ok(())
}
fn resolve_reference(&mut self, reference: &str, reason: &str) -> Result<String, ForgeError> {
let id = if let Some(capability) = reference.strip_prefix("capability:") {
self.provider_for(capability)?
} else {
reference
.strip_prefix("component:")
.unwrap_or(reference)
.to_string()
};
self.resolve_component(&id, reason)?;
Ok(id)
}
fn provider_for(&self, capability: &str) -> Result<String, ForgeError> {
let mut providers = self
.request
.document
.components
.iter()
.filter(|component| self.component_provides(component, capability))
.map(|component| component.id.clone())
.collect::<Vec<_>>();
providers.sort();
match providers.as_slice() {
[] => Err(ForgeError::Config(format!(
"capability has no provider for the current platform: {capability}"
))),
[provider] => Ok(provider.clone()),
_ => Err(ForgeError::Config(format!(
"capability has multiple providers on the current platform; resolve the ambiguity: {capability} -> {}",
providers.join(", ")
))),
}
}
fn component_provides(&self, component: &ComponentDef, capability: &str) -> bool {
let base_matches = component.provides.iter().any(|value| value == capability)
&& component
.platforms
.iter()
.any(|selector| self.request.target.matches(selector));
let variant_matches = component.variants.iter().any(|variant| {
variant.provides.iter().any(|value| value == capability)
&& variant
.platforms
.iter()
.any(|selector| self.request.target.matches(selector))
});
(base_matches || variant_matches) && self.component_has_platform_contract(component)
}
fn component_has_platform_contract(&self, component: &ComponentDef) -> bool {
let variant = component.variants.iter().find(|variant| {
variant
.platforms
.iter()
.any(|selector| self.request.target.matches(selector))
});
let install = variant
.and_then(|variant| variant.install.as_ref())
.or(component.install.as_ref());
!matches!(
install,
Some(InstallSpec::Rustup(rustup))
if rustup.bootstrap.as_ref().is_some_and(|bootstrap| {
!bootstrap.sha256.contains_key(&self.request.target.selector())
})
)
}
fn resolve_component(&mut self, id: &str, reason: &str) -> Result<(), ForgeError> {
if let Some(component) = self.selected.get_mut(id) {
if !component.requested_by.iter().any(|value| value == reason) {
component.requested_by.push(reason.to_string());
}
return Ok(());
}
if self.resolving.iter().any(|value| value == id) {
return Err(ForgeError::Config(format!(
"component dependency cycle: {} -> {id}",
self.resolving.join(" -> ")
)));
}
let definition = self
.component_index
.get(id)
.map(|index| &self.request.document.components[*index])
.ok_or_else(|| ForgeError::Config(format!("component does not exist: {id}")))?
.clone();
let variant = select_variant(&definition, &self.request.target)?.cloned();
if variant.is_none()
&& !definition
.platforms
.iter()
.any(|selector| self.request.target.matches(selector))
{
return Err(ForgeError::Config(format!(
"component {id} does not support platform {}",
self.request.target.selector()
)));
}
self.resolving.push(id.to_string());
let install = variant
.as_ref()
.and_then(|value| value.install.clone())
.or_else(|| definition.install.clone());
let mut requires = definition
.requires
.iter()
.chain(variant.iter().flat_map(|value| value.requires.iter()))
.cloned()
.collect::<BTreeSet<_>>();
let provides = definition
.provides
.iter()
.chain(variant.iter().flat_map(|value| value.provides.iter()))
.cloned()
.collect::<BTreeSet<_>>();
match &install {
Some(InstallSpec::Cargo(_))
if !provides.contains("cargo") && self.has_cargo_provider =>
{
requires.insert("capability:cargo".into());
}
Some(InstallSpec::Rustup(rustup))
if rustup.bootstrap.is_none()
&& !provides.contains("rustup")
&& self.has_rustup_provider =>
{
requires.insert("capability:rustup".into());
}
_ => {}
}
let mut dependencies = Vec::new();
for reference in &requires {
let dependency = self.resolve_reference(reference, id)?;
if !dependencies.contains(&dependency) {
dependencies.push(dependency);
}
}
self.resolving.pop();
if let Some(InstallSpec::Rustup(rustup)) = &install
&& let Some(bootstrap) = &rustup.bootstrap
{
let target = self.request.target.selector();
if !bootstrap.sha256.contains_key(&target) {
return Err(ForgeError::Config(format!(
"component {id} rustup bootstrap is missing a digest for target: {target}"
)));
}
}
let resolved = ResolvedComponent {
id: id.to_string(),
display_name: definition.display_name,
version: variant
.as_ref()
.and_then(|value| value.version.clone())
.or(definition.version),
optional: definition.optional,
allow_insecure_hosts: definition.allow_insecure_hosts,
kind: definition.kind,
variant: variant.as_ref().map(|value| value.id.clone()),
requested_by: vec![reason.to_string()],
dependencies,
provides: provides.into_iter().collect(),
conflicts: definition
.conflicts
.iter()
.chain(variant.iter().flat_map(|value| value.conflicts.iter()))
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect(),
detect: variant
.as_ref()
.and_then(|value| value.detect.clone())
.or(definition.detect),
install,
verify: variant
.as_ref()
.and_then(|value| value.verify.clone())
.or(definition.verify),
source: definition.source.map(|source| {
let path = Path::new(&source);
if path.is_relative() {
self.request
.source_root
.map(|root| root.join(path).display().to_string())
.unwrap_or(source)
} else {
source
}
}),
revision: definition.revision,
agents: definition.agents,
};
self.selected.insert(id.to_string(), resolved);
Ok(())
}
fn apply_filters(&mut self) -> Result<(), ForgeError> {
let only = self.request.only.iter().cloned().collect::<BTreeSet<_>>();
let exclude = self
.request
.exclude
.iter()
.cloned()
.collect::<BTreeSet<_>>();
for id in only.iter().chain(exclude.iter()) {
if !self.requested.contains(id) {
return Err(ForgeError::Config(format!(
"selection is not a direct component of the profile: {id}"
)));
}
}
if !only.is_empty() {
let unsupported = only
.iter()
.filter(|id| !self.selected.contains_key(*id))
.cloned()
.collect::<Vec<_>>();
if !unsupported.is_empty() {
return Err(ForgeError::Config(format!(
"component selected by --only does not support the current platform: {}",
unsupported.join(", ")
)));
}
let roots = self
.requested
.iter()
.filter(|id| only.contains(*id))
.cloned()
.collect::<Vec<_>>();
self.retain_dependency_closure(&roots);
self.unsupported.retain(|id, _| only.contains(id));
}
if !exclude.is_empty() {
let blocked = self
.selected
.values()
.filter(|component| {
component
.dependencies
.iter()
.any(|dependency| exclude.contains(dependency))
})
.map(|component| component.id.clone())
.collect::<Vec<_>>();
if !blocked.is_empty() {
return Err(ForgeError::Config(format!(
"cannot exclude a component that is still required: {}",
blocked.join(", ")
)));
}
self.selected.retain(|id, _| !exclude.contains(id));
self.unsupported.retain(|id, _| !exclude.contains(id));
}
Ok(())
}
fn retain_dependency_closure(&mut self, roots: &[String]) {
let mut keep = BTreeSet::new();
let mut pending = roots.to_vec();
while let Some(id) = pending.pop() {
if keep.insert(id.clone())
&& let Some(component) = self.selected.get(&id)
{
pending.extend(component.dependencies.iter().cloned());
}
}
self.selected.retain(|id, _| keep.contains(id));
}
fn finish(
self,
) -> Result<
(
Vec<ResolvedComponent>,
Vec<crate::planning::plan::UnsupportedComponent>,
),
ForgeError,
> {
for component in self.selected.values() {
for conflict in &component.conflicts {
let conflicting = if let Some(capability) = conflict.strip_prefix("capability:") {
self.selected.values().find(|candidate| {
candidate.id != component.id
&& candidate.provides.iter().any(|value| value == capability)
})
} else {
let id = conflict.strip_prefix("component:").unwrap_or(conflict);
self.selected.get(id)
};
if let Some(conflicting) = conflicting {
return Err(ForgeError::Config(format!(
"component conflict: {} <-> {}",
component.id, conflicting.id
)));
}
}
}
let unsupported = self.unsupported.into_values().collect();
Ok((topological(self.selected)?, unsupported))
}
}
fn select_variant<'a>(
component: &'a ComponentDef,
target: &TargetPlatform,
) -> Result<Option<&'a VariantDef>, ForgeError> {
let matches = component
.variants
.iter()
.filter(|variant| {
variant
.platforms
.iter()
.any(|selector| target.matches(selector))
})
.collect::<Vec<_>>();
if matches.len() > 1 {
return Err(ForgeError::Config(format!(
"component {} matches multiple variants on platform {}: {}",
component.id,
target.selector(),
matches
.iter()
.map(|variant| variant.id.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
Ok(matches.into_iter().next())
}
fn topological(
mut components: BTreeMap<String, ResolvedComponent>,
) -> Result<Vec<ResolvedComponent>, ForgeError> {
let mut indegree = components
.iter()
.map(|(id, component)| (id.clone(), component.dependencies.len()))
.collect::<BTreeMap<_, _>>();
let mut dependents = BTreeMap::<String, Vec<String>>::new();
for (id, component) in &components {
for dependency in &component.dependencies {
dependents
.entry(dependency.clone())
.or_default()
.push(id.clone());
}
}
let mut ready = indegree
.iter()
.filter(|(_, count)| **count == 0)
.map(|(id, _)| id.clone())
.collect::<BTreeSet<_>>();
let mut output = Vec::new();
while let Some(id) = ready.pop_first() {
let component = components.remove(&id).ok_or_else(|| {
ForgeError::Config(format!("component graph is missing a topology node: {id}"))
})?;
output.push(component);
for dependent in dependents.get(&id).into_iter().flatten() {
let count = indegree.get_mut(dependent).ok_or_else(|| {
ForgeError::Config(format!(
"component graph is missing an indegree node: {dependent}"
))
})?;
*count -= 1;
if *count == 0 {
ready.insert(dependent.clone());
}
}
}
if !components.is_empty() {
return Err(ForgeError::Config(
"component graph cannot be topologically sorted".to_string(),
));
}
Ok(output)
}
fn compile_nodes(
components: &[ResolvedComponent],
environment: &[EnvironmentMutation],
) -> Vec<ExecutionNode> {
let environment_node = (!environment.is_empty()).then(|| "environment:apply".to_string());
let terminal = components
.iter()
.map(|component| (component.id.clone(), format!("{}:acquire", component.id)))
.collect::<BTreeMap<_, _>>();
let mut nodes = Vec::new();
if let Some(id) = &environment_node {
nodes.push(node(
id,
"environment",
NodeKind::Environment,
Vec::new(),
vec![lock("environment-write")],
));
}
for component in components {
let mut prerequisites = component
.dependencies
.iter()
.filter_map(|dependency| terminal.get(dependency).cloned())
.collect::<Vec<_>>();
if let Some(environment) = &environment_node {
prerequisites.push(environment.clone());
}
let resources = match &component.install {
Some(InstallSpec::Cargo(cargo)) => cargo_pipeline_resources(component, cargo),
Some(_) => backend_resources(component.install.as_ref()),
None if component.kind == InstallKind::Skill => {
vec![
network(),
disk(),
lock("registry-write"),
lock(&format!("skill:{}", component.id)),
]
}
None => vec![lock("registry-write")],
};
nodes.push(node(
&format!("{}:acquire", component.id),
&component.id,
NodeKind::Acquire,
prerequisites,
resources,
));
}
nodes
}
fn node(
id: &str,
component: &str,
kind: NodeKind,
dependencies: Vec<String>,
resources: Vec<ResourceClaim>,
) -> ExecutionNode {
ExecutionNode {
id: id.to_string(),
component: component.to_string(),
kind,
dependencies,
resources,
}
}
fn backend_resources(install: Option<&InstallSpec>) -> Vec<ResourceClaim> {
match install {
Some(InstallSpec::Apt(_)) => vec![lock("package-manager:apt")],
Some(InstallSpec::Brew(_)) => vec![network(), lock("package-manager:brew")],
Some(InstallSpec::Rustup(_)) => vec![lock("package-manager:rustup")],
Some(InstallSpec::Npm(_)) => vec![network(), lock("package-manager:npm")],
Some(InstallSpec::Pip(_)) => vec![network(), lock("package-manager:pip")],
Some(InstallSpec::UvTool(uv)) => {
let mut resources = vec![network(), lock("package-manager:uv")];
resources.extend(bin_locks(&uv.bins));
resources
}
Some(InstallSpec::Winget(_)) => vec![network(), lock("package-manager:winget")],
Some(InstallSpec::Git(git)) => {
let mut resources = vec![network(), cpu(), disk(), lock("registry-write")];
resources.extend(bin_locks(&git.bins.keys().cloned().collect::<Vec<_>>()));
resources
}
Some(InstallSpec::Shell(shell)) => shell.resources.iter().map(|key| lock(key)).collect(),
_ => Vec::new(),
}
}
fn cargo_pipeline_resources(
_component: &ResolvedComponent,
cargo: &CargoInstall,
) -> Vec<ResourceClaim> {
let mut resources = vec![network(), cpu(), memory(), disk()];
let source_digest = cargo.source_digest();
let fingerprint = cargo.fingerprint(&source_digest, &cargo.lock_digest());
resources.push(lock(&format!("cargo-build:{fingerprint}")));
resources.push(lock(&format!("artifact:{fingerprint}")));
resources.extend(bin_locks(&cargo.bins));
resources
}
fn bin_locks(bins: &[String]) -> Vec<ResourceClaim> {
let mut bins = bins.to_vec();
bins.sort();
bins.into_iter()
.map(|bin| lock(&format!("bin:{bin}")))
.collect()
}
fn network() -> ResourceClaim {
ResourceClaim {
key: "network".to_string(),
units: 1,
}
}
fn cpu() -> ResourceClaim {
ResourceClaim {
key: "cpu".to_string(),
units: 1,
}
}
fn memory() -> ResourceClaim {
ResourceClaim {
key: "memory-mib".to_string(),
units: 512,
}
}
fn disk() -> ResourceClaim {
ResourceClaim {
key: "disk-io".to_string(),
units: 1,
}
}
fn lock(key: &str) -> ResourceClaim {
ResourceClaim {
key: key.to_string(),
units: 1,
}
}
fn digest(value: &impl Serialize) -> Result<String, ForgeError> {
let bytes = serde_json::to_vec(value)
.map_err(|error| ForgeError::Config(format!("failed to compute plan hash: {error}")))?;
Ok(Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
#[cfg(test)]
mod tests {
use crate::config::schema::{
CargoInstall, CertificatePreflightDef, CheckSpec, ComponentDef, EnvironmentMutation,
MutationScope, Policy, ProfileDef, ShellInstall, VariantDef,
};
use crate::config::schema::{ConfigDocument, InstallSpec, OriginMap};
use crate::model::InstallKind;
use crate::planning::{NodeKind, PlanRequest, TargetPlatform, build_plan};
fn cargo_component(id: &str) -> ComponentDef {
ComponentDef {
id: id.to_string(),
kind: InstallKind::Tool,
display_name: None,
version: None,
requires: Vec::new(),
provides: vec!["cargo".into()],
conflicts: Vec::new(),
optional: false,
allow_insecure_hosts: Vec::new(),
platforms: vec!["*".to_string()],
detect: Some(CheckSpec::Command {
program: id.to_string(),
args: vec!["--version".to_string()],
stdout_contains: None,
success_codes: vec![0],
timeout_secs: Some(10),
}),
install: Some(InstallSpec::Cargo(CargoInstall {
crate_name: id.to_string(),
version: "=1.0.0".to_string(),
source: None,
revision: None,
locked: true,
features: Vec::new(),
bins: vec![id.to_string()],
target: None,
toolchain: None,
profile: "release".to_string(),
build_env_allow: Vec::new(),
})),
verify: None,
variants: Vec::new(),
source: None,
revision: None,
agents: Vec::new(),
}
}
#[test]
fn cargo_compiles_to_one_transaction_with_stage_lock_metadata() {
let mut document = ConfigDocument {
policy: Policy::default(),
..ConfigDocument::default()
};
document.profiles.insert(
"default".to_string(),
ProfileDef {
inherits: Vec::new(),
components: vec!["cargo-audit".to_string()],
..ProfileDef::default()
},
);
document.components.push(cargo_component("cargo-audit"));
let plan = build_plan(PlanRequest {
document: &document,
origins: &OriginMap::default(),
profile: "default",
target: TargetPlatform::host(),
only: &[],
exclude: &[],
source_root: None,
})
.unwrap();
let kinds = plan.nodes.iter().map(|node| node.kind).collect::<Vec<_>>();
assert_eq!(kinds, vec![NodeKind::Acquire]);
let transaction = &plan.nodes[0];
assert!(
!transaction
.resources
.iter()
.any(|claim| claim.key == "registry-write")
);
assert!(
!transaction
.resources
.iter()
.any(|claim| claim.key.starts_with("cargo-source:"))
);
assert!(
transaction
.resources
.iter()
.any(|claim| claim.key.starts_with("cargo-build:"))
);
assert_eq!(plan.plan_hash.len(), 64);
assert!(
!serde_json::to_string(&plan)
.unwrap()
.contains("schema_version")
);
}
#[test]
fn certificate_preflight_is_frozen_only_for_matching_targets() {
let mut document = ConfigDocument::default();
document
.profiles
.insert("default".into(), ProfileDef::default());
document.policy.allow_shell = true;
document.preflight.certificate = Some(CertificatePreflightDef {
platforms: vec!["linux-*".into()],
detect: CheckSpec::Path {
path: "/tmp/company.crt".into(),
},
install: InstallSpec::Shell(ShellInstall {
command: "install-company-certificate".into(),
resources: vec!["network".into()],
rollback: None,
timeout_secs: Some(30),
inactivity_timeout_secs: None,
}),
verify: None,
});
document.validate().unwrap();
let origins = OriginMap::default();
let linux = build_plan(PlanRequest {
document: &document,
origins: &origins,
profile: "default",
target: TargetPlatform {
os: "linux".into(),
arch: "x86_64".into(),
abi: "gnu".into(),
},
only: &[],
exclude: &[],
source_root: None,
})
.unwrap();
assert!(linux.certificate_preflight.is_some());
let windows = build_plan(PlanRequest {
document: &document,
origins: &origins,
profile: "default",
target: TargetPlatform {
os: "windows".into(),
arch: "x86_64".into(),
abi: "msvc".into(),
},
only: &[],
exclude: &[],
source_root: None,
})
.unwrap();
assert!(windows.certificate_preflight.is_none());
}
#[test]
fn unsupported_profile_components_are_retained_as_display_metadata() {
let mut document = ConfigDocument::default();
document.profiles.insert(
"default".into(),
ProfileDef {
components: vec!["linux-only".into(), "portable".into()],
..ProfileDef::default()
},
);
let mut linux_only = cargo_component("linux-only");
linux_only.platforms = vec!["linux-*".into()];
document.components.push(linux_only);
document.components.push(cargo_component("portable"));
let plan = build_plan(request(&document, &OriginMap::default())).unwrap();
assert_eq!(
plan.components
.iter()
.map(|c| c.id.as_str())
.collect::<Vec<_>>(),
["portable"]
);
assert_eq!(plan.unsupported_components[0].id, "linux-only");
assert!(
!serde_json::to_string(&plan)
.unwrap()
.contains("unsupported_components")
);
}
#[test]
fn rejects_ambiguous_capability_provider_on_target() {
let mut document = ConfigDocument::default();
document.profiles.insert(
"default".into(),
ProfileDef {
inherits: Vec::new(),
components: vec!["consumer".into()],
..ProfileDef::default()
},
);
let mut first = cargo_component("first");
first.provides = vec!["compiler".into()];
let mut second = cargo_component("second");
second.provides = vec!["compiler".into()];
let mut consumer = cargo_component("consumer");
consumer.requires = vec!["capability:compiler".into()];
document.components = vec![first, second, consumer];
let origins = OriginMap::default();
let error = build_plan(request(&document, &origins))
.unwrap_err()
.to_string();
assert!(error.contains("multiple providers"), "{error}");
}
#[test]
fn variant_adds_dependencies_and_conflicts_to_component_contract() {
let mut document = ConfigDocument::default();
document.profiles.insert(
"default".into(),
ProfileDef {
inherits: Vec::new(),
components: vec!["tool".into(), "blocked".into()],
..ProfileDef::default()
},
);
let mut tool = cargo_component("tool");
tool.requires = vec!["base".into()];
tool.variants = vec![VariantDef {
id: "host".into(),
version: None,
requires: vec!["extra".into()],
provides: vec!["selected-tool".into()],
conflicts: vec!["blocked".into()],
platforms: vec!["*".into()],
detect: None,
install: None,
verify: None,
}];
document.components = vec![
cargo_component("base"),
cargo_component("extra"),
tool,
cargo_component("blocked"),
];
let origins = OriginMap::default();
let error = build_plan(request(&document, &origins))
.unwrap_err()
.to_string();
assert!(error.contains("tool <-> blocked"), "{error}");
document
.profiles
.get_mut("default")
.unwrap()
.components
.pop();
let plan = build_plan(request(&document, &origins)).unwrap();
let tool = plan
.components
.iter()
.find(|item| item.id == "tool")
.unwrap();
assert_eq!(tool.dependencies, vec!["base", "extra"]);
assert_eq!(tool.provides, vec!["cargo", "selected-tool"]);
assert_eq!(tool.requested_by, vec!["profile:default"]);
}
#[test]
fn environment_mutations_form_one_real_prerequisite_node() {
let mut document = ConfigDocument::default();
document.profiles.insert(
"default".into(),
ProfileDef {
inherits: Vec::new(),
components: vec!["tool".into()],
..ProfileDef::default()
},
);
document.components.push(cargo_component("tool"));
document
.environment
.mutations
.push(EnvironmentMutation::PathPrepend {
id: "managed-bin".into(),
value: "$BOT_FORGE_HOME/bin".into(),
scope: MutationScope::User,
platforms: vec!["*".into()],
});
let origins = OriginMap::default();
let plan = build_plan(request(&document, &origins)).unwrap();
let environment = plan.nodes.first().unwrap();
assert_eq!(environment.id, "environment:apply");
assert_eq!(environment.kind, NodeKind::Environment);
assert!(environment.dependencies.is_empty());
assert_eq!(plan.nodes[1].dependencies, vec!["environment:apply"]);
}
#[test]
fn shell_execution_contract_reaches_the_plan() {
let mut document = ConfigDocument::parse(
r#"catalog = "rust-dev"
[policy]
allow_shell = true
[profiles.test]
components = ["shell-tool"]
[[components]]
id = "shell-tool"
[components.install]
backend = "shell"
command = "install-demo"
resources = ["external-state"]
rollback = "remove-demo"
timeout_secs = 30
inactivity_timeout_secs = 5
"#,
)
.unwrap();
document.resolve_version_references().unwrap();
let origins = OriginMap::default();
let plan = build_plan(PlanRequest {
document: &document,
origins: &origins,
profile: "test",
target: TargetPlatform::host(),
only: &[],
exclude: &[],
source_root: None,
})
.unwrap();
let acquire = plan
.nodes
.iter()
.find(|node| node.id == "shell-tool:acquire")
.unwrap();
assert!(
acquire
.resources
.iter()
.any(|claim| claim.key == "external-state")
);
}
#[test]
fn same_binary_activations_share_a_scheduler_lock() {
let mut document = ConfigDocument::default();
document.profiles.insert(
"default".into(),
ProfileDef {
inherits: Vec::new(),
components: vec!["first".into(), "second".into()],
..ProfileDef::default()
},
);
let mut first = cargo_component("first");
let mut second = cargo_component("second");
for component in [&mut first, &mut second] {
let Some(InstallSpec::Cargo(cargo)) = component.install.as_mut() else {
unreachable!()
};
cargo.bins = vec!["shared-bin".into()];
}
document.components = vec![first, second];
let origins = OriginMap::default();
let plan = build_plan(request(&document, &origins)).unwrap();
for component in ["first", "second"] {
let transaction = plan
.nodes
.iter()
.find(|node| node.id == format!("{component}:acquire"))
.unwrap();
assert!(
transaction
.resources
.iter()
.any(|claim| claim.key == "bin:shared-bin")
);
}
}
fn request<'a>(document: &'a ConfigDocument, origins: &'a OriginMap) -> PlanRequest<'a> {
PlanRequest {
document,
origins,
profile: "default",
target: TargetPlatform::host(),
only: &[],
exclude: &[],
source_root: None,
}
}
}