use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet};
use crate::model::Bundle;
use crate::pipeline::ItemKind;
#[derive(Debug, Clone, PartialEq)]
pub struct Resolved {
pub bundles: Vec<Bundle>,
pub items: ResolvedItems,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ResolvedItems {
pub rules: Vec<String>,
pub skills: Vec<String>,
pub agents: Vec<String>,
}
impl ResolvedItems {
pub fn total(&self) -> usize {
self.rules.len() + self.skills.len() + self.agents.len()
}
pub fn contains(&self, kind: ItemKind, name: &str) -> bool {
let bucket = match kind {
ItemKind::Rule => &self.rules,
ItemKind::Skill => &self.skills,
ItemKind::Agent => &self.agents,
};
bucket.iter().any(|n| n == name)
}
}
pub fn resolve(entry: &Bundle, available: &[Bundle]) -> Result<Resolved> {
let by_name: BTreeMap<&str, &Bundle> = available.iter().map(|b| (b.name.as_str(), b)).collect();
let mut visited: BTreeSet<String> = BTreeSet::new();
let mut on_stack: BTreeSet<String> = BTreeSet::new();
let mut order: Vec<Bundle> = Vec::new();
visit(entry, &by_name, &mut visited, &mut on_stack, &mut order)?;
let items = union_items(&order)?;
Ok(Resolved {
bundles: order,
items,
})
}
fn visit(
bundle: &Bundle,
by_name: &BTreeMap<&str, &Bundle>,
visited: &mut BTreeSet<String>,
on_stack: &mut BTreeSet<String>,
order: &mut Vec<Bundle>,
) -> Result<()> {
if visited.contains(&bundle.name) {
return Ok(());
}
if on_stack.contains(&bundle.name) {
anyhow::bail!("bundle dependency cycle including `{}`", bundle.name);
}
on_stack.insert(bundle.name.clone());
for req in &bundle.requires {
if on_stack.contains(&req.name) {
anyhow::bail!(
"bundle dependency cycle: `{}` requires `{}` which is already on the resolution stack",
bundle.name,
req.name
);
}
let dep = by_name.get(req.name.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"bundle `{}` requires `{}` which was not found in the source registry",
bundle.name,
req.name
)
})?;
visit(dep, by_name, visited, on_stack, order)?;
}
on_stack.remove(&bundle.name);
visited.insert(bundle.name.clone());
order.push(bundle.clone());
Ok(())
}
fn union_items(bundles: &[Bundle]) -> Result<ResolvedItems> {
let mut seen: BTreeMap<(ItemKind, String), String> = BTreeMap::new();
for bundle in bundles {
for name in &bundle.items.rules {
insert_or_conflict(&mut seen, ItemKind::Rule, name, &bundle.name)?;
}
for name in &bundle.items.skills {
insert_or_conflict(&mut seen, ItemKind::Skill, name, &bundle.name)?;
}
for name in &bundle.items.agents {
insert_or_conflict(&mut seen, ItemKind::Agent, name, &bundle.name)?;
}
}
let mut rules = Vec::new();
let mut skills = Vec::new();
let mut agents = Vec::new();
for ((kind, name), _origin) in seen {
match kind {
ItemKind::Rule => rules.push(name),
ItemKind::Skill => skills.push(name),
ItemKind::Agent => agents.push(name),
}
}
Ok(ResolvedItems {
rules,
skills,
agents,
})
}
fn insert_or_conflict(
seen: &mut BTreeMap<(ItemKind, String), String>,
kind: ItemKind,
name: &str,
origin: &str,
) -> Result<()> {
let key = (kind, name.to_string());
if let Some(prior) = seen.get(&key) {
anyhow::bail!(
"item conflict: {:?} `{}` is named by both bundles `{}` and `{}`",
kind,
name,
prior,
origin
);
}
seen.insert(key, origin.to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{BundleItems, Requires, SchemaVersion};
fn bundle(name: &str, items: BundleItems, requires: Vec<&str>) -> Bundle {
Bundle {
schema: SchemaVersion::new(1).unwrap(),
name: name.to_string(),
description: format!("test bundle {name}"),
license: None,
items,
requires: requires
.into_iter()
.map(|n| Requires {
name: n.to_string(),
version: None,
})
.collect(),
plugins: Default::default(),
metadata: None,
extra: Default::default(),
}
}
fn items(rules: &[&str], skills: &[&str], agents: &[&str]) -> BundleItems {
BundleItems {
rules: rules.iter().map(|s| s.to_string()).collect(),
skills: skills.iter().map(|s| s.to_string()).collect(),
agents: agents.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn resolve_single_bundle_with_no_requires() {
let entry = bundle("solo", items(&["r1"], &["s1"], &["a1"]), vec![]);
let resolved = resolve(&entry, std::slice::from_ref(&entry)).unwrap();
assert_eq!(resolved.bundles.len(), 1);
assert_eq!(resolved.bundles[0].name, "solo");
assert_eq!(resolved.items.total(), 3);
assert!(resolved.items.contains(ItemKind::Rule, "r1"));
assert!(resolved.items.contains(ItemKind::Skill, "s1"));
assert!(resolved.items.contains(ItemKind::Agent, "a1"));
}
#[test]
fn resolve_walks_requires_in_dependency_order() {
let leaf = bundle("leaf", items(&["leaf-rule"], &[], &[]), vec![]);
let mid = bundle("mid", items(&[], &["mid-skill"], &[]), vec!["leaf"]);
let entry = bundle("entry", items(&[], &[], &["entry-agent"]), vec!["mid"]);
let available = vec![leaf.clone(), mid.clone(), entry.clone()];
let resolved = resolve(&entry, &available).unwrap();
let order: Vec<&str> = resolved.bundles.iter().map(|b| b.name.as_str()).collect();
assert_eq!(
order,
vec!["leaf", "mid", "entry"],
"post-order topological"
);
assert_eq!(resolved.items.total(), 3);
}
#[test]
fn resolve_unions_items_across_bundles() {
let a = bundle("a", items(&["a-rule"], &["common-skill"], &[]), vec![]);
let b = bundle("b", items(&["b-rule"], &[], &["b-agent"]), vec!["a"]);
let resolved = resolve(&b, &[a, b.clone()]).unwrap();
assert_eq!(resolved.items.rules.len(), 2);
assert_eq!(resolved.items.skills, vec!["common-skill"]);
assert_eq!(resolved.items.agents, vec!["b-agent"]);
}
#[test]
fn resolve_dedupe_path_diamond_does_not_error() {
let leaf = bundle("leaf", items(&["leaf-rule"], &[], &[]), vec![]);
let a = bundle("a", items(&["a-rule"], &[], &[]), vec!["leaf"]);
let b = bundle("b", items(&["b-rule"], &[], &[]), vec!["leaf"]);
let entry = bundle("entry", items(&[], &[], &[]), vec!["a", "b"]);
let available = vec![leaf, a, b, entry.clone()];
let resolved = resolve(&entry, &available).unwrap();
assert_eq!(
resolved.bundles.len(),
4,
"leaf reached twice, visited once"
);
assert_eq!(resolved.items.rules.len(), 3);
}
#[test]
fn resolve_errors_on_item_conflict() {
let a = bundle("a", items(&["x"], &[], &[]), vec![]);
let b = bundle("b", items(&["x"], &[], &[]), vec!["a"]);
let err = resolve(&b, &[a, b.clone()]).expect_err("conflict");
let msg = format!("{:#}", err);
assert!(msg.contains("item conflict"), "{msg}");
assert!(msg.contains("`x`"), "{msg}");
}
#[test]
fn resolve_allows_same_name_in_different_kinds() {
let a = bundle("a", items(&["shared"], &[], &[]), vec![]);
let b = bundle("b", items(&[], &["shared"], &[]), vec!["a"]);
let resolved = resolve(&b, &[a, b.clone()]).unwrap();
assert_eq!(resolved.items.rules, vec!["shared"]);
assert_eq!(resolved.items.skills, vec!["shared"]);
}
#[test]
fn resolve_errors_on_cycle() {
let a = bundle("a", items(&[], &[], &[]), vec!["b"]);
let b = bundle("b", items(&[], &[], &[]), vec!["a"]);
let err = resolve(&a, &[a.clone(), b]).expect_err("cycle");
let msg = format!("{:#}", err);
assert!(msg.contains("cycle"), "{msg}");
}
#[test]
fn resolve_errors_on_missing_requirement() {
let a = bundle("a", items(&[], &[], &[]), vec!["does-not-exist"]);
let err = resolve(&a, std::slice::from_ref(&a)).expect_err("missing");
let msg = format!("{:#}", err);
assert!(msg.contains("does-not-exist"), "{msg}");
assert!(msg.contains("not found"), "{msg}");
}
}