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