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_or_else(|| "unknown".to_string(), |s| s.replace("status:", ""));
25
26        let agent = task
27            .labels
28            .iter()
29            .find(|l| l.starts_with("agent:"))
30            .map(|s| s.replace("agent:", ""));
31
32        Self {
33            id: task.id.0.clone(),
34            title: task.title.clone(),
35            status,
36            agent,
37            children: Vec::new(),
38        }
39    }
40
41    /// Create a tree node for an internal task (from store).
42    pub fn from_internal(task: &crate::store::Task) -> Self {
43        Self {
44            id: task
45                .external_id
46                .clone()
47                .unwrap_or_else(|| task.id.to_string()),
48            title: task.title.clone(),
49            status: task.status.as_str().to_string(),
50            agent: task.agent.clone(),
51            children: Vec::new(),
52        }
53    }
54}
55
56/// Build a forest (collection of trees) from a list of tasks.
57///
58/// Uses `parent:*` labels to determine hierarchy. Tasks without a parent label
59/// are roots. Tasks with a parent label that doesn't exist in the list are
60/// also treated as roots (orphaned children become top-level).
61pub fn build_forest(tasks: &[Task]) -> Vec<TreeNode> {
62    fn build_node(
63        id: &str,
64        nodes: &HashMap<String, TreeNode>,
65        children_map: &HashMap<String, Vec<String>>,
66    ) -> Option<TreeNode> {
67        let mut node = nodes.get(id)?.clone();
68
69        if let Some(children_ids) = children_map.get(id) {
70            for child_id in children_ids {
71                if let Some(child_node) = build_node(child_id, nodes, children_map) {
72                    node.children.push(child_node);
73                }
74            }
75        }
76
77        // Sort children by numeric ID for deterministic output
78        node.children.sort_by(|a, b| {
79            let a_num = a.id.parse::<i64>().unwrap_or(0);
80            let b_num = b.id.parse::<i64>().unwrap_or(0);
81            a_num.cmp(&b_num)
82        });
83
84        Some(node)
85    }
86
87    let mut nodes: HashMap<String, TreeNode> = HashMap::new();
88    let mut parent_map: HashMap<String, Option<String>> = HashMap::new();
89
90    // First pass: create nodes and extract parent relationships
91    for task in tasks {
92        let (id, parent_id, node) = match task {
93            Task::External(ext) => {
94                let parent = ext
95                    .labels
96                    .iter()
97                    .find(|l| l.starts_with("parent:"))
98                    .map(|l| {
99                        let parent_num = l.trim_start_matches("parent:");
100                        parent_num.to_string()
101                    });
102                let node = TreeNode::from_external(ext);
103                (ext.id.0.clone(), parent, node)
104            }
105            Task::Internal(int) => {
106                // Internal tasks don't have parent labels (for now)
107                let node = TreeNode::from_internal(int);
108                let id = int
109                    .external_id
110                    .clone()
111                    .unwrap_or_else(|| int.id.to_string());
112                (id, None, node)
113            }
114        };
115
116        parent_map.insert(id.clone(), parent_id);
117        nodes.insert(id, node);
118    }
119
120    // Second pass: build the tree structure
121    let mut root_ids: Vec<String> = Vec::new();
122    let mut children_map: HashMap<String, Vec<String>> = HashMap::new();
123
124    for (id, parent_opt) in &parent_map {
125        match parent_opt {
126            Some(parent_id) => {
127                // If parent exists in our task list, add as child
128                if nodes.contains_key(parent_id) {
129                    children_map
130                        .entry(parent_id.clone())
131                        .or_default()
132                        .push(id.clone());
133                } else {
134                    // Parent not in list, treat as root
135                    root_ids.push(id.clone());
136                }
137            }
138            None => {
139                // No parent, this is a root
140                root_ids.push(id.clone());
141            }
142        }
143    }
144
145    // Third pass: recursively build the tree
146    let mut roots: Vec<TreeNode> = Vec::new();
147    for root_id in root_ids {
148        if let Some(tree) = build_node(&root_id, &nodes, &children_map) {
149            roots.push(tree);
150        }
151    }
152
153    // Sort roots by ID numerically for consistent output
154    roots.sort_by(|a, b| {
155        let a_num = a.id.parse::<i64>().unwrap_or(0);
156        let b_num = b.id.parse::<i64>().unwrap_or(0);
157        a_num.cmp(&b_num)
158    });
159
160    roots
161}
162
163/// Render a tree as ASCII with unicode box-drawing characters.
164pub fn render_tree(root: &TreeNode) -> String {
165    let mut output = String::new();
166    render_node(root, &mut output, "", true, true);
167    output
168}
169
170/// Render a tree node and its children recursively.
171fn render_node(node: &TreeNode, output: &mut String, prefix: &str, is_last: bool, is_root: bool) {
172    use std::fmt::Write as _;
173
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
263                .iter()
264                .map(std::string::ToString::to_string)
265                .collect(),
266            author: "test".to_string(),
267            created_at: "2026-01-01T00:00:00Z".to_string(),
268            updated_at: "2026-01-01T00:00:00Z".to_string(),
269            url: format!("https://github.com/test/test/issues/{id}"),
270        })
271    }
272
273    #[test]
274    fn test_build_forest_empty() {
275        let forest = build_forest(&[]);
276        assert!(forest.is_empty());
277    }
278
279    #[test]
280    fn test_build_forest_single_root() {
281        let tasks = vec![create_test_task(
282            "30",
283            "Implement auth",
284            "open",
285            &["status:blocked", "agent:claude"],
286        )];
287
288        let forest = build_forest(&tasks);
289        assert_eq!(forest.len(), 1);
290        assert_eq!(forest[0].id, "30");
291        assert_eq!(forest[0].status, "blocked");
292        assert_eq!(forest[0].agent, Some("claude".to_string()));
293        assert!(forest[0].children.is_empty());
294    }
295
296    #[test]
297    fn test_build_forest_with_children() {
298        let tasks = vec![
299            create_test_task(
300                "30",
301                "Implement auth",
302                "open",
303                &["status:blocked", "agent:claude"],
304            ),
305            create_test_task(
306                "31",
307                "Add user model",
308                "open",
309                &["status:done", "parent:30"],
310            ),
311            create_test_task(
312                "32",
313                "Add login API",
314                "open",
315                &["status:in_progress", "parent:30"],
316            ),
317        ];
318
319        let forest = build_forest(&tasks);
320        assert_eq!(forest.len(), 1);
321        assert_eq!(forest[0].id, "30");
322        assert_eq!(forest[0].children.len(), 2);
323
324        // Children should be sorted by ID
325        assert_eq!(forest[0].children[0].id, "31");
326        assert_eq!(forest[0].children[1].id, "32");
327    }
328
329    #[test]
330    fn test_build_forest_multiple_roots() {
331        let tasks = vec![
332            create_test_task("35", "Refactor DB", "open", &["status:in_progress"]),
333            create_test_task("30", "Implement auth", "open", &["status:blocked"]),
334        ];
335
336        let forest = build_forest(&tasks);
337        assert_eq!(forest.len(), 2);
338        // Should be sorted by ID: 30, 35
339        assert_eq!(forest[0].id, "30");
340        assert_eq!(forest[1].id, "35");
341    }
342
343    #[test]
344    fn test_render_tree() {
345        let root = TreeNode {
346            id: "30".to_string(),
347            title: "Implement auth system".to_string(),
348            status: "blocked".to_string(),
349            agent: Some("claude".to_string()),
350            children: vec![
351                TreeNode {
352                    id: "31".to_string(),
353                    title: "Add user model".to_string(),
354                    status: "done".to_string(),
355                    agent: None,
356                    children: vec![],
357                },
358                TreeNode {
359                    id: "32".to_string(),
360                    title: "Add login API".to_string(),
361                    status: "in_progress".to_string(),
362                    agent: Some("codex".to_string()),
363                    children: vec![],
364                },
365            ],
366        };
367
368        let output = render_tree(&root);
369        assert!(output.contains("#30 [blocked] (claude) Implement auth system"));
370        assert!(output.contains("├── #31 [done] Add user model"));
371        assert!(output.contains("└── #32 [in_progress] (codex) Add login API"));
372    }
373
374    #[test]
375    fn test_render_forest() {
376        let roots = vec![TreeNode {
377            id: "35".to_string(),
378            title: "Refactor database".to_string(),
379            status: "in_progress".to_string(),
380            agent: None,
381            children: vec![],
382        }];
383
384        let output = render_forest(&roots);
385        assert!(output.contains("#35 [in_progress] Refactor database"));
386    }
387
388    #[test]
389    fn test_orphaned_child_becomes_root() {
390        // Task has parent:99 but 99 doesn't exist in our task list
391        let tasks = vec![create_test_task(
392            "31",
393            "Orphan task",
394            "open",
395            &["status:new", "parent:99"],
396        )];
397
398        let forest = build_forest(&tasks);
399        assert_eq!(forest.len(), 1);
400        assert_eq!(forest[0].id, "31");
401        assert!(forest[0].children.is_empty());
402    }
403}