Skip to main content

agm_core/renderer/
graph.rs

1//! Graph renderers: DOT and Mermaid output from node relationships.
2
3use petgraph::visit::EdgeRef;
4
5use crate::graph::{RelationKind, build_graph};
6use crate::model::fields::NodeType;
7use crate::model::file::AgmFile;
8
9// ---------------------------------------------------------------------------
10// Public API
11// ---------------------------------------------------------------------------
12
13/// Renders the node relationship graph in Graphviz DOT format.
14#[must_use]
15pub fn render_graph_dot(file: &AgmFile) -> String {
16    let graph = build_graph(file);
17    let mut buf = String::new();
18
19    let graph_name = &file.header.package;
20    buf.push_str(&format!("digraph \"{graph_name}\" {{\n"));
21    buf.push_str("    rankdir=LR;\n");
22    buf.push_str("    node [shape=box, style=\"rounded,filled\", fontname=\"Helvetica\"];\n");
23    buf.push('\n');
24
25    // Node declarations
26    buf.push_str("    // Node declarations\n");
27    for node in &file.nodes {
28        let color = dot_node_color(&node.node_type);
29        let label = format!("{}\\n[{}]", node.id, node.node_type);
30        buf.push_str(&format!(
31            "    \"{}\" [label=\"{}\", fillcolor=\"{}\"];\n",
32            node.id, label, color
33        ));
34    }
35
36    buf.push('\n');
37
38    // Edges
39    buf.push_str("    // Edges\n");
40    for edge in graph.inner.edge_references() {
41        let src = &graph.inner[edge.source()];
42        let tgt = &graph.inner[edge.target()];
43        let kind = edge.weight();
44        let label = rel_kind_label(kind);
45        let style_attrs = dot_edge_style(kind);
46        buf.push_str(&format!(
47            "    \"{}\" -> \"{}\" [label=\"{}\"{style_attrs}];\n",
48            src, tgt, label
49        ));
50    }
51
52    buf.push_str("}\n");
53    buf
54}
55
56/// Renders the node relationship graph in Mermaid format.
57#[must_use]
58pub fn render_graph_mermaid(file: &AgmFile) -> String {
59    let graph = build_graph(file);
60    let mut buf = String::new();
61
62    buf.push_str("graph LR\n");
63
64    // Node declarations
65    for node in &file.nodes {
66        let mermaid_id = mermaid_id(&node.id);
67        let label = format!("{}<br/>[{}]", node.id, node.node_type);
68        buf.push_str(&format!("    {mermaid_id}[\"{label}\"]\n"));
69    }
70
71    buf.push('\n');
72
73    // Edges
74    for edge in graph.inner.edge_references() {
75        let src_id = mermaid_id(&graph.inner[edge.source()]);
76        let tgt_id = mermaid_id(&graph.inner[edge.target()]);
77        let kind = edge.weight();
78        let label = rel_kind_label(kind);
79        let arrow = mermaid_arrow(kind);
80        buf.push_str(&format!("    {src_id} {arrow}|{label}| {tgt_id}\n"));
81    }
82
83    buf
84}
85
86// ---------------------------------------------------------------------------
87// Helpers
88// ---------------------------------------------------------------------------
89
90fn dot_node_color(node_type: &NodeType) -> &'static str {
91    match node_type {
92        NodeType::Facts => "#e8f4fd",
93        NodeType::Rules => "#fef9e7",
94        NodeType::Workflow => "#e8fde8",
95        NodeType::Entity => "#f3e8fd",
96        NodeType::Decision => "#fde8c8",
97        NodeType::Exception => "#fde8e8",
98        NodeType::AntiPattern => "#fde8f0",
99        NodeType::Orchestration => "#f0f0f0",
100        _ => "#ffffff",
101    }
102}
103
104fn rel_kind_label(kind: &RelationKind) -> &'static str {
105    match kind {
106        RelationKind::Depends => "depends",
107        RelationKind::RelatedTo => "related_to",
108        RelationKind::Replaces => "replaces",
109        RelationKind::Conflicts => "conflicts",
110        RelationKind::SeeAlso => "see_also",
111    }
112}
113
114fn dot_edge_style(kind: &RelationKind) -> String {
115    match kind {
116        RelationKind::Depends => String::new(),
117        RelationKind::RelatedTo => ", style=dashed, color=blue".into(),
118        RelationKind::Replaces => ", style=bold, color=orange".into(),
119        RelationKind::Conflicts => ", style=dashed, color=red".into(),
120        RelationKind::SeeAlso => ", style=dotted, color=gray".into(),
121    }
122}
123
124fn mermaid_arrow(kind: &RelationKind) -> &'static str {
125    match kind {
126        RelationKind::Depends => "-->",
127        RelationKind::RelatedTo => "-.->",
128        RelationKind::Replaces => "==>",
129        RelationKind::Conflicts => "-.-",
130        RelationKind::SeeAlso => "-.->",
131    }
132}
133
134fn mermaid_id(node_id: &str) -> String {
135    node_id.replace('.', "_")
136}
137
138// ---------------------------------------------------------------------------
139// Tests
140// ---------------------------------------------------------------------------
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::model::fields::NodeType;
146    use crate::model::file::{AgmFile, Header};
147    use crate::model::node::Node;
148
149    fn make_node(id: &str, node_type: NodeType) -> Node {
150        Node {
151            id: id.to_owned(),
152            node_type,
153            summary: format!("node {id}"),
154            ..Default::default()
155        }
156    }
157
158    fn file_with_nodes(nodes: Vec<Node>) -> AgmFile {
159        AgmFile {
160            header: Header {
161                agm: "1".to_owned(),
162                package: "test.pkg".to_owned(),
163                version: "0.1.0".to_owned(),
164                title: None,
165                owner: None,
166                imports: None,
167                default_load: None,
168                description: None,
169                tags: None,
170                status: None,
171                load_profiles: None,
172                target_runtime: None,
173            },
174            nodes,
175        }
176    }
177
178    #[test]
179    fn test_render_dot_empty_graph_valid_dot() {
180        let file = file_with_nodes(vec![]);
181        let output = render_graph_dot(&file);
182        assert!(output.starts_with("digraph"));
183        assert!(output.ends_with("}\n"));
184    }
185
186    #[test]
187    fn test_render_dot_has_edges() {
188        let mut a = make_node("auth.login", NodeType::Workflow);
189        let b = make_node("auth.constraints", NodeType::Rules);
190        a.depends = Some(vec!["auth.constraints".into()]);
191        let file = file_with_nodes(vec![a, b]);
192        let output = render_graph_dot(&file);
193        assert!(output.contains("auth.login"));
194        assert!(output.contains("auth.constraints"));
195        assert!(output.contains("depends"));
196        assert!(output.contains("->"));
197    }
198
199    #[test]
200    fn test_render_dot_conflicts_edge_styled() {
201        let mut a = make_node("decision.a", NodeType::Decision);
202        let b = make_node("pattern.b", NodeType::AntiPattern);
203        a.conflicts = Some(vec!["pattern.b".into()]);
204        let file = file_with_nodes(vec![a, b]);
205        let output = render_graph_dot(&file);
206        assert!(output.contains("conflicts"));
207        assert!(output.contains("color=red"));
208    }
209
210    #[test]
211    fn test_render_mermaid_replaces_dots() {
212        let a = make_node("auth.login", NodeType::Workflow);
213        let file = file_with_nodes(vec![a]);
214        let output = render_graph_mermaid(&file);
215        // Node ID in Mermaid should have _ instead of .
216        assert!(output.contains("auth_login"));
217        assert!(!output.contains("auth_login[\"auth_login"));
218        // Label should preserve dots
219        assert!(output.contains("auth.login"));
220    }
221
222    #[test]
223    fn test_render_mermaid_empty_graph_valid() {
224        let file = file_with_nodes(vec![]);
225        let output = render_graph_mermaid(&file);
226        assert!(output.starts_with("graph LR\n"));
227    }
228
229    #[test]
230    fn test_render_mermaid_has_edges() {
231        let mut a = make_node("auth.login", NodeType::Workflow);
232        let b = make_node("auth.session", NodeType::Entity);
233        a.depends = Some(vec!["auth.session".into()]);
234        let file = file_with_nodes(vec![a, b]);
235        let output = render_graph_mermaid(&file);
236        assert!(output.contains("auth_login"));
237        assert!(output.contains("auth_session"));
238        assert!(output.contains("-->"));
239    }
240
241    #[test]
242    fn test_render_dot_node_colors_by_type() {
243        let facts = make_node("f.one", NodeType::Facts);
244        let workflow = make_node("w.one", NodeType::Workflow);
245        let file = file_with_nodes(vec![facts, workflow]);
246        let output = render_graph_dot(&file);
247        assert!(output.contains("#e8f4fd")); // facts color
248        assert!(output.contains("#e8fde8")); // workflow color
249    }
250}