Skip to main content

ralph/queue/
hierarchy.rs

1//! Task hierarchy indexing and navigation based on `parent_id`.
2//!
3//! Responsibilities:
4//! - Index tasks by parent_id for efficient child lookups.
5//! - Provide safe traversal of parent/child relationships with cycle detection.
6//! - Treat missing-parent tasks as "roots" and mark them as orphans during rendering.
7//!
8//! Not handled here:
9//! - Queue persistence (see `crate::queue`).
10//! - Validation logic (see `crate::queue::validation`).
11//!
12//! Invariants/assumptions:
13//! - Task IDs are unique across active + done files.
14//! - Ordering is deterministic: active tasks first (file order), then done tasks (file order).
15//! - Cycles are detected and reported but do not cause infinite recursion.
16
17use crate::contracts::{QueueFile, Task};
18use std::collections::{HashMap, HashSet};
19
20/// Source of a task in the combined active+done set.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub(crate) enum TaskSource {
23    Active,
24    Done,
25}
26
27/// Reference to a task with metadata for ordering and source tracking.
28#[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/// Index for efficient parent/child navigation.
36#[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    /// Build a hierarchy index from active and optional done queues.
44    ///
45    /// Ordering: active tasks first (by file order), then done tasks (by file order).
46    /// Children within each parent are stored in this deterministic order.
47    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        // Index active tasks first
54        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        // Index done tasks second
69        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        // Build children map.
86        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                    // Treat empty/whitespace parent_id as unset
92                    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        // Sort children within each parent by order for deterministic output
105        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    /// Get a task by ID.
116    pub(crate) fn get(&self, id: &str) -> Option<TaskRef<'a>> {
117        self.by_id.get(id.trim()).copied()
118    }
119
120    /// Get children of a specific parent task.
121    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    /// Check if a task ID exists in the index.
129    pub(crate) fn contains(&self, id: &str) -> bool {
130        self.by_id.contains_key(id.trim())
131    }
132
133    /// Get all root tasks (tasks with no parent or with orphaned parent references).
134    /// Roots are returned in deterministic order (by their order field).
135    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
157/// Detect cycles in the parent_id chain.
158/// Returns a list of task IDs involved in cycles.
159pub(crate) fn detect_parent_cycles(all_tasks: &[&Task]) -> Vec<Vec<String>> {
160    let mut child_to_parent: HashMap<&str, &str> = HashMap::new();
161
162    // Build parent pointer map
163    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                // Cycle detected - extract cycle from path
192                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                // Only add if we haven't seen this exact cycle
197                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                // We've hit a previously processed path, no cycle here
209                break;
210            }
211
212            path.push(node);
213            path_set.insert(node);
214            current = child_to_parent.get(node).copied();
215        }
216
217        // Mark all nodes in this path as visited
218        for &node in &path {
219            visited.insert(node);
220        }
221    }
222
223    cycles
224}
225
226/// Normalize a cycle for deduplication (rotates to start with minimum element).
227fn 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
244/// Render a task hierarchy tree as ASCII art.
245///
246/// Arguments:
247/// - `idx`: The hierarchy index
248/// - `roots`: Root task IDs to start rendering from
249/// - `max_depth`: Maximum depth to render
250/// - `include_done`: Whether to include done tasks in output
251/// - `format_line`: Closure to format a task line
252pub(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            // Cycle detected - mark and stop descending
291            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                // Missing reference - shouldn't happen if roots are computed correctly
303                return;
304            }
305        };
306
307        // Skip done tasks unless include_done is true
308        if !ctx.include_done && matches!(task_ref.source, TaskSource::Done) {
309            return;
310        }
311
312        ctx.visited.insert(trimmed_id.to_string());
313
314        // Check if this is an orphan (has parent_id but parent not in index)
315        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        // Recurse into children (sorted by order via HierarchyIndex)
333        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")), // Note: 0003 before 0002
399            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")), // Missing parent
415        ];
416        let active = make_active_queue(tasks);
417
418        let idx = HierarchyIndex::build(&active, None);
419
420        // Orphans are treated as roots for rendering/navigation.
421        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()); // Whitespace only
440
441        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")), // Orphan
498        ];
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        // RQ-0001 should have lower order than RQ-0002
521        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}