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_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 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
56pub 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 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 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 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 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 nodes.contains_key(parent_id) {
129 children_map
130 .entry(parent_id.clone())
131 .or_default()
132 .push(id.clone());
133 } else {
134 root_ids.push(id.clone());
136 }
137 }
138 None => {
139 root_ids.push(id.clone());
141 }
142 }
143 }
144
145 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 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
163pub fn render_tree(root: &TreeNode) -> String {
165 let mut output = String::new();
166 render_node(root, &mut output, "", true, true);
167 output
168}
169
170fn 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 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 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 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
221pub 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 if i < roots.len() - 1 {
233 output.push('\n');
234 }
235 }
236
237 output
238}
239
240pub 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 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 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 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}