Skip to main content

ralph/cli/queue/
graph.rs

1//! `ralph queue graph` subcommand for dependency visualization.
2//!
3//! Responsibilities:
4//! - Display task dependency graphs in various formats (tree, DOT, JSON, list)
5//! - Highlight critical paths and blocking relationships
6//! - Support filtering by task and status
7//!
8//! Not handled here:
9//! - Graph algorithm computation (handled by queue::graph)
10//! - File I/O (handled by queue module)
11//! - GUI rendering
12//!
13//! Invariants/assumptions:
14//! - Queue files are valid and already validated by the loader
15//! - Task IDs are matched after trimming whitespace
16//! - Output is written to stdout
17
18use 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/// Arguments for `ralph queue graph`.
31#[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    /// Focus on a specific task (show its dependency tree).
38    #[arg(long, short)]
39    pub task: Option<String>,
40
41    /// Output format.
42    #[arg(long, short, value_enum, default_value_t = GraphFormatArg::Tree)]
43    pub format: GraphFormatArg,
44
45    /// Include completed tasks in output.
46    #[arg(long)]
47    pub include_done: bool,
48
49    /// Show only critical path.
50    #[arg(long, short)]
51    pub critical: bool,
52
53    /// Show reverse dependencies (what this task blocks).
54    #[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    /// ASCII art tree (default).
62    Tree,
63    /// Graphviz DOT format.
64    Dot,
65    /// JSON for external tools.
66    Json,
67    /// Flat list with indentation.
68    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    // Build the dependency graph
89    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    // Find critical paths for highlighting
97    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 a specific task is specified, focus on it
104    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        // Show full graph
143        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
162/// Render a tree view for a specific task.
163fn 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    // Show critical path info
185    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
199/// Render dependencies recursively (upstream).
200fn 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    // Determine prefix characters
219    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    // Recurse into dependencies
237    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
264/// Render dependents recursively (downstream).
265fn 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    // Recurse into dependents
290    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
329/// Render full dependency tree (all roots).
330fn 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    // Show statistics
338    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    // Show trees from each root
353    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    // Show legend
369    println!("\nLegend:");
370    println!("  * = on critical path");
371    println!("  ⏳ = todo, 🔄 = doing, ✅ = done, ❌ = rejected");
372
373    Ok(())
374}
375
376/// Render Graphviz DOT format.
377fn 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    // Collect tasks to include
391    let mut included_tasks: Vec<String> = Vec::new();
392
393    if let Some(task_id) = focus_task {
394        // Include only the focus task and its related tasks
395        included_tasks.push(task_id.to_string());
396
397        if reverse {
398            // Include dependents
399            let chain = graph.get_blocked_chain(task_id);
400            included_tasks.extend(chain);
401        } else {
402            // Include dependencies
403            let chain = graph.get_blocking_chain(task_id);
404            included_tasks.extend(chain);
405        }
406    } else {
407        // Include all tasks
408        included_tasks.extend(graph.task_ids().cloned());
409    }
410
411    // Filter by status if needed
412    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    // Define nodes
422    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    // Define edges for depends_on relationships (solid)
456    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    // Define edges for blocks relationships (dashed)
474    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    // Define edges for relates_to relationships (dotted)
488    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                    // Only draw one direction to avoid duplicate edges
493                    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    // Define edges for duplicates relationships (bold, red)
505    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
522/// Render JSON output for a specific task.
523fn 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
572/// Render JSON output for full graph.
573fn 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
627/// Render flat list view for a specific task.
628fn 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
693/// Render flat list view for full graph.
694fn 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    // Group by status
712    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    // Print by status
726    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
760/// Convert status to emoji.
761fn 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
771/// Escape a string for use in DOT label.
772fn escape_label(s: &str) -> String {
773    s.replace('"', "\\\"").replace('\n', "\\n")
774}