1pub mod derivations;
2pub mod migrate;
3pub mod repository;
4pub mod search;
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use rusqlite::{Connection, Transaction};
11
12use crate::errors::AppError;
13
14#[derive(Debug, Clone)]
15pub struct Storage {
16 db_path: PathBuf,
17}
18
19impl Storage {
20 pub fn new(db_path: PathBuf) -> Self {
21 Self { db_path }
22 }
23
24 pub fn db_path(&self) -> &Path {
25 &self.db_path
26 }
27
28 pub fn init(&self) -> Result<(), AppError> {
29 let _conn = self.open_connection()?;
30 Ok(())
31 }
32
33 pub fn with_connection<T, F>(&self, f: F) -> Result<T, AppError>
34 where
35 F: FnOnce(&Connection) -> Result<T, AppError>,
36 {
37 let conn = self.open_connection()?;
38 f(&conn)
39 }
40
41 pub fn with_transaction<T, F>(&self, f: F) -> Result<T, AppError>
42 where
43 F: FnOnce(&Transaction<'_>) -> Result<T, AppError>,
44 {
45 let mut conn = self.open_connection()?;
46 let tx = conn.transaction().map_err(AppError::db_write)?;
47 let out = f(&tx)?;
48 tx.commit().map_err(AppError::db_write)?;
49 Ok(out)
50 }
51
52 fn open_connection(&self) -> Result<Connection, AppError> {
53 if let Some(parent) = self.db_path.parent()
54 && !parent.as_os_str().is_empty()
55 {
56 fs::create_dir_all(parent).map_err(AppError::db_open)?;
57 }
58
59 let conn = Connection::open(&self.db_path).map_err(AppError::db_open)?;
60 conn.pragma_update(None, "foreign_keys", "ON")
61 .map_err(AppError::db_open)?;
62 conn.pragma_update(None, "journal_mode", "WAL")
63 .map_err(AppError::db_open)?;
64 conn.busy_timeout(Duration::from_secs(2))
65 .map_err(AppError::db_open)?;
66
67 migrate::apply(&conn)?;
68 Ok(conn)
69 }
70}
71
72#[cfg(test)]
73pub(crate) mod tests {
74 use std::path::PathBuf;
75
76 use pretty_assertions::assert_eq;
77
78 use super::Storage;
79
80 fn test_db_path(name: &str) -> PathBuf {
81 let dir = tempfile::tempdir().expect("tempdir should be created");
82 dir.keep().join(format!("{name}.db"))
83 }
84
85 #[test]
86 fn init_db() {
87 let db_path = test_db_path("init_db");
88 let storage = Storage::new(db_path);
89 storage.init().expect("storage init should succeed");
90
91 let table_name: String = storage
92 .with_connection(|conn| {
93 conn.query_row(
94 "select name from sqlite_master where type='table' and name='inbox_items'",
95 [],
96 |row| row.get(0),
97 )
98 .map_err(crate::errors::AppError::db_query)
99 })
100 .expect("inbox_items table should exist");
101
102 assert_eq!(table_name, "inbox_items");
103 }
104
105 #[test]
106 fn migration_idempotent() {
107 let db_path = test_db_path("migration_idempotent");
108 let storage = Storage::new(db_path);
109 storage.init().expect("first init should succeed");
110 storage.init().expect("second init should succeed");
111
112 let applied_count_v1: i64 = storage
113 .with_connection(|conn| {
114 conn.query_row(
115 "select count(*) from schema_migrations where version = 1",
116 [],
117 |row| row.get(0),
118 )
119 .map_err(crate::errors::AppError::db_query)
120 })
121 .expect("schema migration count query should succeed");
122 let applied_count_total: i64 = storage
123 .with_connection(|conn| {
124 conn.query_row("select count(*) from schema_migrations", [], |row| {
125 row.get(0)
126 })
127 .map_err(crate::errors::AppError::db_query)
128 })
129 .expect("schema migration count query should succeed");
130
131 assert_eq!(applied_count_v1, 1);
132 assert_eq!(applied_count_total, 1);
133 }
134}