Wrap db plumbing into a Db struct
Replace the free open/migrate functions with a Db struct that owns
its path and handles migration at construction time. Callers get a
connection via Db::connect() instead of storing a raw PathBuf and
calling db::open() directly. QuireCi.db_path()/open_db() are replaced
by QuireCi.db() returning the Db.

https://claude.ai/code/session_01YXVu67hVnQSAY8wzmfmRHw
change
commit cedec48d7952dfb21e8d10b457602a4f0008d18c
author Claude <noreply@anthropic.com>
date
parent e3d210a3
diff --git a/quire-ci/src/db.rs b/quire-ci/src/db.rs
index fabfada..4bf8e9c 100644
--- a/quire-ci/src/db.rs
+++ b/quire-ci/src/db.rs
@@ -1,4 +1,4 @@
-use std::path::Path;
+use std::path::{Path, PathBuf};
 
 use rusqlite::Connection;
 use rusqlite_migration::{M, Migrations};
@@ -8,14 +8,40 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
 });
 
 #[derive(Debug, thiserror::Error)]
-pub enum MigrationError {
+pub enum Error {
     #[error(transparent)]
     Sqlite(#[from] rusqlite::Error),
     #[error("migration error: {0}")]
     Migration(#[from] rusqlite_migration::Error),
 }
 
-pub fn open(path: &Path) -> Result<Connection, rusqlite::Error> {
+/// An opened, migrated SQLite database. Cheap to clone — holds only the path.
+#[derive(Clone, Debug)]
+pub struct Db {
+    path: PathBuf,
+}
+
+impl Db {
+    /// Open the database at `path`, run pending migrations, and return a `Db`.
+    pub fn open(path: PathBuf) -> Result<Self, Error> {
+        let mut conn = connect(&path)?;
+        MIGRATIONS.to_latest(&mut conn)?;
+        let db = Self { path };
+        tracing::debug!(path = %db.path().display(), "database opened");
+        Ok(db)
+    }
+
+    /// Open a new connection to the database.
+    pub fn connect(&self) -> Result<Connection, rusqlite::Error> {
+        connect(&self.path)
+    }
+
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+}
+
+fn connect(path: &Path) -> Result<Connection, rusqlite::Error> {
     let conn = Connection::open(path)?;
     conn.execute_batch(
         "PRAGMA journal_mode = WAL;
@@ -25,13 +51,8 @@ pub fn open(path: &Path) -> Result<Connection, rusqlite::Error> {
     Ok(conn)
 }
 
-pub fn migrate(conn: &mut Connection) -> Result<(), MigrationError> {
-    MIGRATIONS.to_latest(conn)?;
-    Ok(())
-}
-
 #[cfg(test)]
-pub fn open_in_memory() -> Result<Connection, MigrationError> {
+pub fn open_in_memory() -> Result<Connection, Error> {
     let mut conn = Connection::open_in_memory()?;
     conn.execute_batch("PRAGMA foreign_keys = ON;")?;
     MIGRATIONS.to_latest(&mut conn)?;
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 80d090c..f2d24ab 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -1,11 +1,10 @@
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
 
 use miette::{IntoDiagnostic, Result};
-use rusqlite::Connection;
 
 use quire_core::fennel::Fennel;
 
-use crate::db;
+use crate::db::Db;
 
 /// Parsed global configuration (`<base-dir>/config.fnl`).
 #[derive(serde::Deserialize, Debug, Clone)]
@@ -37,7 +36,7 @@ pub use quire_core::telemetry::SentryConfig;
 #[derive(Clone)]
 pub struct QuireCi {
     config: GlobalConfig,
-    db_path: PathBuf,
+    db: Db,
 }
 
 impl QuireCi {
@@ -49,22 +48,16 @@ impl QuireCi {
         } else {
             GlobalConfig::default()
         };
-        let db_path = base_dir.join("quire-ci.db");
-        let mut conn = db::open(&db_path).into_diagnostic()?;
-        db::migrate(&mut conn).into_diagnostic()?;
-        Ok(Self { config, db_path })
+        let db = Db::open(base_dir.join("quire-ci.db")).into_diagnostic()?;
+        Ok(Self { config, db })
     }
 
     pub fn config(&self) -> &GlobalConfig {
         &self.config
     }
 
-    pub fn db_path(&self) -> &Path {
-        &self.db_path
-    }
-
-    pub fn open_db(&self) -> Result<Connection, rusqlite::Error> {
-        db::open(self.db_path())
+    pub fn db(&self) -> &Db {
+        &self.db
     }
 }
 
@@ -135,7 +128,7 @@ mod tests {
     fn db_is_created_at_expected_path() {
         let dir = tempfile::tempdir().expect("tempdir");
         let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
-        assert_eq!(q.db_path(), dir.path().join("quire-ci.db"));
-        assert!(q.db_path().exists(), "db file should exist after new()");
+        assert_eq!(q.db().path(), dir.path().join("quire-ci.db"));
+        assert!(q.db().path().exists(), "db file should exist after new()");
     }
 }
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index ded931d..18022f4 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -64,7 +64,7 @@ async fn webhook(
         .and_then(|v| v.to_str().ok())
         .map(|s| s.to_string());
 
-    let conn = match quire.open_db() {
+    let conn = match quire.db().connect() {
         Ok(c) => c,
         Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
     };
@@ -193,7 +193,7 @@ mod tests {
     async fn valid_hmac_creates_run_row() {
         let secret = "test-secret-key";
         let (_dir, quire) = quire_with_secret(secret);
-        let db_path = quire.db_path().to_path_buf();
+        let db = quire.db().clone();
         let app = make_app(quire);
 
         let body = push_event_body();
@@ -210,7 +210,7 @@ mod tests {
         let resp = app.oneshot(req).await.unwrap();
         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
 
-        let conn = crate::db::open(&db_path).expect("open db");
+        let conn = db.connect().expect("connect");
         let count: i64 = conn
             .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
             .expect("count");
@@ -220,7 +220,7 @@ mod tests {
     #[tokio::test]
     async fn no_secret_configured_allows_unsigned_post() {
         let (_dir, quire) = quire_without_secret();
-        let db_path = quire.db_path().to_path_buf();
+        let db = quire.db().clone();
         let app = make_app(quire);
 
         let body = push_event_body();
@@ -235,7 +235,7 @@ mod tests {
         let resp = app.oneshot(req).await.unwrap();
         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
 
-        let conn = crate::db::open(&db_path).expect("open db");
+        let conn = db.connect().expect("connect");
         let count: i64 = conn
             .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
             .expect("count");