cascade_cli/cli/commands/
viz.rs1use crate::errors::{CascadeError, Result};
2use crate::stack::{Stack, StackManager};
3use std::collections::HashMap;
4use std::env;
5use std::fs;
6
7#[derive(Debug, Clone)]
9pub enum OutputFormat {
10 Ascii,
12 Mermaid,
14 Dot,
16 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#[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
54pub struct StackVisualizer {
56 style: VisualizationStyle,
57}
58
59impl StackVisualizer {
60 pub fn new(style: VisualizationStyle) -> Self {
61 Self { style }
62 }
63
64 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 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 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 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 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 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 if self.style.show_branch_names && !self.style.compact_mode {
144 output.push_str(&format!("ā {} šæ {:<50} ā\n", vertical, entry.branch));
145 }
146
147 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 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 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 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 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 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 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 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 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 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 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 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 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 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
621pub 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(¤t_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
671pub 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(¤t_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}