Skip to main content

aprender_contracts_cli/commands/
graph.rs

1use 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/// Output format for the dependency graph rendering
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GraphFormat {
11    /// Human-readable text output.
12    Text,
13    /// Graphviz DOT language output.
14    Dot,
15    /// JSON structured output.
16    Json,
17    /// Mermaid diagram syntax output.
18    Mermaid,
19}
20
21impl FromStr for GraphFormat {
22    type Err = String;
23
24    /// Parse a format name string into a `GraphFormat` variant
25    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
38/// Load contracts from a directory and render their dependency graph
39pub 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
62/// Render the dependency graph as human-readable text to stdout
63fn 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
101/// Render the dependency graph in Graphviz DOT format to stdout
102fn 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    // Emit all nodes (including isolated ones)
110    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    // Emit edges
118    for (node, deps) in &graph.edges {
119        for dep in deps {
120            println!("    \"{node}\" -> \"{dep}\";");
121        }
122    }
123
124    // Highlight cycles in red
125    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
141/// Render the dependency graph as a JSON object to stdout
142fn render_json(graph: &DependencyGraph) {
143    println!("{{");
144    // Nodes array
145    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    // Edges array
151    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    // Topo order
162    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    // Cycles
172    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
186/// Render the dependency graph in Mermaid diagram syntax to stdout
187fn render_mermaid(graph: &DependencyGraph) {
188    println!("graph TD");
189
190    // Emit edges
191    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
212/// Convert a contract stem to a valid Mermaid node ID (no hyphens).
213fn mermaid_id(stem: &str) -> String {
214    stem.replace('-', "_")
215}
216
217/// Check whether any other node in the graph depends on the given node
218fn 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        // Exercises the `other =>` catch-all arm in from_str
244        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}