1use rusqlite::Connection;
5
6pub const MAX_CONSTRAINTS: usize = 3;
7
8pub fn active_task_reminder(conn: &Connection) -> anyhow::Result<Option<String>> {
11 let row: Option<(String, String)> = conn
12 .query_row(
13 "SELECT task_id, title FROM tasks \
14 WHERE status='open' ORDER BY last_event_at DESC LIMIT 1",
15 [],
16 |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
17 )
18 .ok();
19 let Some((task_id, title)) = row else {
20 return Ok(None);
21 };
22
23 let goal = crate::db::task_metadata(conn, &task_id)?
24 .and_then(|m| m.goal)
25 .filter(|g| !g.trim().is_empty());
26
27 let mut stmt = conn.prepare(
30 "SELECT sf.text FROM events_index ei \
31 LEFT JOIN search_fts sf ON sf.event_id = ei.event_id \
32 WHERE ei.task_id = ?1 AND ei.type = 'constraint' \
33 ORDER BY ei.timestamp DESC LIMIT ?2",
34 )?;
35 let constraints: Vec<String> = stmt
36 .query_map(rusqlite::params![task_id, MAX_CONSTRAINTS as i64], |r| {
37 r.get::<_, Option<String>>(0)
38 })?
39 .filter_map(|r| r.ok().flatten())
40 .filter(|t| !t.trim().is_empty())
41 .collect();
42
43 let mut out = format!("[Active task after compaction] {task_id} — {title}");
44 if let Some(g) = goal {
45 out.push_str(&format!("\nGoal: {g}"));
46 }
47 if !constraints.is_empty() {
48 out.push_str("\nConstraints still in force:");
49 for c in &constraints {
50 out.push_str(&format!("\n - {c}"));
51 }
52 }
53 Ok(Some(out))
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59 use crate::db;
60 use crate::event::{Author, Event, EventStatus, EventType, Source};
61
62 const PH: &str = "ph-test";
63
64 fn open_event(task: &str, title: &str) -> Event {
65 let mut e = Event::new(
66 task,
67 EventType::Open,
68 Author::User,
69 Source::Cli,
70 title.into(),
71 );
72 e.meta = serde_json::json!({ "title": title });
73 e
74 }
75
76 fn constraint_event(task: &str, text: &str, ts: &str) -> Event {
77 let mut e = Event::new(
78 task,
79 EventType::Constraint,
80 Author::Agent,
81 Source::Chat,
82 text.into(),
83 );
84 e.status = EventStatus::Confirmed;
85 e.timestamp = ts.into();
86 e
87 }
88
89 fn seed(events: &[Event]) -> (tempfile::TempDir, rusqlite::Connection) {
90 let d = tempfile::TempDir::new().unwrap();
91 let conn = db::open(d.path().join("s.sqlite")).unwrap();
92 for e in events {
93 db::upsert_task_from_event(&conn, e, PH).unwrap();
94 db::index_event(&conn, e).unwrap();
95 }
96 (d, conn)
97 }
98
99 #[test]
100 fn reminder_includes_title_goal_and_up_to_3_constraints() {
101 let events = vec![
104 open_event("tj-1", "Build the widget"),
105 constraint_event(
106 "tj-1",
107 "OLDEST: rate limit is 100/min",
108 "2026-06-01T00:00:00Z",
109 ),
110 constraint_event("tj-1", "API key rotates daily", "2026-06-02T00:00:00Z"),
111 constraint_event("tj-1", "Must support offline mode", "2026-06-03T00:00:00Z"),
112 constraint_event("tj-1", "NEWEST: ship before Friday", "2026-06-04T00:00:00Z"),
113 ];
114 let (_d, conn) = seed(&events);
115 db::set_task_goal(&conn, "tj-1", "Ship the dashboard widget").unwrap();
116
117 let r = active_task_reminder(&conn).unwrap().unwrap();
118 assert!(r.starts_with("[Active task after compaction]"), "got: {r}");
119 assert!(r.contains("Build the widget"), "got: {r}");
120 assert!(r.contains("Goal: Ship the dashboard widget"), "got: {r}");
121 assert!(r.contains("NEWEST: ship before Friday"), "got: {r}");
122 assert!(r.contains("Must support offline mode"), "got: {r}");
123 assert!(r.contains("API key rotates daily"), "got: {r}");
124 assert!(!r.contains("OLDEST"), "oldest constraint leaked: {r}");
125 }
126
127 #[test]
128 fn reminder_none_when_no_open_task() {
129 let (_d, conn) = seed(&[]);
130 assert!(active_task_reminder(&conn).unwrap().is_none());
131 }
132
133 #[test]
134 fn reminder_none_when_task_closed() {
135 let mut close = Event::new(
136 "tj-1",
137 EventType::Close,
138 Author::User,
139 Source::Cli,
140 "done".into(),
141 );
142 close.timestamp = "2026-06-05T00:00:00Z".into();
143 let events = vec![open_event("tj-1", "Build the widget"), close];
144 let (_d, conn) = seed(&events);
145 assert!(active_task_reminder(&conn).unwrap().is_none());
146 }
147}