1#![deny(unsafe_code)]
10#![warn(missing_docs)]
11
12use std::str::FromStr;
13
14pub type DbConn = sqlx::SqlitePool;
17
18pub async fn connect(url: &str) -> anyhow::Result<sqlx::SqlitePool> {
35 let opts = sqlx::sqlite::SqliteConnectOptions::from_str(url)?
36 .create_if_missing(true)
37 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
38 .foreign_keys(true);
39
40 let pool = sqlx::sqlite::SqlitePoolOptions::new()
41 .max_connections(8)
42 .connect_with(opts)
43 .await?;
44
45 tracing::info!("connected to SQLite database: {}", url);
46
47 Ok(pool)
48}
49
50pub async fn run_internal_migrations(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
56 let migrator = sqlx::migrate!("./migrations");
57 let num_migrations = migrator.migrations.len();
58
59 migrator.run(pool).await?;
60
61 tracing::info!(
62 count = num_migrations,
63 "applied atrg internal migrations (if pending)"
64 );
65
66 Ok(())
67}
68
69pub async fn run_user_migrations(
78 pool: &sqlx::SqlitePool,
79 dir: &std::path::Path,
80) -> anyhow::Result<()> {
81 if !dir.exists() {
82 tracing::debug!(
83 path = %dir.display(),
84 "user migrations directory does not exist, skipping"
85 );
86 return Ok(());
87 }
88
89 let has_sql_files = std::fs::read_dir(dir)?
90 .filter_map(|entry| entry.ok())
91 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "sql"));
92
93 if !has_sql_files {
94 tracing::debug!(
95 path = %dir.display(),
96 "user migrations directory contains no .sql files, skipping"
97 );
98 return Ok(());
99 }
100
101 let migrator = sqlx::migrate::Migrator::new(dir).await?;
102 let num_migrations = migrator.migrations.len();
103
104 migrator.run(pool).await?;
105
106 tracing::info!(
107 count = num_migrations,
108 path = %dir.display(),
109 "applied user migrations (if pending)"
110 );
111
112 Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[tokio::test]
120 async fn test_connect_memory() {
121 let pool = connect("sqlite::memory:")
122 .await
123 .expect("should connect to in-memory SQLite");
124
125 let row: (i32,) = sqlx::query_as("SELECT 1")
126 .fetch_one(&pool)
127 .await
128 .expect("should execute SELECT 1");
129
130 assert_eq!(row.0, 1);
131 }
132
133 #[tokio::test]
134 async fn test_internal_migrations() {
135 let pool = connect("sqlite::memory:").await.expect("should connect");
136
137 run_internal_migrations(&pool)
138 .await
139 .expect("should run internal migrations");
140
141 let row: (String,) = sqlx::query_as(
142 "SELECT name FROM sqlite_master WHERE type='table' AND name='atrg_sessions'",
143 )
144 .fetch_one(&pool)
145 .await
146 .expect("atrg_sessions table should exist");
147
148 assert_eq!(row.0, "atrg_sessions");
149 }
150
151 #[tokio::test]
152 async fn test_migrations_idempotent() {
153 let pool = connect("sqlite::memory:").await.expect("should connect");
154
155 run_internal_migrations(&pool)
156 .await
157 .expect("first run should succeed");
158
159 run_internal_migrations(&pool)
160 .await
161 .expect("second run should also succeed (idempotent)");
162 }
163
164 #[tokio::test]
165 async fn test_user_migrations_empty_dir() {
166 let pool = connect("sqlite::memory:").await.expect("should connect");
167
168 let tmp_dir = std::env::temp_dir().join(format!("atrg_test_empty_{}", std::process::id()));
169 std::fs::create_dir_all(&tmp_dir).expect("should create temp dir");
170
171 let result = run_user_migrations(&pool, &tmp_dir).await;
172
173 let _ = std::fs::remove_dir_all(&tmp_dir);
175
176 result.expect("empty dir should succeed silently");
177 }
178
179 #[tokio::test]
180 async fn test_user_migrations_nonexistent_dir() {
181 let pool = connect("sqlite::memory:").await.expect("should connect");
182
183 let nonexistent =
184 std::path::Path::new("/tmp/atrg_test_nonexistent_dir_that_does_not_exist");
185
186 run_user_migrations(&pool, nonexistent)
187 .await
188 .expect("nonexistent dir should succeed silently");
189 }
190}