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 }
41}
42
43pub 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
54pub 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
63pub 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 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 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 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 let mut visited = HashSet::new();
101
102 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 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 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 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 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 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
178pub fn render_dot(graph: &Graph) -> String {
180 let mut output = Vec::new();
181
182 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 output.push(" // Status colors".to_string());
193 output.push(" node [fillstyle=solid];".to_string());
194
195 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 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
250pub fn render_mermaid(graph: &Graph) -> String {
252 let mut output = Vec::new();
253
254 output.push("graph TD".to_string());
256
257 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 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 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 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 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}