1use crate::backends::ExternalTask;
4use crate::engine::tasks::Task;
5use std::collections::HashMap;
6
7#[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 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 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
57pub 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 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 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 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 nodes.contains_key(parent_id) {
105 children_map
106 .entry(parent_id.clone())
107 .or_default()
108 .push(id.clone());
109 } else {
110 root_ids.push(id.clone());
112 }
113 }
114 None => {
115 root_ids.push(id.clone());
117 }
118 }
119 }
120
121 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 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 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
164pub fn render_tree(root: &TreeNode) -> String {
166 let mut output = String::new();
167 render_node(root, &mut output, "", true, true);
168 output
169}
170
171fn render_node(node: &TreeNode, output: &mut String, prefix: &str, is_last: bool, is_root: bool) {
173 if is_root {
174 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 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 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
218pub 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 if i < roots.len() - 1 {
230 output.push('\n');
231 }
232 }
233
234 output
235}
236
237pub 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 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 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 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}