Skip to main content

hj_sqlite/
lib.rs

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}