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