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