agm_core/renderer/
graph.rs1use petgraph::visit::EdgeRef;
4
5use crate::graph::{RelationKind, build_graph};
6use crate::model::fields::NodeType;
7use crate::model::file::AgmFile;
8
9#[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 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 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#[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 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 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
86fn 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#[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 assert!(output.contains("auth_login"));
217 assert!(!output.contains("auth_login[\"auth_login"));
218 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")); assert!(output.contains("#e8fde8")); }
250}