cascade_cli/cli/commands/
viz.rs1use 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#[derive(Debug, Clone)]
11pub enum OutputFormat {
12 Ascii,
14 Mermaid,
16 Dot,
18 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#[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
56pub struct StackVisualizer {
58 style: VisualizationStyle,
59}
60
61impl StackVisualizer {
62 pub fn new(style: VisualizationStyle) -> Self {
63 Self { style }
64 }
65
66 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 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 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 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 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 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 if self.style.show_branch_names && !self.style.compact_mode {
146 output.push_str(&format!("ā {} šæ {:<50} ā\n", vertical, entry.branch));
147 }
148
149 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 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 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 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 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 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 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 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 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 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 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 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 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 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
623pub 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(¤t_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
676pub 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(¤t_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}