use crate::error::DispatchError;
use crate::ir::{ComponentNode, OutcomeKind, RealizationKind, TriggerKind};
use crate::targets::{
CapabilityEntry, CapabilityOutcome, CapabilityProfile, Criticality, ProvidedBy,
};
use serde::Serialize;
use std::collections::HashSet;
const BLAST_RADIUS_CAPABILITY: &str = "blast_radius";
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedCapability {
pub capability: String,
pub outcome: CapabilityOutcome,
pub provided_by: ProvidedBy,
pub criticality: Criticality,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
pub type ResolutionReport = Vec<ResolvedCapability>;
fn unknown_capability_entry() -> CapabilityEntry {
CapabilityEntry {
outcome: CapabilityOutcome::Fail,
via: None,
provided_by: ProvidedBy::None,
criticality: Criticality::SafetyCritical,
note: Some(std::borrow::Cow::Borrowed(
"capability is not declared in the target's capability profile — unknown means it cannot be guaranteed",
)),
}
}
fn implied_capabilities(node: &ComponentNode) -> Vec<String> {
let mut implied = Vec::new();
if node.realization_kind == RealizationKind::Skill {
let distinct_tiers: HashSet<&str> =
node.llm_calls.iter().map(|c| c.tier.as_str()).collect();
if distinct_tiers.len() > 1 {
implied.push("llm:per_step_tier".to_string());
}
}
match node.trigger.kind {
TriggerKind::Scheduled => implied.push("scheduler".to_string()),
TriggerKind::Event => implied.push("event_bus".to_string()),
TriggerKind::OneShot => {}
}
if node
.effect
.outcome
.emits
.as_ref()
.is_some_and(|e| !e.is_empty())
{
implied.push("event_bus".to_string());
}
if !node.effect.render_blocks.is_empty() {
implied.push("render_contract".to_string());
}
if node.effect.outcome.kind == OutcomeKind::Mutation {
implied.push("version_control".to_string());
if node.effect.outcome.target.as_deref() == Some("context") {
implied.push("context_write_authz".to_string());
} else {
implied.push("write_authz".to_string());
}
}
implied
}
fn has_fine_grained_binding(node: &ComponentNode) -> bool {
node.context_binding
.resolved
.as_ref()
.and_then(|resolved| resolved.get("lineage"))
.and_then(|lineage| lineage.get("resolvable"))
.and_then(|resolvable| resolvable.as_bool())
.unwrap_or(false)
}
pub fn collect_required_capabilities(node: &ComponentNode) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for cap in node
.required_capabilities
.iter()
.cloned()
.chain(implied_capabilities(node))
{
if seen.insert(cap.clone()) {
out.push(cap);
}
}
out
}
pub fn resolve_capabilities(
node: &ComponentNode,
target_id: &str,
profile: &CapabilityProfile,
) -> Result<ResolutionReport, DispatchError> {
let required = collect_required_capabilities(node);
let fallback = unknown_capability_entry();
let fine_grained_binding = has_fine_grained_binding(node);
let report: ResolutionReport = required
.into_iter()
.map(|capability| {
let e = profile.get(capability.as_str()).unwrap_or(&fallback);
if capability == BLAST_RADIUS_CAPABILITY && fine_grained_binding {
ResolvedCapability {
capability,
outcome: CapabilityOutcome::Native,
provided_by: ProvidedBy::Warble,
criticality: e.criticality,
note: None,
}
} else if capability == BLAST_RADIUS_CAPABILITY {
ResolvedCapability {
capability,
outcome: CapabilityOutcome::Fail,
provided_by: e.provided_by,
criticality: e.criticality,
note: Some(
"requires fine_grained_binding (coarse binding on this target)".to_string(),
),
}
} else {
ResolvedCapability {
capability,
outcome: e.outcome,
provided_by: e.provided_by,
criticality: e.criticality,
note: e.note.as_deref().map(str::to_string),
}
}
})
.collect();
if let Some(failed) = report.iter().find(|r| r.outcome == CapabilityOutcome::Fail) {
let reason = failed
.note
.clone()
.unwrap_or_else(|| "unsupported on this target".to_string());
return Err(DispatchError(format!(
"{}: fail on {target_id} ({reason}) — component '{}' cannot be dispatched",
failed.capability, node.verb
)));
}
Ok(report)
}