Skip to main content

orch/cli/
tree.rs

1//! Task tree visualization — ASCII tree view of parent-child relationships.
2
3use crate::backends::ExternalTask;
4use crate::engine::tasks::Task;
5use std::collections::HashMap;
6
7/// A node in the task tree.
8#[derive(Debug, Clone)]
9pub struct TreeNode {
10    pub id: String,
11    pub title: String,
12    pub status: String,
13    pub agent: Option<String>,
14    pub children: Vec<TreeNode>,
15}
16
17impl TreeNode {
18    /// Create a new tree node from an external task.
19    pub fn from_external(task: &ExternalTask) -> Self {
20        let status = task
21            .labels
22            .iter()
23            .find(|l| l.starts_with("status:"))
24            .map(|s| s.replace("status:", ""))
25            .unwrap_or_else(|| "unknown".to_string());
26
27        let agent = task
28            .labels
29            .iter()
30            .find(|l| l.starts_with("agent:"))
31            .map(|s| s.replace("agent:", ""));
32
33        Self {
34            id: task.id.0.clone(),
35            title: task.title.clone(),
36            status,
37            agent,
38            children: Vec::new(),
39        }
40    }
41
42    /// Create a tree node for an internal task (from store).
43    pub fn from_internal(task: &crate::store::Task) -> Self {
44        Self {
45            id: task
46                .external_id
47                .clone()
48                .unwrap_or_else(|| task.id.to_string()),
49            title: task.title.clone(),
50            status: task.status.as_str().to_string(),
51            agent: task.agent.clone(),
52            children: Vec::new(),
53        }
54    }
55}
56
57/// Build a forest (collection of trees) from a list of tasks.
58///
59/// Uses `parent:*` labels to determine hierarchy. Tasks without a parent label
60/// are roots. Tasks with a parent label that doesn't exist in the list are
61/// also treated as roots (orphaned children become top-level).
62pub fn build_forest(tasks: Vec<Task>) -> Vec<TreeNode> {
63    let mut nodes: HashMap<String, TreeNode> = HashMap::new();
64    let mut parent_map: HashMap<String, Option<String>> = HashMap::new();
65
66    // First pass: create nodes and extract parent relationships
67    for task in &tasks {
68        let (id, parent_id, node) = match task {
69            Task::External(ext) => {
70                let parent = ext
71                    .labels
72                    .iter()
73                    .find(|l| l.starts_with("parent:"))
74                    .map(|l| {
75                        let parent_num = l.trim_start_matches("parent:");
76                        parent_num.to_string()
77                    });
78                let node = TreeNode::from_external(ext);
79                (ext.id.0.clone(), parent, node)
80            }
81            Task::Internal(int) => {
82                // Internal tasks don't have parent labels (for now)
83                let node = TreeNode::from_internal(int);
84                let id = int
85                    .external_id
86                    .clone()
87                    .unwrap_or_else(|| int.id.to_string());
88                (id, None, node)
89            }
90        };
91
92        parent_map.insert(id.clone(), parent_id);
93        nodes.insert(id, node);
94    }
95
96    // Second pass: build the tree structure
97    let mut root_ids: Vec<String> = Vec::new();
98    let mut children_map: HashMap<String, Vec<String>> = HashMap::new();
99
100    for (id, parent_opt) in &parent_map {
101        match parent_opt {
102            Some(parent_id) => {
103                // If parent exists in our task list, add as child
104                if nodes.contains_key(parent_id) {
105                    children_map
106                        .entry(parent_id.clone())
107                        .or_default()
108                        .push(id.clone());
109                } else {
110                    // Parent not in list, treat as root
111                    root_ids.push(id.clone());
112                }
113            }
114            None => {
115                // No parent, this is a root
116                root_ids.push(id.clone());
117            }
118        }
119    }
120
121    // Third pass: recursively build the tree
122    fn build_node(
123        id: &str,
124        nodes: &HashMap<String, TreeNode>,
125        children_map: &HashMap<String, Vec<String>>,
126    ) -> Option<TreeNode> {
127        let mut node = nodes.get(id)?.clone();
128
129        if let Some(children_ids) = children_map.get(id) {
130            for child_id in children_ids {
131                if let Some(child_node) = build_node(child_id, nodes, children_map) {
132                    node.children.push(child_node);
133                }
134            }
135        }
136
137        // Sort children by numeric ID for deterministic output
138        node.children.sort_by(|a, b| {
139            let a_num = a.id.parse::<i64>().unwrap_or(0);
140            let b_num = b.id.parse::<i64>().unwrap_or(0);
141            a_num.cmp(&b_num)
142        });
143
144        Some(node)
145    }
146
147    let mut roots: Vec<TreeNode> = Vec::new();
148    for root_id in root_ids {
149        if let Some(tree) = build_node(&root_id, &nodes, &children_map) {
150            roots.push(tree);
151        }
152    }
153
154    // Sort roots by ID numerically for consistent output
155    roots.sort_by(|a, b| {
156        let a_num = a.id.parse::<i64>().unwrap_or(0);
157        let b_num = b.id.parse::<i64>().unwrap_or(0);
158        a_num.cmp(&b_num)
159    });
160
161    roots
162}
163
164/// Render a tree as ASCII with unicode box-drawing characters.
165pub fn render_tree(root: &TreeNode) -> String {
166    let mut output = String::new();
167    render_node(root, &mut output, "", true, true);
168    output
169}
170
171/// Render a tree node and its children recursively.
172fn render_node(node: &TreeNode, output: &mut String, prefix: &str, is_last: bool, is_root: bool) {
173    if is_root {
174        // Root node format: #30 [blocked] Implement auth system
175        output.push_str(&format!(
176            "#{} [{}] {}{}\n",
177            node.id,
178            node.status,
179            node.agent
180                .as_ref()
181                .map(|a| format!("({}) ", a))
182                .unwrap_or_default(),
183            node.title
184        ));
185    } else {
186        // Child node format with tree branches
187        let branch = if is_last { "└── " } else { "├── " };
188        output.push_str(&format!(
189            "{}{}#{} [{}] {}{}\n",
190            prefix,
191            branch,
192            node.id,
193            node.status,
194            node.agent
195                .as_ref()
196                .map(|a| format!("({}) ", a))
197                .unwrap_or_default(),
198            node.title
199        ));
200    }
201
202    // Render children
203    let child_prefix = if is_root {
204        prefix.to_string()
205    } else if is_last {
206        format!("{}    ", prefix)
207    } else {
208        format!("{}│   ", prefix)
209    };
210
211    let child_count = node.children.len();
212    for (i, child) in node.children.iter().enumerate() {
213        let child_is_last = i == child_count - 1;
214        render_node(child, output, &child_prefix, child_is_last, false);
215    }
216}
217
218/// Render multiple trees (a forest).
219pub fn render_forest(roots: &[TreeNode]) -> String {
220    if roots.is_empty() {
221        return "No tasks found.".to_string();
222    }
223
224    let mut output = String::new();
225    for (i, root) in roots.iter().enumerate() {
226        output.push_str(&render_tree(root));
227
228        // Add blank line between trees, except for the last one
229        if i < roots.len() - 1 {
230            output.push('\n');
231        }
232    }
233
234    output
235}
236
237/// Render a single tree by ID (for when user requests a specific task tree).
238pub fn render_single_tree(root: &TreeNode, show_no_children_msg: bool) -> String {
239    let mut output = render_tree(root);
240
241    if root.children.is_empty() && show_no_children_msg {
242        output.push_str("    (no children)\n");
243    }
244
245    output
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::backends::{ExternalId, ExternalTask};
252
253    fn create_test_task(id: &str, title: &str, _state: &str, labels: Vec<&str>) -> Task {
254        Task::External(ExternalTask {
255            id: ExternalId(id.to_string()),
256            title: title.to_string(),
257            body: String::new(),
258            state: "open".to_string(),
259            labels: labels.iter().map(|s| s.to_string()).collect(),
260            author: "test".to_string(),
261            created_at: "2026-01-01T00:00:00Z".to_string(),
262            updated_at: "2026-01-01T00:00:00Z".to_string(),
263            url: format!("https://github.com/test/test/issues/{}", id),
264        })
265    }
266
267    #[test]
268    fn test_build_forest_empty() {
269        let forest = build_forest(vec![]);
270        assert!(forest.is_empty());
271    }
272
273    #[test]
274    fn test_build_forest_single_root() {
275        let tasks = vec![create_test_task(
276            "30",
277            "Implement auth",
278            "open",
279            vec!["status:blocked", "agent:claude"],
280        )];
281
282        let forest = build_forest(tasks);
283        assert_eq!(forest.len(), 1);
284        assert_eq!(forest[0].id, "30");
285        assert_eq!(forest[0].status, "blocked");
286        assert_eq!(forest[0].agent, Some("claude".to_string()));
287        assert!(forest[0].children.is_empty());
288    }
289
290    #[test]
291    fn test_build_forest_with_children() {
292        let tasks = vec![
293            create_test_task(
294                "30",
295                "Implement auth",
296                "open",
297                vec!["status:blocked", "agent:claude"],
298            ),
299            create_test_task(
300                "31",
301                "Add user model",
302                "open",
303                vec!["status:done", "parent:30"],
304            ),
305            create_test_task(
306                "32",
307                "Add login API",
308                "open",
309                vec!["status:in_progress", "parent:30"],
310            ),
311        ];
312
313        let forest = build_forest(tasks);
314        assert_eq!(forest.len(), 1);
315        assert_eq!(forest[0].id, "30");
316        assert_eq!(forest[0].children.len(), 2);
317
318        // Children should be sorted by ID
319        assert_eq!(forest[0].children[0].id, "31");
320        assert_eq!(forest[0].children[1].id, "32");
321    }
322
323    #[test]
324    fn test_build_forest_multiple_roots() {
325        let tasks = vec![
326            create_test_task("35", "Refactor DB", "open", vec!["status:in_progress"]),
327            create_test_task("30", "Implement auth", "open", vec!["status:blocked"]),
328        ];
329
330        let forest = build_forest(tasks);
331        assert_eq!(forest.len(), 2);
332        // Should be sorted by ID: 30, 35
333        assert_eq!(forest[0].id, "30");
334        assert_eq!(forest[1].id, "35");
335    }
336
337    #[test]
338    fn test_render_tree() {
339        let root = TreeNode {
340            id: "30".to_string(),
341            title: "Implement auth system".to_string(),
342            status: "blocked".to_string(),
343            agent: Some("claude".to_string()),
344            children: vec![
345                TreeNode {
346                    id: "31".to_string(),
347                    title: "Add user model".to_string(),
348                    status: "done".to_string(),
349                    agent: None,
350                    children: vec![],
351                },
352                TreeNode {
353                    id: "32".to_string(),
354                    title: "Add login API".to_string(),
355                    status: "in_progress".to_string(),
356                    agent: Some("codex".to_string()),
357                    children: vec![],
358                },
359            ],
360        };
361
362        let output = render_tree(&root);
363        assert!(output.contains("#30 [blocked] (claude) Implement auth system"));
364        assert!(output.contains("├── #31 [done] Add user model"));
365        assert!(output.contains("└── #32 [in_progress] (codex) Add login API"));
366    }
367
368    #[test]
369    fn test_render_forest() {
370        let roots = vec![TreeNode {
371            id: "35".to_string(),
372            title: "Refactor database".to_string(),
373            status: "in_progress".to_string(),
374            agent: None,
375            children: vec![],
376        }];
377
378        let output = render_forest(&roots);
379        assert!(output.contains("#35 [in_progress] Refactor database"));
380    }
381
382    #[test]
383    fn test_orphaned_child_becomes_root() {
384        // Task has parent:99 but 99 doesn't exist in our task list
385        let tasks = vec![create_test_task(
386            "31",
387            "Orphan task",
388            "open",
389            vec!["status:new", "parent:99"],
390        )];
391
392        let forest = build_forest(tasks);
393        assert_eq!(forest.len(), 1);
394        assert_eq!(forest[0].id, "31");
395        assert!(forest[0].children.is_empty());
396    }
397}