aprender_contracts_cli/commands/
graph.rs1use std::path::Path;
2use std::str::FromStr;
3
4use provable_contracts::graph::{dependency_graph, graph_nodes, DependencyGraph};
5
6use crate::contract_walk::collect_contracts;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GraphFormat {
11 Text,
13 Dot,
15 Json,
17 Mermaid,
19}
20
21impl FromStr for GraphFormat {
22 type Err = String;
23
24 fn from_str(s: &str) -> Result<Self, String> {
26 match s {
27 "text" => Ok(Self::Text),
28 "dot" => Ok(Self::Dot),
29 "json" => Ok(Self::Json),
30 "mermaid" => Ok(Self::Mermaid),
31 other => Err(format!(
32 "unknown format '{other}', expected 'text', 'dot', 'json', or 'mermaid'"
33 )),
34 }
35 }
36}
37
38pub fn run(contract_dir: &Path, format: GraphFormat) -> Result<(), Box<dyn std::error::Error>> {
40 let mut contracts = Vec::new();
41 collect_contracts(contract_dir, &mut contracts);
42 contracts.sort_by(|a, b| a.0.cmp(&b.0));
43
44 let refs: Vec<(String, &provable_contracts::schema::Contract)> =
45 contracts.iter().map(|(s, c)| (s.clone(), c)).collect();
46
47 let graph = dependency_graph(&refs);
48
49 match format {
50 GraphFormat::Text => render_text(&graph),
51 GraphFormat::Dot => render_dot(&graph),
52 GraphFormat::Json => render_json(&graph),
53 GraphFormat::Mermaid => render_mermaid(&graph),
54 }
55
56 if !graph.cycles.is_empty() {
57 return Err("Dependency graph contains cycles".into());
58 }
59 Ok(())
60}
61
62fn render_text(graph: &DependencyGraph) {
64 let nodes = graph_nodes(graph);
65 println!("Contract Dependency Graph");
66 println!("=========================");
67 println!("Nodes: {}", graph.nodes.len());
68 println!();
69
70 for node in &nodes {
71 let deps = graph.edges.get(&node.stem).cloned().unwrap_or_default();
72 if deps.is_empty() {
73 println!(" {} (dependents: {}, deps: 0)", node.stem, node.dependents);
74 } else {
75 println!(
76 " {} → [{}] (dependents: {})",
77 node.stem,
78 deps.join(", "),
79 node.dependents,
80 );
81 }
82 }
83
84 if !graph.cycles.is_empty() {
85 println!();
86 println!("CYCLES DETECTED:");
87 for cycle in &graph.cycles {
88 println!(" {}", cycle.join(" → "));
89 }
90 }
91
92 if !graph.topo_order.is_empty() {
93 println!();
94 println!("Topological order:");
95 for (i, node) in graph.topo_order.iter().enumerate() {
96 println!(" {}: {node}", i + 1);
97 }
98 }
99}
100
101fn render_dot(graph: &DependencyGraph) {
103 println!("digraph contracts {{");
104 println!(" rankdir=LR;");
105 println!(" node [shape=box, style=rounded, fontname=\"Helvetica\"];");
106 println!(" edge [color=\"#666666\"];");
107 println!();
108
109 for node in &graph.nodes {
111 let deps = graph.edges.get(node).map_or(0, Vec::len);
112 if deps == 0 && !is_depended_on(graph, node) {
113 println!(" \"{node}\";");
114 }
115 }
116
117 for (node, deps) in &graph.edges {
119 for dep in deps {
120 println!(" \"{node}\" -> \"{dep}\";");
121 }
122 }
123
124 if !graph.cycles.is_empty() {
126 println!();
127 println!(" // Cycles detected");
128 for cycle in &graph.cycles {
129 for pair in cycle.windows(2) {
130 println!(
131 " \"{0}\" -> \"{1}\" [color=red, penwidth=2];",
132 pair[0], pair[1]
133 );
134 }
135 }
136 }
137
138 println!("}}");
139}
140
141fn render_json(graph: &DependencyGraph) {
143 println!("{{");
144 let nodes_json: Vec<String> = graph.nodes.iter().map(|n| format!(" \"{n}\"")).collect();
146 println!(" \"nodes\": [");
147 println!("{}", nodes_json.join(",\n"));
148 println!(" ],");
149
150 println!(" \"edges\": [");
152 let mut edge_lines = Vec::new();
153 for (node, deps) in &graph.edges {
154 for dep in deps {
155 edge_lines.push(format!(" {{\"from\": \"{node}\", \"to\": \"{dep}\"}}"));
156 }
157 }
158 println!("{}", edge_lines.join(",\n"));
159 println!(" ],");
160
161 let topo_json: Vec<String> = graph
163 .topo_order
164 .iter()
165 .map(|n| format!(" \"{n}\""))
166 .collect();
167 println!(" \"topo_order\": [");
168 println!("{}", topo_json.join(",\n"));
169 println!(" ],");
170
171 println!(" \"cycles\": [");
173 let cycle_lines: Vec<String> = graph
174 .cycles
175 .iter()
176 .map(|c| {
177 let items: Vec<String> = c.iter().map(|n| format!("\"{n}\"")).collect();
178 format!(" [{}]", items.join(", "))
179 })
180 .collect();
181 println!("{}", cycle_lines.join(",\n"));
182 println!(" ]");
183 println!("}}");
184}
185
186fn render_mermaid(graph: &DependencyGraph) {
188 println!("graph TD");
189
190 for (node, deps) in &graph.edges {
192 let from = mermaid_id(node);
193 if deps.is_empty() {
194 println!(" {from}[\"{node}\"]");
195 }
196 for dep in deps {
197 let to = mermaid_id(dep);
198 println!(" {from}[\"{node}\"] --> {to}[\"{dep}\"]");
199 }
200 }
201
202 if !graph.cycles.is_empty() {
203 println!();
204 println!(" %% Cycles detected");
205 for cycle in &graph.cycles {
206 let names: Vec<&str> = cycle.iter().map(String::as_str).collect();
207 println!(" %% {}", names.join(" -> "));
208 }
209 }
210}
211
212fn mermaid_id(stem: &str) -> String {
214 stem.replace('-', "_")
215}
216
217fn is_depended_on(graph: &DependencyGraph, node: &str) -> bool {
219 graph
220 .edges
221 .values()
222 .any(|deps| deps.iter().any(|d| d == node))
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_graph_format_from_str() {
231 assert_eq!(GraphFormat::from_str("text").unwrap(), GraphFormat::Text);
232 assert_eq!(GraphFormat::from_str("dot").unwrap(), GraphFormat::Dot);
233 assert_eq!(GraphFormat::from_str("json").unwrap(), GraphFormat::Json);
234 assert_eq!(
235 GraphFormat::from_str("mermaid").unwrap(),
236 GraphFormat::Mermaid
237 );
238 assert!(GraphFormat::from_str("xml").is_err());
239 }
240
241 #[test]
242 fn test_from_str_other_format_returns_descriptive_error() {
243 let other = "yaml";
245 let err = GraphFormat::from_str(other).unwrap_err();
246 assert!(
247 err.contains(other),
248 "error should include the unrecognized format"
249 );
250 assert!(err.contains("unknown format"));
251 }
252
253 #[test]
254 fn test_mermaid_id() {
255 assert_eq!(mermaid_id("softmax-kernel-v1"), "softmax_kernel_v1");
256 assert_eq!(mermaid_id("silu"), "silu");
257 }
258}