#![allow(
clippy::must_use_candidate,
clippy::module_name_repetitions,
clippy::doc_markdown
)]
use anyhow::Context as AnyhowContext;
use rusqlite::Connection;
use std::collections::{HashSet, VecDeque};
use std::fmt;
use crate::db::query::{self, QueryItem};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GoalProgress {
pub done: u32,
pub in_progress: u32,
pub total: u32,
}
impl GoalProgress {
pub const fn zero() -> Self {
Self {
done: 0,
in_progress: 0,
total: 0,
}
}
pub fn percent_complete(&self) -> f32 {
if self.total == 0 {
return 100.0;
}
#[allow(clippy::cast_precision_loss)]
{
(self.done as f32 / self.total as f32) * 100.0
}
}
pub const fn is_complete(&self) -> bool {
self.total == 0 || self.done == self.total
}
pub const fn remaining(&self) -> u32 {
self.total.saturating_sub(self.done)
}
}
impl fmt::Display for GoalProgress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}/{} ({:.0}%)",
self.done,
self.total,
self.percent_complete()
)
}
}
#[derive(Debug)]
pub enum HierarchyError {
NotAGoal {
item_id: String,
actual_kind: String,
},
ItemNotFound(String),
CycleDetected {
item_id: String,
proposed_parent: String,
},
Db(anyhow::Error),
}
impl fmt::Display for HierarchyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotAGoal {
item_id,
actual_kind,
} => write!(
f,
"item '{item_id}' is not a goal (kind={actual_kind}): only goals may be parents"
),
Self::ItemNotFound(id) => write!(f, "item not found: '{id}'"),
Self::CycleDetected {
item_id,
proposed_parent,
} => write!(
f,
"reparenting '{item_id}' under '{proposed_parent}' would create a cycle"
),
Self::Db(e) => write!(f, "database error: {e}"),
}
}
}
impl std::error::Error for HierarchyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
if let Self::Db(e) = self {
Some(e.as_ref())
} else {
None
}
}
}
impl From<anyhow::Error> for HierarchyError {
fn from(e: anyhow::Error) -> Self {
Self::Db(e)
}
}
pub fn compute_direct_progress(
conn: &Connection,
goal_id: &str,
) -> Result<GoalProgress, HierarchyError> {
let goal = require_goal(conn, goal_id)?;
let _ = goal;
let children = query::get_children(conn, goal_id)
.with_context(|| format!("get_children for '{goal_id}'"))?;
Ok(tally_progress(&children))
}
pub fn compute_nested_progress(
conn: &Connection,
goal_id: &str,
) -> Result<GoalProgress, HierarchyError> {
require_goal(conn, goal_id)?;
let mut total_progress = GoalProgress::zero();
accumulate_progress(conn, goal_id, &mut total_progress, &mut HashSet::new())?;
Ok(total_progress)
}
pub fn get_subtree_ids(conn: &Connection, root_id: &str) -> Result<Vec<String>, HierarchyError> {
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
let mut result: Vec<String> = Vec::new();
queue.push_back(root_id.to_string());
while let Some(current_id) = queue.pop_front() {
if !visited.insert(current_id.clone()) {
continue; }
result.push(current_id.clone());
let children = query::get_children(conn, ¤t_id)
.with_context(|| format!("get_children for '{current_id}'"))?;
for child in children {
if !visited.contains(&child.item_id) {
queue.push_back(child.item_id);
}
}
}
Ok(result)
}
pub fn get_ancestors(conn: &Connection, item_id: &str) -> Result<Vec<QueryItem>, HierarchyError> {
let start = query::get_item(conn, item_id, false)
.with_context(|| format!("get_item '{item_id}'"))?
.ok_or_else(|| HierarchyError::ItemNotFound(item_id.to_string()))?;
let mut ancestors: Vec<QueryItem> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
visited.insert(start.item_id.clone());
let mut current_parent_id = start.parent_id;
while let Some(ref parent_id) = current_parent_id {
if parent_id.is_empty() {
break;
}
if !visited.insert(parent_id.clone()) {
break; }
let parent = query::get_item(conn, parent_id, false)
.with_context(|| format!("get_item '{parent_id}'"))?
.ok_or_else(|| HierarchyError::ItemNotFound(parent_id.clone()))?;
current_parent_id.clone_from(&parent.parent_id);
ancestors.push(parent);
}
Ok(ancestors)
}
pub fn validate_reparent(
conn: &Connection,
item_id: &str,
new_parent_id: &str,
) -> Result<(), HierarchyError> {
if query::get_item(conn, item_id, false)
.with_context(|| format!("get_item '{item_id}'"))?
.is_none()
{
return Err(HierarchyError::ItemNotFound(item_id.to_string()));
}
require_goal(conn, new_parent_id)?;
let subtree = get_subtree_ids(conn, item_id)?;
if subtree.contains(&new_parent_id.to_string()) {
return Err(HierarchyError::CycleDetected {
item_id: item_id.to_string(),
proposed_parent: new_parent_id.to_string(),
});
}
Ok(())
}
fn require_goal(conn: &Connection, item_id: &str) -> Result<QueryItem, HierarchyError> {
let item = query::get_item(conn, item_id, false)
.with_context(|| format!("get_item '{item_id}'"))?
.ok_or_else(|| HierarchyError::ItemNotFound(item_id.to_string()))?;
if item.kind != "goal" {
return Err(HierarchyError::NotAGoal {
item_id: item_id.to_string(),
actual_kind: item.kind,
});
}
Ok(item)
}
fn tally_progress(items: &[QueryItem]) -> GoalProgress {
let mut progress = GoalProgress::zero();
for item in items {
progress.total += 1;
match item.state.as_str() {
"done" | "archived" => progress.done += 1,
"doing" => progress.in_progress += 1,
_ => {} }
}
progress
}
fn accumulate_progress(
conn: &Connection,
current_id: &str,
accumulator: &mut GoalProgress,
visited: &mut HashSet<String>,
) -> Result<(), HierarchyError> {
if !visited.insert(current_id.to_string()) {
return Ok(()); }
let children = query::get_children(conn, current_id)
.with_context(|| format!("get_children for '{current_id}'"))?;
if children.is_empty() {
return Ok(());
}
for child in &children {
if child.kind == "goal" {
accumulate_progress(conn, &child.item_id, accumulator, visited)?;
} else {
accumulator.total += 1;
match child.state.as_str() {
"done" | "archived" => accumulator.done += 1,
"doing" => accumulator.in_progress += 1,
_ => {}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::migrations;
use rusqlite::{Connection, params};
fn test_db() -> Connection {
let mut conn = Connection::open_in_memory().expect("open in-memory db");
migrations::migrate(&mut conn).expect("migrate");
conn
}
fn insert_item(conn: &Connection, id: &str, kind: &str, state: &str, parent_id: Option<&str>) {
conn.execute(
"INSERT INTO items \
(item_id, title, kind, state, urgency, is_deleted, search_labels, \
parent_id, created_at_us, updated_at_us) \
VALUES (?1, ?2, ?3, ?4, 'default', 0, '', ?5, 1000, 2000)",
params![id, format!("Title for {id}"), kind, state, parent_id],
)
.expect("insert item");
}
#[test]
fn goal_progress_zero() {
let p = GoalProgress::zero();
assert_eq!(p.done, 0);
assert_eq!(p.in_progress, 0);
assert_eq!(p.total, 0);
}
#[test]
fn goal_progress_percent_zero_total_is_100() {
let p = GoalProgress::zero();
assert_eq!(p.percent_complete(), 100.0);
assert!(p.is_complete());
}
#[test]
fn goal_progress_percent_half() {
let p = GoalProgress {
done: 1,
in_progress: 0,
total: 2,
};
assert!((p.percent_complete() - 50.0).abs() < f32::EPSILON);
assert!(!p.is_complete());
}
#[test]
fn goal_progress_all_done() {
let p = GoalProgress {
done: 5,
in_progress: 0,
total: 5,
};
assert_eq!(p.percent_complete(), 100.0);
assert!(p.is_complete());
}
#[test]
fn goal_progress_remaining() {
let p = GoalProgress {
done: 3,
in_progress: 1,
total: 5,
};
assert_eq!(p.remaining(), 2);
}
#[test]
fn goal_progress_display() {
let p = GoalProgress {
done: 2,
in_progress: 1,
total: 4,
};
let s = p.to_string();
assert!(s.contains("2/4"), "display: {s}");
assert!(s.contains("50%"), "display: {s}");
}
#[test]
fn hierarchy_error_display_not_a_goal() {
let e = HierarchyError::NotAGoal {
item_id: "bn-001".to_string(),
actual_kind: "task".to_string(),
};
assert!(e.to_string().contains("bn-001"));
assert!(e.to_string().contains("task"));
}
#[test]
fn hierarchy_error_display_not_found() {
let e = HierarchyError::ItemNotFound("bn-missing".to_string());
assert!(e.to_string().contains("bn-missing"));
}
#[test]
fn hierarchy_error_display_cycle() {
let e = HierarchyError::CycleDetected {
item_id: "bn-001".to_string(),
proposed_parent: "bn-002".to_string(),
};
let s = e.to_string();
assert!(s.contains("bn-001"));
assert!(s.contains("bn-002"));
assert!(s.contains("cycle"));
}
#[test]
fn direct_progress_empty_goal() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
let p = compute_direct_progress(&conn, "bn-goal").unwrap();
assert_eq!(p.total, 0);
assert_eq!(p.done, 0);
assert!(p.is_complete()); }
#[test]
fn direct_progress_all_open() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-c1", "task", "open", Some("bn-goal"));
insert_item(&conn, "bn-c2", "task", "open", Some("bn-goal"));
let p = compute_direct_progress(&conn, "bn-goal").unwrap();
assert_eq!(p.total, 2);
assert_eq!(p.done, 0);
assert_eq!(p.in_progress, 0);
assert!(!p.is_complete());
}
#[test]
fn direct_progress_mixed_states() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
insert_item(&conn, "bn-c2", "task", "doing", Some("bn-goal"));
insert_item(&conn, "bn-c3", "task", "open", Some("bn-goal"));
insert_item(&conn, "bn-c4", "task", "archived", Some("bn-goal"));
let p = compute_direct_progress(&conn, "bn-goal").unwrap();
assert_eq!(p.total, 4);
assert_eq!(p.done, 2); assert_eq!(p.in_progress, 1);
assert!(!p.is_complete());
}
#[test]
fn direct_progress_all_done() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
insert_item(&conn, "bn-c2", "task", "done", Some("bn-goal"));
let p = compute_direct_progress(&conn, "bn-goal").unwrap();
assert_eq!(p.total, 2);
assert_eq!(p.done, 2);
assert!(p.is_complete());
}
#[test]
fn direct_progress_not_found_returns_error() {
let conn = test_db();
let err = compute_direct_progress(&conn, "bn-missing").unwrap_err();
assert!(matches!(err, HierarchyError::ItemNotFound(_)));
}
#[test]
fn direct_progress_not_a_goal_returns_error() {
let conn = test_db();
insert_item(&conn, "bn-task", "task", "open", None);
let err = compute_direct_progress(&conn, "bn-task").unwrap_err();
assert!(matches!(
err,
HierarchyError::NotAGoal { actual_kind, .. } if actual_kind == "task"
));
}
#[test]
fn direct_progress_excludes_deleted_children() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
conn.execute(
"INSERT INTO items \
(item_id, title, kind, state, urgency, is_deleted, search_labels, \
parent_id, created_at_us, updated_at_us) \
VALUES ('bn-del', 'Deleted', 'task', 'open', 'default', 1, '', 'bn-goal', 1000, 2000)",
[],
)
.unwrap();
let p = compute_direct_progress(&conn, "bn-goal").unwrap();
assert_eq!(p.total, 1); assert_eq!(p.done, 1);
}
#[test]
fn nested_progress_flat_goal() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"));
insert_item(&conn, "bn-c2", "task", "open", Some("bn-goal"));
let p = compute_nested_progress(&conn, "bn-goal").unwrap();
assert_eq!(p.total, 2);
assert_eq!(p.done, 1);
}
#[test]
fn nested_progress_rolls_up_through_subgoals() {
let conn = test_db();
insert_item(&conn, "bn-ga", "goal", "open", None);
insert_item(&conn, "bn-tx", "task", "done", Some("bn-ga"));
insert_item(&conn, "bn-gb", "goal", "open", Some("bn-ga"));
insert_item(&conn, "bn-ty", "task", "done", Some("bn-gb"));
insert_item(&conn, "bn-tz", "task", "open", Some("bn-gb"));
insert_item(&conn, "bn-tw", "task", "doing", Some("bn-ga"));
let p = compute_nested_progress(&conn, "bn-ga").unwrap();
assert_eq!(p.total, 4, "leaf items: tx, ty, tz, tw");
assert_eq!(p.done, 2, "tx and ty are done");
assert_eq!(p.in_progress, 1, "tw is doing");
}
#[test]
fn nested_progress_deeply_nested() {
let conn = test_db();
insert_item(&conn, "bn-g1", "goal", "open", None);
insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"));
insert_item(&conn, "bn-g3", "goal", "open", Some("bn-g2"));
insert_item(&conn, "bn-t1", "task", "done", Some("bn-g3"));
let p = compute_nested_progress(&conn, "bn-g1").unwrap();
assert_eq!(p.total, 1);
assert_eq!(p.done, 1);
assert!(p.is_complete());
}
#[test]
fn nested_progress_empty_subgoal_contributes_nothing() {
let conn = test_db();
insert_item(&conn, "bn-ga", "goal", "open", None);
insert_item(&conn, "bn-t1", "task", "done", Some("bn-ga"));
insert_item(&conn, "bn-gb", "goal", "open", Some("bn-ga"));
let p = compute_nested_progress(&conn, "bn-ga").unwrap();
assert_eq!(p.total, 1, "only the task counts");
assert_eq!(p.done, 1);
}
#[test]
fn nested_progress_not_found() {
let conn = test_db();
let err = compute_nested_progress(&conn, "bn-missing").unwrap_err();
assert!(matches!(err, HierarchyError::ItemNotFound(_)));
}
#[test]
fn nested_progress_not_a_goal() {
let conn = test_db();
insert_item(&conn, "bn-task", "task", "open", None);
let err = compute_nested_progress(&conn, "bn-task").unwrap_err();
assert!(matches!(err, HierarchyError::NotAGoal { .. }));
}
#[test]
fn subtree_single_node() {
let conn = test_db();
insert_item(&conn, "bn-root", "goal", "open", None);
let ids = get_subtree_ids(&conn, "bn-root").unwrap();
assert_eq!(ids, vec!["bn-root"]);
}
#[test]
fn subtree_with_children() {
let conn = test_db();
insert_item(&conn, "bn-root", "goal", "open", None);
insert_item(&conn, "bn-c1", "task", "open", Some("bn-root"));
insert_item(&conn, "bn-c2", "task", "done", Some("bn-root"));
let ids = get_subtree_ids(&conn, "bn-root").unwrap();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"bn-root".to_string()));
assert!(ids.contains(&"bn-c1".to_string()));
assert!(ids.contains(&"bn-c2".to_string()));
}
#[test]
fn subtree_bfs_order_root_first() {
let conn = test_db();
insert_item(&conn, "bn-root", "goal", "open", None);
insert_item(&conn, "bn-c1", "goal", "open", Some("bn-root"));
insert_item(&conn, "bn-c2", "task", "open", Some("bn-root"));
insert_item(&conn, "bn-gc1", "task", "done", Some("bn-c1"));
let ids = get_subtree_ids(&conn, "bn-root").unwrap();
assert_eq!(ids[0], "bn-root"); assert_eq!(ids.len(), 4);
}
#[test]
fn ancestors_no_parent() {
let conn = test_db();
insert_item(&conn, "bn-root", "goal", "open", None);
let ancestors = get_ancestors(&conn, "bn-root").unwrap();
assert!(ancestors.is_empty());
}
#[test]
fn ancestors_one_level() {
let conn = test_db();
insert_item(&conn, "bn-parent", "goal", "open", None);
insert_item(&conn, "bn-child", "task", "open", Some("bn-parent"));
let ancestors = get_ancestors(&conn, "bn-child").unwrap();
assert_eq!(ancestors.len(), 1);
assert_eq!(ancestors[0].item_id, "bn-parent");
}
#[test]
fn ancestors_three_levels() {
let conn = test_db();
insert_item(&conn, "bn-g1", "goal", "open", None);
insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"));
insert_item(&conn, "bn-t1", "task", "open", Some("bn-g2"));
let ancestors = get_ancestors(&conn, "bn-t1").unwrap();
assert_eq!(ancestors.len(), 2);
assert_eq!(ancestors[0].item_id, "bn-g2");
assert_eq!(ancestors[1].item_id, "bn-g1");
}
#[test]
fn ancestors_not_found() {
let conn = test_db();
let err = get_ancestors(&conn, "bn-missing").unwrap_err();
assert!(matches!(err, HierarchyError::ItemNotFound(_)));
}
#[test]
fn validate_reparent_ok() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-task", "task", "open", None);
assert!(validate_reparent(&conn, "bn-task", "bn-goal").is_ok());
}
#[test]
fn validate_reparent_to_non_goal_rejected() {
let conn = test_db();
insert_item(&conn, "bn-task1", "task", "open", None);
insert_item(&conn, "bn-task2", "task", "open", None);
let err = validate_reparent(&conn, "bn-task1", "bn-task2").unwrap_err();
assert!(matches!(err, HierarchyError::NotAGoal { .. }));
}
#[test]
fn validate_reparent_item_not_found() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
let err = validate_reparent(&conn, "bn-missing", "bn-goal").unwrap_err();
assert!(matches!(err, HierarchyError::ItemNotFound(_)));
}
#[test]
fn validate_reparent_parent_not_found() {
let conn = test_db();
insert_item(&conn, "bn-task", "task", "open", None);
let err = validate_reparent(&conn, "bn-task", "bn-missing").unwrap_err();
assert!(matches!(err, HierarchyError::ItemNotFound(_)));
}
#[test]
fn validate_reparent_detects_cycle_direct() {
let conn = test_db();
insert_item(&conn, "bn-parent", "goal", "open", None);
insert_item(&conn, "bn-child", "goal", "open", Some("bn-parent"));
let err = validate_reparent(&conn, "bn-parent", "bn-child").unwrap_err();
assert!(matches!(err, HierarchyError::CycleDetected { .. }));
}
#[test]
fn validate_reparent_detects_cycle_indirect() {
let conn = test_db();
insert_item(&conn, "bn-g1", "goal", "open", None);
insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"));
insert_item(&conn, "bn-g3", "goal", "open", Some("bn-g2"));
let err = validate_reparent(&conn, "bn-g1", "bn-g3").unwrap_err();
assert!(matches!(err, HierarchyError::CycleDetected { .. }));
}
#[test]
fn validate_reparent_self_cycle() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
let err = validate_reparent(&conn, "bn-goal", "bn-goal").unwrap_err();
assert!(matches!(err, HierarchyError::CycleDetected { .. }));
}
#[test]
fn validate_reparent_move_to_different_goal() {
let conn = test_db();
insert_item(&conn, "bn-ga", "goal", "open", None);
insert_item(&conn, "bn-gb", "goal", "open", None);
insert_item(&conn, "bn-task", "task", "open", Some("bn-ga"));
assert!(validate_reparent(&conn, "bn-task", "bn-gb").is_ok());
}
#[test]
fn validate_reparent_bug_under_goal_ok() {
let conn = test_db();
insert_item(&conn, "bn-goal", "goal", "open", None);
insert_item(&conn, "bn-bug", "bug", "open", None);
assert!(validate_reparent(&conn, "bn-bug", "bn-goal").is_ok());
}
}