1use std::{fs, path::PathBuf};
2
3use anyhow::{Context, Result, anyhow};
4use hj_core::Handoff;
5use rusqlite::{Connection, params};
6
7#[derive(Debug, Clone, Eq, PartialEq)]
8pub struct HandoffRow {
9 pub id: String,
10 pub priority: String,
11 pub status: String,
12 pub completed: String,
13 pub updated: String,
14}
15
16#[derive(Debug, Clone)]
17pub struct UpsertReport {
18 pub db_path: PathBuf,
19 pub synced: usize,
20}
21
22pub struct HandoffDb {
23 db_path: PathBuf,
24}
25
26#[derive(Debug, Clone, Eq, PartialEq)]
27pub struct HandupCheckpoint {
28 pub project: String,
29 pub cwd: String,
30 pub generated: String,
31 pub recommendation: String,
32 pub json_path: String,
33}
34
35pub struct HandupDb {
36 db_path: PathBuf,
37}
38
39impl HandoffDb {
40 pub fn new() -> Result<Self> {
41 let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?;
42 Ok(Self {
43 db_path: home.join(".local/share/atelier/handoff.db"),
44 })
45 }
46
47 #[cfg(test)]
48 pub fn with_path(db_path: PathBuf) -> Self {
49 Self { db_path }
50 }
51
52 pub fn init(&self) -> Result<PathBuf> {
53 let connection = self.open()?;
54 Self::init_schema(&connection)?;
55 Ok(self.db_path.clone())
56 }
57
58 pub fn upsert(&self, project: &str, handoff: &Handoff, today: &str) -> Result<UpsertReport> {
59 let connection = self.open()?;
60 Self::init_schema(&connection)?;
61
62 let mut synced = 0usize;
63 for item in &handoff.items {
64 connection.execute(
65 "INSERT INTO items (project, id, name, priority, status, completed, updated)
66 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
67 ON CONFLICT(project, id) DO UPDATE SET
68 status = excluded.status,
69 completed = excluded.completed,
70 updated = excluded.updated",
71 params![
72 project,
73 item.id,
74 item.name.as_deref().unwrap_or_default(),
75 item.priority.as_deref().unwrap_or_default(),
76 item.status.as_deref().unwrap_or_default(),
77 item.completed.as_deref().unwrap_or_default(),
78 today,
79 ],
80 )?;
81 synced += 1;
82 }
83
84 Ok(UpsertReport {
85 db_path: self.db_path.clone(),
86 synced,
87 })
88 }
89
90 pub fn query(&self, project: &str) -> Result<Vec<HandoffRow>> {
91 let connection = self.open()?;
92 Self::init_schema(&connection)?;
93
94 let mut statement = connection.prepare(
95 "SELECT id, coalesce(priority, ''), coalesce(status, ''), coalesce(completed, ''),
96 coalesce(updated, '')
97 FROM items
98 WHERE project = ?1
99 ORDER BY priority, id",
100 )?;
101 let rows = statement.query_map(params![project], |row| {
102 Ok(HandoffRow {
103 id: row.get(0)?,
104 priority: row.get(1)?,
105 status: row.get(2)?,
106 completed: row.get(3)?,
107 updated: row.get(4)?,
108 })
109 })?;
110
111 let mut items = Vec::new();
112 for row in rows {
113 items.push(row?);
114 }
115 Ok(items)
116 }
117
118 pub fn complete(&self, project: &str, id: &str, today: &str) -> Result<bool> {
119 self.update_status(project, id, "done", Some(today), today)
120 }
121
122 pub fn set_status(&self, project: &str, id: &str, status: &str, today: &str) -> Result<bool> {
123 self.update_status(project, id, status, None, today)
124 }
125
126 fn open(&self) -> Result<Connection> {
127 let parent = self
128 .db_path
129 .parent()
130 .ok_or_else(|| anyhow!("database path has no parent directory"))?;
131 fs::create_dir_all(parent)
132 .with_context(|| format!("failed to create {}", parent.display()))?;
133
134 Connection::open(&self.db_path)
135 .with_context(|| format!("failed to open {}", self.db_path.display()))
136 }
137
138 fn init_schema(connection: &Connection) -> Result<()> {
139 connection.execute_batch(
140 "CREATE TABLE IF NOT EXISTS items (
141 project TEXT NOT NULL,
142 id TEXT NOT NULL,
143 name TEXT,
144 priority TEXT,
145 status TEXT,
146 completed TEXT,
147 updated TEXT,
148 PRIMARY KEY (project, id)
149 );",
150 )?;
151 Ok(())
152 }
153
154 fn update_status(
155 &self,
156 project: &str,
157 id: &str,
158 status: &str,
159 completed: Option<&str>,
160 today: &str,
161 ) -> Result<bool> {
162 let connection = self.open()?;
163 Self::init_schema(&connection)?;
164 let changed = connection.execute(
165 "UPDATE items
166 SET status = ?3,
167 completed = COALESCE(?4, completed),
168 updated = ?5
169 WHERE project = ?1 AND id = ?2",
170 params![project, id, status, completed, today],
171 )?;
172 Ok(changed > 0)
173 }
174}
175
176impl HandupDb {
177 pub fn new() -> Result<Self> {
178 let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?;
179 Ok(Self {
180 db_path: home.join(".ctx/handoffs/handup.db"),
181 })
182 }
183
184 #[cfg(test)]
185 pub fn with_path(db_path: PathBuf) -> Self {
186 Self { db_path }
187 }
188
189 pub fn checkpoint(&self, checkpoint: &HandupCheckpoint) -> Result<PathBuf> {
190 let connection = self.open()?;
191 Self::init_schema(&connection)?;
192 connection.execute(
193 "INSERT INTO checkpoints (project, cwd, generated, recommendation, json_path)
194 VALUES (?1, ?2, ?3, ?4, ?5)",
195 params![
196 checkpoint.project,
197 checkpoint.cwd,
198 checkpoint.generated,
199 checkpoint.recommendation,
200 checkpoint.json_path
201 ],
202 )?;
203 Ok(self.db_path.clone())
204 }
205
206 fn open(&self) -> Result<Connection> {
207 let parent = self
208 .db_path
209 .parent()
210 .ok_or_else(|| anyhow!("database path has no parent directory"))?;
211 fs::create_dir_all(parent)
212 .with_context(|| format!("failed to create {}", parent.display()))?;
213
214 Connection::open(&self.db_path)
215 .with_context(|| format!("failed to open {}", self.db_path.display()))
216 }
217
218 fn init_schema(connection: &Connection) -> Result<()> {
219 connection.execute_batch(
220 "CREATE TABLE IF NOT EXISTS checkpoints (
221 id INTEGER PRIMARY KEY AUTOINCREMENT,
222 project TEXT NOT NULL,
223 cwd TEXT NOT NULL,
224 generated TEXT NOT NULL,
225 recommendation TEXT,
226 json_path TEXT NOT NULL,
227 created_at TEXT DEFAULT (datetime('now'))
228 );",
229 )?;
230 Ok(())
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use hj_core::{Handoff, HandoffItem};
237 use rusqlite::Connection;
238 use tempfile::tempdir;
239
240 use super::{HandoffDb, HandoffRow, HandupCheckpoint, HandupDb};
241
242 #[test]
243 fn query_returns_rows_in_priority_order() {
244 let tmp = tempdir().expect("tempdir");
245 let db = HandoffDb::with_path(tmp.path().join("handoff.db"));
246 let handoff = Handoff {
247 items: vec![
248 HandoffItem {
249 id: "hj-2".into(),
250 priority: Some("P2".into()),
251 status: Some("open".into()),
252 ..HandoffItem::default()
253 },
254 HandoffItem {
255 id: "hj-1".into(),
256 priority: Some("P1".into()),
257 status: Some("blocked".into()),
258 ..HandoffItem::default()
259 },
260 ],
261 ..Handoff::default()
262 };
263
264 db.upsert("hj", &handoff, "2026-04-16").expect("upsert");
265
266 let rows = db.query("hj").expect("query");
267 assert_eq!(
268 rows,
269 vec![
270 HandoffRow {
271 id: "hj-1".into(),
272 priority: "P1".into(),
273 status: "blocked".into(),
274 completed: String::new(),
275 updated: "2026-04-16".into(),
276 },
277 HandoffRow {
278 id: "hj-2".into(),
279 priority: "P2".into(),
280 status: "open".into(),
281 completed: String::new(),
282 updated: "2026-04-16".into(),
283 },
284 ]
285 );
286 }
287
288 #[test]
289 fn complete_and_status_update_existing_rows() {
290 let tmp = tempdir().expect("tempdir");
291 let db = HandoffDb::with_path(tmp.path().join("handoff.db"));
292 let handoff = Handoff {
293 items: vec![HandoffItem {
294 id: "hj-1".into(),
295 priority: Some("P1".into()),
296 status: Some("open".into()),
297 ..HandoffItem::default()
298 }],
299 ..Handoff::default()
300 };
301
302 db.upsert("hj", &handoff, "2026-04-16").expect("upsert");
303 assert!(
304 db.set_status("hj", "hj-1", "blocked", "2026-04-17")
305 .expect("status")
306 );
307 assert!(db.complete("hj", "hj-1", "2026-04-18").expect("complete"));
308
309 let rows = db.query("hj").expect("query");
310 assert_eq!(rows[0].status, "done");
311 assert_eq!(rows[0].completed, "2026-04-18");
312 assert_eq!(rows[0].updated, "2026-04-18");
313 }
314
315 #[test]
316 fn handup_checkpoint_persists_rows() {
317 let tmp = tempdir().expect("tempdir");
318 let db = HandupDb::with_path(tmp.path().join("handup.db"));
319 let checkpoint = HandupCheckpoint {
320 project: "hj".into(),
321 cwd: "/Users/joe/dev/hj".into(),
322 generated: "2026-04-16".into(),
323 recommendation: "Clean state".into(),
324 json_path: "/Users/joe/.ctx/handoffs/hj/HANDUP.json".into(),
325 };
326
327 let db_path = db.checkpoint(&checkpoint).expect("checkpoint");
328 assert!(db_path.ends_with("handup.db"));
329
330 let connection = Connection::open(db_path).expect("open db");
331 let count: i64 = connection
332 .query_row("SELECT COUNT(*) FROM checkpoints", [], |row| row.get(0))
333 .expect("count");
334 assert_eq!(count, 1);
335 }
336}