cascade_cli/cli/commands/
viz.rs

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