cascade_cli/cli/commands/
viz.rs

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