1use crate::contracts::{QueueFile, Task};
18use std::collections::{HashMap, HashSet};
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub(crate) enum TaskSource {
23 Active,
24 Done,
25}
26
27#[derive(Clone, Copy, Debug)]
29pub(crate) struct TaskRef<'a> {
30 pub(crate) task: &'a Task,
31 pub(crate) source: TaskSource,
32 pub(crate) order: usize,
33}
34
35#[derive(Debug)]
37pub(crate) struct HierarchyIndex<'a> {
38 by_id: HashMap<&'a str, TaskRef<'a>>,
39 children_by_parent: HashMap<&'a str, Vec<TaskRef<'a>>>,
40}
41
42impl<'a> HierarchyIndex<'a> {
43 pub(crate) fn build(active: &'a QueueFile, done: Option<&'a QueueFile>) -> Self {
48 let mut by_id: HashMap<&'a str, TaskRef<'a>> = HashMap::new();
49 let mut children_by_parent: HashMap<&'a str, Vec<TaskRef<'a>>> = HashMap::new();
50
51 let mut order_counter: usize = 0;
52
53 for task in &active.tasks {
55 let id = task.id.trim();
56 if id.is_empty() {
57 continue;
58 }
59 let task_ref = TaskRef {
60 task,
61 source: TaskSource::Active,
62 order: order_counter,
63 };
64 order_counter += 1;
65 by_id.insert(id, task_ref);
66 }
67
68 if let Some(done_file) = done {
70 for task in &done_file.tasks {
71 let id = task.id.trim();
72 if id.is_empty() {
73 continue;
74 }
75 let task_ref = TaskRef {
76 task,
77 source: TaskSource::Done,
78 order: order_counter,
79 };
80 order_counter += 1;
81 by_id.insert(id, task_ref);
82 }
83 }
84
85 for task_ref in by_id.values() {
87 let task = task_ref.task;
88 if let Some(parent_id) = task.parent_id.as_deref() {
89 let parent_id_trimmed = parent_id.trim();
90 if parent_id_trimmed.is_empty() {
91 continue;
93 }
94
95 if by_id.contains_key(parent_id_trimmed) {
96 children_by_parent
97 .entry(parent_id_trimmed)
98 .or_default()
99 .push(*task_ref);
100 }
101 }
102 }
103
104 for (_, children) in children_by_parent.iter_mut() {
106 children.sort_by_key(|c| c.order);
107 }
108
109 Self {
110 by_id,
111 children_by_parent,
112 }
113 }
114
115 pub(crate) fn get(&self, id: &str) -> Option<TaskRef<'a>> {
117 self.by_id.get(id.trim()).copied()
118 }
119
120 pub(crate) fn children_of(&self, parent_id: &str) -> &[TaskRef<'a>] {
122 self.children_by_parent
123 .get(parent_id.trim())
124 .map(|v| v.as_slice())
125 .unwrap_or(&[])
126 }
127
128 pub(crate) fn contains(&self, id: &str) -> bool {
130 self.by_id.contains_key(id.trim())
131 }
132
133 pub(crate) fn roots(&self) -> Vec<TaskRef<'a>> {
136 let mut roots: Vec<TaskRef<'a>> = self
137 .by_id
138 .values()
139 .filter(|task_ref| {
140 let parent_id = task_ref.task.parent_id.as_deref();
141 match parent_id {
142 None => true,
143 Some(pid) => {
144 let pid_trimmed = pid.trim();
145 pid_trimmed.is_empty() || !self.by_id.contains_key(pid_trimmed)
146 }
147 }
148 })
149 .copied()
150 .collect();
151
152 roots.sort_by_key(|r| r.order);
153 roots
154 }
155}
156
157pub(crate) fn detect_parent_cycles(all_tasks: &[&Task]) -> Vec<Vec<String>> {
160 let mut child_to_parent: HashMap<&str, &str> = HashMap::new();
161
162 for task in all_tasks {
164 let task_id = task.id.trim();
165 if task_id.is_empty() {
166 continue;
167 }
168 if let Some(parent_id) = task.parent_id.as_deref() {
169 let parent_id_trimmed = parent_id.trim();
170 if !parent_id_trimmed.is_empty() {
171 child_to_parent.insert(task_id, parent_id_trimmed);
172 }
173 }
174 }
175
176 let mut visited: HashSet<&str> = HashSet::new();
177 let mut cycles: Vec<Vec<String>> = Vec::new();
178
179 for &start_task in all_tasks {
180 let start_id = start_task.id.trim();
181 if start_id.is_empty() || visited.contains(start_id) {
182 continue;
183 }
184
185 let mut path: Vec<&str> = Vec::new();
186 let mut current = Some(start_id);
187 let mut path_set: HashSet<&str> = HashSet::new();
188
189 while let Some(node) = current {
190 if path_set.contains(node) {
191 let cycle_start = path.iter().position(|&x| x == node).unwrap_or(0);
193 let cycle: Vec<String> =
194 path[cycle_start..].iter().map(|&s| s.to_string()).collect();
195
196 let cycle_normalized = normalize_cycle(&cycle);
198 if !cycles
199 .iter()
200 .any(|c| normalize_cycle(c) == cycle_normalized)
201 {
202 cycles.push(cycle);
203 }
204 break;
205 }
206
207 if visited.contains(node) {
208 break;
210 }
211
212 path.push(node);
213 path_set.insert(node);
214 current = child_to_parent.get(node).copied();
215 }
216
217 for &node in &path {
219 visited.insert(node);
220 }
221 }
222
223 cycles
224}
225
226fn normalize_cycle(cycle: &[String]) -> Vec<String> {
228 if cycle.is_empty() {
229 return cycle.to_vec();
230 }
231
232 let min_idx = cycle
233 .iter()
234 .enumerate()
235 .min_by_key(|(_, v)| *v)
236 .map(|(i, _)| i)
237 .unwrap_or(0);
238
239 let mut normalized: Vec<String> = cycle[min_idx..].to_vec();
240 normalized.extend_from_slice(&cycle[..min_idx]);
241 normalized
242}
243
244pub(crate) fn render_tree<F>(
253 idx: &HierarchyIndex<'_>,
254 roots: &[&str],
255 max_depth: usize,
256 include_done: bool,
257 format_line: F,
258) -> String
259where
260 F: Fn(&Task, usize, bool, Option<&str>) -> String,
261{
262 let mut output = String::new();
263 let mut visited: HashSet<String> = HashSet::new();
264
265 struct RenderCtx<'idx, 'task, 'out, F>
266 where
267 F: Fn(&Task, usize, bool, Option<&str>) -> String,
268 {
269 idx: &'idx HierarchyIndex<'task>,
270 max_depth: usize,
271 include_done: bool,
272 visited: &'out mut HashSet<String>,
273 output: &'out mut String,
274 format_line: &'out F,
275 }
276
277 fn render_tree_recursive<'idx, 'task, 'out, F>(
278 ctx: &mut RenderCtx<'idx, 'task, 'out, F>,
279 task_id: &str,
280 depth: usize,
281 ) where
282 F: Fn(&Task, usize, bool, Option<&str>) -> String,
283 {
284 if depth > ctx.max_depth {
285 return;
286 }
287
288 let trimmed_id = task_id.trim();
289 if ctx.visited.contains(trimmed_id) {
290 if let Some(task_ref) = ctx.idx.get(trimmed_id) {
292 let line = (ctx.format_line)(task_ref.task, depth, true, None);
293 ctx.output.push_str(&line);
294 ctx.output.push('\n');
295 }
296 return;
297 }
298
299 let task_ref = match ctx.idx.get(trimmed_id) {
300 Some(t) => t,
301 None => {
302 return;
304 }
305 };
306
307 if !ctx.include_done && matches!(task_ref.source, TaskSource::Done) {
309 return;
310 }
311
312 ctx.visited.insert(trimmed_id.to_string());
313
314 let is_orphan = task_ref
316 .task
317 .parent_id
318 .as_deref()
319 .map(|p| !p.trim().is_empty() && !ctx.idx.contains(p))
320 .unwrap_or(false);
321
322 let orphan_parent = if is_orphan {
323 task_ref.task.parent_id.as_deref()
324 } else {
325 None
326 };
327
328 let line = (ctx.format_line)(task_ref.task, depth, false, orphan_parent);
329 ctx.output.push_str(&line);
330 ctx.output.push('\n');
331
332 let children = ctx.idx.children_of(trimmed_id);
334 for child in children {
335 render_tree_recursive(ctx, &child.task.id, depth + 1);
336 }
337 }
338
339 let mut ctx = RenderCtx {
340 idx,
341 max_depth,
342 include_done,
343 visited: &mut visited,
344 output: &mut output,
345 format_line: &format_line,
346 };
347
348 for &root_id in roots {
349 render_tree_recursive(&mut ctx, root_id, 0);
350 }
351
352 output
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::contracts::{Task, TaskStatus};
359
360 fn make_task(id: &str, parent_id: Option<&str>) -> Task {
361 Task {
362 id: id.to_string(),
363 title: format!("Task {}", id),
364 description: None,
365 status: TaskStatus::Todo,
366 parent_id: parent_id.map(|s| s.to_string()),
367 created_at: Some("2026-01-01T00:00:00Z".to_string()),
368 updated_at: Some("2026-01-01T00:00:00Z".to_string()),
369 ..Default::default()
370 }
371 }
372
373 fn make_active_queue(tasks: Vec<Task>) -> QueueFile {
374 QueueFile { version: 1, tasks }
375 }
376
377 #[test]
378 fn hierarchy_index_builds_correctly() {
379 let tasks = vec![
380 make_task("RQ-0001", None),
381 make_task("RQ-0002", Some("RQ-0001")),
382 make_task("RQ-0003", Some("RQ-0001")),
383 ];
384 let active = make_active_queue(tasks);
385
386 let idx = HierarchyIndex::build(&active, None);
387
388 assert_eq!(idx.children_of("RQ-0001").len(), 2);
389 assert!(idx.children_of("RQ-0002").is_empty());
390 assert!(idx.get("RQ-0001").is_some());
391 assert!(idx.get("RQ-0002").is_some());
392 }
393
394 #[test]
395 fn children_preserve_file_order() {
396 let tasks = vec![
397 make_task("RQ-0001", None),
398 make_task("RQ-0003", Some("RQ-0001")), make_task("RQ-0002", Some("RQ-0001")),
400 ];
401 let active = make_active_queue(tasks);
402
403 let idx = HierarchyIndex::build(&active, None);
404 let children = idx.children_of("RQ-0001");
405
406 assert_eq!(children[0].task.id, "RQ-0003");
407 assert_eq!(children[1].task.id, "RQ-0002");
408 }
409
410 #[test]
411 fn orphan_detection_works() {
412 let tasks = vec![
413 make_task("RQ-0001", None),
414 make_task("RQ-0002", Some("RQ-9999")), ];
416 let active = make_active_queue(tasks);
417
418 let idx = HierarchyIndex::build(&active, None);
419
420 let roots: Vec<&str> = idx.roots().iter().map(|r| r.task.id.as_str()).collect();
422 assert!(roots.contains(&"RQ-0002"));
423
424 let output = render_tree(
425 &idx,
426 &["RQ-0002"],
427 10,
428 true,
429 |_task, _depth, _is_cycle, orphan_parent| {
430 format!("orphan_parent={}", orphan_parent.unwrap_or("<none>"))
431 },
432 );
433 assert!(output.contains("orphan_parent=RQ-9999"));
434 }
435
436 #[test]
437 fn empty_parent_id_treated_as_unset() {
438 let mut task = make_task("RQ-0001", None);
439 task.parent_id = Some(" ".to_string()); let active = make_active_queue(vec![task]);
442 let idx = HierarchyIndex::build(&active, None);
443
444 assert_eq!(idx.roots().len(), 1);
445 }
446
447 #[test]
448 fn cycle_detection_finds_simple_cycle() {
449 let tasks = [
450 Task {
451 id: "RQ-0001".to_string(),
452 parent_id: Some("RQ-0002".to_string()),
453 title: "Task 1".to_string(),
454 created_at: Some("2026-01-01T00:00:00Z".to_string()),
455 updated_at: Some("2026-01-01T00:00:00Z".to_string()),
456 ..Default::default()
457 },
458 Task {
459 id: "RQ-0002".to_string(),
460 parent_id: Some("RQ-0001".to_string()),
461 title: "Task 2".to_string(),
462 created_at: Some("2026-01-01T00:00:00Z".to_string()),
463 updated_at: Some("2026-01-01T00:00:00Z".to_string()),
464 ..Default::default()
465 },
466 ];
467
468 let task_refs: Vec<&Task> = tasks.iter().collect();
469 let cycles = detect_parent_cycles(&task_refs);
470
471 assert_eq!(cycles.len(), 1);
472 assert_eq!(cycles[0].len(), 2);
473 }
474
475 #[test]
476 fn cycle_detection_finds_self_cycle() {
477 let tasks = [Task {
478 id: "RQ-0001".to_string(),
479 parent_id: Some("RQ-0001".to_string()),
480 title: "Task 1".to_string(),
481 created_at: Some("2026-01-01T00:00:00Z".to_string()),
482 updated_at: Some("2026-01-01T00:00:00Z".to_string()),
483 ..Default::default()
484 }];
485
486 let task_refs: Vec<&Task> = tasks.iter().collect();
487 let cycles = detect_parent_cycles(&task_refs);
488
489 assert_eq!(cycles.len(), 1);
490 assert_eq!(cycles[0], vec!["RQ-0001"]);
491 }
492
493 #[test]
494 fn roots_includes_orphans() {
495 let tasks = vec![
496 make_task("RQ-0001", None),
497 make_task("RQ-0002", Some("RQ-9999")), ];
499 let active = make_active_queue(tasks);
500
501 let idx = HierarchyIndex::build(&active, None);
502 let roots = idx.roots();
503
504 assert_eq!(roots.len(), 2);
505 }
506
507 #[test]
508 fn active_done_combined_ordering() {
509 let active = make_active_queue(vec![make_task("RQ-0001", None)]);
510 let done = QueueFile {
511 version: 1,
512 tasks: vec![make_task("RQ-0002", Some("RQ-0001"))],
513 };
514
515 let idx = HierarchyIndex::build(&active, Some(&done));
516
517 assert!(idx.get("RQ-0001").is_some());
518 assert!(idx.get("RQ-0002").is_some());
519
520 let r1 = idx.get("RQ-0001").unwrap();
522 let r2 = idx.get("RQ-0002").unwrap();
523 assert!(r1.order < r2.order);
524 }
525
526 #[test]
527 fn tree_rendering_produces_output() {
528 let tasks = vec![
529 make_task("RQ-0001", None),
530 make_task("RQ-0002", Some("RQ-0001")),
531 ];
532 let active = make_active_queue(tasks);
533 let idx = HierarchyIndex::build(&active, None);
534
535 let output = render_tree(
536 &idx,
537 &["RQ-0001"],
538 10,
539 true,
540 |task, depth, _is_cycle, _orphan| format!("{}{}", " ".repeat(depth), task.id),
541 );
542
543 assert!(output.contains("RQ-0001"));
544 assert!(output.contains(" RQ-0002"));
545 }
546
547 #[test]
548 fn tree_rendering_respects_max_depth() {
549 let tasks = vec![
550 make_task("RQ-0001", None),
551 make_task("RQ-0002", Some("RQ-0001")),
552 make_task("RQ-0003", Some("RQ-0002")),
553 ];
554 let active = make_active_queue(tasks);
555 let idx = HierarchyIndex::build(&active, None);
556
557 let output = render_tree(
558 &idx,
559 &["RQ-0001"],
560 1,
561 true,
562 |task, depth, _is_cycle, _orphan| format!("{}{}", " ".repeat(depth), task.id),
563 );
564
565 assert!(output.contains("RQ-0001"));
566 assert!(output.contains("RQ-0002"));
567 assert!(!output.contains("RQ-0003"));
568 }
569
570 #[test]
571 fn tree_rendering_handles_cycles() {
572 let tasks = vec![
573 Task {
574 id: "RQ-0001".to_string(),
575 parent_id: Some("RQ-0002".to_string()),
576 title: "Task 1".to_string(),
577 created_at: Some("2026-01-01T00:00:00Z".to_string()),
578 updated_at: Some("2026-01-01T00:00:00Z".to_string()),
579 ..Default::default()
580 },
581 Task {
582 id: "RQ-0002".to_string(),
583 parent_id: Some("RQ-0001".to_string()),
584 title: "Task 2".to_string(),
585 created_at: Some("2026-01-01T00:00:00Z".to_string()),
586 updated_at: Some("2026-01-01T00:00:00Z".to_string()),
587 ..Default::default()
588 },
589 ];
590 let active = make_active_queue(tasks);
591 let idx = HierarchyIndex::build(&active, None);
592
593 let output = render_tree(
594 &idx,
595 &["RQ-0001"],
596 10,
597 true,
598 |task, depth, is_cycle, _orphan| {
599 let marker = if is_cycle { " (cycle)" } else { "" };
600 format!("{}{}{}", " ".repeat(depth), task.id, marker)
601 },
602 );
603
604 assert!(output.contains("RQ-0001"));
605 assert!(output.contains("(cycle)"));
606 }
607}