Skip to main content

bones_core/graph/
hierarchy.rs

1//! Parent-child containment model and goal progress computation.
2//!
3//! This module provides higher-level query functions that operate on the
4//! SQLite projection database to answer hierarchy questions:
5//!
6//! - Which items are children of a given goal?
7//! - What is a goal's progress (done/total direct children)?
8//! - What is a goal's recursive progress (rolling up through nested goals)?
9//! - Is a reparenting operation valid?
10//! - What is the full subtree of a given item?
11//! - What are the ancestors of a given item?
12//!
13//! # Terminology
14//!
15//! - **Parent**: An item whose `parent_id` is null (root) or set to another item.
16//! - **Goal**: An item with `kind = 'goal'`. Only goals may have children
17//!   (reparenting to a non-goal is rejected).
18//! - **Progress**: The ratio of done (or archived) children to total non-deleted
19//!   children. Nested goals contribute their own progress recursively.
20//!
21//! # Cycle prevention
22//!
23//! `validate_reparent` checks that the proposed new parent is not a descendant
24//! of the item being moved, preventing reference cycles.
25//!
26//! # Error handling
27//!
28//! All functions return [`HierarchyError`], which distinguishes between
29//! domain errors (wrong kind, cycle detected, item not found) and database
30//! errors.
31
32#![allow(
33    clippy::must_use_candidate,
34    clippy::module_name_repetitions,
35    clippy::doc_markdown
36)]
37
38use anyhow::Context as AnyhowContext;
39use rusqlite::Connection;
40use std::collections::{HashSet, VecDeque};
41use std::fmt;
42
43use crate::db::query::{self, QueryItem};
44
45// ---------------------------------------------------------------------------
46// Types
47// ---------------------------------------------------------------------------
48
49/// Progress of a goal: how many children are done vs total.
50///
51/// A child is **done** when its state is `'done'` or `'archived'`.
52/// In-progress children have state `'doing'`. Open children are counted
53/// in `total` but not in `done` or `in_progress`.
54///
55/// For nested goals, `compute_nested_progress` includes the leaf-item counts
56/// across the entire subtree, rolling up through intermediate goal nodes.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct GoalProgress {
59    /// Number of children (or leaf items in subtree) in the done/archived state.
60    pub done: u32,
61    /// Number of children (or leaf items in subtree) in the doing state.
62    pub in_progress: u32,
63    /// Total non-deleted children (or leaf items in subtree).
64    pub total: u32,
65}
66
67impl GoalProgress {
68    /// Create a new zeroed `GoalProgress`.
69    pub const fn zero() -> Self {
70        Self {
71            done: 0,
72            in_progress: 0,
73            total: 0,
74        }
75    }
76
77    /// Percentage of work completed, in the range `0.0..=100.0`.
78    ///
79    /// Returns `100.0` if total is 0 (vacuously complete).
80    pub fn percent_complete(&self) -> f32 {
81        if self.total == 0 {
82            return 100.0;
83        }
84        #[allow(clippy::cast_precision_loss)]
85        {
86            (self.done as f32 / self.total as f32) * 100.0
87        }
88    }
89
90    /// Returns `true` if all children are done (or there are no children).
91    pub const fn is_complete(&self) -> bool {
92        self.total == 0 || self.done == self.total
93    }
94
95    /// Number of children that are not yet done.
96    pub const fn remaining(&self) -> u32 {
97        self.total.saturating_sub(self.done)
98    }
99}
100
101impl fmt::Display for GoalProgress {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(
104            f,
105            "{}/{} ({:.0}%)",
106            self.done,
107            self.total,
108            self.percent_complete()
109        )
110    }
111}
112
113/// Errors that can occur in hierarchy operations.
114#[derive(Debug)]
115pub enum HierarchyError {
116    /// The target parent item is not of kind `goal`. Reparenting to non-goals
117    /// is rejected.
118    NotAGoal {
119        item_id: String,
120        actual_kind: String,
121    },
122    /// The requested item does not exist (or is soft-deleted).
123    ItemNotFound(String),
124    /// The reparenting would create a cycle (the proposed parent is a
125    /// descendant of the item being moved).
126    CycleDetected {
127        item_id: String,
128        proposed_parent: String,
129    },
130    /// An underlying database error.
131    Db(anyhow::Error),
132}
133
134impl fmt::Display for HierarchyError {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        match self {
137            Self::NotAGoal {
138                item_id,
139                actual_kind,
140            } => write!(
141                f,
142                "item '{item_id}' is not a goal (kind={actual_kind}): only goals may be parents"
143            ),
144            Self::ItemNotFound(id) => write!(f, "item not found: '{id}'"),
145            Self::CycleDetected {
146                item_id,
147                proposed_parent,
148            } => write!(
149                f,
150                "reparenting '{item_id}' under '{proposed_parent}' would create a cycle"
151            ),
152            Self::Db(e) => write!(f, "database error: {e}"),
153        }
154    }
155}
156
157impl std::error::Error for HierarchyError {
158    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
159        if let Self::Db(e) = self {
160            Some(e.as_ref())
161        } else {
162            None
163        }
164    }
165}
166
167impl From<anyhow::Error> for HierarchyError {
168    fn from(e: anyhow::Error) -> Self {
169        Self::Db(e)
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Core functions
175// ---------------------------------------------------------------------------
176
177/// Compute the **direct** progress of a goal: counts its immediate children.
178///
179/// A child state of `done` or `archived` increments `done`.
180/// A child state of `doing` increments `in_progress`.
181/// All non-deleted children are counted in `total`.
182///
183/// # Errors
184///
185/// Returns [`HierarchyError::ItemNotFound`] if `goal_id` does not exist,
186/// [`HierarchyError::NotAGoal`] if the item is not of kind `goal`, or
187/// [`HierarchyError::Db`] for database failures.
188pub fn compute_direct_progress(
189    conn: &Connection,
190    goal_id: &str,
191) -> Result<GoalProgress, HierarchyError> {
192    // Verify the item exists and is a goal.
193    let goal = require_goal(conn, goal_id)?;
194    let _ = goal; // just confirming it exists
195
196    let children = query::get_children(conn, goal_id)
197        .with_context(|| format!("get_children for '{goal_id}'"))?;
198
199    Ok(tally_progress(&children))
200}
201
202/// Compute the **nested** progress of a goal, rolling up through the entire
203/// subtree.
204///
205/// For a goal hierarchy like:
206/// ```text
207/// Goal A
208///   ├── Task X (done)
209///   ├── Goal B
210///   │   ├── Task Y (done)
211///   │   └── Task Z (open)
212///   └── Task W (doing)
213/// ```
214///
215/// The nested progress for Goal A counts leaf items (tasks) across the whole
216/// tree: `done=2, in_progress=1, total=4`.
217///
218/// Intermediate goal nodes themselves are **not** counted as progress items
219/// — only their leaf children are. This means a goal with no children
220/// contributes zero to the total.
221///
222/// # Errors
223///
224/// Returns [`HierarchyError::ItemNotFound`] if `goal_id` does not exist,
225/// [`HierarchyError::NotAGoal`] if the item is not of kind `goal`, or
226/// [`HierarchyError::Db`] for database failures.
227pub fn compute_nested_progress(
228    conn: &Connection,
229    goal_id: &str,
230) -> Result<GoalProgress, HierarchyError> {
231    // Verify the item exists and is a goal.
232    require_goal(conn, goal_id)?;
233
234    let mut total_progress = GoalProgress::zero();
235    accumulate_progress(conn, goal_id, &mut total_progress, &mut HashSet::new())?;
236
237    Ok(total_progress)
238}
239
240/// Get all item IDs in the subtree rooted at `root_id`, including `root_id`
241/// itself.
242///
243/// Uses BFS traversal. Cycles are detected and skipped (though they should
244/// not occur in a validated tree).
245///
246/// Returns items in BFS order (root first, then breadth-by-breadth).
247///
248/// # Errors
249///
250/// Returns [`HierarchyError::Db`] for database failures.
251pub fn get_subtree_ids(conn: &Connection, root_id: &str) -> Result<Vec<String>, HierarchyError> {
252    let mut visited: HashSet<String> = HashSet::new();
253    let mut queue: VecDeque<String> = VecDeque::new();
254    let mut result: Vec<String> = Vec::new();
255
256    queue.push_back(root_id.to_string());
257
258    while let Some(current_id) = queue.pop_front() {
259        if !visited.insert(current_id.clone()) {
260            continue; // already visited — skip (cycle guard)
261        }
262        result.push(current_id.clone());
263
264        let children = query::get_children(conn, &current_id)
265            .with_context(|| format!("get_children for '{current_id}'"))?;
266
267        for child in children {
268            if !visited.contains(&child.item_id) {
269                queue.push_back(child.item_id);
270            }
271        }
272    }
273
274    Ok(result)
275}
276
277/// Get the ancestor chain of an item, from immediate parent up to root.
278///
279/// Returns an empty vec if the item has no parent. The first element is
280/// the immediate parent, and the last is the root ancestor.
281///
282/// Cycles are detected and cause an early return (the chain is truncated
283/// at the point of the repeat).
284///
285/// # Errors
286///
287/// Returns [`HierarchyError::ItemNotFound`] if `item_id` does not exist,
288/// or [`HierarchyError::Db`] for database failures.
289pub fn get_ancestors(conn: &Connection, item_id: &str) -> Result<Vec<QueryItem>, HierarchyError> {
290    // Verify the item exists.
291    let start = query::get_item(conn, item_id, false)
292        .with_context(|| format!("get_item '{item_id}'"))?
293        .ok_or_else(|| HierarchyError::ItemNotFound(item_id.to_string()))?;
294
295    let mut ancestors: Vec<QueryItem> = Vec::new();
296    let mut visited: HashSet<String> = HashSet::new();
297    visited.insert(start.item_id.clone());
298
299    let mut current_parent_id = start.parent_id;
300
301    while let Some(ref parent_id) = current_parent_id {
302        if parent_id.is_empty() {
303            break;
304        }
305        if !visited.insert(parent_id.clone()) {
306            break; // cycle guard
307        }
308        let parent = query::get_item(conn, parent_id, false)
309            .with_context(|| format!("get_item '{parent_id}'"))?
310            .ok_or_else(|| HierarchyError::ItemNotFound(parent_id.clone()))?;
311
312        current_parent_id.clone_from(&parent.parent_id);
313        ancestors.push(parent);
314    }
315
316    Ok(ancestors)
317}
318
319/// Validate that reparenting `item_id` under `new_parent_id` is allowed.
320///
321/// This checks:
322/// 1. Both items exist.
323/// 2. The new parent is of kind `goal`.
324/// 3. The move would not create a cycle (i.e., `new_parent_id` is not a
325///    descendant of `item_id`).
326///
327/// On success returns `Ok(())`. On failure returns the appropriate
328/// [`HierarchyError`] variant.
329///
330/// # Errors
331///
332/// Returns [`HierarchyError::ItemNotFound`] if either item does not exist,
333/// [`HierarchyError::NotAGoal`] if the new parent is not a goal, or
334/// [`HierarchyError::CycleDetected`] if the move would create a cycle.
335pub fn validate_reparent(
336    conn: &Connection,
337    item_id: &str,
338    new_parent_id: &str,
339) -> Result<(), HierarchyError> {
340    // 1. Verify both items exist.
341    if query::get_item(conn, item_id, false)
342        .with_context(|| format!("get_item '{item_id}'"))?
343        .is_none()
344    {
345        return Err(HierarchyError::ItemNotFound(item_id.to_string()));
346    }
347
348    // 2. Verify the proposed parent is a goal.
349    require_goal(conn, new_parent_id)?;
350
351    // 3. Check for cycles: new_parent_id must not be in the subtree of item_id.
352    let subtree = get_subtree_ids(conn, item_id)?;
353    if subtree.contains(&new_parent_id.to_string()) {
354        return Err(HierarchyError::CycleDetected {
355            item_id: item_id.to_string(),
356            proposed_parent: new_parent_id.to_string(),
357        });
358    }
359
360    Ok(())
361}
362
363// ---------------------------------------------------------------------------
364// Helpers
365// ---------------------------------------------------------------------------
366
367/// Look up `item_id` and return it if it is of kind `goal`.
368///
369/// Returns `HierarchyError::ItemNotFound` if not found,
370/// `HierarchyError::NotAGoal` if found but not a goal.
371fn require_goal(conn: &Connection, item_id: &str) -> Result<QueryItem, HierarchyError> {
372    let item = query::get_item(conn, item_id, false)
373        .with_context(|| format!("get_item '{item_id}'"))?
374        .ok_or_else(|| HierarchyError::ItemNotFound(item_id.to_string()))?;
375
376    if item.kind != "goal" {
377        return Err(HierarchyError::NotAGoal {
378            item_id: item_id.to_string(),
379            actual_kind: item.kind,
380        });
381    }
382
383    Ok(item)
384}
385
386/// Tally a list of items into a `GoalProgress`.
387fn tally_progress(items: &[QueryItem]) -> GoalProgress {
388    let mut progress = GoalProgress::zero();
389    for item in items {
390        progress.total += 1;
391        match item.state.as_str() {
392            "done" | "archived" => progress.done += 1,
393            "doing" => progress.in_progress += 1,
394            _ => {} // "open" — counted in total but not done/in_progress
395        }
396    }
397    progress
398}
399
400/// Recursively accumulate leaf-item progress from a subtree.
401///
402/// Intermediate goal nodes are not counted; only leaf items (non-goal items
403/// or goal items with no children) contribute to `accumulator`.
404///
405/// `visited` prevents infinite loops in malformed trees.
406fn accumulate_progress(
407    conn: &Connection,
408    current_id: &str,
409    accumulator: &mut GoalProgress,
410    visited: &mut HashSet<String>,
411) -> Result<(), HierarchyError> {
412    if !visited.insert(current_id.to_string()) {
413        return Ok(()); // cycle guard
414    }
415
416    let children = query::get_children(conn, current_id)
417        .with_context(|| format!("get_children for '{current_id}'"))?;
418
419    if children.is_empty() {
420        // Leaf goal with no children — contributes nothing to progress.
421        return Ok(());
422    }
423
424    for child in &children {
425        if child.kind == "goal" {
426            // Recurse into nested goal.
427            accumulate_progress(conn, &child.item_id, accumulator, visited)?;
428        } else {
429            // Leaf item — tally directly.
430            accumulator.total += 1;
431            match child.state.as_str() {
432                "done" | "archived" => accumulator.done += 1,
433                "doing" => accumulator.in_progress += 1,
434                _ => {}
435            }
436        }
437    }
438
439    Ok(())
440}
441
442// ---------------------------------------------------------------------------
443// Tests
444// ---------------------------------------------------------------------------
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::db::migrations;
450    use rusqlite::{Connection, params};
451
452    // -----------------------------------------------------------------------
453    // Test helpers
454    // -----------------------------------------------------------------------
455
456    fn test_db() -> Connection {
457        let mut conn = Connection::open_in_memory().expect("open in-memory db");
458        migrations::migrate(&mut conn).expect("migrate");
459        conn
460    }
461
462    /// Insert an item with minimal required fields.
463    fn insert_item(conn: &Connection, id: &str, kind: &str, state: &str, parent_id: Option<&str>) {
464        conn.execute(
465            "INSERT INTO items \
466             (item_id, title, kind, state, urgency, is_deleted, search_labels, \
467              parent_id, created_at_us, updated_at_us) \
468             VALUES (?1, ?2, ?3, ?4, 'default', 0, '', ?5, 1000, 2000)",
469            params![id, format!("Title for {id}"), kind, state, parent_id],
470        )
471        .expect("insert item");
472    }
473
474    // -----------------------------------------------------------------------
475    // GoalProgress: unit tests for the type itself
476    // -----------------------------------------------------------------------
477
478    #[test]
479    fn goal_progress_zero() {
480        let p = GoalProgress::zero();
481        assert_eq!(p.done, 0);
482        assert_eq!(p.in_progress, 0);
483        assert_eq!(p.total, 0);
484    }
485
486    #[test]
487    fn goal_progress_percent_zero_total_is_100() {
488        let p = GoalProgress::zero();
489        assert_eq!(p.percent_complete(), 100.0);
490        assert!(p.is_complete());
491    }
492
493    #[test]
494    fn goal_progress_percent_half() {
495        let p = GoalProgress {
496            done: 1,
497            in_progress: 0,
498            total: 2,
499        };
500        assert!((p.percent_complete() - 50.0).abs() < f32::EPSILON);
501        assert!(!p.is_complete());
502    }
503
504    #[test]
505    fn goal_progress_all_done() {
506        let p = GoalProgress {
507            done: 5,
508            in_progress: 0,
509            total: 5,
510        };
511        assert_eq!(p.percent_complete(), 100.0);
512        assert!(p.is_complete());
513    }
514
515    #[test]
516    fn goal_progress_remaining() {
517        let p = GoalProgress {
518            done: 3,
519            in_progress: 1,
520            total: 5,
521        };
522        assert_eq!(p.remaining(), 2);
523    }
524
525    #[test]
526    fn goal_progress_display() {
527        let p = GoalProgress {
528            done: 2,
529            in_progress: 1,
530            total: 4,
531        };
532        let s = p.to_string();
533        assert!(s.contains("2/4"), "display: {s}");
534        assert!(s.contains("50%"), "display: {s}");
535    }
536
537    // -----------------------------------------------------------------------
538    // HierarchyError: display
539    // -----------------------------------------------------------------------
540
541    #[test]
542    fn hierarchy_error_display_not_a_goal() {
543        let e = HierarchyError::NotAGoal {
544            item_id: "bn-001".to_string(),
545            actual_kind: "task".to_string(),
546        };
547        assert!(e.to_string().contains("bn-001"));
548        assert!(e.to_string().contains("task"));
549    }
550
551    #[test]
552    fn hierarchy_error_display_not_found() {
553        let e = HierarchyError::ItemNotFound("bn-missing".to_string());
554        assert!(e.to_string().contains("bn-missing"));
555    }
556
557    #[test]
558    fn hierarchy_error_display_cycle() {
559        let e = HierarchyError::CycleDetected {
560            item_id: "bn-001".to_string(),
561            proposed_parent: "bn-002".to_string(),
562        };
563        let s = e.to_string();
564        assert!(s.contains("bn-001"));
565        assert!(s.contains("bn-002"));
566        assert!(s.contains("cycle"));
567    }
568
569    // -----------------------------------------------------------------------
570    // compute_direct_progress
571    // -----------------------------------------------------------------------
572
573    #[test]
574    fn direct_progress_empty_goal() {
575        let conn = test_db();
576        insert_item(&conn, "bn-goal", "goal", "open", None);
577
578        let p = compute_direct_progress(&conn, "bn-goal").unwrap();
579        assert_eq!(p.total, 0);
580        assert_eq!(p.done, 0);
581        assert!(p.is_complete()); // vacuously complete
582    }
583
584    #[test]
585    fn direct_progress_all_open() {
586        let conn = test_db();
587        insert_item(&conn, "bn-goal", "goal", "open", None);
588        insert_item(&conn, "bn-c1", "task", "open", Some("bn-goal"));
589        insert_item(&conn, "bn-c2", "task", "open", Some("bn-goal"));
590
591        let p = compute_direct_progress(&conn, "bn-goal").unwrap();
592        assert_eq!(p.total, 2);
593        assert_eq!(p.done, 0);
594        assert_eq!(p.in_progress, 0);
595        assert!(!p.is_complete());
596    }
597
598    #[test]
599    fn direct_progress_mixed_states() {
600        let conn = test_db();
601        insert_item(&conn, "bn-goal", "goal", "open", None);
602        insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
603        insert_item(&conn, "bn-c2", "task", "doing", Some("bn-goal"));
604        insert_item(&conn, "bn-c3", "task", "open", Some("bn-goal"));
605        insert_item(&conn, "bn-c4", "task", "archived", Some("bn-goal"));
606
607        let p = compute_direct_progress(&conn, "bn-goal").unwrap();
608        assert_eq!(p.total, 4);
609        assert_eq!(p.done, 2); // done + archived
610        assert_eq!(p.in_progress, 1);
611        assert!(!p.is_complete());
612    }
613
614    #[test]
615    fn direct_progress_all_done() {
616        let conn = test_db();
617        insert_item(&conn, "bn-goal", "goal", "open", None);
618        insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
619        insert_item(&conn, "bn-c2", "task", "done", Some("bn-goal"));
620
621        let p = compute_direct_progress(&conn, "bn-goal").unwrap();
622        assert_eq!(p.total, 2);
623        assert_eq!(p.done, 2);
624        assert!(p.is_complete());
625    }
626
627    #[test]
628    fn direct_progress_not_found_returns_error() {
629        let conn = test_db();
630        let err = compute_direct_progress(&conn, "bn-missing").unwrap_err();
631        assert!(matches!(err, HierarchyError::ItemNotFound(_)));
632    }
633
634    #[test]
635    fn direct_progress_not_a_goal_returns_error() {
636        let conn = test_db();
637        insert_item(&conn, "bn-task", "task", "open", None);
638
639        let err = compute_direct_progress(&conn, "bn-task").unwrap_err();
640        assert!(matches!(
641            err,
642            HierarchyError::NotAGoal { actual_kind, .. } if actual_kind == "task"
643        ));
644    }
645
646    #[test]
647    fn direct_progress_excludes_deleted_children() {
648        let conn = test_db();
649        insert_item(&conn, "bn-goal", "goal", "open", None);
650        insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
651        // Insert a deleted child manually
652        conn.execute(
653            "INSERT INTO items \
654             (item_id, title, kind, state, urgency, is_deleted, search_labels, \
655              parent_id, created_at_us, updated_at_us) \
656             VALUES ('bn-del', 'Deleted', 'task', 'open', 'default', 1, '', 'bn-goal', 1000, 2000)",
657            [],
658        )
659        .unwrap();
660
661        let p = compute_direct_progress(&conn, "bn-goal").unwrap();
662        assert_eq!(p.total, 1); // deleted child excluded by get_children
663        assert_eq!(p.done, 1);
664    }
665
666    // -----------------------------------------------------------------------
667    // compute_nested_progress
668    // -----------------------------------------------------------------------
669
670    #[test]
671    fn nested_progress_flat_goal() {
672        let conn = test_db();
673        insert_item(&conn, "bn-goal", "goal", "open", None);
674        insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
675        insert_item(&conn, "bn-c2", "task", "open", Some("bn-goal"));
676
677        let p = compute_nested_progress(&conn, "bn-goal").unwrap();
678        assert_eq!(p.total, 2);
679        assert_eq!(p.done, 1);
680    }
681
682    #[test]
683    fn nested_progress_rolls_up_through_subgoals() {
684        // Goal A
685        //   ├── Task X (done)
686        //   ├── Goal B
687        //   │   ├── Task Y (done)
688        //   │   └── Task Z (open)
689        //   └── Task W (doing)
690        let conn = test_db();
691        insert_item(&conn, "bn-ga", "goal", "open", None);
692        insert_item(&conn, "bn-tx", "task", "done", Some("bn-ga"));
693        insert_item(&conn, "bn-gb", "goal", "open", Some("bn-ga"));
694        insert_item(&conn, "bn-ty", "task", "done", Some("bn-gb"));
695        insert_item(&conn, "bn-tz", "task", "open", Some("bn-gb"));
696        insert_item(&conn, "bn-tw", "task", "doing", Some("bn-ga"));
697
698        let p = compute_nested_progress(&conn, "bn-ga").unwrap();
699        assert_eq!(p.total, 4, "leaf items: tx, ty, tz, tw");
700        assert_eq!(p.done, 2, "tx and ty are done");
701        assert_eq!(p.in_progress, 1, "tw is doing");
702    }
703
704    #[test]
705    fn nested_progress_deeply_nested() {
706        // G1 -> G2 -> G3 -> Task (done)
707        let conn = test_db();
708        insert_item(&conn, "bn-g1", "goal", "open", None);
709        insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"));
710        insert_item(&conn, "bn-g3", "goal", "open", Some("bn-g2"));
711        insert_item(&conn, "bn-t1", "task", "done", Some("bn-g3"));
712
713        let p = compute_nested_progress(&conn, "bn-g1").unwrap();
714        assert_eq!(p.total, 1);
715        assert_eq!(p.done, 1);
716        assert!(p.is_complete());
717    }
718
719    #[test]
720    fn nested_progress_empty_subgoal_contributes_nothing() {
721        // Goal has one task child and one empty subgoal child
722        let conn = test_db();
723        insert_item(&conn, "bn-ga", "goal", "open", None);
724        insert_item(&conn, "bn-t1", "task", "done", Some("bn-ga"));
725        insert_item(&conn, "bn-gb", "goal", "open", Some("bn-ga")); // empty
726
727        let p = compute_nested_progress(&conn, "bn-ga").unwrap();
728        assert_eq!(p.total, 1, "only the task counts");
729        assert_eq!(p.done, 1);
730    }
731
732    #[test]
733    fn nested_progress_not_found() {
734        let conn = test_db();
735        let err = compute_nested_progress(&conn, "bn-missing").unwrap_err();
736        assert!(matches!(err, HierarchyError::ItemNotFound(_)));
737    }
738
739    #[test]
740    fn nested_progress_not_a_goal() {
741        let conn = test_db();
742        insert_item(&conn, "bn-task", "task", "open", None);
743        let err = compute_nested_progress(&conn, "bn-task").unwrap_err();
744        assert!(matches!(err, HierarchyError::NotAGoal { .. }));
745    }
746
747    // -----------------------------------------------------------------------
748    // get_subtree_ids
749    // -----------------------------------------------------------------------
750
751    #[test]
752    fn subtree_single_node() {
753        let conn = test_db();
754        insert_item(&conn, "bn-root", "goal", "open", None);
755
756        let ids = get_subtree_ids(&conn, "bn-root").unwrap();
757        assert_eq!(ids, vec!["bn-root"]);
758    }
759
760    #[test]
761    fn subtree_with_children() {
762        let conn = test_db();
763        insert_item(&conn, "bn-root", "goal", "open", None);
764        insert_item(&conn, "bn-c1", "task", "open", Some("bn-root"));
765        insert_item(&conn, "bn-c2", "task", "done", Some("bn-root"));
766
767        let ids = get_subtree_ids(&conn, "bn-root").unwrap();
768        assert_eq!(ids.len(), 3);
769        assert!(ids.contains(&"bn-root".to_string()));
770        assert!(ids.contains(&"bn-c1".to_string()));
771        assert!(ids.contains(&"bn-c2".to_string()));
772    }
773
774    #[test]
775    fn subtree_bfs_order_root_first() {
776        // Root -> C1, C2 -> G3 (under C1)
777        let conn = test_db();
778        insert_item(&conn, "bn-root", "goal", "open", None);
779        insert_item(&conn, "bn-c1", "goal", "open", Some("bn-root"));
780        insert_item(&conn, "bn-c2", "task", "open", Some("bn-root"));
781        insert_item(&conn, "bn-gc1", "task", "done", Some("bn-c1"));
782
783        let ids = get_subtree_ids(&conn, "bn-root").unwrap();
784        assert_eq!(ids[0], "bn-root"); // root is always first
785        assert_eq!(ids.len(), 4);
786    }
787
788    // -----------------------------------------------------------------------
789    // get_ancestors
790    // -----------------------------------------------------------------------
791
792    #[test]
793    fn ancestors_no_parent() {
794        let conn = test_db();
795        insert_item(&conn, "bn-root", "goal", "open", None);
796
797        let ancestors = get_ancestors(&conn, "bn-root").unwrap();
798        assert!(ancestors.is_empty());
799    }
800
801    #[test]
802    fn ancestors_one_level() {
803        let conn = test_db();
804        insert_item(&conn, "bn-parent", "goal", "open", None);
805        insert_item(&conn, "bn-child", "task", "open", Some("bn-parent"));
806
807        let ancestors = get_ancestors(&conn, "bn-child").unwrap();
808        assert_eq!(ancestors.len(), 1);
809        assert_eq!(ancestors[0].item_id, "bn-parent");
810    }
811
812    #[test]
813    fn ancestors_three_levels() {
814        let conn = test_db();
815        insert_item(&conn, "bn-g1", "goal", "open", None);
816        insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"));
817        insert_item(&conn, "bn-t1", "task", "open", Some("bn-g2"));
818
819        let ancestors = get_ancestors(&conn, "bn-t1").unwrap();
820        assert_eq!(ancestors.len(), 2);
821        assert_eq!(ancestors[0].item_id, "bn-g2");
822        assert_eq!(ancestors[1].item_id, "bn-g1");
823    }
824
825    #[test]
826    fn ancestors_not_found() {
827        let conn = test_db();
828        let err = get_ancestors(&conn, "bn-missing").unwrap_err();
829        assert!(matches!(err, HierarchyError::ItemNotFound(_)));
830    }
831
832    // -----------------------------------------------------------------------
833    // validate_reparent
834    // -----------------------------------------------------------------------
835
836    #[test]
837    fn validate_reparent_ok() {
838        let conn = test_db();
839        insert_item(&conn, "bn-goal", "goal", "open", None);
840        insert_item(&conn, "bn-task", "task", "open", None);
841
842        assert!(validate_reparent(&conn, "bn-task", "bn-goal").is_ok());
843    }
844
845    #[test]
846    fn validate_reparent_to_non_goal_rejected() {
847        let conn = test_db();
848        insert_item(&conn, "bn-task1", "task", "open", None);
849        insert_item(&conn, "bn-task2", "task", "open", None);
850
851        let err = validate_reparent(&conn, "bn-task1", "bn-task2").unwrap_err();
852        assert!(matches!(err, HierarchyError::NotAGoal { .. }));
853    }
854
855    #[test]
856    fn validate_reparent_item_not_found() {
857        let conn = test_db();
858        insert_item(&conn, "bn-goal", "goal", "open", None);
859
860        let err = validate_reparent(&conn, "bn-missing", "bn-goal").unwrap_err();
861        assert!(matches!(err, HierarchyError::ItemNotFound(_)));
862    }
863
864    #[test]
865    fn validate_reparent_parent_not_found() {
866        let conn = test_db();
867        insert_item(&conn, "bn-task", "task", "open", None);
868
869        let err = validate_reparent(&conn, "bn-task", "bn-missing").unwrap_err();
870        assert!(matches!(err, HierarchyError::ItemNotFound(_)));
871    }
872
873    #[test]
874    fn validate_reparent_detects_cycle_direct() {
875        // Moving bn-goal under one of its own children
876        let conn = test_db();
877        insert_item(&conn, "bn-parent", "goal", "open", None);
878        insert_item(&conn, "bn-child", "goal", "open", Some("bn-parent"));
879
880        // Trying to move bn-parent under bn-child would create a cycle
881        let err = validate_reparent(&conn, "bn-parent", "bn-child").unwrap_err();
882        assert!(matches!(err, HierarchyError::CycleDetected { .. }));
883    }
884
885    #[test]
886    fn validate_reparent_detects_cycle_indirect() {
887        // G1 -> G2 -> G3; try to move G1 under G3
888        let conn = test_db();
889        insert_item(&conn, "bn-g1", "goal", "open", None);
890        insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"));
891        insert_item(&conn, "bn-g3", "goal", "open", Some("bn-g2"));
892
893        let err = validate_reparent(&conn, "bn-g1", "bn-g3").unwrap_err();
894        assert!(matches!(err, HierarchyError::CycleDetected { .. }));
895    }
896
897    #[test]
898    fn validate_reparent_self_cycle() {
899        // Reparenting an item under itself
900        let conn = test_db();
901        insert_item(&conn, "bn-goal", "goal", "open", None);
902
903        let err = validate_reparent(&conn, "bn-goal", "bn-goal").unwrap_err();
904        assert!(matches!(err, HierarchyError::CycleDetected { .. }));
905    }
906
907    #[test]
908    fn validate_reparent_move_to_different_goal() {
909        // Moving a task from one goal to another — should succeed
910        let conn = test_db();
911        insert_item(&conn, "bn-ga", "goal", "open", None);
912        insert_item(&conn, "bn-gb", "goal", "open", None);
913        insert_item(&conn, "bn-task", "task", "open", Some("bn-ga"));
914
915        assert!(validate_reparent(&conn, "bn-task", "bn-gb").is_ok());
916    }
917
918    #[test]
919    fn validate_reparent_bug_under_goal_ok() {
920        // A bug item can be parented under a goal
921        let conn = test_db();
922        insert_item(&conn, "bn-goal", "goal", "open", None);
923        insert_item(&conn, "bn-bug", "bug", "open", None);
924
925        assert!(validate_reparent(&conn, "bn-bug", "bn-goal").is_ok());
926    }
927}