use std::collections::{HashMap, HashSet};
use serde::Serialize;
use crate::catalog::CatalogItem;
use crate::namespace;
fn make_by_name(items: &[CatalogItem]) -> HashMap<(&str, &str), Vec<usize>> {
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
for (i, item) in items.iter().enumerate() {
by_name
.entry((item.source.as_str(), item.name.as_str()))
.or_default()
.push(i);
}
by_name
}
fn item_edges(
node: usize,
items: &[CatalogItem],
by_name: &HashMap<(&str, &str), Vec<usize>>,
text: &str,
) -> Vec<usize> {
let item = &items[node];
let mut out: Vec<usize> = Vec::new();
for name in namespace::referenced_names(text) {
if let Some(matches) = by_name.get(&(item.source.as_str(), name.as_str())) {
for &m in matches {
if m != node && !out.contains(&m) {
out.push(m);
}
}
}
}
for entry in &item.requires {
let Ok(r) = crate::resolve::parse_item_ref(entry) else {
continue;
};
if r.source.is_some() {
continue;
}
if let Some(matches) = by_name.get(&(item.source.as_str(), r.name.as_str())) {
if r.kind.is_none() && matches.len() > 1 {
continue;
}
for &m in matches {
let candidate = &items[m];
if r.kind.is_some_and(|k| candidate.kind != k) {
continue;
}
if m != node && !out.contains(&m) {
out.push(m);
}
}
}
}
out
}
#[allow(dead_code)]
pub fn direct_dependency_keys(
item: &CatalogItem,
items: &[CatalogItem],
read: &impl Fn(&CatalogItem) -> String,
) -> Vec<String> {
let by_name = make_by_name(items);
let node = items
.iter()
.position(|it| std::ptr::eq(it, item))
.unwrap_or(usize::MAX);
if node == usize::MAX {
return Vec::new();
}
let text = read(item);
item_edges(node, items, &by_name, &text)
.into_iter()
.map(|i| items[i].key())
.collect()
}
pub struct Resolution {
roots: Vec<usize>,
deps: HashMap<usize, Vec<usize>>,
pulled: HashSet<usize>,
installed: HashSet<usize>,
order: Vec<usize>,
}
pub fn resolve(
items: &[CatalogItem],
selected: &[usize],
installed: &HashSet<String>,
read: impl Fn(&CatalogItem) -> String,
) -> Resolution {
let installed_idx: HashSet<usize> = (0..items.len())
.filter(|&i| installed.contains(&items[i].key()))
.collect();
let mut source_items: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, item) in items.iter().enumerate() {
source_items.entry(&item.source).or_default().push(i);
}
let selected_set: HashSet<usize> = selected.iter().copied().collect();
let mut expand_source: HashMap<&str, bool> = HashMap::new();
for (src, idxs) in &source_items {
let all_selected = idxs.iter().all(|i| selected_set.contains(i));
expand_source.insert(src, !all_selected);
}
let by_name = make_by_name(items);
let mut deps: HashMap<usize, Vec<usize>> = HashMap::new();
let mut edges_of = |node: usize| -> Vec<usize> {
if let Some(d) = deps.get(&node) {
return d.clone();
}
let item = &items[node];
let edges = if *expand_source.get(item.source.as_str()).unwrap_or(&true) {
let text = read(item);
item_edges(node, items, &by_name, &text)
} else {
Vec::new()
};
deps.insert(node, edges.clone());
edges
};
let mut visited: HashSet<usize> = HashSet::new();
let mut order: Vec<usize> = Vec::new();
let mut closure: HashSet<usize> = HashSet::new();
enum Frame {
Enter(usize),
Exit(usize),
}
for &root in selected {
if visited.contains(&root) {
continue;
}
let mut stack = vec![Frame::Enter(root)];
while let Some(frame) = stack.pop() {
match frame {
Frame::Enter(node) => {
if !visited.insert(node) {
continue;
}
closure.insert(node);
stack.push(Frame::Exit(node));
let children = edges_of(node);
for &child in children.iter().rev() {
if !visited.contains(&child) {
stack.push(Frame::Enter(child));
}
}
}
Frame::Exit(node) => {
if !installed_idx.contains(&node) {
order.push(node);
}
}
}
}
}
let pulled: HashSet<usize> = closure
.iter()
.copied()
.filter(|i| !selected_set.contains(i))
.collect();
Resolution {
roots: selected.to_vec(),
deps,
pulled,
installed: installed_idx,
order,
}
}
impl Resolution {
pub fn install_order(&self) -> &[usize] {
&self.order
}
pub fn adds_dependencies(&self) -> bool {
!self.pulled.is_empty()
}
pub fn render_tree(&self, items: &[CatalogItem]) -> String {
let mut out = String::new();
for &root in &self.roots {
let mut path: Vec<usize> = Vec::new();
self.render_node(items, root, 0, &mut path, &mut out);
}
out
}
fn render_node(
&self,
items: &[CatalogItem],
node: usize,
depth: usize,
path: &mut Vec<usize>,
out: &mut String,
) {
for _ in 0..depth {
out.push_str(" ");
}
out.push_str("- ");
out.push_str(&items[node].key());
if path.contains(&node) {
out.push_str(" (cycle)\n");
return;
}
let role = if self.installed.contains(&node) {
"installed"
} else if self.pulled.contains(&node) {
"dep"
} else {
"selected"
};
out.push_str(" [");
out.push_str(role);
out.push_str("]\n");
path.push(node);
if let Some(children) = self.deps.get(&node) {
for &child in children {
self.render_node(items, child, depth + 1, path, out);
}
}
path.pop();
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DepNode {
pub key: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub cycle: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependencies: Option<Vec<DepNode>>,
}
impl DepNode {
pub fn normal(key: String, dependencies: Vec<DepNode>) -> Self {
Self {
key,
cycle: false,
dependencies: Some(dependencies),
}
}
fn cycle_leaf(key: String) -> Self {
Self {
key,
cycle: true,
dependencies: None,
}
}
}
#[allow(dead_code)]
pub struct InstalledGraph {
nodes: Vec<CatalogItem>,
edges: Vec<Vec<usize>>,
key_to_idx: HashMap<String, usize>,
}
#[allow(dead_code)]
pub fn installed_graph(
items: &[CatalogItem],
installed_keys: &HashSet<String>,
read: impl Fn(&CatalogItem) -> String,
) -> InstalledGraph {
let nodes: Vec<CatalogItem> = items
.iter()
.filter(|it| installed_keys.contains(&it.key()))
.cloned()
.collect();
let key_to_idx: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(i, it)| (it.key(), i))
.collect();
let by_name = make_by_name(items);
let mut edges: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
let installed_to_full: HashMap<String, usize> = items
.iter()
.enumerate()
.filter(|(_, it)| installed_keys.contains(&it.key()))
.map(|(full_i, it)| (it.key(), full_i))
.collect();
for (installed_i, node_item) in nodes.iter().enumerate() {
let full_i = match installed_to_full.get(&node_item.key()) {
Some(&idx) => idx,
None => continue,
};
let text = read(node_item);
let dep_full_indices = item_edges(full_i, items, &by_name, &text);
for dep_full_idx in dep_full_indices {
let dep_key = items[dep_full_idx].key();
if let Some(&dep_installed_i) = key_to_idx.get(&dep_key)
&& dep_installed_i != installed_i
&& !edges[installed_i].contains(&dep_installed_i)
{
edges[installed_i].push(dep_installed_i);
}
}
}
InstalledGraph {
nodes,
edges,
key_to_idx,
}
}
#[allow(dead_code)]
impl InstalledGraph {
pub fn dependents(&self, target_key: &str) -> Vec<String> {
let Some(&target_idx) = self.key_to_idx.get(target_key) else {
return Vec::new();
};
let mut out = Vec::new();
for (i, dep_list) in self.edges.iter().enumerate() {
if dep_list.contains(&target_idx) {
let key = self.nodes[i].key();
if !out.contains(&key) {
out.push(key);
}
}
}
out
}
pub fn render_forest(&self) -> String {
let mut in_degree = vec![0usize; self.nodes.len()];
for dep_list in &self.edges {
for &dep_idx in dep_list {
in_degree[dep_idx] += 1;
}
}
let mut out = String::new();
let mut emitted: HashSet<usize> = HashSet::new();
for (i, °) in in_degree.iter().enumerate() {
if deg == 0 {
self.mark_reachable(i, &mut emitted);
let mut path = Vec::new();
self.render_installed_node(i, 0, &mut path, &mut out);
}
}
for i in 0..self.nodes.len() {
if !emitted.contains(&i) {
self.mark_reachable(i, &mut emitted);
let mut path = Vec::new();
self.render_installed_node(i, 0, &mut path, &mut out);
}
}
out
}
fn mark_reachable(&self, start: usize, emitted: &mut HashSet<usize>) {
let mut stack = vec![start];
while let Some(node) = stack.pop() {
if emitted.insert(node) {
for &child in &self.edges[node] {
if !emitted.contains(&child) {
stack.push(child);
}
}
}
}
}
pub fn render_subtree(&self, root_key: &str) -> Option<String> {
let &root_idx = self.key_to_idx.get(root_key)?;
let mut out = String::new();
let mut path = Vec::new();
self.render_installed_node(root_idx, 0, &mut path, &mut out);
Some(out)
}
fn render_installed_node(
&self,
node: usize,
depth: usize,
path: &mut Vec<usize>,
out: &mut String,
) {
for _ in 0..depth {
out.push_str(" ");
}
out.push_str("- ");
out.push_str(&self.nodes[node].key());
if path.contains(&node) {
out.push_str(" (cycle)\n");
return;
}
out.push('\n');
path.push(node);
for &child in &self.edges[node] {
self.render_installed_node(child, depth + 1, path, out);
}
path.pop();
}
pub fn forest_nodes(&self) -> Vec<DepNode> {
let mut in_degree = vec![0usize; self.nodes.len()];
for dep_list in &self.edges {
for &dep_idx in dep_list {
in_degree[dep_idx] += 1;
}
}
let mut emitted: HashSet<usize> = HashSet::new();
let mut roots: Vec<DepNode> = Vec::new();
for (i, °) in in_degree.iter().enumerate() {
if deg == 0 {
self.mark_reachable(i, &mut emitted);
let mut path = Vec::new();
roots.push(self.build_node(i, &mut path));
}
}
for i in 0..self.nodes.len() {
if !emitted.contains(&i) {
self.mark_reachable(i, &mut emitted);
let mut path = Vec::new();
roots.push(self.build_node(i, &mut path));
}
}
roots
}
pub fn subtree_node(&self, root_key: &str) -> Option<DepNode> {
let &root_idx = self.key_to_idx.get(root_key)?;
let mut path = Vec::new();
Some(self.build_node(root_idx, &mut path))
}
fn build_node(&self, node: usize, path: &mut Vec<usize>) -> DepNode {
let key = self.nodes[node].key();
if path.contains(&node) {
return DepNode::cycle_leaf(key);
}
path.push(node);
let children: Vec<DepNode> = self.edges[node]
.iter()
.map(|&child| self.build_node(child, path))
.collect();
path.pop();
DepNode::normal(key, children)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use std::collections::HashMap;
use std::path::PathBuf;
fn item(kind: ItemKind, name: &str, source: &str) -> CatalogItem {
CatalogItem {
kind,
name: name.to_string(),
source: source.to_string(),
prefix: None,
path: PathBuf::from("/tmp/fake"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
hooks: Vec::new(),
}
}
fn reader(map: HashMap<String, String>) -> impl Fn(&CatalogItem) -> String {
move |it: &CatalogItem| -> String {
map.get(&format!("{}:{}", it.kind.as_str(), it.name))
.cloned()
.unwrap_or_default()
}
}
fn no_installed() -> HashSet<String> {
HashSet::new()
}
#[test]
fn skill_pulls_referenced_agent_before_itself() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "hand off to {{ns:test}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
assert_eq!(r.roots, vec![0]);
assert_eq!(r.install_order(), &[1, 0]);
assert!(r.adds_dependencies());
}
#[test]
fn references_never_cross_sources() {
let items = vec![
item(ItemKind::Skill, "review", "a"), item(ItemKind::Agent, "test", "b"), ];
let mut content = HashMap::new();
content.insert("skill:review".into(), "see {{ns:test}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
assert_eq!(r.install_order(), &[0]);
assert!(!r.adds_dependencies());
}
#[test]
fn transitive_chain_resolves_dependency_first() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:c}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
assert_eq!(r.install_order(), &[2, 1, 0]);
assert!(r.adds_dependencies());
}
#[test]
fn selecting_whole_source_adds_nothing() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Agent, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let r = resolve(&items, &[0, 1], &no_installed(), reader(content));
assert!(!r.adds_dependencies());
let mut order = r.install_order().to_vec();
order.sort_unstable();
assert_eq!(order, vec![0, 1]);
}
#[test]
fn no_token_selection_is_unchanged() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let content = HashMap::new(); let r = resolve(&items, &[0], &no_installed(), reader(content));
assert_eq!(r.install_order(), &[0]);
assert!(!r.adds_dependencies());
}
#[test]
fn cycle_terminates_with_back_edge_and_stable_order() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert_eq!(order.len(), 2);
assert!(order.contains(&0) && order.contains(&1));
assert_eq!(order, vec![1, 0]);
let tree = r.render_tree(&items);
assert!(
tree.contains("(cycle)"),
"tree should mark a back-edge: {tree}"
);
}
#[test]
fn already_installed_dep_excluded_from_order_but_shown_in_tree() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let mut installed = HashSet::new();
installed.insert("agent:test".to_string());
let r = resolve(&items, &[0], &installed, reader(content));
assert_eq!(r.install_order(), &[0]);
let tree = r.render_tree(&items);
assert!(
tree.contains("agent:test [installed]"),
"installed dep must be shown marked: {tree}"
);
}
#[test]
fn tree_roots_are_selected_items() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let tree = r.render_tree(&items);
assert!(
tree.starts_with("- skill:review [selected]\n"),
"root must be the selected item: {tree}"
);
assert!(
tree.contains(" - agent:test [dep]\n"),
"dependency must be an indented [dep] descendant: {tree}"
);
}
#[test]
fn one_token_can_match_several_siblings_across_kinds() {
let items = vec![
item(ItemKind::Skill, "root", "s"),
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let mut content = HashMap::new();
content.insert("skill:root".into(), "{{ns:shared}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order();
assert!(order.contains(&1) && order.contains(&2));
assert_eq!(*order.last().unwrap(), 0);
}
#[test]
fn diamond_shares_one_install_of_the_common_dependency() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
item(ItemKind::Skill, "d", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}} {{ns:c}}".into());
content.insert("skill:b".into(), "{{ns:d}}".into());
content.insert("skill:c".into(), "{{ns:d}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert_eq!(order.iter().filter(|&&n| n == 3).count(), 1);
let pos = |n: usize| order.iter().position(|&x| x == n).unwrap();
assert!(pos(3) < pos(1), "d before b: {order:?}");
assert!(pos(3) < pos(2), "d before c: {order:?}");
assert!(pos(1) < pos(0), "b before a: {order:?}");
assert!(pos(2) < pos(0), "c before a: {order:?}");
assert_eq!(order, vec![3, 1, 2, 0]);
assert_eq!(order.len(), 4);
assert!(r.adds_dependencies());
}
#[test]
fn multiple_roots_across_sources_resolve_per_source_independently() {
let items = vec![
item(ItemKind::Skill, "root", "x"), item(ItemKind::Agent, "helper", "x"), item(ItemKind::Rule, "spare", "x"), item(ItemKind::Skill, "root", "y"), item(ItemKind::Agent, "helper", "y"), item(ItemKind::Rule, "spare", "y"), ];
let mut content = HashMap::new();
content.insert("skill:root".into(), "{{ns:helper}}".into());
let r = resolve(&items, &[0, 3], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert!(!order.contains(&2) && !order.contains(&5));
let pos = |n: usize| order.iter().position(|&x| x == n);
assert!(
pos(1).unwrap() < pos(0).unwrap(),
"x-helper before x-root: {order:?}"
);
assert!(
pos(4).unwrap() < pos(3).unwrap(),
"y-helper before y-root: {order:?}"
);
assert_eq!(order, vec![1, 0, 4, 3]);
assert!(r.adds_dependencies());
}
#[test]
fn selected_root_that_is_already_installed_is_a_root_but_not_reinstalled() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let mut installed = HashSet::new();
installed.insert("skill:review".to_string()); let r = resolve(&items, &[0], &installed, reader(content));
assert_eq!(r.install_order(), &[1]);
let tree = r.render_tree(&items);
assert!(
tree.starts_with("- skill:review [installed]\n"),
"installed selected root must still head the tree: {tree}"
);
assert!(
tree.contains(" - agent:test [dep]\n"),
"dep of an installed root is still shown: {tree}"
);
}
#[test]
fn adds_dependencies_true_even_when_pulled_dep_is_already_installed() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let mut installed = HashSet::new();
installed.insert("agent:test".to_string());
let r = resolve(&items, &[0], &installed, reader(content));
assert_eq!(r.install_order(), &[0]);
assert!(
r.adds_dependencies(),
"a pulled-in (already-installed) dep still counts as adding deps"
);
}
#[test]
fn render_tree_exact_nested_format_is_locked() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
item(ItemKind::Skill, "d", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}} {{ns:c}}".into());
content.insert("skill:b".into(), "{{ns:d}}".into());
content.insert("skill:c".into(), "{{ns:d}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let expected = "\
- skill:a [selected]
- skill:b [dep]
- skill:d [dep]
- skill:c [dep]
- skill:d [dep]
";
assert_eq!(r.render_tree(&items), expected);
}
#[test]
fn render_tree_exact_cycle_back_edge_format_is_locked() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let expected = "\
- skill:a [selected]
- skill:b [dep]
- skill:a (cycle)
";
assert_eq!(r.render_tree(&items), expected);
}
#[test]
fn token_with_no_matching_sibling_is_a_non_edge() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "see {{ns:nonexistent}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
assert_eq!(r.install_order(), &[0]);
assert!(!r.adds_dependencies());
}
#[test]
fn self_reference_token_forms_no_edge() {
let items = vec![item(ItemKind::Skill, "solo", "s")];
let mut content = HashMap::new();
content.insert("skill:solo".into(), "I call {{ns:solo}} recursively".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
assert_eq!(r.install_order(), &[0]);
assert!(!r.adds_dependencies());
assert_eq!(r.render_tree(&items), "- skill:solo [selected]\n");
}
#[test]
fn requires_entry_adds_a_dependency_edge() {
let mut skill = item(ItemKind::Skill, "review", "s");
skill.requires = vec!["agent:test".to_string()];
let items = vec![skill, item(ItemKind::Agent, "test", "s")];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
assert_eq!(
r.install_order(),
&[1, 0],
"dep (1) must precede the skill (0)"
);
assert!(r.adds_dependencies());
}
#[test]
fn requires_and_token_same_dep_deduped_to_one_edge() {
let mut skill = item(ItemKind::Skill, "review", "s");
skill.requires = vec!["agent:test".to_string()];
let items = vec![skill, item(ItemKind::Agent, "test", "s")];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert_eq!(
order.iter().filter(|&&n| n == 1).count(),
1,
"dep must appear once"
);
assert_eq!(order, vec![1, 0]);
}
#[test]
fn requires_ambiguous_bare_name_emits_no_edge() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["shared".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
let order = r.install_order().to_vec();
assert!(
!order.contains(&1) && !order.contains(&2),
"ambiguous bare requires must not fan out to all kinds: {order:?}"
);
assert_eq!(
order,
vec![0],
"only root installs when requires is ambiguous: {order:?}"
);
assert!(!r.adds_dependencies());
}
#[test]
fn requires_bare_name_unique_match_resolves_to_single_edge() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["only".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "only", "s"), item(ItemKind::Agent, "other", "s"),
];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
let order = r.install_order().to_vec();
assert!(
order.contains(&1),
"unique bare requires must pull the match: {order:?}"
);
assert!(
!order.contains(&2),
"unrelated sibling must not be pulled: {order:?}"
);
assert_eq!(order, vec![1, 0], "dep (1) before root (0): {order:?}");
assert!(r.adds_dependencies());
}
#[test]
fn token_ambiguous_bare_name_still_matches_all_kinds() {
let items = vec![
item(ItemKind::Skill, "root", "s"),
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let mut content = HashMap::new();
content.insert("skill:root".into(), "{{ns:shared}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert!(
order.contains(&1) && order.contains(&2),
"{{{{ns:shared}}}} token must match BOTH kinds (DEP-3): {order:?}"
);
assert_eq!(*order.last().unwrap(), 0, "root is last: {order:?}");
}
#[test]
fn requires_kind_prefix_narrows_to_that_kind() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["agent:shared".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "shared", "s"), item(ItemKind::Rule, "shared", "s"), ];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
let order = r.install_order().to_vec();
assert!(order.contains(&1), "agent:shared must be pulled: {order:?}");
assert!(
!order.contains(&2),
"rule:shared must NOT be pulled: {order:?}"
);
}
#[test]
fn requires_source_qualified_entry_contributes_no_edge() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["owner/repo#agent:test".to_string()];
let items = vec![skill, item(ItemKind::Agent, "test", "s")];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
assert_eq!(
r.install_order(),
&[0],
"source-qualified entry must not pull the dep"
);
assert!(!r.adds_dependencies());
}
#[test]
fn same_bare_name_different_kind_is_a_distinct_dependency() {
let items = vec![
item(ItemKind::Skill, "root", "s"),
item(ItemKind::Agent, "test", "s"),
item(ItemKind::Rule, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:root".into(), "{{ns:test}}".into());
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert_eq!(order.len(), 3);
let pos = |n: usize| order.iter().position(|&x| x == n).unwrap();
assert!(
pos(1) < pos(0) && pos(2) < pos(0),
"both kinds before root: {order:?}"
);
let tree = r.render_tree(&items);
assert!(tree.contains(" - agent:test [dep]\n"), "{tree}");
assert!(tree.contains(" - rule:test [dep]\n"), "{tree}");
}
#[test]
fn requires_chain_resolves_transitively_dependency_first() {
let mut a = item(ItemKind::Skill, "a", "s");
a.requires = vec!["skill:b".to_string()];
let mut b = item(ItemKind::Skill, "b", "s");
b.requires = vec!["skill:c".to_string()];
let c = item(ItemKind::Skill, "c", "s");
let items = vec![a, b, c];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
assert_eq!(
r.install_order(),
&[2, 1, 0],
"transitive requires chain must order c before b before a"
);
assert!(r.adds_dependencies());
}
#[test]
fn empty_requires_yields_no_edge() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
assert_eq!(r.install_order(), &[0]);
assert!(!r.adds_dependencies());
}
#[test]
fn requires_edge_is_a_noop_when_whole_source_selected() {
let mut a = item(ItemKind::Skill, "a", "s");
a.requires = vec!["agent:b".to_string()];
let items = vec![a, item(ItemKind::Agent, "b", "s")];
let r = resolve(&items, &[0, 1], &no_installed(), reader(HashMap::new()));
assert!(
!r.adds_dependencies(),
"a requires edge must be a no-op under full-source selection"
);
let mut order = r.install_order().to_vec();
order.sort_unstable();
assert_eq!(order, vec![0, 1]);
}
#[test]
fn requires_self_reference_forms_no_edge() {
let mut solo = item(ItemKind::Skill, "solo", "s");
solo.requires = vec!["skill:solo".to_string()];
let items = vec![solo];
let r = resolve(&items, &[0], &no_installed(), reader(HashMap::new()));
assert_eq!(r.install_order(), &[0]);
assert!(!r.adds_dependencies());
assert_eq!(r.render_tree(&items), "- skill:solo [selected]\n");
}
#[test]
fn requires_already_installed_dep_excluded_but_still_pulled() {
let mut review = item(ItemKind::Skill, "review", "s");
review.requires = vec!["agent:test".to_string()];
let items = vec![review, item(ItemKind::Agent, "test", "s")];
let mut installed = HashSet::new();
installed.insert("agent:test".to_string());
let r = resolve(&items, &[0], &installed, reader(HashMap::new()));
assert_eq!(
r.install_order(),
&[0],
"an already-installed requires dep must not be reinstalled"
);
assert!(
r.adds_dependencies(),
"the pulled-in (installed) requires dep still counts as adding deps"
);
let tree = r.render_tree(&items);
assert!(
tree.contains("agent:test [installed]"),
"installed requires dep must be shown marked: {tree}"
);
}
#[test]
fn direct_dependency_keys_ns_token_edge() {
let items = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let mut content = HashMap::new();
content.insert("skill:review".into(), "hand off to {{ns:test}}".into());
let read = reader(content);
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(keys, vec!["agent:test".to_string()]);
}
#[test]
fn direct_dependency_keys_requires_edge() {
let mut skill = item(ItemKind::Skill, "review", "s");
skill.requires = vec!["agent:test".to_string()];
let items = vec![skill, item(ItemKind::Agent, "test", "s")];
let read = reader(HashMap::new());
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(keys, vec!["agent:test".to_string()]);
}
#[test]
fn direct_dependency_keys_union_deduped() {
let mut skill = item(ItemKind::Skill, "review", "s");
skill.requires = vec!["agent:test".to_string()];
let items = vec![skill, item(ItemKind::Agent, "test", "s")];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let read = reader(content);
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(keys, vec!["agent:test".to_string()]);
assert_eq!(keys.len(), 1, "must be deduped to one key");
}
#[test]
fn direct_dependency_keys_kind_narrowing() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["agent:shared".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let read = reader(HashMap::new());
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(keys, vec!["agent:shared".to_string()]);
assert!(
!keys.contains(&"rule:shared".to_string()),
"rule:shared must not be included with kind-narrowed requires"
);
}
#[test]
fn direct_dependency_keys_within_source_only() {
let items = vec![
item(ItemKind::Skill, "review", "a"),
item(ItemKind::Agent, "test", "b"), ];
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let read = reader(content);
let keys = direct_dependency_keys(&items[0], &items, &read);
assert!(
keys.is_empty(),
"cross-source token must yield no dependency key"
);
}
#[test]
fn installed_dependents_direct_dep_reported() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let deps = g.dependents("skill:b");
assert_eq!(deps, vec!["skill:a".to_string()]);
}
#[test]
fn installed_dependents_non_depended_item_returns_empty() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let deps = g.dependents("skill:a");
assert!(
deps.is_empty(),
"nothing depends on a, so dependents must be empty: {deps:?}"
);
}
#[test]
fn installed_dependents_non_installed_edge_not_shown() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let deps = g.dependents("skill:b");
assert!(
deps.is_empty(),
"non-installed item is not a graph node: {deps:?}"
);
}
#[test]
fn installed_dependents_reverse_direction_only() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
assert!(
!g.dependents("skill:a").contains(&"skill:b".to_string()),
"b must not appear as a dependent of a"
);
}
#[test]
fn installed_dependents_requires_edge() {
let mut a = item(ItemKind::Skill, "a", "s");
a.requires = vec!["skill:b".to_string()];
let items = vec![a, item(ItemKind::Skill, "b", "s")];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
let deps = g.dependents("skill:b");
assert_eq!(deps, vec!["skill:a".to_string()]);
}
#[test]
fn forest_roots_are_items_with_no_incoming_edge() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let forest = g.render_forest();
assert!(
forest.starts_with("- skill:a\n"),
"a must be the root line: {forest:?}"
);
assert!(
forest.contains(" - skill:b\n"),
"b must be nested under a: {forest:?}"
);
let root_b = forest.lines().any(|l| l == "- skill:b");
assert!(!root_b, "b must not be its own root line: {forest:?}");
}
#[test]
fn forest_transitive_nesting() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:c}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let expected = "- skill:a\n - skill:b\n - skill:c\n";
assert_eq!(g.render_forest(), expected);
}
#[test]
fn forest_cycle_marks_back_edge() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let forest = g.render_forest();
assert!(
forest.contains("(cycle)"),
"render_forest must mark a back-edge as (cycle): {forest:?}"
);
assert!(!forest.is_empty());
let sub = g.render_subtree("skill:a").expect("skill:a must be a node");
assert!(
sub.contains("(cycle)"),
"render_subtree must mark a back-edge as (cycle): {sub:?}"
);
}
#[test]
fn forest_render_subtree_scopes_to_one_root() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:c}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let sub = g.render_subtree("skill:a").expect("skill:a must be a node");
let expected = "- skill:a\n - skill:b\n - skill:c\n";
assert_eq!(sub, expected);
}
#[test]
fn forest_render_subtree_none_for_non_node() {
let items = vec![item(ItemKind::Skill, "a", "s")];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
assert!(
g.render_subtree("skill:nonexistent").is_none(),
"non-installed key must return None"
);
}
#[test]
fn forest_exact_format_locked() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:c}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let expected = "\
- skill:a
- skill:b
- skill:c
";
assert_eq!(g.render_forest(), expected);
}
#[test]
fn forest_cycle_exact_format_locked() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let expected = "\
- skill:a
- skill:b
- skill:a (cycle)
";
assert_eq!(
g.render_forest(),
expected,
"pure 2-cycle must render with the lowest-index node promoted as root"
);
let sub = g.render_subtree("skill:a").expect("skill:a must be a node");
assert_eq!(sub, expected, "subtree of a must match the forest root");
}
#[test]
fn direct_dependency_keys_item_not_in_catalog_is_empty() {
let in_catalog = vec![
item(ItemKind::Skill, "review", "s"),
item(ItemKind::Agent, "test", "s"),
];
let outsider = item(ItemKind::Skill, "review", "s");
let mut content = HashMap::new();
content.insert("skill:review".into(), "{{ns:test}}".into());
let read = reader(content);
let keys = direct_dependency_keys(&outsider, &in_catalog, &read);
assert!(
keys.is_empty(),
"an item not present in `items` must yield no keys, not panic: {keys:?}"
);
}
#[test]
fn direct_dependency_keys_prefixed_item_keys_use_effective_name() {
let mut review = item(ItemKind::Skill, "review", "s");
review.prefix = Some("jk".to_string());
let mut test = item(ItemKind::Agent, "test", "s");
test.prefix = Some("jk".to_string());
let items = vec![review, test];
let mut content = HashMap::new();
content.insert("skill:review".into(), "hand off to {{ns:test}}".into());
let read = reader(content);
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(
keys,
vec!["agent:jk-test".to_string()],
"dep key must use the effective (prefixed) name while the token resolved by bare name"
);
}
#[test]
fn direct_dependency_keys_multiple_tokens_in_stable_discovery_order() {
let items = vec![
item(ItemKind::Skill, "root", "s"),
item(ItemKind::Agent, "alpha", "s"),
item(ItemKind::Agent, "beta", "s"),
item(ItemKind::Agent, "gamma", "s"),
];
let mut content = HashMap::new();
content.insert(
"skill:root".into(),
"first {{ns:gamma}} then {{ns:alpha}} then {{ns:beta}} again {{ns:alpha}}".into(),
);
let read = reader(content);
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(
keys,
vec![
"agent:gamma".to_string(),
"agent:alpha".to_string(),
"agent:beta".to_string(),
],
"keys must follow token discovery order and be deduped: {keys:?}"
);
}
#[test]
fn installed_graph_diamond_forest_nests_shared_dep_under_both_dependents() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
item(ItemKind::Skill, "d", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}} {{ns:c}}".into());
content.insert("skill:b".into(), "{{ns:d}}".into());
content.insert("skill:c".into(), "{{ns:d}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
installed_keys.insert("skill:d".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let expected = "\
- skill:a
- skill:b
- skill:d
- skill:c
- skill:d
";
assert_eq!(
g.render_forest(),
expected,
"diamond forest must nest the shared dep under each dependent with a as sole root"
);
assert!(
!g.render_forest().lines().any(|l| l == "- skill:d"),
"shared dependency d must not appear as its own root"
);
}
#[test]
fn installed_graph_excludes_catalog_items_with_no_installed_key() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "ghost", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}} {{ns:ghost}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let forest = g.render_forest();
assert!(
!forest.contains("ghost"),
"uninstalled catalog item must not appear in the forest: {forest:?}"
);
assert_eq!(forest, "- skill:a\n - skill:b\n");
assert!(
g.render_subtree("skill:ghost").is_none(),
"uninstalled item is not a node: render_subtree must be None"
);
assert!(g.dependents("skill:ghost").is_empty());
}
#[test]
fn installed_graph_prefixed_items_resolve_edges_by_effective_keys() {
let mut a = item(ItemKind::Skill, "a", "s");
a.prefix = Some("jk".to_string());
let mut b = item(ItemKind::Skill, "b", "s");
b.prefix = Some("jk".to_string());
let items = vec![a, b];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:jk-a".to_string());
installed_keys.insert("skill:jk-b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
assert_eq!(
g.dependents("skill:jk-b"),
vec!["skill:jk-a".to_string()],
"edge must resolve between prefixed nodes by effective key"
);
assert_eq!(
g.render_forest(),
"- skill:jk-a\n - skill:jk-b\n",
"forest must render the prefixed effective keys"
);
}
#[test]
fn installed_dependents_multiple_dependents_all_returned() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "shared", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:shared}}".into());
content.insert("skill:b".into(), "{{ns:shared}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:shared".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let deps = g.dependents("skill:shared");
assert_eq!(
deps,
vec!["skill:a".to_string(), "skill:b".to_string()],
"all dependents of the shared target must be returned in index order"
);
}
#[test]
fn installed_dependents_target_with_no_dependents_in_larger_graph() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
item(ItemKind::Skill, "d", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}} {{ns:c}}".into());
content.insert("skill:b".into(), "{{ns:d}}".into());
content.insert("skill:c".into(), "{{ns:d}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
installed_keys.insert("skill:d".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
assert!(
g.dependents("skill:a").is_empty(),
"the top root must have no dependents in a diamond"
);
assert_eq!(
g.dependents("skill:d"),
vec!["skill:b".to_string(), "skill:c".to_string()]
);
}
#[test]
fn forest_dependency_only_item_never_appears_as_root_two_dependents() {
let items = vec![
item(ItemKind::Skill, "r1", "s"),
item(ItemKind::Skill, "r2", "s"),
item(ItemKind::Skill, "lib", "s"),
];
let mut content = HashMap::new();
content.insert("skill:r1".into(), "{{ns:lib}}".into());
content.insert("skill:r2".into(), "{{ns:lib}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:r1".to_string());
installed_keys.insert("skill:r2".to_string());
installed_keys.insert("skill:lib".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let expected = "\
- skill:r1
- skill:lib
- skill:r2
- skill:lib
";
assert_eq!(
g.render_forest(),
expected,
"lib must nest under both roots and never appear as its own top-level root"
);
assert!(
!g.render_forest().lines().any(|l| l == "- skill:lib"),
"dependency-only item must not be a root line"
);
}
#[test]
fn render_subtree_from_dependency_only_node_renders_its_own_subtree() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:c}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let sub = g.render_subtree("skill:b").expect("skill:b must be a node");
assert_eq!(
sub, "- skill:b\n - skill:c\n",
"subtree of a dependency-only node is rooted at that node, ignoring in-degree"
);
}
#[test]
fn forest_all_cycle_component_promoted_alongside_independent_root() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let forest = g.render_forest();
let expected = "\
- skill:c
- skill:a
- skill:b
- skill:a (cycle)
";
assert_eq!(
forest, expected,
"independent root then promoted cycle root: all items must appear"
);
assert!(
forest.contains("skill:a") && forest.contains("skill:b") && forest.contains("skill:c"),
"no installed item may be hidden from the forest"
);
}
#[test]
fn forest_independent_items_both_roots() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
let forest = g.render_forest();
assert!(
forest.contains("- skill:a\n"),
"a must be a root: {forest:?}"
);
assert!(
forest.contains("- skill:b\n"),
"b must be a root: {forest:?}"
);
}
#[test]
fn forest_nodes_simple_chain_structured() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let nodes = g.forest_nodes();
assert_eq!(nodes.len(), 1, "expected 1 root: {nodes:?}");
let root = &nodes[0];
assert_eq!(root.key, "skill:a");
assert!(!root.cycle, "root must not be a cycle leaf");
let children = root
.dependencies
.as_ref()
.expect("root must have dependencies");
assert_eq!(children.len(), 1);
assert_eq!(children[0].key, "skill:b");
assert!(!children[0].cycle);
let leaf_deps = children[0]
.dependencies
.as_ref()
.expect("leaf must have dependencies");
assert!(
leaf_deps.is_empty(),
"leaf must have empty dependencies: {leaf_deps:?}"
);
}
#[test]
fn forest_nodes_cycle_yields_cycle_leaf() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let nodes = g.forest_nodes();
assert_eq!(nodes.len(), 1);
let root = &nodes[0];
assert_eq!(root.key, "skill:a");
assert!(!root.cycle);
let children = root.dependencies.as_ref().unwrap();
assert_eq!(children.len(), 1);
let b_node = &children[0];
assert_eq!(b_node.key, "skill:b");
assert!(!b_node.cycle);
let b_children = b_node.dependencies.as_ref().unwrap();
assert_eq!(b_children.len(), 1);
let cycle_leaf = &b_children[0];
assert_eq!(cycle_leaf.key, "skill:a");
assert!(cycle_leaf.cycle, "back-edge to a must be a cycle leaf");
assert!(
cycle_leaf.dependencies.is_none(),
"cycle leaf must have no dependencies field"
);
}
#[test]
fn subtree_node_returns_scoped_node() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let node = g.subtree_node("skill:a").expect("skill:a must be a node");
assert_eq!(node.key, "skill:a");
let children = node.dependencies.as_ref().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].key, "skill:b");
assert!(g.subtree_node("skill:ghost").is_none());
}
#[test]
fn subtree_node_leaf_has_empty_dependencies() {
let items = vec![item(ItemKind::Skill, "leaf", "s")];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:leaf".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
let node = g.subtree_node("skill:leaf").expect("leaf must be a node");
assert_eq!(node.key, "skill:leaf");
assert!(!node.cycle);
let deps = node
.dependencies
.as_ref()
.expect("leaf must have dependencies field");
assert!(deps.is_empty(), "leaf must have empty dependencies");
}
#[test]
fn forest_nodes_covers_same_items_as_render_forest() {
let items = vec![
item(ItemKind::Skill, "a", "s"), item(ItemKind::Skill, "b", "s"), item(ItemKind::Skill, "c", "s"), item(ItemKind::Skill, "d", "s"), ];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
content.insert("skill:c".into(), "{{ns:d}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
installed_keys.insert("skill:d".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
fn collect_keys(nodes: &[DepNode], out: &mut HashSet<String>) {
for n in nodes {
out.insert(n.key.clone());
if let Some(children) = &n.dependencies {
collect_keys(children, out);
}
}
}
let nodes = g.forest_nodes();
let mut node_keys: HashSet<String> = HashSet::new();
collect_keys(&nodes, &mut node_keys);
for key in &installed_keys {
assert!(
node_keys.contains(key),
"forest_nodes must include installed key {key}: {nodes:?}"
);
}
let forest = g.render_forest();
for key in &installed_keys {
assert!(
forest.contains(key.as_str()),
"render_forest must contain key {key}: {forest:?}"
);
}
}
#[test]
fn forest_nodes_json_serialization_shape() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let nodes = g.forest_nodes();
let json_str = serde_json::to_string(&nodes).expect("must serialize");
let v: serde_json::Value = serde_json::from_str(&json_str).expect("must parse");
assert!(v.is_array());
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 1);
let root = &arr[0];
assert_eq!(root["key"], "skill:a");
assert!(
root.get("dependencies").is_some(),
"normal node must have dependencies"
);
assert!(
root.get("cycle").is_none(),
"normal node must not have cycle field"
);
let b_node = &root["dependencies"][0];
assert_eq!(b_node["key"], "skill:b");
assert!(b_node.get("cycle").is_none());
assert!(b_node.get("dependencies").is_some());
let cycle_leaf = &b_node["dependencies"][0];
assert_eq!(cycle_leaf["key"], "skill:a");
assert_eq!(cycle_leaf["cycle"], true);
assert!(
cycle_leaf.get("dependencies").is_none(),
"cycle leaf must not have dependencies field"
);
}
#[test]
fn forest_nodes_prefixed_items_use_effective_keys() {
let mut a = item(ItemKind::Skill, "a", "s");
a.prefix = Some("jk".to_string());
let mut b = item(ItemKind::Skill, "b", "s");
b.prefix = Some("jk".to_string());
let items = vec![a, b];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:jk-a".to_string());
installed_keys.insert("skill:jk-b".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let nodes = g.forest_nodes();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].key, "skill:jk-a");
let children = nodes[0].dependencies.as_ref().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].key, "skill:jk-b");
}
#[test]
fn forest_nodes_diamond_nests_shared_dep_under_both_dependents() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
item(ItemKind::Skill, "d", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}} {{ns:c}}".into());
content.insert("skill:b".into(), "{{ns:d}}".into());
content.insert("skill:c".into(), "{{ns:d}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
installed_keys.insert("skill:d".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let nodes = g.forest_nodes();
assert_eq!(nodes.len(), 1, "a must be the only root: {nodes:?}");
let a = &nodes[0];
assert_eq!(a.key, "skill:a");
assert!(!a.cycle);
let a_children = a.dependencies.as_ref().expect("a has deps");
assert_eq!(
a_children
.iter()
.map(|n| n.key.as_str())
.collect::<Vec<_>>(),
vec!["skill:b", "skill:c"],
"a's children must be b then c in discovery order"
);
for (i, parent) in ["skill:b", "skill:c"].iter().enumerate() {
let p = &a_children[i];
assert_eq!(&p.key, parent);
assert!(!p.cycle, "{parent} must not be a cycle leaf");
let p_children = p.dependencies.as_ref().expect("parent has deps");
assert_eq!(p_children.len(), 1, "{parent} must have exactly one child");
let d = &p_children[0];
assert_eq!(d.key, "skill:d", "{parent}'s child must be d");
assert!(
!d.cycle,
"d under {parent} must be a normal node, not a cycle"
);
assert!(
d.dependencies.as_ref().is_some_and(|v| v.is_empty()),
"d is a leaf: empty dependencies, not absent or a cycle"
);
}
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&nodes).unwrap()).unwrap();
assert_eq!(v[0]["key"], "skill:a");
assert_eq!(v[0]["dependencies"][0]["key"], "skill:b");
assert_eq!(v[0]["dependencies"][0]["dependencies"][0]["key"], "skill:d");
assert_eq!(v[0]["dependencies"][1]["key"], "skill:c");
assert_eq!(v[0]["dependencies"][1]["dependencies"][0]["key"], "skill:d");
let forest = g.render_forest();
assert_eq!(
forest.matches("- skill:d").count(),
2,
"human forest renders d twice (under b and under c): {forest:?}"
);
}
#[test]
fn forest_nodes_independent_root_plus_cycle_component_all_items_present() {
let items = vec![
item(ItemKind::Skill, "a", "s"),
item(ItemKind::Skill, "b", "s"),
item(ItemKind::Skill, "c", "s"),
];
let mut content = HashMap::new();
content.insert("skill:a".into(), "{{ns:b}}".into());
content.insert("skill:b".into(), "{{ns:a}}".into());
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:a".to_string());
installed_keys.insert("skill:b".to_string());
installed_keys.insert("skill:c".to_string());
let g = installed_graph(&items, &installed_keys, reader(content));
let nodes = g.forest_nodes();
assert_eq!(
nodes.iter().map(|n| n.key.as_str()).collect::<Vec<_>>(),
vec!["skill:c", "skill:a"],
"natural root c first, then promoted cycle root a: {nodes:?}"
);
assert!(nodes[0].dependencies.as_ref().is_some_and(|v| v.is_empty()));
let a = &nodes[1];
let b = &a.dependencies.as_ref().unwrap()[0];
assert_eq!(b.key, "skill:b");
assert!(!b.cycle);
let back = &b.dependencies.as_ref().unwrap()[0];
assert_eq!(back.key, "skill:a");
assert!(back.cycle, "the b->a back-edge must be a cycle leaf");
assert!(
back.dependencies.is_none(),
"cycle leaf has no dependencies"
);
fn collect(nodes: &[DepNode], out: &mut HashSet<String>) {
for n in nodes {
out.insert(n.key.clone());
if let Some(c) = &n.dependencies {
collect(c, out);
}
}
}
let mut seen = HashSet::new();
collect(&nodes, &mut seen);
for k in &installed_keys {
assert!(
seen.contains(k),
"every installed item must appear: missing {k}"
);
}
}
#[test]
fn direct_dependency_keys_ambiguous_bare_requires_emits_no_key() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["shared".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let read = reader(HashMap::new());
let keys = direct_dependency_keys(&items[0], &items, &read);
assert!(
keys.is_empty(),
"ambiguous bare requires must yield no adjacency key: {keys:?}"
);
}
#[test]
fn direct_dependency_keys_unique_bare_requires_emits_single_key() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["only".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "only", "s"),
item(ItemKind::Agent, "other", "s"),
];
let read = reader(HashMap::new());
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(
keys,
vec!["agent:only".to_string()],
"unique bare requires must resolve to exactly one key: {keys:?}"
);
}
#[test]
fn direct_dependency_keys_kind_qualified_requires_beats_same_named_other_kind() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["rule:shared".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let read = reader(HashMap::new());
let keys = direct_dependency_keys(&items[0], &items, &read);
assert_eq!(
keys,
vec!["rule:shared".to_string()],
"kind-qualified requires must pick exactly that kind: {keys:?}"
);
}
#[test]
fn installed_graph_ambiguous_bare_requires_yields_no_edge() {
let mut root = item(ItemKind::Skill, "root", "s");
root.requires = vec!["shared".to_string()];
let items = vec![
root,
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:root".to_string());
installed_keys.insert("agent:shared".to_string());
installed_keys.insert("rule:shared".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
assert!(
g.dependents("agent:shared").is_empty(),
"ambiguous bare requires must not edge to agent:shared: {:?}",
g.dependents("agent:shared")
);
assert!(
g.dependents("rule:shared").is_empty(),
"ambiguous bare requires must not edge to rule:shared: {:?}",
g.dependents("rule:shared")
);
let forest = g.render_forest();
assert_eq!(
forest, "- skill:root\n- agent:shared\n- rule:shared\n",
"ambiguous bare requires produces a flat forest (no edges): {forest:?}"
);
}
#[test]
fn installed_graph_unique_bare_requires_yields_one_edge() {
let mut root = item(ItemKind::Skill, "root", "s");
root.requires = vec!["only".to_string()];
let items = vec![root, item(ItemKind::Agent, "only", "s")];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:root".to_string());
installed_keys.insert("agent:only".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
assert_eq!(
g.dependents("agent:only"),
vec!["skill:root".to_string()],
"unique bare requires must form one edge in the installed graph"
);
assert_eq!(
g.render_forest(),
"- skill:root\n - agent:only\n",
"unique bare requires nests the single dep under root"
);
}
#[test]
fn token_and_requires_ambiguity_split_proven_in_one_scenario() {
let mut skill = item(ItemKind::Skill, "root", "s");
skill.requires = vec!["shared".to_string()];
let items = vec![
skill,
item(ItemKind::Agent, "shared", "s"),
item(ItemKind::Rule, "shared", "s"),
];
let mut content = HashMap::new();
content.insert("skill:root".into(), "{{ns:shared}}".into());
let read = reader(content.clone());
let mut keys = direct_dependency_keys(&items[0], &items, &read);
keys.sort();
assert_eq!(
keys,
vec!["agent:shared".to_string(), "rule:shared".to_string()],
"token fans out to both kinds; ambiguous requires adds nothing: {keys:?}"
);
let r = resolve(&items, &[0], &no_installed(), reader(content));
let order = r.install_order().to_vec();
assert_eq!(
order.len(),
3,
"only the two token deps plus root: {order:?}"
);
assert!(
order.contains(&1) && order.contains(&2),
"token must pull both kinds (DEP-3): {order:?}"
);
assert_eq!(*order.last().unwrap(), 0, "root installs last: {order:?}");
}
#[test]
fn installed_graph_prefixed_ambiguous_bare_requires_yields_no_edge() {
let mut root = item(ItemKind::Skill, "root", "s");
root.prefix = Some("jk".to_string());
root.requires = vec!["shared".to_string()];
let mut a = item(ItemKind::Agent, "shared", "s");
a.prefix = Some("jk".to_string());
let mut ru = item(ItemKind::Rule, "shared", "s");
ru.prefix = Some("jk".to_string());
let items = vec![root, a, ru];
let mut installed_keys = HashSet::new();
installed_keys.insert("skill:jk-root".to_string());
installed_keys.insert("agent:jk-shared".to_string());
installed_keys.insert("rule:jk-shared".to_string());
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
assert!(
g.dependents("agent:jk-shared").is_empty() && g.dependents("rule:jk-shared").is_empty(),
"ambiguous bare requires must emit no edge even when prefixed"
);
assert_eq!(
g.render_forest(),
"- skill:jk-root\n- agent:jk-shared\n- rule:jk-shared\n",
"prefixed ambiguous requires yields a flat (edgeless) forest"
);
}
#[test]
fn forest_nodes_empty_graph_is_empty_array() {
let items: Vec<CatalogItem> = vec![];
let installed_keys: HashSet<String> = HashSet::new();
let g = installed_graph(&items, &installed_keys, reader(HashMap::new()));
let nodes = g.forest_nodes();
assert!(nodes.is_empty(), "empty graph yields no roots: {nodes:?}");
assert_eq!(
serde_json::to_string(&nodes).unwrap(),
"[]",
"empty forest must serialize to a JSON empty array"
);
}
}