1use anyhow::{Result, bail};
17use clap::Args;
18
19use crate::cli::load_and_validate_queues;
20use crate::config::Resolved;
21use crate::queue::hierarchy::{HierarchyIndex, TaskSource, detect_parent_cycles, render_tree};
22
23#[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
40pub fn handle(resolved: &Resolved, args: QueueTreeArgs) -> Result<()> {
42 let (queue_file, done_file) = load_and_validate_queues(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 let idx = HierarchyIndex::build(&queue_file, done_ref);
50
51 let roots: Vec<String> = if let Some(ref root_id) = args.root {
53 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 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 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 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 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}