Skip to main content

bones_core/model/
goal.rs

1use anyhow::{Context, Result, bail};
2use rusqlite::{Connection, params};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashSet};
5
6use crate::{
7    config::ProjectConfig,
8    db::query,
9    error::ModelError,
10    event::{Event, EventData, EventType, MoveData},
11    model::{
12        item::{State, WorkItemFields},
13        item_id::ItemId,
14    },
15};
16
17/// `SQLite` projection handle used by goal model helpers.
18pub type Db = Connection;
19
20/// Safety cap for containment-depth validation.
21pub const MAX_CONTAINMENT_DEPTH: usize = 256;
22
23const AUTO_CLOSE_REASON: &str = "all children complete";
24const AUTO_REOPEN_REASON: &str = "child reopened";
25
26/// Policy controlling goal auto-close and auto-reopen behavior.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub struct GoalPolicy {
29    /// Auto-close a goal when all active children are done/archived.
30    pub auto_close: bool,
31    /// Auto-reopen a done goal when an open/doing child appears.
32    pub auto_reopen: bool,
33}
34
35impl Default for GoalPolicy {
36    fn default() -> Self {
37        Self {
38            auto_close: true,
39            auto_reopen: true,
40        }
41    }
42}
43
44impl GoalPolicy {
45    /// Map project-level config into a goal policy.
46    ///
47    /// `goals.auto_complete = true` enables both auto-close and auto-reopen.
48    /// `goals.auto_complete = false` disables both.
49    #[must_use]
50    pub const fn from_project_config(config: &ProjectConfig) -> Self {
51        let enabled = config.goals.auto_complete;
52        Self {
53            auto_close: enabled,
54            auto_reopen: enabled,
55        }
56    }
57
58    /// Apply per-goal overrides on top of project defaults.
59    #[must_use]
60    pub fn apply_override(self, override_policy: GoalPolicyOverride) -> Self {
61        Self {
62            auto_close: override_policy.auto_close.unwrap_or(self.auto_close),
63            auto_reopen: override_policy.auto_reopen.unwrap_or(self.auto_reopen),
64        }
65    }
66}
67
68/// Optional per-goal policy override values.
69#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub struct GoalPolicyOverride {
71    /// Override for [`GoalPolicy::auto_close`].
72    pub auto_close: Option<bool>,
73    /// Override for [`GoalPolicy::auto_reopen`].
74    pub auto_reopen: Option<bool>,
75}
76
77/// Progress summary for a goal's direct children.
78#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
79pub struct GoalProgress {
80    pub total_children: usize,
81    pub done_count: usize,
82    pub doing_count: usize,
83    pub open_count: usize,
84    pub archived_count: usize,
85    /// Number of open/doing children with at least one unresolved blocker.
86    pub blocked_count: usize,
87}
88
89impl GoalProgress {
90    const fn active_children(self) -> usize {
91        self.open_count + self.doing_count
92    }
93
94    const fn all_active_complete(self) -> bool {
95        self.total_children > 0 && self.active_children() == 0
96    }
97}
98
99/// Parse policy override labels from a goal's labels.
100///
101/// Supported labels:
102/// - `goal:auto-close=<bool>`
103/// - `goal:auto-reopen=<bool>`
104/// - `goal:manual` (disables both)
105/// - `goal:auto` (enables both)
106#[must_use]
107pub fn goal_policy_override_from_labels(labels: &[String]) -> GoalPolicyOverride {
108    let mut override_policy = GoalPolicyOverride::default();
109
110    for label in labels {
111        let normalized = label.trim().to_ascii_lowercase();
112
113        match normalized.as_str() {
114            "goal:manual" => {
115                override_policy.auto_close = Some(false);
116                override_policy.auto_reopen = Some(false);
117                continue;
118            }
119            "goal:auto" => {
120                override_policy.auto_close = Some(true);
121                override_policy.auto_reopen = Some(true);
122                continue;
123            }
124            _ => {}
125        }
126
127        if let Some(value) = normalized.strip_prefix("goal:auto-close=")
128            && let Some(parsed) = parse_policy_bool(value)
129        {
130            override_policy.auto_close = Some(parsed);
131        }
132
133        if let Some(value) = normalized.strip_prefix("goal:auto-reopen=")
134            && let Some(parsed) = parse_policy_bool(value)
135        {
136            override_policy.auto_reopen = Some(parsed);
137        }
138    }
139
140    override_policy
141}
142
143/// Parse policy override labels from a projection-level work item aggregate.
144#[must_use]
145pub fn goal_policy_override_from_fields(fields: &WorkItemFields) -> GoalPolicyOverride {
146    goal_policy_override_from_labels(&fields.labels)
147}
148
149/// Evaluate whether a goal should auto-close under default policy.
150///
151/// This returns a synthetic `item.move` event with `agent = "bones"` and
152/// reason `"all children complete"` when a transition should be emitted.
153///
154/// Errors are intentionally swallowed in this convenience wrapper.
155#[must_use]
156pub fn check_auto_close(goal_id: &str, db: &Db) -> Option<Event> {
157    check_auto_close_with_policy(goal_id, db, GoalPolicy::default())
158        .ok()
159        .flatten()
160}
161
162/// Evaluate whether a goal should auto-close under the provided project policy.
163///
164/// # Errors
165///
166/// Returns an error if the goal cannot be loaded or is not a goal kind.
167pub fn check_auto_close_with_policy(
168    goal_id: &str,
169    db: &Db,
170    project_policy: GoalPolicy,
171) -> Result<Option<Event>> {
172    let goal = require_goal(db, goal_id)?;
173    if !matches!(goal.state.as_str(), "open" | "doing") {
174        return Ok(None);
175    }
176
177    let policy = resolve_policy(db, goal_id, project_policy)?;
178    if !policy.auto_close {
179        return Ok(None);
180    }
181
182    let progress = goal_progress(goal_id, db)?;
183    if progress.all_active_complete() {
184        return Ok(Some(system_move_event(
185            goal_id,
186            State::Done,
187            AUTO_CLOSE_REASON,
188        )));
189    }
190
191    Ok(None)
192}
193
194/// Evaluate whether a goal should auto-reopen under default policy.
195///
196/// This returns a synthetic `item.move` event with `agent = "bones"` and
197/// reason `"child reopened"` when a transition should be emitted.
198///
199/// Errors are intentionally swallowed in this convenience wrapper.
200#[must_use]
201pub fn check_auto_reopen(goal_id: &str, db: &Db) -> Option<Event> {
202    check_auto_reopen_with_policy(goal_id, db, GoalPolicy::default())
203        .ok()
204        .flatten()
205}
206
207/// Evaluate whether a goal should auto-reopen under the provided project policy.
208///
209/// # Errors
210///
211/// Returns an error if the goal cannot be loaded or is not a goal kind.
212pub fn check_auto_reopen_with_policy(
213    goal_id: &str,
214    db: &Db,
215    project_policy: GoalPolicy,
216) -> Result<Option<Event>> {
217    let goal = require_goal(db, goal_id)?;
218    if goal.state != "done" {
219        return Ok(None);
220    }
221
222    let policy = resolve_policy(db, goal_id, project_policy)?;
223    if !policy.auto_reopen {
224        return Ok(None);
225    }
226
227    let progress = goal_progress(goal_id, db)?;
228    if progress.active_children() > 0 {
229        return Ok(Some(system_move_event(
230            goal_id,
231            State::Open,
232            AUTO_REOPEN_REASON,
233        )));
234    }
235
236    Ok(None)
237}
238
239/// Compute direct-child progress summary for a goal.
240///
241/// # Errors
242///
243/// Returns an error if the goal cannot be loaded, is not a goal kind,
244/// or the children query fails.
245pub fn goal_progress(goal_id: &str, db: &Db) -> Result<GoalProgress> {
246    require_goal(db, goal_id)?;
247
248    let children = query::get_children(db, goal_id)
249        .with_context(|| format!("load children for goal '{goal_id}'"))?;
250
251    let mut progress = GoalProgress::default();
252    for child in children {
253        progress.total_children += 1;
254        match child.state.as_str() {
255            "done" => progress.done_count += 1,
256            "doing" => progress.doing_count += 1,
257            "open" => progress.open_count += 1,
258            "archived" => progress.archived_count += 1,
259            _ => {}
260        }
261    }
262
263    progress.blocked_count = blocked_children_count(goal_id, db)?;
264
265    Ok(progress)
266}
267
268/// Validate that adding `child_id` under `parent_id` will not create circular
269/// containment, and that the ancestry depth stays under a safety threshold.
270///
271/// # Errors
272///
273/// Returns an error if circular containment is detected, the parent is not
274/// a goal, items cannot be loaded, or the containment depth exceeds the
275/// safety limit.
276pub fn check_circular_containment(parent_id: &str, child_id: &str, db: &Db) -> Result<()> {
277    check_circular_containment_with_limit(parent_id, child_id, db, MAX_CONTAINMENT_DEPTH)
278}
279
280fn check_circular_containment_with_limit(
281    parent_id: &str,
282    child_id: &str,
283    db: &Db,
284    max_depth: usize,
285) -> Result<()> {
286    if parent_id == child_id {
287        return Err(ModelError::CircularContainment {
288            cycle: vec![child_id.to_string(), child_id.to_string()],
289        }
290        .into());
291    }
292
293    let parent = require_item(db, parent_id)?;
294    if parent.kind != "goal" {
295        bail!(
296            "item '{parent_id}' is not a goal (kind={}): only goals may contain children",
297            parent.kind
298        );
299    }
300
301    let _ = require_item(db, child_id)?;
302
303    let mut lineage = vec![parent_id.to_string()];
304    let mut visited: HashSet<String> = HashSet::from([child_id.to_string(), parent_id.to_string()]);
305    let mut depth = 1usize;
306    let mut current = parent.parent_id;
307
308    while let Some(ancestor_id) = current {
309        depth += 1;
310        if depth > max_depth {
311            bail!(
312                "containment depth exceeds safety limit: depth={depth}, max={max_depth}, parent='{parent_id}', child='{child_id}'"
313            );
314        }
315
316        lineage.push(ancestor_id.clone());
317
318        if ancestor_id == child_id {
319            let mut cycle = vec![child_id.to_string()];
320            cycle.extend(lineage.iter().cloned());
321            return Err(ModelError::CircularContainment { cycle }.into());
322        }
323
324        if !visited.insert(ancestor_id.clone()) {
325            let cycle_start = lineage
326                .iter()
327                .position(|id| id == &ancestor_id)
328                .unwrap_or(0);
329            let mut cycle = lineage[cycle_start..].to_vec();
330            cycle.push(ancestor_id);
331            return Err(ModelError::CircularContainment { cycle }.into());
332        }
333
334        current = require_item(db, lineage.last().expect("lineage has ancestor"))?.parent_id;
335    }
336
337    Ok(())
338}
339
340fn resolve_policy(db: &Db, goal_id: &str, project_policy: GoalPolicy) -> Result<GoalPolicy> {
341    let labels = query::get_labels(db, goal_id)
342        .with_context(|| format!("load labels for goal '{goal_id}'"))?
343        .into_iter()
344        .map(|label| label.label)
345        .collect::<Vec<_>>();
346
347    Ok(project_policy.apply_override(goal_policy_override_from_labels(&labels)))
348}
349
350fn parse_policy_bool(raw: &str) -> Option<bool> {
351    match raw.trim().to_ascii_lowercase().as_str() {
352        "1" | "true" | "on" | "yes" | "enabled" => Some(true),
353        "0" | "false" | "off" | "no" | "disabled" => Some(false),
354        _ => None,
355    }
356}
357
358fn require_item(db: &Db, item_id: &str) -> Result<query::QueryItem> {
359    query::get_item(db, item_id, false)
360        .with_context(|| format!("load item '{item_id}'"))?
361        .ok_or_else(|| {
362            ModelError::ItemNotFound {
363                item_id: item_id.to_string(),
364            }
365            .into()
366        })
367}
368
369fn require_goal(db: &Db, goal_id: &str) -> Result<query::QueryItem> {
370    let goal = require_item(db, goal_id)?;
371    if goal.kind != "goal" {
372        bail!(
373            "item '{goal_id}' is not a goal (kind={}): goal policy applies only to goals",
374            goal.kind
375        );
376    }
377    Ok(goal)
378}
379
380fn blocked_children_count(goal_id: &str, db: &Db) -> Result<usize> {
381    let blocked: i64 = db
382        .query_row(
383            "SELECT COUNT(DISTINCT child.item_id)
384             FROM items child
385             JOIN item_dependencies dep
386               ON dep.item_id = child.item_id
387              AND dep.link_type IN ('blocks', 'blocked_by')
388             JOIN items blocker
389               ON blocker.item_id = dep.depends_on_item_id
390             WHERE child.parent_id = ?1
391               AND child.is_deleted = 0
392               AND child.state IN ('open', 'doing')
393               AND blocker.is_deleted = 0
394               AND blocker.state NOT IN ('done', 'archived')",
395            params![goal_id],
396            |row| row.get(0),
397        )
398        .with_context(|| format!("count blocked children for goal '{goal_id}'"))?;
399
400    usize::try_from(blocked).context("blocked children count overflow")
401}
402
403fn system_move_event(goal_id: &str, target_state: State, reason: &str) -> Event {
404    Event {
405        wall_ts_us: 0,
406        agent: "bones".to_string(),
407        itc: "itc:auto-goal-policy".to_string(),
408        parents: vec![],
409        event_type: EventType::Move,
410        item_id: ItemId::new_unchecked(goal_id.to_string()),
411        data: EventData::Move(MoveData {
412            state: target_state,
413            reason: Some(reason.to_string()),
414            extra: BTreeMap::new(),
415        }),
416        event_hash: String::new(),
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::db::migrations;
424    use rusqlite::Connection;
425
426    fn test_db() -> Connection {
427        let mut conn = Connection::open_in_memory().expect("open in-memory db");
428        migrations::migrate(&mut conn).expect("migrate");
429        conn
430    }
431
432    fn insert_item(
433        conn: &Connection,
434        id: &str,
435        kind: &str,
436        state: &str,
437        parent_id: Option<&str>,
438        created_at: i64,
439    ) {
440        conn.execute(
441            "INSERT INTO items
442             (item_id, title, kind, state, urgency, is_deleted, search_labels,
443              parent_id, created_at_us, updated_at_us)
444             VALUES (?1, ?2, ?3, ?4, 'default', 0, '', ?5, ?6, ?6)",
445            params![
446                id,
447                format!("title {id}"),
448                kind,
449                state,
450                parent_id,
451                created_at
452            ],
453        )
454        .expect("insert item");
455    }
456
457    fn insert_dependency(conn: &Connection, item_id: &str, depends_on_item_id: &str) {
458        conn.execute(
459            "INSERT INTO item_dependencies
460             (item_id, depends_on_item_id, link_type, created_at_us)
461             VALUES (?1, ?2, 'blocks', 1000)",
462            params![item_id, depends_on_item_id],
463        )
464        .expect("insert dependency");
465    }
466
467    fn insert_label(conn: &Connection, item_id: &str, label: &str) {
468        conn.execute(
469            "INSERT INTO item_labels (item_id, label, created_at_us)
470             VALUES (?1, ?2, 1000)",
471            params![item_id, label],
472        )
473        .expect("insert label");
474    }
475
476    #[test]
477    fn policy_defaults_and_project_mapping() {
478        let defaults = GoalPolicy::default();
479        assert!(defaults.auto_close);
480        assert!(defaults.auto_reopen);
481
482        let mut cfg = ProjectConfig::default();
483        cfg.goals.auto_complete = false;
484        let from_cfg = GoalPolicy::from_project_config(&cfg);
485        assert!(!from_cfg.auto_close);
486        assert!(!from_cfg.auto_reopen);
487    }
488
489    #[test]
490    fn policy_override_from_labels_and_fields() {
491        let labels = vec![
492            "goal:auto-close=off".to_string(),
493            "goal:auto-reopen=yes".to_string(),
494        ];
495        let override_policy = goal_policy_override_from_labels(&labels);
496        assert_eq!(override_policy.auto_close, Some(false));
497        assert_eq!(override_policy.auto_reopen, Some(true));
498
499        let fields = WorkItemFields {
500            labels,
501            ..WorkItemFields::default()
502        };
503        let from_fields = goal_policy_override_from_fields(&fields);
504        assert_eq!(from_fields, override_policy);
505    }
506
507    #[test]
508    fn progress_counts_states_and_blocked_children() {
509        let conn = test_db();
510        insert_item(&conn, "bn-goal", "goal", "open", None, 1);
511
512        insert_item(&conn, "bn-block-open", "task", "open", None, 2);
513        insert_item(&conn, "bn-block-done", "task", "done", None, 3);
514
515        insert_item(&conn, "bn-c-open", "task", "open", Some("bn-goal"), 10);
516        insert_item(&conn, "bn-c-doing", "task", "doing", Some("bn-goal"), 11);
517        insert_item(&conn, "bn-c-done", "task", "done", Some("bn-goal"), 12);
518        insert_item(&conn, "bn-c-arch", "task", "archived", Some("bn-goal"), 13);
519
520        // Open child blocked by an open blocker (counts as blocked).
521        insert_dependency(&conn, "bn-c-open", "bn-block-open");
522        // Doing child blocked by a done blocker (resolved; should not count).
523        insert_dependency(&conn, "bn-c-doing", "bn-block-done");
524
525        let progress = goal_progress("bn-goal", &conn).unwrap();
526        assert_eq!(progress.total_children, 4);
527        assert_eq!(progress.open_count, 1);
528        assert_eq!(progress.doing_count, 1);
529        assert_eq!(progress.done_count, 1);
530        assert_eq!(progress.archived_count, 1);
531        assert_eq!(progress.blocked_count, 1);
532    }
533
534    #[test]
535    fn auto_close_emits_move_event_when_all_children_complete() {
536        let conn = test_db();
537        insert_item(&conn, "bn-goal", "goal", "open", None, 1);
538        insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"), 2);
539        insert_item(&conn, "bn-c2", "task", "archived", Some("bn-goal"), 3);
540
541        let event = check_auto_close("bn-goal", &conn).expect("auto-close event");
542        assert_eq!(event.agent, "bones");
543        assert_eq!(event.event_type, EventType::Move);
544        assert_eq!(event.item_id.as_str(), "bn-goal");
545
546        let EventData::Move(data) = event.data else {
547            panic!("expected move event");
548        };
549        assert_eq!(data.state, State::Done);
550        assert_eq!(data.reason.as_deref(), Some(AUTO_CLOSE_REASON));
551    }
552
553    #[test]
554    fn auto_close_is_order_independent() {
555        let conn = test_db();
556        insert_item(&conn, "bn-g1", "goal", "open", None, 1);
557        insert_item(&conn, "bn-g2", "goal", "open", None, 2);
558
559        insert_item(&conn, "bn-g1-a", "task", "done", Some("bn-g1"), 10);
560        insert_item(&conn, "bn-g1-b", "task", "archived", Some("bn-g1"), 11);
561
562        // Same effective states, inserted in opposite order.
563        insert_item(&conn, "bn-g2-b", "task", "archived", Some("bn-g2"), 20);
564        insert_item(&conn, "bn-g2-a", "task", "done", Some("bn-g2"), 21);
565
566        let e1 = check_auto_close("bn-g1", &conn).expect("g1 auto-close");
567        let e2 = check_auto_close("bn-g2", &conn).expect("g2 auto-close");
568
569        let EventData::Move(d1) = e1.data else {
570            panic!("expected move")
571        };
572        let EventData::Move(d2) = e2.data else {
573            panic!("expected move")
574        };
575
576        assert_eq!(d1.state, d2.state);
577        assert_eq!(d1.reason, d2.reason);
578    }
579
580    #[test]
581    fn auto_close_respects_goal_override_label() {
582        let conn = test_db();
583        insert_item(&conn, "bn-goal", "goal", "open", None, 1);
584        insert_item(&conn, "bn-c1", "task", "done", Some("bn-goal"), 2);
585        insert_label(&conn, "bn-goal", "goal:auto-close=off");
586
587        let event = check_auto_close("bn-goal", &conn);
588        assert!(event.is_none());
589    }
590
591    #[test]
592    fn auto_reopen_emits_move_event_when_done_goal_gets_active_child() {
593        let conn = test_db();
594        insert_item(&conn, "bn-goal", "goal", "done", None, 1);
595        insert_item(&conn, "bn-c1", "task", "open", Some("bn-goal"), 2);
596
597        let event = check_auto_reopen("bn-goal", &conn).expect("auto-reopen event");
598        assert_eq!(event.agent, "bones");
599        assert_eq!(event.event_type, EventType::Move);
600
601        let EventData::Move(data) = event.data else {
602            panic!("expected move event");
603        };
604        assert_eq!(data.state, State::Open);
605        assert_eq!(data.reason.as_deref(), Some(AUTO_REOPEN_REASON));
606    }
607
608    #[test]
609    fn auto_reopen_respects_project_policy() {
610        let conn = test_db();
611        insert_item(&conn, "bn-goal", "goal", "done", None, 1);
612        insert_item(&conn, "bn-c1", "task", "open", Some("bn-goal"), 2);
613
614        let disabled = GoalPolicy {
615            auto_close: true,
616            auto_reopen: false,
617        };
618
619        let event = check_auto_reopen_with_policy("bn-goal", &conn, disabled).unwrap();
620        assert!(event.is_none());
621    }
622
623    #[test]
624    fn circular_containment_detects_cycle() {
625        let conn = test_db();
626        insert_item(&conn, "bn-parent", "goal", "open", None, 1);
627        insert_item(&conn, "bn-child", "goal", "open", Some("bn-parent"), 2);
628
629        let err = check_circular_containment("bn-child", "bn-parent", &conn).unwrap_err();
630        assert!(err.to_string().contains("cycle"));
631    }
632
633    #[test]
634    fn circular_containment_rejects_non_goal_parent() {
635        let conn = test_db();
636        insert_item(&conn, "bn-task-parent", "task", "open", None, 1);
637        insert_item(&conn, "bn-child", "task", "open", None, 2);
638
639        let err = check_circular_containment("bn-task-parent", "bn-child", &conn).unwrap_err();
640        assert!(err.to_string().contains("not a goal"));
641    }
642
643    #[test]
644    fn circular_containment_enforces_depth_safety_limit() {
645        let conn = test_db();
646        insert_item(&conn, "bn-root", "goal", "open", None, 1);
647        insert_item(&conn, "bn-g1", "goal", "open", Some("bn-root"), 2);
648        insert_item(&conn, "bn-g2", "goal", "open", Some("bn-g1"), 3);
649        insert_item(&conn, "bn-g3", "goal", "open", Some("bn-g2"), 4);
650        insert_item(&conn, "bn-g4", "goal", "open", Some("bn-g3"), 5);
651        insert_item(&conn, "bn-child", "task", "open", None, 6);
652
653        let err = check_circular_containment_with_limit("bn-g4", "bn-child", &conn, 3)
654            .expect_err("depth should exceed limit");
655        assert!(err.to_string().contains("safety limit"));
656    }
657
658    #[test]
659    fn progress_updates_after_reparenting_and_state_changes() {
660        let conn = test_db();
661        insert_item(&conn, "bn-ga", "goal", "open", None, 1);
662        insert_item(&conn, "bn-gb", "goal", "open", None, 2);
663        insert_item(&conn, "bn-task", "task", "open", Some("bn-ga"), 3);
664
665        let p_a = goal_progress("bn-ga", &conn).unwrap();
666        let p_b = goal_progress("bn-gb", &conn).unwrap();
667        assert_eq!(p_a.total_children, 1);
668        assert_eq!(p_a.open_count, 1);
669        assert_eq!(p_b.total_children, 0);
670
671        conn.execute(
672            "UPDATE items
673             SET parent_id = 'bn-gb', state = 'done', updated_at_us = 999
674             WHERE item_id = 'bn-task'",
675            [],
676        )
677        .unwrap();
678
679        let p_a2 = goal_progress("bn-ga", &conn).unwrap();
680        let p_b2 = goal_progress("bn-gb", &conn).unwrap();
681
682        assert_eq!(p_a2.total_children, 0);
683        assert_eq!(p_b2.total_children, 1);
684        assert_eq!(p_b2.done_count, 1);
685    }
686}