1use crate::graph::{Graph, Node, NodeStatus};
6use crate::query::QueryEngine;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10#[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
32pub 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
45pub 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
58pub 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
67pub 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 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 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 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 let mut visited = HashSet::new();
105
106 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 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 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 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 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 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
182pub fn render_dot(graph: &Graph) -> String {
184 let mut output = Vec::new();
185
186 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 output.push(" // Status colors".to_string());
197 output.push(" node [fillstyle=solid];".to_string());
198
199 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 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
256pub fn render_mermaid(graph: &Graph) -> String {
258 let mut output = Vec::new();
259
260 output.push("graph TD".to_string());
262
263 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 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 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 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 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}