1use crate::backends::ExternalTask;
4use crate::engine::tasks::Task;
5use crate::store;
6use std::collections::HashMap;
7use std::fmt::Write as _;
8
9#[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 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 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
58pub 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 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 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 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 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 nodes.contains_key(parent_id) {
131 children_map
132 .entry(parent_id.clone())
133 .or_default()
134 .push(id.clone());
135 } else {
136 root_ids.push(id.clone());
138 }
139 }
140 None => {
141 root_ids.push(id.clone());
143 }
144 }
145 }
146
147 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 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
165pub fn render_tree(root: &TreeNode) -> String {
167 let mut output = String::new();
168 render_node(root, &mut output, "", true, true);
169 output
170}
171
172fn render_node(node: &TreeNode, output: &mut String, prefix: &str, is_last: bool, is_root: bool) {
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.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 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 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 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}