Skip to main content

ralph/cli/queue/
tree.rs

1//! Handler for `ralph queue tree` subcommand.
2//!
3//! Responsibilities:
4//! - Render an ASCII tree of task hierarchy based on parent_id.
5//! - Support filtering by root task, max depth, and done inclusion.
6//! - Show orphan markers for tasks with missing parents.
7//!
8//! Not handled here:
9//! - Queue mutation (this is a read-only command).
10//! - Dependency graph rendering (see `graph.rs` for depends_on).
11//!
12//! Invariants/assumptions:
13//! - Output is deterministic (stable ordering).
14//! - Cycles are detected and marked but don't cause infinite recursion.
15
16use anyhow::{Result, bail};
17use clap::Args;
18
19use crate::cli::load_and_validate_queues_read_only;
20use crate::config::Resolved;
21use crate::queue::hierarchy::{HierarchyIndex, TaskSource, detect_parent_cycles, render_tree};
22
23/// Arguments for `ralph queue tree`.
24#[derive(Args)]
25#[command(
26    about = "Render a parent/child hierarchy tree (based on parent_id)",
27    after_long_help = "Examples:\n  ralph queue tree\n  ralph queue tree --include-done\n  ralph queue tree --root RQ-0001\n  ralph queue tree --max-depth 25"
28)]
29pub struct QueueTreeArgs {
30    #[arg(long, value_name = "TASK_ID")]
31    pub root: Option<String>,
32
33    #[arg(long)]
34    pub include_done: bool,
35
36    #[arg(long, default_value = "20")]
37    pub max_depth: usize,
38}
39
40/// Handle the `queue tree` command.
41pub fn handle(resolved: &Resolved, args: QueueTreeArgs) -> Result<()> {
42    let (queue_file, done_file) = load_and_validate_queues_read_only(resolved, args.include_done)?;
43
44    let done_ref = done_file
45        .as_ref()
46        .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
47
48    // Build hierarchy index
49    let idx = HierarchyIndex::build(&queue_file, done_ref);
50
51    // Determine roots to render
52    let roots: Vec<String> = if let Some(ref root_id) = args.root {
53        // Validate root exists
54        if !idx.contains(root_id) {
55            if !args.include_done {
56                bail!(
57                    "{}",
58                    crate::error_messages::root_task_not_found(root_id, false)
59                );
60            }
61            bail!(
62                "{}",
63                crate::error_messages::root_task_not_found(root_id, true)
64            );
65        }
66        vec![root_id.clone()]
67    } else {
68        // Compute all roots deterministically
69        idx.roots()
70            .iter()
71            .filter(|r| args.include_done || matches!(r.source, TaskSource::Active))
72            .map(|r| r.task.id.clone())
73            .collect()
74    };
75
76    let roots = if roots.is_empty() {
77        // This happens when every task has a non-empty parent_id that points to an existing task.
78        // In a finite parent-pointer graph, that implies one or more cycles; render cycle entry points.
79        let mut all_tasks: Vec<&crate::contracts::Task> = queue_file.tasks.iter().collect();
80        if args.include_done
81            && let Some(done_file) = done_ref
82        {
83            all_tasks.extend(done_file.tasks.iter());
84        }
85
86        let cycles = detect_parent_cycles(&all_tasks);
87        if cycles.is_empty() {
88            println!("No tasks with hierarchy (parent_id) found.");
89            return Ok(());
90        }
91
92        let mut cycle_roots: Vec<_> = cycles
93            .iter()
94            .filter_map(|cycle| cycle.first())
95            .filter(|id| idx.contains(id))
96            .filter_map(|id| idx.get(id))
97            .collect();
98        cycle_roots.sort_by_key(|r| r.order);
99        let roots: Vec<String> = cycle_roots.iter().map(|r| r.task.id.clone()).collect();
100
101        if roots.is_empty() {
102            println!("No tasks with hierarchy (parent_id) found.");
103            return Ok(());
104        }
105
106        println!("[Note: no root tasks found; rendering parent_id cycles]");
107        roots
108    } else {
109        roots
110    };
111
112    // Render the tree
113    let root_refs: Vec<&str> = roots.iter().map(|s| s.as_str()).collect();
114    let output = render_tree(
115        &idx,
116        &root_refs,
117        args.max_depth,
118        args.include_done,
119        |task, depth, is_cycle, orphan_parent| {
120            let indent = "  ".repeat(depth);
121            let prefix = if depth == 0 { "" } else { "└─ " };
122            let base = format!("{}{}{}", indent, prefix, task.id);
123
124            if is_cycle {
125                return format!(
126                    "{}: {} [{}] (cycle)",
127                    base,
128                    task.title,
129                    task.status.as_str()
130                );
131            }
132
133            if let Some(parent) = orphan_parent {
134                return format!(
135                    "{}: {} [{}] (orphan: missing parent {})",
136                    base,
137                    task.title,
138                    task.status.as_str(),
139                    parent
140                );
141            }
142
143            format!("{}: {} [{}]", base, task.title, task.status.as_str())
144        },
145    );
146
147    if output.trim().is_empty() {
148        println!("No tasks with hierarchy (parent_id) found.");
149    } else {
150        println!("{}", output.trim());
151    }
152
153    Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn tree_args_defaults() {
162        // Simple test that the struct can be created
163        let args = QueueTreeArgs {
164            root: None,
165            include_done: false,
166            max_depth: 20,
167        };
168        assert_eq!(args.root, None);
169        assert_eq!(args.max_depth, 20);
170        assert!(!args.include_done);
171    }
172
173    #[test]
174    fn tree_args_with_root() {
175        let args = QueueTreeArgs {
176            root: Some("RQ-0001".to_string()),
177            include_done: true,
178            max_depth: 10,
179        };
180        assert_eq!(args.root, Some("RQ-0001".to_string()));
181        assert_eq!(args.max_depth, 10);
182        assert!(args.include_done);
183    }
184}