cascade_cli/cli/commands/
viz.rs

1use crate::errors::{CascadeError, Result};
2use crate::git::find_repository_root;
3use crate::stack::{Stack, StackManager};
4use std::collections::HashMap;
5use std::env;
6use std::fs;
7
8/// Visualization output formats
9#[derive(Debug, Clone)]
10pub enum OutputFormat {
11    /// ASCII art in terminal
12    Ascii,
13    /// Mermaid diagram syntax
14    Mermaid,
15    /// Graphviz DOT notation
16    Dot,
17    /// PlantUML syntax
18    PlantUml,
19}
20
21impl OutputFormat {
22    fn from_str(s: &str) -> Result<Self> {
23        match s.to_lowercase().as_str() {
24            "ascii" => Ok(OutputFormat::Ascii),
25            "mermaid" => Ok(OutputFormat::Mermaid),
26            "dot" | "graphviz" => Ok(OutputFormat::Dot),
27            "plantuml" | "puml" => Ok(OutputFormat::PlantUml),
28            _ => Err(CascadeError::config(format!("Unknown output format: {s}"))),
29        }
30    }
31}
32
33/// Visualization style options
34#[derive(Debug, Clone)]
35pub struct VisualizationStyle {
36    pub show_commit_hashes: bool,
37    pub show_pr_status: bool,
38    pub show_branch_names: bool,
39    pub compact_mode: bool,
40    pub color_coding: bool,
41}
42
43impl Default for VisualizationStyle {
44    fn default() -> Self {
45        Self {
46            show_commit_hashes: true,
47            show_pr_status: true,
48            show_branch_names: true,
49            compact_mode: false,
50            color_coding: true,
51        }
52    }
53}
54
55/// Stack visualizer
56pub struct StackVisualizer {
57    style: VisualizationStyle,
58}
59
60impl StackVisualizer {
61    pub fn new(style: VisualizationStyle) -> Self {
62        Self { style }
63    }
64
65    /// Generate stack diagram in specified format
66    pub fn generate_stack_diagram(&self, stack: &Stack, format: &OutputFormat) -> Result<String> {
67        match format {
68            OutputFormat::Ascii => self.generate_ascii_diagram(stack),
69            OutputFormat::Mermaid => self.generate_mermaid_diagram(stack),
70            OutputFormat::Dot => self.generate_dot_diagram(stack),
71            OutputFormat::PlantUml => self.generate_plantuml_diagram(stack),
72        }
73    }
74
75    /// Generate dependency graph showing relationships between entries
76    pub fn generate_dependency_graph(
77        &self,
78        stacks: &[Stack],
79        format: &OutputFormat,
80    ) -> Result<String> {
81        match format {
82            OutputFormat::Ascii => self.generate_ascii_dependency_graph(stacks),
83            OutputFormat::Mermaid => self.generate_mermaid_dependency_graph(stacks),
84            OutputFormat::Dot => self.generate_dot_dependency_graph(stacks),
85            OutputFormat::PlantUml => self.generate_plantuml_dependency_graph(stacks),
86        }
87    }
88
89    fn generate_ascii_diagram(&self, stack: &Stack) -> Result<String> {
90        let mut output = String::new();
91
92        // Header
93        output.push_str(&format!("šŸ“š Stack: {}\n", stack.name));
94        output.push_str(&format!("🌿 Base: {}\n", stack.base_branch));
95        if let Some(desc) = &stack.description {
96            output.push_str(&format!("šŸ“ Description: {desc}\n"));
97        }
98        output.push_str(&format!("šŸ“Š Status: {:?}\n", stack.status));
99        output.push('\n');
100
101        if stack.entries.is_empty() {
102            output.push_str("   (empty stack)\n");
103            return Ok(output);
104        }
105
106        // Stack visualization
107        output.push_str("Stack Flow:\n");
108        output.push_str("ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n");
109
110        for (i, entry) in stack.entries.iter().enumerate() {
111            let is_last = i == stack.entries.len() - 1;
112            let connector = if is_last { "└─" } else { "ā”œā”€" };
113            let vertical = if is_last { "  " } else { "│ " };
114
115            // Status icon
116            let status_icon = if entry.pull_request_id.is_some() {
117                if entry.is_synced {
118                    "āœ…"
119                } else {
120                    "šŸ“¤"
121                }
122            } else {
123                "šŸ“"
124            };
125
126            // Main entry line
127            output.push_str(&format!("│ {}{} {} ", connector, status_icon, i + 1));
128
129            if self.style.show_commit_hashes {
130                output.push_str(&format!("[{}] ", entry.short_hash()));
131            }
132
133            output.push_str(&entry.short_message(40));
134
135            if self.style.show_pr_status {
136                if let Some(pr_id) = &entry.pull_request_id {
137                    output.push_str(&format!(" (PR #{pr_id})"));
138                }
139            }
140
141            output.push_str(" │\n");
142
143            // Branch info
144            if self.style.show_branch_names && !self.style.compact_mode {
145                output.push_str(&format!("│ {} 🌿 {:<50} │\n", vertical, entry.branch));
146            }
147
148            // Separator for non-compact mode
149            if !self.style.compact_mode && !is_last {
150                output.push_str(&format!("│ {} {:<50} │\n", vertical, ""));
151            }
152        }
153
154        output.push_str("ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n");
155
156        // Legend
157        output.push_str("\nLegend:\n");
158        output.push_str("  šŸ“ Draft  šŸ“¤ Submitted  āœ… Merged\n");
159
160        Ok(output)
161    }
162
163    fn generate_mermaid_diagram(&self, stack: &Stack) -> Result<String> {
164        let mut output = String::new();
165
166        output.push_str("graph TD\n");
167        output.push_str(&format!("    subgraph \"Stack: {}\"\n", stack.name));
168        output.push_str(&format!(
169            "        BASE[\"šŸ“ Base: {}\"]\n",
170            stack.base_branch
171        ));
172
173        if stack.entries.is_empty() {
174            output.push_str("        EMPTY[\"(empty stack)\"]\n");
175            output.push_str("        BASE --> EMPTY\n");
176        } else {
177            let mut previous = "BASE".to_string();
178
179            for (i, entry) in stack.entries.iter().enumerate() {
180                let node_id = format!("ENTRY{}", i + 1);
181                let status_icon = if entry.pull_request_id.is_some() {
182                    if entry.is_synced {
183                        "āœ…"
184                    } else {
185                        "šŸ“¤"
186                    }
187                } else {
188                    "šŸ“"
189                };
190
191                let label = if self.style.compact_mode {
192                    format!("{} {}", status_icon, entry.short_message(30))
193                } else {
194                    format!(
195                        "{} {}\\n🌿 {}\\nšŸ“‹ {}",
196                        status_icon,
197                        entry.short_message(30),
198                        entry.branch,
199                        entry.short_hash()
200                    )
201                };
202
203                output.push_str(&format!("        {node_id}[\"{label}\"]\n"));
204                output.push_str(&format!("        {previous} --> {node_id}\n"));
205
206                // Style based on status
207                if entry.pull_request_id.is_some() {
208                    if entry.is_synced {
209                        output.push_str(&format!("        {node_id} --> {node_id}[Merged]\n"));
210                        output.push_str(&format!("        class {node_id} merged\n"));
211                    } else {
212                        output.push_str(&format!("        class {node_id} submitted\n"));
213                    }
214                } else {
215                    output.push_str(&format!("        class {node_id} draft\n"));
216                }
217
218                previous = node_id;
219            }
220        }
221
222        output.push_str("    end\n");
223
224        // Add styling
225        output.push('\n');
226        output.push_str("    classDef draft fill:#fef3c7,stroke:#d97706,stroke-width:2px\n");
227        output.push_str("    classDef submitted fill:#dbeafe,stroke:#2563eb,stroke-width:2px\n");
228        output.push_str("    classDef merged fill:#d1fae5,stroke:#059669,stroke-width:2px\n");
229
230        Ok(output)
231    }
232
233    fn generate_dot_diagram(&self, stack: &Stack) -> Result<String> {
234        let mut output = String::new();
235
236        output.push_str("digraph StackDiagram {\n");
237        output.push_str("    rankdir=TB;\n");
238        output.push_str("    node [shape=box, style=rounded];\n");
239        output.push_str("    edge [arrowhead=open];\n");
240        output.push('\n');
241
242        // Subgraph for the stack
243        output.push_str("    subgraph cluster_stack {\n");
244        output.push_str(&format!("        label=\"Stack: {}\";\n", stack.name));
245        output.push_str("        color=blue;\n");
246
247        output.push_str(&format!(
248            "        base [label=\"šŸ“ Base: {}\" style=filled fillcolor=lightgray];\n",
249            stack.base_branch
250        ));
251
252        if stack.entries.is_empty() {
253            output.push_str(
254                "        empty [label=\"(empty stack)\" style=filled fillcolor=lightgray];\n",
255            );
256            output.push_str("        base -> empty;\n");
257        } else {
258            let mut previous = String::from("base");
259
260            for (i, entry) in stack.entries.iter().enumerate() {
261                let node_id = format!("entry{}", i + 1);
262                let status_icon = if entry.pull_request_id.is_some() {
263                    if entry.is_synced {
264                        "āœ…"
265                    } else {
266                        "šŸ“¤"
267                    }
268                } else {
269                    "šŸ“"
270                };
271
272                let label = format!(
273                    "{} {}\\n🌿 {}\\nšŸ“‹ {}",
274                    status_icon,
275                    entry.short_message(25).replace("\"", "\\\""),
276                    entry.branch,
277                    entry.short_hash()
278                );
279
280                let color = if entry.pull_request_id.is_some() {
281                    if entry.is_synced {
282                        "lightgreen"
283                    } else {
284                        "lightblue"
285                    }
286                } else {
287                    "lightyellow"
288                };
289
290                output.push_str(&format!(
291                    "        {node_id} [label=\"{label}\" style=filled fillcolor={color}];\n"
292                ));
293                output.push_str(&format!("        {previous} -> {node_id};\n"));
294
295                previous = node_id;
296            }
297        }
298
299        output.push_str("    }\n");
300        output.push_str("}\n");
301
302        Ok(output)
303    }
304
305    fn generate_plantuml_diagram(&self, stack: &Stack) -> Result<String> {
306        let mut output = String::new();
307
308        output.push_str("@startuml\n");
309        output.push_str("!theme plain\n");
310        output.push_str("skinparam backgroundColor #FAFAFA\n");
311        output.push_str("skinparam shadowing false\n");
312        output.push('\n');
313
314        output.push_str(&format!("title Stack: {}\n", stack.name));
315        output.push('\n');
316
317        if stack.entries.is_empty() {
318            output.push_str(&format!(
319                "rectangle \"šŸ“ Base: {}\" as base #lightgray\n",
320                stack.base_branch
321            ));
322            output.push_str("rectangle \"(empty stack)\" as empty #lightgray\n");
323            output.push_str("base --> empty\n");
324        } else {
325            output.push_str(&format!(
326                "rectangle \"šŸ“ Base: {}\" as base #lightgray\n",
327                stack.base_branch
328            ));
329
330            for (i, entry) in stack.entries.iter().enumerate() {
331                let node_id = format!("entry{}", i + 1);
332                let status_icon = if entry.pull_request_id.is_some() {
333                    if entry.is_synced {
334                        "āœ…"
335                    } else {
336                        "šŸ“¤"
337                    }
338                } else {
339                    "šŸ“"
340                };
341
342                let color = if entry.pull_request_id.is_some() {
343                    if entry.is_synced {
344                        "#90EE90"
345                    } else {
346                        "#ADD8E6"
347                    }
348                } else {
349                    "#FFFFE0"
350                };
351
352                let label = format!(
353                    "{} {}\\n🌿 {}\\nšŸ“‹ {}",
354                    status_icon,
355                    entry.short_message(25),
356                    entry.branch,
357                    entry.short_hash()
358                );
359
360                output.push_str(&format!("rectangle \"{label}\" as {node_id} {color}\n"));
361
362                if i == 0 {
363                    output.push_str(&format!("base --> {node_id}\n"));
364                } else {
365                    output.push_str(&format!("entry{i} --> {node_id}\n"));
366                }
367            }
368        }
369
370        output.push_str("\n@enduml\n");
371
372        Ok(output)
373    }
374
375    fn generate_ascii_dependency_graph(&self, stacks: &[Stack]) -> Result<String> {
376        let mut output = String::new();
377
378        output.push_str("šŸ“Š Stack Dependencies Overview\n");
379        output.push_str("═══════════════════════════════\n\n");
380
381        if stacks.is_empty() {
382            output.push_str("No stacks found.\n");
383            return Ok(output);
384        }
385
386        // Group by base branch
387        let mut by_base: HashMap<String, Vec<&Stack>> = HashMap::new();
388        for stack in stacks {
389            by_base
390                .entry(stack.base_branch.clone())
391                .or_default()
392                .push(stack);
393        }
394
395        let base_count = by_base.len();
396        for (base_branch, base_stacks) in by_base {
397            output.push_str(&format!("🌿 Base Branch: {base_branch}\n"));
398            output.push_str("ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n");
399
400            for (i, stack) in base_stacks.iter().enumerate() {
401                let is_last_stack = i == base_stacks.len() - 1;
402                let stack_connector = if is_last_stack { "└─" } else { "ā”œā”€" };
403                let stack_vertical = if is_last_stack { "  " } else { "│ " };
404
405                // Stack header
406                output.push_str(&format!(
407                    "│ {} šŸ“š {} ({} entries) ",
408                    stack_connector,
409                    stack.name,
410                    stack.entries.len()
411                ));
412
413                if stack.is_active {
414                    output.push_str("šŸ‘‰ ACTIVE");
415                }
416
417                let padding = 50 - (stack.name.len() + stack.entries.len().to_string().len() + 15);
418                output.push_str(&" ".repeat(padding.max(0)));
419                output.push_str("│\n");
420
421                // Show entries if not in compact mode
422                if !self.style.compact_mode && !stack.entries.is_empty() {
423                    for (j, entry) in stack.entries.iter().enumerate() {
424                        let is_last_entry = j == stack.entries.len() - 1;
425                        let entry_connector = if is_last_entry { "└─" } else { "ā”œā”€" };
426
427                        let status_icon = if entry.pull_request_id.is_some() {
428                            if entry.is_synced {
429                                "āœ…"
430                            } else {
431                                "šŸ“¤"
432                            }
433                        } else {
434                            "šŸ“"
435                        };
436
437                        output.push_str(&format!(
438                            "│ {} {} {} {} ",
439                            stack_vertical,
440                            entry_connector,
441                            status_icon,
442                            entry.short_message(30)
443                        ));
444
445                        let padding = 45 - entry.short_message(30).len();
446                        output.push_str(&" ".repeat(padding.max(0)));
447                        output.push_str("│\n");
448                    }
449                }
450            }
451
452            output.push_str("ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n\n");
453        }
454
455        // Statistics
456        output.push_str("šŸ“ˆ Statistics:\n");
457        output.push_str(&format!("  Total stacks: {}\n", stacks.len()));
458        output.push_str(&format!("  Base branches: {base_count}\n"));
459
460        let total_entries: usize = stacks.iter().map(|s| s.entries.len()).sum();
461        output.push_str(&format!("  Total entries: {total_entries}\n"));
462
463        let active_stacks = stacks.iter().filter(|s| s.is_active).count();
464        output.push_str(&format!("  Active stacks: {active_stacks}\n"));
465
466        Ok(output)
467    }
468
469    fn generate_mermaid_dependency_graph(&self, stacks: &[Stack]) -> Result<String> {
470        let mut output = String::new();
471
472        output.push_str("graph TB\n");
473        output.push_str("    subgraph \"Stack Dependencies\"\n");
474
475        // Group by base branch
476        let mut by_base: HashMap<String, Vec<&Stack>> = HashMap::new();
477        for stack in stacks {
478            by_base
479                .entry(stack.base_branch.clone())
480                .or_default()
481                .push(stack);
482        }
483
484        for (i, (base_branch, base_stacks)) in by_base.iter().enumerate() {
485            let base_id = format!("BASE{i}");
486            output.push_str(&format!("        {base_id}[\"🌿 {base_branch}\"]\n"));
487
488            for (j, stack) in base_stacks.iter().enumerate() {
489                let stack_id = format!("STACK{i}_{j}");
490                let active_marker = if stack.is_active { " šŸ‘‰" } else { "" };
491
492                output.push_str(&format!(
493                    "        {}[\"šŸ“š {} ({} entries){}\"]\n",
494                    stack_id,
495                    stack.name,
496                    stack.entries.len(),
497                    active_marker
498                ));
499                output.push_str(&format!("        {base_id} --> {stack_id}\n"));
500
501                // Add class for active stacks
502                if stack.is_active {
503                    output.push_str(&format!("        class {stack_id} active\n"));
504                }
505            }
506        }
507
508        output.push_str("    end\n");
509
510        // Add styling
511        output.push('\n');
512        output.push_str("    classDef active fill:#fef3c7,stroke:#d97706,stroke-width:3px\n");
513
514        Ok(output)
515    }
516
517    fn generate_dot_dependency_graph(&self, stacks: &[Stack]) -> Result<String> {
518        let mut output = String::new();
519
520        output.push_str("digraph DependencyGraph {\n");
521        output.push_str("    rankdir=TB;\n");
522        output.push_str("    node [shape=box, style=rounded];\n");
523        output.push_str("    edge [arrowhead=open];\n");
524        output.push('\n');
525
526        // Group by base branch
527        let mut by_base: HashMap<String, Vec<&Stack>> = HashMap::new();
528        for stack in stacks {
529            by_base
530                .entry(stack.base_branch.clone())
531                .or_default()
532                .push(stack);
533        }
534
535        for (i, (base_branch, base_stacks)) in by_base.iter().enumerate() {
536            output.push_str(&format!("    subgraph cluster_{i} {{\n"));
537            output.push_str(&format!("        label=\"Base: {base_branch}\";\n"));
538            output.push_str("        color=blue;\n");
539
540            let base_id = format!("base{i}");
541            output.push_str(&format!(
542                "        {base_id} [label=\"🌿 {base_branch}\" style=filled fillcolor=lightgray];\n"
543            ));
544
545            for (j, stack) in base_stacks.iter().enumerate() {
546                let stack_id = format!("stack{i}_{j}");
547                let active_marker = if stack.is_active { " šŸ‘‰" } else { "" };
548                let color = if stack.is_active { "gold" } else { "lightblue" };
549
550                output.push_str(&format!(
551                    "        {} [label=\"šŸ“š {} ({} entries){}\" style=filled fillcolor={}];\n",
552                    stack_id,
553                    stack.name,
554                    stack.entries.len(),
555                    active_marker,
556                    color
557                ));
558                output.push_str(&format!("        {base_id} -> {stack_id};\n"));
559            }
560
561            output.push_str("    }\n");
562        }
563
564        output.push_str("}\n");
565
566        Ok(output)
567    }
568
569    fn generate_plantuml_dependency_graph(&self, stacks: &[Stack]) -> Result<String> {
570        let mut output = String::new();
571
572        output.push_str("@startuml\n");
573        output.push_str("!theme plain\n");
574        output.push_str("skinparam backgroundColor #FAFAFA\n");
575        output.push('\n');
576
577        output.push_str("title Stack Dependencies\n");
578        output.push('\n');
579
580        // Group by base branch
581        let mut by_base: HashMap<String, Vec<&Stack>> = HashMap::new();
582        for stack in stacks {
583            by_base
584                .entry(stack.base_branch.clone())
585                .or_default()
586                .push(stack);
587        }
588
589        for (i, (base_branch, base_stacks)) in by_base.iter().enumerate() {
590            let base_id = format!("base{i}");
591            output.push_str(&format!(
592                "rectangle \"🌿 {base_branch}\" as {base_id} #lightgray\n"
593            ));
594
595            for (j, stack) in base_stacks.iter().enumerate() {
596                let stack_id = format!("stack{i}_{j}");
597                let active_marker = if stack.is_active { " šŸ‘‰" } else { "" };
598                let color = if stack.is_active {
599                    "#FFD700"
600                } else {
601                    "#ADD8E6"
602                };
603
604                output.push_str(&format!(
605                    "rectangle \"šŸ“š {} ({} entries){}\" as {} {}\n",
606                    stack.name,
607                    stack.entries.len(),
608                    active_marker,
609                    stack_id,
610                    color
611                ));
612                output.push_str(&format!("{base_id} --> {stack_id}\n"));
613            }
614        }
615
616        output.push_str("\n@enduml\n");
617
618        Ok(output)
619    }
620}
621
622/// Visualize a specific stack
623pub async fn show_stack(
624    stack_name: Option<String>,
625    format: Option<String>,
626    output_file: Option<String>,
627    compact: bool,
628    no_colors: bool,
629) -> Result<()> {
630    let current_dir = env::current_dir()
631        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
632
633    let repo_root = find_repository_root(&current_dir)
634        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
635
636    let manager = StackManager::new(&repo_root)?;
637
638    let stack = if let Some(name) = stack_name {
639        manager
640            .get_stack_by_name(&name)
641            .ok_or_else(|| CascadeError::config(format!("Stack '{name}' not found")))?
642    } else {
643        manager.get_active_stack().ok_or_else(|| {
644            CascadeError::config("No active stack. Use 'ca stack list' to see available stacks")
645        })?
646    };
647
648    let output_format = format
649        .as_ref()
650        .map(|f| OutputFormat::from_str(f))
651        .transpose()?
652        .unwrap_or(OutputFormat::Ascii);
653
654    let style = VisualizationStyle {
655        compact_mode: compact,
656        color_coding: !no_colors,
657        ..Default::default()
658    };
659
660    let visualizer = StackVisualizer::new(style);
661    let diagram = visualizer.generate_stack_diagram(stack, &output_format)?;
662
663    if let Some(file_path) = output_file {
664        fs::write(&file_path, diagram).map_err(|e| {
665            CascadeError::config(format!("Failed to write to file '{file_path}': {e}"))
666        })?;
667        println!("āœ… Stack diagram saved to: {file_path}");
668    } else {
669        println!("{diagram}");
670    }
671
672    Ok(())
673}
674
675/// Visualize all stacks and their dependencies
676pub async fn show_dependencies(
677    format: Option<String>,
678    output_file: Option<String>,
679    compact: bool,
680    no_colors: bool,
681) -> Result<()> {
682    let current_dir = env::current_dir()
683        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
684
685    let repo_root = find_repository_root(&current_dir)
686        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
687
688    let manager = StackManager::new(&repo_root)?;
689    let stacks = manager.get_all_stacks_objects()?;
690
691    if stacks.is_empty() {
692        println!("No stacks found. Create one with: ca stack create <name>");
693        return Ok(());
694    }
695
696    let output_format = format
697        .as_ref()
698        .map(|f| OutputFormat::from_str(f))
699        .transpose()?
700        .unwrap_or(OutputFormat::Ascii);
701
702    let style = VisualizationStyle {
703        compact_mode: compact,
704        color_coding: !no_colors,
705        ..Default::default()
706    };
707
708    let visualizer = StackVisualizer::new(style);
709    let diagram = visualizer.generate_dependency_graph(&stacks, &output_format)?;
710
711    if let Some(file_path) = output_file {
712        fs::write(&file_path, diagram).map_err(|e| {
713            CascadeError::config(format!("Failed to write to file '{file_path}': {e}"))
714        })?;
715        println!("āœ… Dependency graph saved to: {file_path}");
716    } else {
717        println!("{diagram}");
718    }
719
720    Ok(())
721}