Skip to main content

gid_core/
visual.rs

1//! Visual rendering for GID graphs.
2//!
3//! Supports ASCII, Graphviz DOT, and Mermaid diagram formats.
4
5use crate::graph::{Graph, Node, NodeStatus};
6use crate::query::QueryEngine;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10/// Output format for graph visualization.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum VisualFormat {
14    Ascii,
15    Dot,
16    Mermaid,
17}
18
19impl std::str::FromStr for VisualFormat {
20    type Err = anyhow::Error;
21    
22    fn from_str(s: &str) -> Result<Self, Self::Err> {
23        match s.to_lowercase().as_str() {
24            "ascii" => Ok(Self::Ascii),
25            "dot" | "graphviz" => Ok(Self::Dot),
26            "mermaid" => Ok(Self::Mermaid),
27            _ => Err(anyhow::anyhow!("Unknown format: {}. Valid: ascii, dot, mermaid", s)),
28        }
29    }
30}
31
32/// Status symbols for different output formats.
33pub fn status_symbol(status: &NodeStatus) -> &'static str {
34    match status {
35        NodeStatus::Done => "✅",
36        NodeStatus::InProgress => "🔄",
37        NodeStatus::Todo => "○",
38        NodeStatus::Blocked => "⛔",
39        NodeStatus::Cancelled => "⊘",
40        NodeStatus::Failed => "❌",
41        NodeStatus::NeedsResolution => "⚠️",
42    }
43}
44
45/// ASCII status symbol (simpler).
46pub fn status_symbol_ascii(status: &NodeStatus) -> &'static str {
47    match status {
48        NodeStatus::Done => "[x]",
49        NodeStatus::InProgress => "[~]",
50        NodeStatus::Todo => "[ ]",
51        NodeStatus::Blocked => "[!]",
52        NodeStatus::Cancelled => "[-]",
53        NodeStatus::Failed => "[F]",
54        NodeStatus::NeedsResolution => "[?]",
55    }
56}
57
58/// Render graph to the specified format.
59pub fn render(graph: &Graph, format: VisualFormat) -> String {
60    match format {
61        VisualFormat::Ascii => render_ascii(graph),
62        VisualFormat::Dot => render_dot(graph),
63        VisualFormat::Mermaid => render_mermaid(graph),
64    }
65}
66
67/// Render graph as ASCII tree/diagram.
68pub fn render_ascii(graph: &Graph) -> String {
69    if graph.nodes.is_empty() {
70        return "Empty graph.".to_string();
71    }
72    
73    let _engine = QueryEngine::new(graph);
74    let mut output = Vec::new();
75    
76    // Get project name
77    if let Some(ref project) = graph.project {
78        output.push(format!("📊 {}", project.name));
79        if let Some(ref desc) = project.description {
80            output.push(format!("   {}", desc));
81        }
82        output.push(String::new());
83    }
84    
85    // Find root nodes (nodes with no incoming depends_on edges)
86    let has_incoming: HashSet<&str> = graph.edges.iter()
87        .filter(|e| e.relation == "depends_on")
88        .map(|e| e.to.as_str())
89        .collect();
90    
91    let roots: Vec<&Node> = graph.nodes.iter()
92        .filter(|n| !has_incoming.contains(n.id.as_str()))
93        .collect();
94    
95    // Build adjacency list for depends_on edges (from -> [to])
96    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
97    for edge in &graph.edges {
98        if edge.relation == "depends_on" {
99            children.entry(edge.from.as_str()).or_default().push(&edge.to);
100        }
101    }
102    
103    // Track visited nodes to avoid infinite loops
104    let mut visited = HashSet::new();
105    
106    // Render each root and its subtree
107    for (i, root) in roots.iter().enumerate() {
108        let is_last = i == roots.len() - 1;
109        render_node_ascii(graph, root.id.as_str(), &children, &mut visited, &mut output, "", is_last);
110    }
111    
112    // Show orphan nodes if any
113    let all_rendered: HashSet<&str> = visited.iter().map(|s| s.as_str()).collect();
114    let orphans: Vec<&Node> = graph.nodes.iter()
115        .filter(|n| !all_rendered.contains(n.id.as_str()))
116        .collect();
117    
118    if !orphans.is_empty() {
119        output.push(String::new());
120        output.push("📦 Disconnected nodes:".to_string());
121        for node in orphans {
122            output.push(format!("   {} {} — {}", 
123                status_symbol(&node.status),
124                node.id,
125                node.title
126            ));
127        }
128    }
129    
130    // Summary
131    let summary = graph.summary();
132    output.push(String::new());
133    output.push(format!("─────────────────────────────────"));
134    output.push(format!("{}", summary));
135    
136    output.join("\n")
137}
138
139fn render_node_ascii(
140    graph: &Graph,
141    node_id: &str,
142    children: &HashMap<&str, Vec<&str>>,
143    visited: &mut HashSet<String>,
144    output: &mut Vec<String>,
145    prefix: &str,
146    is_last: bool,
147) {
148    if visited.contains(node_id) {
149        // Show back-reference
150        output.push(format!("{}{}↺ {}", prefix, if is_last { "└── " } else { "├── " }, node_id));
151        return;
152    }
153    visited.insert(node_id.to_string());
154    
155    let node = match graph.get_node(node_id) {
156        Some(n) => n,
157        None => return,
158    };
159    
160    let connector = if is_last { "└── " } else { "├── " };
161    let status = status_symbol(&node.status);
162    
163    // Format node line
164    let tags = if node.tags.is_empty() {
165        String::new()
166    } else {
167        format!(" [{}]", node.tags.join(", "))
168    };
169    
170    output.push(format!("{}{}{} {} — {}{}", prefix, connector, status, node.id, node.title, tags));
171    
172    // Render children
173    if let Some(deps) = children.get(node_id) {
174        let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
175        for (i, &child_id) in deps.iter().enumerate() {
176            let child_is_last = i == deps.len() - 1;
177            render_node_ascii(graph, child_id, children, visited, output, &child_prefix, child_is_last);
178        }
179    }
180}
181
182/// Render graph as Graphviz DOT format.
183pub fn render_dot(graph: &Graph) -> String {
184    let mut output = Vec::new();
185    
186    // Header
187    let name = graph.project.as_ref()
188        .map(|p| p.name.as_str())
189        .unwrap_or("graph");
190    output.push(format!("digraph \"{}\" {{", escape_dot(name)));
191    output.push("    rankdir=TB;".to_string());
192    output.push("    node [shape=box, style=rounded];".to_string());
193    output.push(String::new());
194    
195    // Node styles by status
196    output.push("    // Status colors".to_string());
197    output.push("    node [fillstyle=solid];".to_string());
198    
199    // Nodes
200    for node in &graph.nodes {
201        let color = match node.status {
202            NodeStatus::Done => "palegreen",
203            NodeStatus::InProgress => "lightyellow",
204            NodeStatus::Todo => "white",
205            NodeStatus::Blocked => "lightcoral",
206            NodeStatus::Cancelled => "lightgray",
207            NodeStatus::Failed => "salmon",
208            NodeStatus::NeedsResolution => "lightyellow",
209        };
210        
211        let label = format!("{} {}\\n{}", 
212            status_symbol_ascii(&node.status),
213            escape_dot(&node.id),
214            escape_dot(&node.title)
215        );
216        
217        output.push(format!(
218            "    \"{}\" [label=\"{}\", fillcolor=\"{}\", style=filled];",
219            escape_dot(&node.id),
220            label,
221            color
222        ));
223    }
224    
225    output.push(String::new());
226    
227    // Edges
228    output.push("    // Edges".to_string());
229    for edge in &graph.edges {
230        let style = match edge.relation.as_str() {
231            "depends_on" => "solid",
232            "implements" => "dashed",
233            "contains" => "dotted",
234            _ => "solid",
235        };
236        
237        output.push(format!(
238            "    \"{}\" -> \"{}\" [label=\"{}\", style={}];",
239            escape_dot(&edge.from),
240            escape_dot(&edge.to),
241            escape_dot(&edge.relation),
242            style
243        ));
244    }
245    
246    output.push("}".to_string());
247    output.join("\n")
248}
249
250fn escape_dot(s: &str) -> String {
251    s.replace('\\', "\\\\")
252        .replace('"', "\\\"")
253        .replace('\n', "\\n")
254}
255
256/// Render graph as Mermaid diagram syntax.
257pub fn render_mermaid(graph: &Graph) -> String {
258    let mut output = Vec::new();
259    
260    // Header
261    output.push("graph TD".to_string());
262    
263    // Subgraphs by status
264    let mut by_status: HashMap<&NodeStatus, Vec<&Node>> = HashMap::new();
265    for node in &graph.nodes {
266        by_status.entry(&node.status).or_default().push(node);
267    }
268    
269    // Nodes with status styling
270    for node in &graph.nodes {
271        let shape = match node.status {
272            NodeStatus::Done => format!("{}[✅ {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
273            NodeStatus::InProgress => format!("{}[🔄 {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
274            NodeStatus::Todo => format!("{}[○ {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
275            NodeStatus::Blocked => format!("{}[⛔ {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
276            NodeStatus::Cancelled => format!("{}[⊘ {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
277            NodeStatus::Failed => format!("{}[❌ {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
278            NodeStatus::NeedsResolution => format!("{}[⚠️ {}]", escape_mermaid_id(&node.id), escape_mermaid(&node.title)),
279        };
280        output.push(format!("    {}", shape));
281    }
282    
283    output.push(String::new());
284    
285    // Edges
286    for edge in &graph.edges {
287        let arrow = match edge.relation.as_str() {
288            "depends_on" => "-->",
289            "implements" => "-.->",
290            "contains" => "---",
291            _ => "-->",
292        };
293        
294        let label = if edge.relation != "depends_on" {
295            format!("|{}|", escape_mermaid(&edge.relation))
296        } else {
297            String::new()
298        };
299        
300        output.push(format!(
301            "    {}{} {}{}",
302            escape_mermaid_id(&edge.from),
303            arrow,
304            label,
305            escape_mermaid_id(&edge.to)
306        ));
307    }
308    
309    // Add styling classes
310    output.push(String::new());
311    output.push("    %% Styling".to_string());
312    
313    for node in &graph.nodes {
314        let class = match node.status {
315            NodeStatus::Done => "done",
316            NodeStatus::InProgress => "inprogress",
317            NodeStatus::Todo => "todo",
318            NodeStatus::Blocked => "blocked",
319            NodeStatus::Cancelled => "cancelled",
320            NodeStatus::Failed => "failed",
321            NodeStatus::NeedsResolution => "needsresolution",
322        };
323        output.push(format!("    class {} {}", escape_mermaid_id(&node.id), class));
324    }
325    
326    output.push(String::new());
327    output.push("    classDef done fill:#90EE90,stroke:#228B22".to_string());
328    output.push("    classDef inprogress fill:#FFFFE0,stroke:#DAA520".to_string());
329    output.push("    classDef todo fill:#FFFFFF,stroke:#808080".to_string());
330    output.push("    classDef blocked fill:#FFC0CB,stroke:#DC143C".to_string());
331    output.push("    classDef cancelled fill:#D3D3D3,stroke:#696969".to_string());
332    
333    output.join("\n")
334}
335
336fn escape_mermaid(s: &str) -> String {
337    s.replace('"', "'")
338        .replace('[', "(")
339        .replace(']', ")")
340        .replace('{', "(")
341        .replace('}', ")")
342}
343
344fn escape_mermaid_id(s: &str) -> String {
345    // Mermaid IDs should be alphanumeric with underscores
346    s.chars()
347        .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
348        .collect()
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::graph::{Node, Edge};
355    
356    #[test]
357    fn test_render_ascii_empty() {
358        let graph = Graph::new();
359        let result = render_ascii(&graph);
360        assert_eq!(result, "Empty graph.");
361    }
362    
363    #[test]
364    fn test_render_ascii_simple() {
365        let mut graph = Graph::new();
366        graph.add_node(Node::new("a", "Task A"));
367        graph.add_node(Node::new("b", "Task B").with_status(NodeStatus::Done));
368        graph.add_edge(Edge::depends_on("a", "b"));
369        
370        let result = render_ascii(&graph);
371        assert!(result.contains("Task A"));
372        assert!(result.contains("Task B"));
373        assert!(result.contains("✅"));
374    }
375    
376    #[test]
377    fn test_render_dot() {
378        let mut graph = Graph::new();
379        graph.add_node(Node::new("a", "Task A"));
380        graph.add_node(Node::new("b", "Task B"));
381        graph.add_edge(Edge::depends_on("a", "b"));
382        
383        let result = render_dot(&graph);
384        assert!(result.starts_with("digraph"));
385        assert!(result.contains("\"a\""));
386        assert!(result.contains("\"b\""));
387        assert!(result.contains("->"));
388    }
389    
390    #[test]
391    fn test_render_mermaid() {
392        let mut graph = Graph::new();
393        graph.add_node(Node::new("a", "Task A"));
394        graph.add_node(Node::new("b", "Task B"));
395        graph.add_edge(Edge::depends_on("a", "b"));
396        
397        let result = render_mermaid(&graph);
398        assert!(result.starts_with("graph TD"));
399        assert!(result.contains("-->"));
400    }
401}