use petgraph::visit::EdgeRef;
use crate::graph::{RelationKind, build_graph};
use crate::model::fields::NodeType;
use crate::model::file::AgmFile;
#[must_use]
pub fn render_graph_dot(file: &AgmFile) -> String {
let graph = build_graph(file);
let mut buf = String::new();
let graph_name = &file.header.package;
buf.push_str(&format!("digraph \"{graph_name}\" {{\n"));
buf.push_str(" rankdir=LR;\n");
buf.push_str(" node [shape=box, style=\"rounded,filled\", fontname=\"Helvetica\"];\n");
buf.push('\n');
buf.push_str(" // Node declarations\n");
for node in &file.nodes {
let color = dot_node_color(&node.node_type);
let label = format!("{}\\n[{}]", node.id, node.node_type);
buf.push_str(&format!(
" \"{}\" [label=\"{}\", fillcolor=\"{}\"];\n",
node.id, label, color
));
}
buf.push('\n');
buf.push_str(" // Edges\n");
for edge in graph.inner.edge_references() {
let src = &graph.inner[edge.source()];
let tgt = &graph.inner[edge.target()];
let kind = edge.weight();
let label = rel_kind_label(kind);
let style_attrs = dot_edge_style(kind);
buf.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"{style_attrs}];\n",
src, tgt, label
));
}
buf.push_str("}\n");
buf
}
#[must_use]
pub fn render_graph_mermaid(file: &AgmFile) -> String {
let graph = build_graph(file);
let mut buf = String::new();
buf.push_str("graph LR\n");
for node in &file.nodes {
let mermaid_id = mermaid_id(&node.id);
let label = format!("{}<br/>[{}]", node.id, node.node_type);
buf.push_str(&format!(" {mermaid_id}[\"{label}\"]\n"));
}
buf.push('\n');
for edge in graph.inner.edge_references() {
let src_id = mermaid_id(&graph.inner[edge.source()]);
let tgt_id = mermaid_id(&graph.inner[edge.target()]);
let kind = edge.weight();
let label = rel_kind_label(kind);
let arrow = mermaid_arrow(kind);
buf.push_str(&format!(" {src_id} {arrow}|{label}| {tgt_id}\n"));
}
buf
}
fn dot_node_color(node_type: &NodeType) -> &'static str {
match node_type {
NodeType::Facts => "#e8f4fd",
NodeType::Rules => "#fef9e7",
NodeType::Workflow => "#e8fde8",
NodeType::Entity => "#f3e8fd",
NodeType::Decision => "#fde8c8",
NodeType::Exception => "#fde8e8",
NodeType::AntiPattern => "#fde8f0",
NodeType::Orchestration => "#f0f0f0",
_ => "#ffffff",
}
}
fn rel_kind_label(kind: &RelationKind) -> &'static str {
match kind {
RelationKind::Depends => "depends",
RelationKind::RelatedTo => "related_to",
RelationKind::Replaces => "replaces",
RelationKind::Conflicts => "conflicts",
RelationKind::SeeAlso => "see_also",
}
}
fn dot_edge_style(kind: &RelationKind) -> String {
match kind {
RelationKind::Depends => String::new(),
RelationKind::RelatedTo => ", style=dashed, color=blue".into(),
RelationKind::Replaces => ", style=bold, color=orange".into(),
RelationKind::Conflicts => ", style=dashed, color=red".into(),
RelationKind::SeeAlso => ", style=dotted, color=gray".into(),
}
}
fn mermaid_arrow(kind: &RelationKind) -> &'static str {
match kind {
RelationKind::Depends => "-->",
RelationKind::RelatedTo => "-.->",
RelationKind::Replaces => "==>",
RelationKind::Conflicts => "-.-",
RelationKind::SeeAlso => "-.->",
}
}
fn mermaid_id(node_id: &str) -> String {
node_id.replace('.', "_")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::fields::{NodeType, Span};
use crate::model::file::{AgmFile, Header};
use crate::model::node::Node;
use std::collections::BTreeMap;
fn make_node(id: &str, node_type: NodeType) -> Node {
Node {
id: id.to_owned(),
node_type,
summary: format!("node {id}"),
priority: None,
stability: None,
confidence: None,
status: None,
depends: None,
related_to: None,
replaces: None,
conflicts: None,
see_also: None,
items: None,
steps: None,
fields: None,
input: None,
output: None,
detail: None,
rationale: None,
tradeoffs: None,
resolution: None,
examples: None,
notes: None,
code: None,
code_blocks: None,
verify: None,
agent_context: None,
target: None,
execution_status: None,
executed_by: None,
executed_at: None,
execution_log: None,
retry_count: None,
parallel_groups: None,
memory: None,
scope: None,
applies_when: None,
valid_from: None,
valid_until: None,
tags: None,
aliases: None,
keywords: None,
extra_fields: BTreeMap::new(),
span: Span::default(),
}
}
fn file_with_nodes(nodes: Vec<Node>) -> AgmFile {
AgmFile {
header: Header {
agm: "1".to_owned(),
package: "test.pkg".to_owned(),
version: "0.1.0".to_owned(),
title: None,
owner: None,
imports: None,
default_load: None,
description: None,
tags: None,
status: None,
load_profiles: None,
target_runtime: None,
},
nodes,
}
}
#[test]
fn test_render_dot_empty_graph_valid_dot() {
let file = file_with_nodes(vec![]);
let output = render_graph_dot(&file);
assert!(output.starts_with("digraph"));
assert!(output.ends_with("}\n"));
}
#[test]
fn test_render_dot_has_edges() {
let mut a = make_node("auth.login", NodeType::Workflow);
let b = make_node("auth.constraints", NodeType::Rules);
a.depends = Some(vec!["auth.constraints".into()]);
let file = file_with_nodes(vec![a, b]);
let output = render_graph_dot(&file);
assert!(output.contains("auth.login"));
assert!(output.contains("auth.constraints"));
assert!(output.contains("depends"));
assert!(output.contains("->"));
}
#[test]
fn test_render_dot_conflicts_edge_styled() {
let mut a = make_node("decision.a", NodeType::Decision);
let b = make_node("pattern.b", NodeType::AntiPattern);
a.conflicts = Some(vec!["pattern.b".into()]);
let file = file_with_nodes(vec![a, b]);
let output = render_graph_dot(&file);
assert!(output.contains("conflicts"));
assert!(output.contains("color=red"));
}
#[test]
fn test_render_mermaid_replaces_dots() {
let a = make_node("auth.login", NodeType::Workflow);
let file = file_with_nodes(vec![a]);
let output = render_graph_mermaid(&file);
assert!(output.contains("auth_login"));
assert!(!output.contains("auth_login[\"auth_login"));
assert!(output.contains("auth.login"));
}
#[test]
fn test_render_mermaid_empty_graph_valid() {
let file = file_with_nodes(vec![]);
let output = render_graph_mermaid(&file);
assert!(output.starts_with("graph LR\n"));
}
#[test]
fn test_render_mermaid_has_edges() {
let mut a = make_node("auth.login", NodeType::Workflow);
let b = make_node("auth.session", NodeType::Entity);
a.depends = Some(vec!["auth.session".into()]);
let file = file_with_nodes(vec![a, b]);
let output = render_graph_mermaid(&file);
assert!(output.contains("auth_login"));
assert!(output.contains("auth_session"));
assert!(output.contains("-->"));
}
#[test]
fn test_render_dot_node_colors_by_type() {
let facts = make_node("f.one", NodeType::Facts);
let workflow = make_node("w.one", NodeType::Workflow);
let file = file_with_nodes(vec![facts, workflow]);
let output = render_graph_dot(&file);
assert!(output.contains("#e8f4fd")); assert!(output.contains("#e8fde8")); }
}