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};
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}