1use std::{fs, path::PathBuf};
2
3use anyhow::{Context, Result, anyhow};
4use hj_core::Handoff;
5use rusqlite::{Connection, params, params_from_iter};
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 mut connection = self.open()?;
60 Self::init_schema(&connection)?;
61 let transaction = connection.transaction()?;
62
63 let mut synced = 0usize;
64 for item in &handoff.items {
65 transaction.execute(
66 "INSERT INTO items (project, id, name, priority, status, completed, updated)
67 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
68 ON CONFLICT(project, id) DO UPDATE SET
69 status = excluded.status,
70 completed = excluded.completed,
71 updated = excluded.updated",
72 params![
73 project,
74 item.id,
75 item.name.as_deref().unwrap_or_default(),
76 item.priority.as_deref().unwrap_or_default(),
77 item.status.as_deref().unwrap_or_default(),
78 item.completed.as_deref().unwrap_or_default(),
79 today,
80 ],
81 )?;
82 synced += 1;
83 }
84 Self::prune_missing_items(&transaction, project, handoff)?;
85 transaction.commit()?;
86
87 Ok(UpsertReport {
88 db_path: self.db_path.clone(),
89 synced,
90 })
91 }
92
93 pub fn query(&self, project: &str) -> Result<Vec<HandoffRow>> {
94 let connection = self.open()?;
95 Self::init_schema(&connection)?;
96
97 let mut statement = connection.prepare(
98 "SELECT id, coalesce(priority, ''), coalesce(status, ''), coalesce(completed, ''),
99 coalesce(updated, '')
100 FROM items
101 WHERE project = ?1
102 ORDER BY priority, id",
103 )?;
104 let rows = statement.query_map(params![project], |row| {
105 Ok(HandoffRow {
106 id: row.get(0)?,
107 priority: row.get(1)?,
108 status: row.get(2)?,
109 completed: row.get(3)?,
110 updated: row.get(4)?,
111 })
112 })?;
113
114 let mut items = Vec::new();
115 for row in rows {
116 items.push(row?);
117 }
118 Ok(items)
119 }
120
121 pub fn complete(&self, project: &str, id: &str, today: &str) -> Result<bool> {
122 self.update_status(project, id, "done", Some(today), today)
123 }
124
125 pub fn set_status(&self, project: &str, id: &str, status: &str, today: &str) -> Result<bool> {
126 self.update_status(project, id, status, None, today)
127 }
128
129 fn open(&self) -> Result<Connection> {
130 let parent = self
131 .db_path
132 .parent()
133 .ok_or_else(|| anyhow!("database path has no parent directory"))?;
134 fs::create_dir_all(parent)
135 .with_context(|| format!("failed to create {}", parent.display()))?;
136
137 Connection::open(&self.db_path)
138 .with_context(|| format!("failed to open {}", self.db_path.display()))
139 }
140
141 fn init_schema(connection: &Connection) -> Result<()> {
142 connection.execute_batch(
143 "CREATE TABLE IF NOT EXISTS items (
144 project TEXT NOT NULL,
145 id TEXT NOT NULL,
146 name TEXT,
147 priority TEXT,
148 status TEXT,
149 completed TEXT,
150 updated TEXT,
151 PRIMARY KEY (project, id)
152 );",
153 )?;
154 Ok(())
155 }
156
157 fn prune_missing_items(
158 connection: &Connection,
159 project: &str,
160 handoff: &Handoff,
161 ) -> Result<()> {
162 if handoff.items.is_empty() {
163 connection.execute("DELETE FROM items WHERE project = ?1", params![project])?;
164 return Ok(());
165 }
166
167 let placeholders = std::iter::repeat_n("?", handoff.items.len())
168 .collect::<Vec<_>>()
169 .join(", ");
170 let sql = format!("DELETE FROM items WHERE project = ? AND id NOT IN ({placeholders})");
171 let params = std::iter::once(project.to_string())
172 .chain(handoff.items.iter().map(|item| item.id.clone()))
173 .collect::<Vec<_>>();
174 connection.execute(&sql, params_from_iter(params))?;
175 Ok(())
176 }
177
178 fn update_status(
179 &self,
180 project: &str,
181 id: &str,
182 status: &str,
183 completed: Option<&str>,
184 today: &str,
185 ) -> Result<bool> {
186 let connection = self.open()?;
187 Self::init_schema(&connection)?;
188 let changed = connection.execute(
189 "UPDATE items
190 SET status = ?3,
191 completed = COALESCE(?4, completed),
192 updated = ?5
193 WHERE project = ?1 AND id = ?2",
194 params![project, id, status, completed, today],
195 )?;
196 Ok(changed > 0)
197 }
198}
199
200impl HandupDb {
201 pub fn new() -> Result<Self> {
202 let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?;
203 Ok(Self {
204 db_path: home.join(".ctx/handoffs/handup.db"),
205 })
206 }
207
208 #[cfg(test)]
209 pub fn with_path(db_path: PathBuf) -> Self {
210 Self { db_path }
211 }
212
213 pub fn checkpoint(&self, checkpoint: &HandupCheckpoint) -> Result<PathBuf> {
214 let connection = self.open()?;
215 Self::init_schema(&connection)?;
216 connection.execute(
217 "INSERT INTO checkpoints (project, cwd, generated, recommendation, json_path)
218 VALUES (?1, ?2, ?3, ?4, ?5)",
219 params![
220 checkpoint.project,
221 checkpoint.cwd,
222 checkpoint.generated,
223 checkpoint.recommendation,
224 checkpoint.json_path
225 ],
226 )?;
227 Ok(self.db_path.clone())
228 }
229
230 fn open(&self) -> Result<Connection> {
231 let parent = self
232 .db_path
233 .parent()
234 .ok_or_else(|| anyhow!("database path has no parent directory"))?;
235 fs::create_dir_all(parent)
236 .with_context(|| format!("failed to create {}", parent.display()))?;
237
238 Connection::open(&self.db_path)
239 .with_context(|| format!("failed to open {}", self.db_path.display()))
240 }
241
242 fn init_schema(connection: &Connection) -> Result<()> {
243 connection.execute_batch(
244 "CREATE TABLE IF NOT EXISTS checkpoints (
245 id INTEGER PRIMARY KEY AUTOINCREMENT,
246 project TEXT NOT NULL,
247 cwd TEXT NOT NULL,
248 generated TEXT NOT NULL,
249 recommendation TEXT,
250 json_path TEXT NOT NULL,
251 created_at TEXT DEFAULT (datetime('now'))
252 );",
253 )?;
254 Ok(())
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use hj_core::{Handoff, HandoffItem};
261 use rusqlite::Connection;
262 use tempfile::tempdir;
263
264 use super::{HandoffDb, HandoffRow, HandupCheckpoint, HandupDb};
265
266 #[test]
267 fn query_returns_rows_in_priority_order() {
268 let tmp = tempdir().expect("tempdir");
269 let db = HandoffDb::with_path(tmp.path().join("handoff.db"));
270 let handoff = Handoff {
271 items: vec![
272 HandoffItem {
273 id: "hj-2".into(),
274 priority: Some("P2".into()),
275 status: Some("open".into()),
276 ..HandoffItem::default()
277 },
278 HandoffItem {
279 id: "hj-1".into(),
280 priority: Some("P1".into()),
281 status: Some("blocked".into()),
282 ..HandoffItem::default()
283 },
284 ],
285 ..Handoff::default()
286 };
287
288 db.upsert("hj", &handoff, "2026-04-16").expect("upsert");
289
290 let rows = db.query("hj").expect("query");
291 assert_eq!(
292 rows,
293 vec![
294 HandoffRow {
295 id: "hj-1".into(),
296 priority: "P1".into(),
297 status: "blocked".into(),
298 completed: String::new(),
299 updated: "2026-04-16".into(),
300 },
301 HandoffRow {
302 id: "hj-2".into(),
303 priority: "P2".into(),
304 status: "open".into(),
305 completed: String::new(),
306 updated: "2026-04-16".into(),
307 },
308 ]
309 );
310 }
311
312 #[test]
313 fn complete_and_status_update_existing_rows() {
314 let tmp = tempdir().expect("tempdir");
315 let db = HandoffDb::with_path(tmp.path().join("handoff.db"));
316 let handoff = Handoff {
317 items: vec![HandoffItem {
318 id: "hj-1".into(),
319 priority: Some("P1".into()),
320 status: Some("open".into()),
321 ..HandoffItem::default()
322 }],
323 ..Handoff::default()
324 };
325
326 db.upsert("hj", &handoff, "2026-04-16").expect("upsert");
327 assert!(
328 db.set_status("hj", "hj-1", "blocked", "2026-04-17")
329 .expect("status")
330 );
331 assert!(db.complete("hj", "hj-1", "2026-04-18").expect("complete"));
332
333 let rows = db.query("hj").expect("query");
334 assert_eq!(rows[0].status, "done");
335 assert_eq!(rows[0].completed, "2026-04-18");
336 assert_eq!(rows[0].updated, "2026-04-18");
337 }
338
339 #[test]
340 fn upsert_prunes_rows_removed_from_handoff() {
341 let tmp = tempdir().expect("tempdir");
342 let db = HandoffDb::with_path(tmp.path().join("handoff.db"));
343 let initial = Handoff {
344 items: vec![
345 HandoffItem {
346 id: "hj-1".into(),
347 priority: Some("P1".into()),
348 status: Some("open".into()),
349 ..HandoffItem::default()
350 },
351 HandoffItem {
352 id: "hj-2".into(),
353 priority: Some("P2".into()),
354 status: Some("open".into()),
355 ..HandoffItem::default()
356 },
357 ],
358 ..Handoff::default()
359 };
360 let updated = Handoff {
361 items: vec![HandoffItem {
362 id: "hj-2".into(),
363 priority: Some("P2".into()),
364 status: Some("blocked".into()),
365 ..HandoffItem::default()
366 }],
367 ..Handoff::default()
368 };
369
370 db.upsert("hj", &initial, "2026-04-16")
371 .expect("initial upsert");
372 db.upsert("hj", &updated, "2026-04-17")
373 .expect("updated upsert");
374
375 let rows = db.query("hj").expect("query");
376 assert_eq!(
377 rows,
378 vec![HandoffRow {
379 id: "hj-2".into(),
380 priority: "P2".into(),
381 status: "blocked".into(),
382 completed: String::new(),
383 updated: "2026-04-17".into(),
384 }]
385 );
386 }
387
388 #[test]
389 fn upsert_empty_handoff_prunes_only_target_project() {
390 let tmp = tempdir().expect("tempdir");
391 let db = HandoffDb::with_path(tmp.path().join("handoff.db"));
392 let initial = Handoff {
393 items: vec![HandoffItem {
394 id: "hj-1".into(),
395 priority: Some("P1".into()),
396 status: Some("open".into()),
397 ..HandoffItem::default()
398 }],
399 ..Handoff::default()
400 };
401 let other_project = Handoff {
402 items: vec![HandoffItem {
403 id: "other-1".into(),
404 priority: Some("P2".into()),
405 status: Some("open".into()),
406 ..HandoffItem::default()
407 }],
408 ..Handoff::default()
409 };
410
411 db.upsert("hj", &initial, "2026-04-16").expect("hj upsert");
412 db.upsert("other", &other_project, "2026-04-16")
413 .expect("other upsert");
414 db.upsert("hj", &Handoff::default(), "2026-04-17")
415 .expect("empty upsert");
416
417 assert!(db.query("hj").expect("hj query").is_empty());
418 assert_eq!(
419 db.query("other").expect("other query"),
420 vec![HandoffRow {
421 id: "other-1".into(),
422 priority: "P2".into(),
423 status: "open".into(),
424 completed: String::new(),
425 updated: "2026-04-16".into(),
426 }]
427 );
428 }
429
430 #[test]
431 fn handup_checkpoint_persists_rows() {
432 let tmp = tempdir().expect("tempdir");
433 let db = HandupDb::with_path(tmp.path().join("handup.db"));
434 let checkpoint = HandupCheckpoint {
435 project: "hj".into(),
436 cwd: "/Users/joe/dev/hj".into(),
437 generated: "2026-04-16".into(),
438 recommendation: "Clean state".into(),
439 json_path: "/Users/joe/.ctx/handoffs/hj/HANDUP.json".into(),
440 };
441
442 let db_path = db.checkpoint(&checkpoint).expect("checkpoint");
443 assert!(db_path.ends_with("handup.db"));
444
445 let connection = Connection::open(db_path).expect("open db");
446 let count: i64 = connection
447 .query_row("SELECT COUNT(*) FROM checkpoints", [], |row| row.get(0))
448 .expect("count");
449 assert_eq!(count, 1);
450 }
451}