1use anyhow::{Result, anyhow};
19use clap::{Args, ValueEnum};
20
21use crate::cli::load_and_validate_queues;
22use crate::config::Resolved;
23use crate::contracts::TaskStatus;
24use crate::queue::find_task_across;
25use crate::queue::graph::{
26 GraphFormat, build_graph, find_critical_path_from, find_critical_paths, get_blocked_tasks,
27 get_runnable_tasks,
28};
29
30#[derive(Args)]
32#[command(
33 about = "Visualize task dependencies as a graph",
34 after_long_help = "Examples:\n ralph queue graph\n ralph queue graph --task RQ-0001\n ralph queue graph --format dot\n ralph queue graph --critical\n ralph queue graph --reverse --task RQ-0001"
35)]
36pub struct QueueGraphArgs {
37 #[arg(long, short)]
39 pub task: Option<String>,
40
41 #[arg(long, short, value_enum, default_value_t = GraphFormatArg::Tree)]
43 pub format: GraphFormatArg,
44
45 #[arg(long)]
47 pub include_done: bool,
48
49 #[arg(long, short)]
51 pub critical: bool,
52
53 #[arg(long, short)]
55 pub reverse: bool,
56}
57
58#[derive(Clone, Copy, Debug, ValueEnum)]
59#[clap(rename_all = "snake_case")]
60pub enum GraphFormatArg {
61 Tree,
63 Dot,
65 Json,
67 List,
69}
70
71impl From<GraphFormatArg> for GraphFormat {
72 fn from(arg: GraphFormatArg) -> Self {
73 match arg {
74 GraphFormatArg::Tree => GraphFormat::Tree,
75 GraphFormatArg::Dot => GraphFormat::Dot,
76 GraphFormatArg::Json => GraphFormat::Json,
77 GraphFormatArg::List => GraphFormat::List,
78 }
79 }
80}
81
82pub(crate) fn handle(resolved: &Resolved, args: QueueGraphArgs) -> Result<()> {
83 let (queue_file, done_file) = load_and_validate_queues(resolved, true)?;
84 let done_ref = done_file
85 .as_ref()
86 .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
87
88 let graph = build_graph(&queue_file, done_ref);
90
91 if graph.is_empty() {
92 println!("No tasks found in queue.");
93 return Ok(());
94 }
95
96 let critical_paths = if args.critical || matches!(args.format, GraphFormatArg::Tree) {
98 find_critical_paths(&graph)
99 } else {
100 Vec::new()
101 };
102
103 if let Some(task_id) = args.task {
105 let task_id = task_id.trim();
106 let task = find_task_across(&queue_file, done_ref, task_id)
107 .ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(task_id)))?;
108
109 match args.format {
110 GraphFormatArg::Tree => {
111 render_task_tree(
112 &graph,
113 task_id,
114 &critical_paths,
115 args.reverse,
116 args.include_done,
117 )?;
118 }
119 GraphFormatArg::Dot => {
120 render_task_dot(&graph, Some(task_id), args.reverse, args.include_done)?;
121 }
122 GraphFormatArg::Json => {
123 render_task_json(
124 &graph,
125 task,
126 &critical_paths,
127 args.reverse,
128 args.include_done,
129 )?;
130 }
131 GraphFormatArg::List => {
132 render_task_list(
133 &graph,
134 task_id,
135 &critical_paths,
136 args.reverse,
137 args.include_done,
138 )?;
139 }
140 }
141 } else {
142 match args.format {
144 GraphFormatArg::Tree => {
145 render_full_tree(&graph, &critical_paths, args.include_done)?;
146 }
147 GraphFormatArg::Dot => {
148 render_task_dot(&graph, None, args.reverse, args.include_done)?;
149 }
150 GraphFormatArg::Json => {
151 render_full_json(&graph, &critical_paths, args.include_done)?;
152 }
153 GraphFormatArg::List => {
154 render_full_list(&graph, &critical_paths, args.include_done)?;
155 }
156 }
157 }
158
159 Ok(())
160}
161
162fn render_task_tree(
164 graph: &crate::queue::graph::DependencyGraph,
165 task_id: &str,
166 critical_paths: &[crate::queue::graph::CriticalPathResult],
167 reverse: bool,
168 include_done: bool,
169) -> Result<()> {
170 let task = graph
171 .get(task_id)
172 .ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(task_id)))?;
173
174 println!("Dependency tree for {}: {}", task_id, task.task.title);
175
176 if reverse {
177 println!("\nTasks blocked by this task (downstream):");
178 render_dependents_tree(graph, task_id, "", include_done, critical_paths)?;
179 } else {
180 println!("\nTasks this task depends on (upstream):");
181 render_dependencies_tree(graph, task_id, "", include_done, critical_paths, true)?;
182 }
183
184 if let Some(cp) = find_critical_path_from(graph, task_id)
186 && cp.length > 1
187 {
188 println!("\nCritical path from this task: {} tasks", cp.length);
189 if cp.is_blocked {
190 println!(" Status: BLOCKED (incomplete dependencies)");
191 } else {
192 println!(" Status: Unblocked");
193 }
194 }
195
196 Ok(())
197}
198
199fn render_dependencies_tree(
201 graph: &crate::queue::graph::DependencyGraph,
202 task_id: &str,
203 prefix: &str,
204 include_done: bool,
205 critical_paths: &[crate::queue::graph::CriticalPathResult],
206 is_last: bool,
207) -> Result<()> {
208 let node = match graph.get(task_id) {
209 Some(n) => n,
210 None => return Ok(()),
211 };
212
213 let is_done = matches!(node.task.status, TaskStatus::Done | TaskStatus::Rejected);
214 if is_done && !include_done {
215 return Ok(());
216 }
217
218 let branch = if prefix.is_empty() {
220 ""
221 } else if is_last {
222 "└─ "
223 } else {
224 "├─ "
225 };
226
227 let is_critical = graph.is_on_critical_path(task_id, critical_paths);
228 let critical_marker = if is_critical { "* " } else { " " };
229 let status_marker = status_to_emoji(node.task.status);
230
231 println!(
232 "{}{}{}{}: {} [{}]",
233 prefix, branch, critical_marker, task_id, node.task.title, status_marker
234 );
235
236 let deps: Vec<_> = node
238 .dependencies
239 .iter()
240 .filter(|id| include_done || !graph.is_task_completed(id))
241 .collect();
242
243 let new_prefix = if prefix.is_empty() {
244 (if is_last { " " } else { "│ " }).to_string()
245 } else {
246 format!("{}{}", prefix, if is_last { " " } else { "│ " })
247 };
248
249 for (i, dep_id) in deps.iter().enumerate() {
250 let last = i == deps.len() - 1;
251 render_dependencies_tree(
252 graph,
253 dep_id,
254 &new_prefix,
255 include_done,
256 critical_paths,
257 last,
258 )?;
259 }
260
261 Ok(())
262}
263
264fn render_dependents_tree(
266 graph: &crate::queue::graph::DependencyGraph,
267 task_id: &str,
268 prefix: &str,
269 include_done: bool,
270 critical_paths: &[crate::queue::graph::CriticalPathResult],
271) -> Result<()> {
272 let node = match graph.get(task_id) {
273 Some(n) => n,
274 None => return Ok(()),
275 };
276
277 let is_done = matches!(node.task.status, TaskStatus::Done | TaskStatus::Rejected);
278 if is_done && !include_done && !prefix.is_empty() {
279 return Ok(());
280 }
281
282 let _is_critical = graph.is_on_critical_path(task_id, critical_paths);
283 let status_marker = status_to_emoji(node.task.status);
284
285 if prefix.is_empty() {
286 println!("* {}: {} [{}]", task_id, node.task.title, status_marker);
287 }
288
289 let deps: Vec<_> = node
291 .dependents
292 .iter()
293 .filter(|id| include_done || !graph.is_task_completed(id))
294 .collect();
295
296 for (i, dep_id) in deps.iter().enumerate() {
297 let dep_node = match graph.get(dep_id) {
298 Some(n) => n,
299 None => continue,
300 };
301
302 let is_last = i == deps.len() - 1;
303 let branch = if is_last { "└─ " } else { "├─ " };
304 let dep_is_done = matches!(
305 dep_node.task.status,
306 TaskStatus::Done | TaskStatus::Rejected
307 );
308
309 if dep_is_done && !include_done {
310 continue;
311 }
312
313 let dep_is_critical = graph.is_on_critical_path(dep_id, critical_paths);
314 let dep_critical_marker = if dep_is_critical { "* " } else { " " };
315 let dep_status_marker = status_to_emoji(dep_node.task.status);
316
317 println!(
318 "{}{}{}{}: {} [{}]",
319 prefix, branch, dep_critical_marker, dep_id, dep_node.task.title, dep_status_marker
320 );
321
322 let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
323 render_dependents_tree(graph, dep_id, &new_prefix, include_done, critical_paths)?;
324 }
325
326 Ok(())
327}
328
329fn render_full_tree(
331 graph: &crate::queue::graph::DependencyGraph,
332 critical_paths: &[crate::queue::graph::CriticalPathResult],
333 include_done: bool,
334) -> Result<()> {
335 println!("Task Dependency Graph\n");
336
337 let runnable = get_runnable_tasks(graph);
339 let blocked = get_blocked_tasks(graph);
340
341 println!("Summary:");
342 println!(" Total tasks: {}", graph.len());
343 println!(" Ready to run: {}", runnable.len());
344 println!(" Blocked: {}", blocked.len());
345
346 if !critical_paths.is_empty() {
347 println!(" Critical path length: {}", critical_paths[0].length);
348 }
349
350 println!("\nDependency Chains:\n");
351
352 for (i, root_id) in graph.roots().iter().enumerate() {
354 if let Some(node) = graph.get(root_id) {
355 let is_done = matches!(node.task.status, TaskStatus::Done | TaskStatus::Rejected);
356 if is_done && !include_done {
357 continue;
358 }
359
360 render_dependencies_tree(graph, root_id, "", include_done, critical_paths, true)?;
361
362 if i < graph.roots().len() - 1 {
363 println!();
364 }
365 }
366 }
367
368 println!("\nLegend:");
370 println!(" * = on critical path");
371 println!(" ⏳ = todo, 🔄 = doing, ✅ = done, ❌ = rejected");
372
373 Ok(())
374}
375
376fn render_task_dot(
378 graph: &crate::queue::graph::DependencyGraph,
379 focus_task: Option<&str>,
380 reverse: bool,
381 include_done: bool,
382) -> Result<()> {
383 let critical_paths = find_critical_paths(graph);
384
385 println!("digraph dependencies {{");
386 println!(" rankdir=TB;");
387 println!(" node [shape=box, style=rounded];");
388 println!();
389
390 let mut included_tasks: Vec<String> = Vec::new();
392
393 if let Some(task_id) = focus_task {
394 included_tasks.push(task_id.to_string());
396
397 if reverse {
398 let chain = graph.get_blocked_chain(task_id);
400 included_tasks.extend(chain);
401 } else {
402 let chain = graph.get_blocking_chain(task_id);
404 included_tasks.extend(chain);
405 }
406 } else {
407 included_tasks.extend(graph.task_ids().cloned());
409 }
410
411 if !include_done {
413 included_tasks.retain(|id| {
414 graph
415 .get(id)
416 .map(|n| !matches!(n.task.status, TaskStatus::Done | TaskStatus::Rejected))
417 .unwrap_or(false)
418 });
419 }
420
421 for task_id in &included_tasks {
423 if let Some(node) = graph.get(task_id) {
424 let is_critical = graph.is_on_critical_path(task_id, &critical_paths);
425 let color = match node.task.status {
426 TaskStatus::Done => "green",
427 TaskStatus::Rejected => "gray",
428 TaskStatus::Doing => "orange",
429 TaskStatus::Draft => "yellow",
430 TaskStatus::Todo => {
431 if is_critical {
432 "red"
433 } else {
434 "lightblue"
435 }
436 }
437 };
438
439 let style = if is_critical {
440 ", style=filled, fillcolor=red, fontcolor=white"
441 } else {
442 ""
443 };
444 let label = format!("{}\\n{}", task_id, escape_label(&node.task.title));
445
446 println!(
447 " \"{}\" [label=\"{}\", color={}{}];",
448 task_id, label, color, style
449 );
450 }
451 }
452
453 println!();
454
455 for task_id in &included_tasks {
457 if let Some(node) = graph.get(task_id) {
458 for dep_id in &node.dependencies {
459 if included_tasks.contains(dep_id) {
460 let is_critical = graph.is_on_critical_path(task_id, &critical_paths)
461 && graph.is_on_critical_path(dep_id, &critical_paths);
462 let edge_style = if is_critical {
463 " [color=red, penwidth=2]"
464 } else {
465 ""
466 };
467 println!(" \"{}\" -> \"{}\"{};", task_id, dep_id, edge_style);
468 }
469 }
470 }
471 }
472
473 for task_id in &included_tasks {
475 if let Some(node) = graph.get(task_id) {
476 for blocked_id in &node.blocks {
477 if included_tasks.contains(blocked_id) {
478 println!(
479 " \"{}\" -> \"{}\" [style=dashed, color=orange, label=\"blocks\"];",
480 task_id, blocked_id
481 );
482 }
483 }
484 }
485 }
486
487 for task_id in &included_tasks {
489 if let Some(node) = graph.get(task_id) {
490 for related_id in &node.relates_to {
491 if included_tasks.contains(related_id) {
492 if task_id < related_id {
494 println!(
495 " \"{}\" -> \"{}\" [style=dotted, color=blue, label=\"relates\"];",
496 task_id, related_id
497 );
498 }
499 }
500 }
501 }
502 }
503
504 for task_id in &included_tasks {
506 if let Some(node) = graph.get(task_id)
507 && let Some(duplicates_id) = &node.duplicates
508 && included_tasks.contains(duplicates_id)
509 {
510 println!(
511 " \"{}\" -> \"{}\" [style=bold, color=red, label=\"duplicates\"];",
512 task_id, duplicates_id
513 );
514 }
515 }
516
517 println!("}}");
518
519 Ok(())
520}
521
522fn render_task_json(
524 graph: &crate::queue::graph::DependencyGraph,
525 task: &crate::contracts::Task,
526 critical_paths: &[crate::queue::graph::CriticalPathResult],
527 reverse: bool,
528 include_done: bool,
529) -> Result<()> {
530 use serde_json::json;
531
532 let task_id = &task.id;
533
534 let (related_tasks, relationship_type) = if reverse {
535 (graph.get_blocked_chain(task_id), "blocked_by")
536 } else {
537 (graph.get_blocking_chain(task_id), "depends_on")
538 };
539
540 let is_critical = graph.is_on_critical_path(task_id, critical_paths);
541
542 let related: Vec<_> = related_tasks
543 .iter()
544 .filter_map(|id| graph.get(id))
545 .filter(|n| {
546 include_done || !matches!(n.task.status, TaskStatus::Done | TaskStatus::Rejected)
547 })
548 .map(|n| {
549 json!({
550 "id": n.task.id,
551 "title": n.task.title,
552 "status": format!("{:?}", n.task.status).to_lowercase(),
553 "critical": graph.is_on_critical_path(&n.task.id, critical_paths),
554 })
555 })
556 .collect();
557
558 let output = json!({
559 "task": task_id,
560 "title": task.title,
561 "status": format!("{:?}", task.status).to_lowercase(),
562 "critical": is_critical,
563 "relationship": relationship_type,
564 "related_tasks": related,
565 });
566
567 println!("{}", serde_json::to_string_pretty(&output)?);
568
569 Ok(())
570}
571
572fn render_full_json(
574 graph: &crate::queue::graph::DependencyGraph,
575 critical_paths: &[crate::queue::graph::CriticalPathResult],
576 include_done: bool,
577) -> Result<()> {
578 use serde_json::json;
579
580 let runnable = get_runnable_tasks(graph);
581 let blocked = get_blocked_tasks(graph);
582
583 let tasks: Vec<_> = graph
584 .task_ids()
585 .filter_map(|id| graph.get(id))
586 .filter(|n| {
587 include_done || !matches!(n.task.status, TaskStatus::Done | TaskStatus::Rejected)
588 })
589 .map(|n| {
590 json!({
591 "id": n.task.id,
592 "title": n.task.title,
593 "status": format!("{:?}", n.task.status).to_lowercase(),
594 "dependencies": n.dependencies,
595 "dependents": n.dependents,
596 "critical": graph.is_on_critical_path(&n.task.id, critical_paths),
597 })
598 })
599 .collect();
600
601 let critical_path_json: Vec<_> = critical_paths
602 .iter()
603 .map(|cp| {
604 json!({
605 "path": cp.path,
606 "length": cp.length,
607 "blocked": cp.is_blocked,
608 })
609 })
610 .collect();
611
612 let output = json!({
613 "summary": {
614 "total_tasks": graph.len(),
615 "runnable_tasks": runnable.len(),
616 "blocked_tasks": blocked.len(),
617 },
618 "critical_paths": critical_path_json,
619 "tasks": tasks,
620 });
621
622 println!("{}", serde_json::to_string_pretty(&output)?);
623
624 Ok(())
625}
626
627fn render_task_list(
629 graph: &crate::queue::graph::DependencyGraph,
630 task_id: &str,
631 critical_paths: &[crate::queue::graph::CriticalPathResult],
632 reverse: bool,
633 include_done: bool,
634) -> Result<()> {
635 let task = graph
636 .get(task_id)
637 .ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(task_id)))?;
638
639 println!("{}: {}", task_id, task.task.title);
640 println!("Status: {:?}", task.task.status);
641
642 if graph.is_on_critical_path(task_id, critical_paths) {
643 println!("CRITICAL PATH TASK");
644 }
645
646 if reverse {
647 println!("\nBlocked tasks (downstream):");
648 let chain = graph.get_blocked_chain(task_id);
649 for (i, id) in chain.iter().enumerate() {
650 if let Some(node) = graph.get(id) {
651 let is_done = matches!(node.task.status, TaskStatus::Done | TaskStatus::Rejected);
652 if is_done && !include_done {
653 continue;
654 }
655 let indent = " ".repeat(i + 1);
656 let critical = if graph.is_on_critical_path(id, critical_paths) {
657 " *"
658 } else {
659 ""
660 };
661 println!(
662 "{}{}: {} [{:?}]{}",
663 indent, id, node.task.title, node.task.status, critical
664 );
665 }
666 }
667 } else {
668 println!("\nDependencies (upstream):");
669 let chain = graph.get_blocking_chain(task_id);
670 for (i, id) in chain.iter().enumerate() {
671 if let Some(node) = graph.get(id) {
672 let is_done = matches!(node.task.status, TaskStatus::Done | TaskStatus::Rejected);
673 if is_done && !include_done {
674 continue;
675 }
676 let indent = " ".repeat(i + 1);
677 let critical = if graph.is_on_critical_path(id, critical_paths) {
678 " *"
679 } else {
680 ""
681 };
682 println!(
683 "{}{}: {} [{:?}]{}",
684 indent, id, node.task.title, node.task.status, critical
685 );
686 }
687 }
688 }
689
690 Ok(())
691}
692
693fn render_full_list(
695 graph: &crate::queue::graph::DependencyGraph,
696 critical_paths: &[crate::queue::graph::CriticalPathResult],
697 include_done: bool,
698) -> Result<()> {
699 println!("Task Dependency List\n");
700
701 let runnable = get_runnable_tasks(graph);
702 let blocked = get_blocked_tasks(graph);
703
704 println!(
705 "Summary: {} total, {} ready, {} blocked\n",
706 graph.len(),
707 runnable.len(),
708 blocked.len()
709 );
710
711 let mut by_status: std::collections::HashMap<TaskStatus, Vec<&str>> =
713 std::collections::HashMap::new();
714
715 for task_id in graph.task_ids() {
716 if let Some(node) = graph.get(task_id) {
717 let is_done = matches!(node.task.status, TaskStatus::Done | TaskStatus::Rejected);
718 if is_done && !include_done {
719 continue;
720 }
721 by_status.entry(node.task.status).or_default().push(task_id);
722 }
723 }
724
725 for status in [
727 TaskStatus::Doing,
728 TaskStatus::Todo,
729 TaskStatus::Draft,
730 TaskStatus::Done,
731 TaskStatus::Rejected,
732 ] {
733 if let Some(tasks) = by_status.get(&status) {
734 println!("{:?}:", status);
735 for task_id in tasks {
736 if let Some(node) = graph.get(task_id) {
737 let critical = if graph.is_on_critical_path(task_id, critical_paths) {
738 " *"
739 } else {
740 ""
741 };
742 let deps = if node.dependencies.is_empty() {
743 "none".to_string()
744 } else {
745 node.dependencies.join(", ")
746 };
747 println!(
748 " {}: {} (depends on: {}){}",
749 task_id, node.task.title, deps, critical
750 );
751 }
752 }
753 println!();
754 }
755 }
756
757 Ok(())
758}
759
760fn status_to_emoji(status: TaskStatus) -> &'static str {
762 match status {
763 TaskStatus::Todo => "⏳",
764 TaskStatus::Doing => "🔄",
765 TaskStatus::Done => "✅",
766 TaskStatus::Rejected => "❌",
767 TaskStatus::Draft => "📝",
768 }
769}
770
771fn escape_label(s: &str) -> String {
773 s.replace('"', "\\\"").replace('\n', "\\n")
774}