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
17pub type Db = Connection;
19
20pub const MAX_CONTAINMENT_DEPTH: usize = 256;
22
23const AUTO_CLOSE_REASON: &str = "all children complete";
24const AUTO_REOPEN_REASON: &str = "child reopened";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub struct GoalPolicy {
29 pub auto_close: bool,
31 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 #[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 #[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub struct GoalPolicyOverride {
71 pub auto_close: Option<bool>,
73 pub auto_reopen: Option<bool>,
75}
76
77#[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 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#[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#[must_use]
145pub fn goal_policy_override_from_fields(fields: &WorkItemFields) -> GoalPolicyOverride {
146 goal_policy_override_from_labels(&fields.labels)
147}
148
149#[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
162pub 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#[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
207pub 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
239pub 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
268pub 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 insert_dependency(&conn, "bn-c-open", "bn-block-open");
522 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 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}