1use crate::error::StorageResult;
4use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
5use sqlx::SqlitePool;
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9pub const DEFAULT_DB_PATH: &str = "luft.db";
11
12pub type DbPool = SqlitePool;
13
14pub async fn open_db(path: &Path) -> StorageResult<DbPool> {
19 if let Some(parent) = path.parent() {
20 std::fs::create_dir_all(parent)?;
21 }
22
23 let options = SqliteConnectOptions::new()
24 .filename(path)
25 .create_if_missing(true)
26 .journal_mode(SqliteJournalMode::Wal)
27 .synchronous(SqliteSynchronous::Normal)
28 .busy_timeout(Duration::from_secs(5))
29 .foreign_keys(true);
30
31 let pool = SqlitePoolOptions::new()
32 .max_connections(8)
33 .connect_with(options)
34 .await?;
35
36 sqlx::migrate!("./migrations").run(&pool).await?;
37
38 Ok(pool)
39}
40
41pub fn default_db_path(luft_dir: &Path) -> PathBuf {
43 luft_dir.join(DEFAULT_DB_PATH)
44}
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49 use tempfile::tempdir;
50
51 #[tokio::test]
52 async fn open_db_creates_tables() {
53 let dir = tempdir().unwrap();
54 let db_path = dir.path().join("test.db");
55
56 let pool = open_db(&db_path).await.unwrap();
57
58 let rows = sqlx::query_as::<_, (String,)>(
60 "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
61 )
62 .fetch_all(&pool)
63 .await
64 .unwrap();
65
66 let names: Vec<String> = rows.into_iter().map(|r| r.0).collect();
67 assert!(names.contains(&"runs".to_string()));
68 assert!(names.contains(&"phases".to_string()));
69 assert!(names.contains(&"agents".to_string()));
70 assert!(names.contains(&"turns".to_string()));
71 assert!(names.contains(&"spans".to_string()));
72 assert!(names.contains(&"findings".to_string()));
73 assert!(names.contains(&"events".to_string()));
74 }
75
76 #[tokio::test]
77 async fn open_db_idempotent_migration() {
78 let dir = tempdir().unwrap();
79 let db_path = dir.path().join("test.db");
80
81 let _pool1 = open_db(&db_path).await.unwrap();
82 let _pool2 = open_db(&db_path).await.unwrap();
84 }
85
86 #[tokio::test]
87 async fn foreign_keys_enabled() {
88 let dir = tempdir().unwrap();
89 let db_path = dir.path().join("test.db");
90 let pool = open_db(&db_path).await.unwrap();
91
92 let enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
93 .fetch_one(&pool)
94 .await
95 .unwrap();
96 assert_eq!(enabled, 1);
97 }
98
99 #[tokio::test]
100 async fn wal_mode_enabled() {
101 let dir = tempdir().unwrap();
102 let db_path = dir.path().join("test.db");
103 let pool = open_db(&db_path).await.unwrap();
104
105 let mode: String = sqlx::query_scalar("PRAGMA journal_mode")
106 .fetch_one(&pool)
107 .await
108 .unwrap();
109 assert_eq!(mode.to_lowercase(), "wal");
110 }
111
112 #[tokio::test]
113 async fn missing_parent_dir_is_created() {
114 let dir = tempdir().unwrap();
115 let nested = dir.path().join("a/b/c/test.db");
116
117 let _pool = open_db(&nested).await.unwrap();
119 assert!(nested.exists());
120 }
121
122 #[allow(dead_code)]
123 fn _assert_send_sync() {
124 fn assert<T: Send + Sync>() {}
125 assert::<DbPool>();
126 }
127}