#![deny(warnings)]
use std::collections::{HashMap, HashSet, VecDeque};
use serde::Serialize;
use crate::catalog::store::{CatalogStore, EdgeKind};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetType {
Agent,
Skill,
Role,
Mcp,
Rule,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
pub struct DependencyNode {
pub id: String,
pub asset_type: AssetType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum EdgeType {
DependsOn,
Contains,
References,
Configures,
}
impl EdgeType {
fn from_edge_kind(kind: &EdgeKind) -> Self {
match kind {
EdgeKind::AgentSkill => EdgeType::DependsOn,
EdgeKind::RoleAgent => EdgeType::Contains,
EdgeKind::AgentMcp => EdgeType::References,
EdgeKind::AgentRule => EdgeType::Configures,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DependencyEdge {
pub from: String,
pub to: String,
pub edge_type: EdgeType,
}
#[derive(Debug, Clone, Default)]
pub struct BlastRadius {
pub dependents: Vec<String>,
}
pub struct DependencyGraph {
pub nodes: HashMap<String, DependencyNode>,
pub edges: Vec<DependencyEdge>,
}
impl DependencyGraph {
pub fn build(store: &CatalogStore) -> Self {
let mut nodes: HashMap<String, DependencyNode> = HashMap::new();
for a in &store.agents {
nodes.insert(
a.id.clone(),
DependencyNode {
id: a.id.clone(),
asset_type: AssetType::Agent,
},
);
}
for s in &store.skills {
nodes.insert(
s.id.clone(),
DependencyNode {
id: s.id.clone(),
asset_type: AssetType::Skill,
},
);
}
for rid in store.roles.keys() {
nodes.insert(
rid.clone(),
DependencyNode {
id: rid.clone(),
asset_type: AssetType::Role,
},
);
}
for m in &store.mcp_refs {
nodes.insert(
m.id.clone(),
DependencyNode {
id: m.id.clone(),
asset_type: AssetType::Mcp,
},
);
}
for r in &store.rules {
nodes.insert(
r.id.clone(),
DependencyNode {
id: r.id.clone(),
asset_type: AssetType::Rule,
},
);
}
let raw_edges = store.dependency_edges();
let mut edges: Vec<DependencyEdge> = Vec::with_capacity(raw_edges.len());
for (from, to, kind) in &raw_edges {
nodes.entry(from.clone()).or_insert_with(|| DependencyNode {
id: from.clone(),
asset_type: AssetType::Unknown,
});
nodes.entry(to.clone()).or_insert_with(|| DependencyNode {
id: to.clone(),
asset_type: AssetType::Unknown,
});
edges.push(DependencyEdge {
from: from.clone(),
to: to.clone(),
edge_type: EdgeType::from_edge_kind(kind),
});
}
edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
DependencyGraph { nodes, edges }
}
pub fn upstream(&self, asset_id: &str) -> Vec<&DependencyEdge> {
let mut result: Vec<&DependencyEdge> =
self.edges.iter().filter(|e| e.from == asset_id).collect();
result.sort_by(|a, b| a.to.cmp(&b.to));
result
}
pub fn downstream(&self, asset_id: &str) -> Vec<&DependencyEdge> {
let mut result: Vec<&DependencyEdge> =
self.edges.iter().filter(|e| e.to == asset_id).collect();
result.sort_by(|a, b| a.from.cmp(&b.from));
result
}
pub fn upstream_ids(&self, asset_id: &str) -> Vec<String> {
let mut ids: Vec<String> = self
.upstream(asset_id)
.iter()
.map(|e| e.to.clone())
.collect();
ids.sort();
ids
}
pub fn downstream_ids(&self, asset_id: &str) -> Vec<String> {
let mut ids: Vec<String> = self
.downstream(asset_id)
.iter()
.map(|e| e.from.clone())
.collect();
ids.sort();
ids
}
pub fn blast_radius(&self, asset_id: &str) -> BlastRadius {
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(asset_id.to_string());
while let Some(current) = queue.pop_front() {
for edge in self.downstream(¤t) {
if visited.insert(edge.from.clone()) {
queue.push_back(edge.from.clone());
}
}
}
visited.remove(asset_id);
let mut dependents: Vec<String> = visited.into_iter().collect();
dependents.sort();
BlastRadius { dependents }
}
pub fn find_cycles(&self) -> Vec<Vec<String>> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &self.edges {
adj.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
for neighbors in adj.values_mut() {
neighbors.sort_unstable();
}
let mut all_nodes: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
all_nodes.sort_unstable();
let mut color: HashMap<&str, u8> = HashMap::new();
let mut path: Vec<String> = Vec::new();
let mut cycles: Vec<Vec<String>> = Vec::new();
for &start in &all_nodes {
if color.get(start).copied().unwrap_or(0) == 0 {
Self::dfs_cycles(start, &adj, &mut color, &mut path, &mut cycles);
}
}
for c in &mut cycles {
if let Some(min_pos) = c
.iter()
.enumerate()
.min_by_key(|(_, v)| v.as_str())
.map(|(i, _)| i)
{
c.rotate_left(min_pos);
}
}
cycles.sort();
cycles.dedup();
cycles
}
fn dfs_cycles<'a>(
node: &'a str,
adj: &HashMap<&'a str, Vec<&'a str>>,
color: &mut HashMap<&'a str, u8>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
color.insert(node, 1); path.push(node.to_string());
if let Some(neighbors) = adj.get(node) {
for &neighbor in neighbors {
let c = color.get(neighbor).copied().unwrap_or(0);
if c == 1 {
if let Some(pos) = path.iter().position(|s| s == neighbor) {
let cycle: Vec<String> = path[pos..].to_vec();
cycles.push(cycle);
}
} else if c == 0 {
Self::dfs_cycles(neighbor, adj, color, path, cycles);
}
}
}
path.pop();
color.insert(node, 2); }
pub fn render_ascii_tree(&self, focus_id: &str, max_depth: usize) -> String {
let mut out = String::new();
let mut visited: HashSet<String> = HashSet::new();
Self::render_node(self, focus_id, 0, max_depth, &mut visited, &mut out);
out
}
fn render_node(
graph: &DependencyGraph,
node_id: &str,
depth: usize,
max_depth: usize,
visited: &mut HashSet<String>,
out: &mut String,
) {
let prefix = " ".repeat(depth);
if visited.contains(node_id) {
out.push_str(&format!("{}{} [cycle]\n", prefix, node_id));
return;
}
out.push_str(&format!("{}{}\n", prefix, node_id));
if depth >= max_depth {
return;
}
visited.insert(node_id.to_string());
let mut children: Vec<String> = graph
.edges
.iter()
.filter(|e| e.from == node_id)
.map(|e| e.to.clone())
.collect();
children.sort();
children.dedup();
for child in &children {
Self::render_node(graph, child, depth + 1, max_depth, visited, out);
}
visited.remove(node_id);
}
pub fn to_adjacency_json(&self) -> serde_json::Value {
let mut node_ids: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
node_ids.sort_unstable();
let nodes_array: Vec<serde_json::Value> = node_ids
.iter()
.map(|id| {
let node = &self.nodes[*id];
serde_json::json!({
"id": node.id,
"asset_type": node.asset_type,
})
})
.collect();
let mut adj_map: HashMap<&str, Vec<serde_json::Value>> = HashMap::new();
for edge in &self.edges {
adj_map
.entry(edge.from.as_str())
.or_default()
.push(serde_json::json!({
"to": edge.to,
"edge_type": edge.edge_type,
}));
}
for v in adj_map.values_mut() {
v.sort_by(|a, b| {
a["to"]
.as_str()
.unwrap_or("")
.cmp(b["to"].as_str().unwrap_or(""))
});
}
let mut adj_keys: Vec<&str> = adj_map.keys().copied().collect();
adj_keys.sort_unstable();
let mut adjacency = serde_json::Map::new();
for key in adj_keys {
adjacency.insert(
key.to_string(),
serde_json::Value::Array(adj_map[key].clone()),
);
}
serde_json::json!({
"nodes": nodes_array,
"adjacency": adjacency,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn graph_from_edges(edges: Vec<(String, String, EdgeType)>) -> DependencyGraph {
let mut nodes: HashMap<String, DependencyNode> = HashMap::new();
let mut dep_edges: Vec<DependencyEdge> = Vec::new();
for (from, to, etype) in edges {
nodes.entry(from.clone()).or_insert_with(|| DependencyNode {
id: from.clone(),
asset_type: AssetType::Unknown,
});
nodes.entry(to.clone()).or_insert_with(|| DependencyNode {
id: to.clone(),
asset_type: AssetType::Unknown,
});
dep_edges.push(DependencyEdge {
from,
to,
edge_type: etype,
});
}
dep_edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
DependencyGraph {
nodes,
edges: dep_edges,
}
}
#[test]
fn upstream_downstream_are_inverses() {
let g = graph_from_edges(vec![
("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
("agent-a".into(), "skill-y".into(), EdgeType::DependsOn),
("role-r".into(), "agent-a".into(), EdgeType::Contains),
]);
let up = g.upstream_ids("agent-a");
assert_eq!(up, vec!["skill-x", "skill-y"]);
let down = g.downstream_ids("agent-a");
assert_eq!(down, vec!["role-r"]);
assert!(g.upstream_ids("skill-x").is_empty());
assert_eq!(g.downstream_ids("skill-x"), vec!["agent-a"]);
}
#[test]
fn blast_radius_transitive() {
let g = graph_from_edges(vec![
("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
("role-r".into(), "agent-a".into(), EdgeType::Contains),
]);
let br = g.blast_radius("skill-x");
assert!(br.dependents.contains(&"agent-a".to_string()));
assert!(br.dependents.contains(&"role-r".to_string()));
assert!(!br.dependents.contains(&"skill-x".to_string()));
}
#[test]
fn blast_radius_direct_only() {
let g = graph_from_edges(vec![(
"agent-a".into(),
"skill-x".into(),
EdgeType::DependsOn,
)]);
let br = g.blast_radius("skill-x");
assert_eq!(br.dependents, vec!["agent-a"]);
}
#[test]
fn find_cycles_detects_simple_cycle() {
let g = graph_from_edges(vec![
("a".into(), "b".into(), EdgeType::DependsOn),
("b".into(), "c".into(), EdgeType::DependsOn),
("c".into(), "a".into(), EdgeType::DependsOn),
]);
let cycles = g.find_cycles();
assert!(!cycles.is_empty(), "expected at least one cycle");
let flat: HashSet<String> = cycles.iter().flatten().cloned().collect();
assert!(flat.contains("a"));
assert!(flat.contains("b"));
assert!(flat.contains("c"));
}
#[test]
fn find_cycles_dag_no_cycle() {
let g = graph_from_edges(vec![
("role-r".into(), "agent-a".into(), EdgeType::Contains),
("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
]);
assert!(g.find_cycles().is_empty());
}
#[test]
fn render_ascii_tree_basic() {
let g = graph_from_edges(vec![
("agent-a".into(), "skill-x".into(), EdgeType::DependsOn),
("agent-a".into(), "skill-y".into(), EdgeType::DependsOn),
]);
let tree = g.render_ascii_tree("agent-a", 5);
assert!(tree.contains("agent-a"));
assert!(tree.contains("skill-x"));
assert!(tree.contains("skill-y"));
}
#[test]
fn adjacency_json_structure() {
let g = graph_from_edges(vec![(
"agent-a".into(),
"skill-x".into(),
EdgeType::DependsOn,
)]);
let json = g.to_adjacency_json();
assert!(json["nodes"].is_array());
assert!(json["adjacency"].is_object());
let edges = &json["adjacency"]["agent-a"];
assert_eq!(edges[0]["to"].as_str().unwrap(), "skill-x");
assert_eq!(edges[0]["edge_type"].as_str().unwrap(), "depends-on");
}
use proptest::prelude::*;
fn arb_dag_edges(
n_agents: usize,
n_skills: usize,
n_roles: usize,
) -> Vec<(String, String, EdgeType)> {
let agents: Vec<String> = (0..n_agents).map(|i| format!("agent-{i}")).collect();
let skills: Vec<String> = (0..n_skills).map(|i| format!("skill-{i}")).collect();
let roles: Vec<String> = (0..n_roles).map(|i| format!("role-{i}")).collect();
let mut edges = Vec::new();
for (ai, agent) in agents.iter().enumerate() {
for (si, skill) in skills.iter().enumerate() {
if si <= ai {
edges.push((agent.clone(), skill.clone(), EdgeType::DependsOn));
}
}
}
for (ri, role) in roles.iter().enumerate() {
for (ai, agent) in agents.iter().enumerate() {
if ai <= ri {
edges.push((role.clone(), agent.clone(), EdgeType::Contains));
}
}
}
edges
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn prop_upstream_downstream_inverses(
n_agents in 1usize..5,
n_skills in 1usize..5,
n_roles in 1usize..5,
) {
let edges = arb_dag_edges(n_agents, n_skills, n_roles);
let g = graph_from_edges(edges.clone());
for (from, to, _) in &edges {
let up = g.upstream_ids(from);
let down = g.downstream_ids(to);
prop_assert!(up.contains(to),
"{to} should be in upstream({from})");
prop_assert!(down.contains(from),
"{from} should be in downstream({to})");
}
}
#[test]
fn prop_blast_radius_includes_direct_agents(
n_agents in 1usize..4,
n_skills in 1usize..4,
n_roles in 1usize..4,
) {
let edges = arb_dag_edges(n_agents, n_skills, n_roles);
let g = graph_from_edges(edges);
for si in 0..n_skills {
let skill_id = format!("skill-{si}");
let br = g.blast_radius(&skill_id);
for ai in si..n_agents {
let agent_id = format!("agent-{ai}");
prop_assert!(
br.dependents.contains(&agent_id),
"blast_radius({skill_id}) missing {agent_id}"
);
}
}
}
#[test]
fn prop_cycle_detected_not_infinite(
n in 2usize..6,
) {
let mut edges: Vec<(String, String, EdgeType)> = (0..n)
.map(|i| (
format!("node-{i}"),
format!("node-{}", (i + 1) % n),
EdgeType::DependsOn,
))
.collect();
edges.push(("extra".into(), format!("node-{}", n - 1), EdgeType::DependsOn));
let g = graph_from_edges(edges);
let cycles = g.find_cycles();
prop_assert!(!cycles.is_empty(), "cycle must be detected");
}
}
}