1pub mod models;
2
3use crate::error::Result;
4use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
5use std::path::Path;
6
7pub async fn create_pool(db_path: &Path) -> Result<SqlitePool> {
8 let options = SqliteConnectOptions::new()
9 .filename(db_path)
10 .create_if_missing(true)
11 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
12 .busy_timeout(std::time::Duration::from_millis(5000));
13
14 let pool = SqlitePoolOptions::new()
15 .max_connections(5)
16 .connect_with(options)
17 .await?;
18
19 Ok(pool)
20}
21
22pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
23 sqlx::query("PRAGMA journal_mode=WAL;")
25 .execute(pool)
26 .await?;
27
28 sqlx::query(
30 r#"
31 CREATE TABLE IF NOT EXISTS tasks (
32 id INTEGER PRIMARY KEY AUTOINCREMENT,
33 parent_id INTEGER,
34 name TEXT NOT NULL,
35 spec TEXT,
36 status TEXT NOT NULL DEFAULT 'todo',
37 complexity INTEGER,
38 priority INTEGER DEFAULT 0,
39 first_todo_at DATETIME,
40 first_doing_at DATETIME,
41 first_done_at DATETIME,
42 FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE,
43 CHECK (status IN ('todo', 'doing', 'done'))
44 )
45 "#,
46 )
47 .execute(pool)
48 .await?;
49
50 sqlx::query(
52 r#"
53 CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
54 name,
55 spec,
56 content=tasks,
57 content_rowid=id
58 )
59 "#,
60 )
61 .execute(pool)
62 .await?;
63
64 sqlx::query(
66 r#"
67 CREATE TRIGGER IF NOT EXISTS tasks_ai AFTER INSERT ON tasks BEGIN
68 INSERT INTO tasks_fts(rowid, name, spec) VALUES (new.id, new.name, new.spec);
69 END
70 "#,
71 )
72 .execute(pool)
73 .await?;
74
75 sqlx::query(
76 r#"
77 CREATE TRIGGER IF NOT EXISTS tasks_ad AFTER DELETE ON tasks BEGIN
78 DELETE FROM tasks_fts WHERE rowid = old.id;
79 END
80 "#,
81 )
82 .execute(pool)
83 .await?;
84
85 sqlx::query(
86 r#"
87 CREATE TRIGGER IF NOT EXISTS tasks_au AFTER UPDATE ON tasks BEGIN
88 UPDATE tasks_fts SET name = new.name, spec = new.spec WHERE rowid = old.id;
89 END
90 "#,
91 )
92 .execute(pool)
93 .await?;
94
95 sqlx::query(
97 r#"
98 CREATE TABLE IF NOT EXISTS events (
99 id INTEGER PRIMARY KEY AUTOINCREMENT,
100 task_id INTEGER NOT NULL,
101 timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
102 log_type TEXT NOT NULL,
103 discussion_data TEXT NOT NULL,
104 FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
105 )
106 "#,
107 )
108 .execute(pool)
109 .await?;
110
111 sqlx::query(
113 r#"
114 CREATE INDEX IF NOT EXISTS idx_events_task_id ON events(task_id)
115 "#,
116 )
117 .execute(pool)
118 .await?;
119
120 sqlx::query(
122 r#"
123 CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
124 discussion_data,
125 content=events,
126 content_rowid=id
127 )
128 "#,
129 )
130 .execute(pool)
131 .await?;
132
133 sqlx::query(
135 r#"
136 CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN
137 INSERT INTO events_fts(rowid, discussion_data) VALUES (new.id, new.discussion_data);
138 END
139 "#,
140 )
141 .execute(pool)
142 .await?;
143
144 sqlx::query(
145 r#"
146 CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN
147 DELETE FROM events_fts WHERE rowid = old.id;
148 END
149 "#,
150 )
151 .execute(pool)
152 .await?;
153
154 sqlx::query(
155 r#"
156 CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN
157 UPDATE events_fts SET discussion_data = new.discussion_data WHERE rowid = old.id;
158 END
159 "#,
160 )
161 .execute(pool)
162 .await?;
163
164 sqlx::query(
166 r#"
167 CREATE TABLE IF NOT EXISTS workspace_state (
168 key TEXT PRIMARY KEY,
169 value TEXT NOT NULL
170 )
171 "#,
172 )
173 .execute(pool)
174 .await?;
175
176 Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use tempfile::TempDir;
183
184 #[tokio::test]
185 async fn test_create_pool_success() {
186 let temp_dir = TempDir::new().unwrap();
187 let db_path = temp_dir.path().join("test.db");
188
189 let pool = create_pool(&db_path).await.unwrap();
190
191 let result: i64 = sqlx::query_scalar("SELECT 1")
193 .fetch_one(&pool)
194 .await
195 .unwrap();
196
197 assert_eq!(result, 1);
198 }
199
200 #[tokio::test]
201 async fn test_run_migrations_creates_tables() {
202 let temp_dir = TempDir::new().unwrap();
203 let db_path = temp_dir.path().join("test.db");
204 let pool = create_pool(&db_path).await.unwrap();
205
206 run_migrations(&pool).await.unwrap();
207
208 let tables: Vec<String> =
210 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
211 .fetch_all(&pool)
212 .await
213 .unwrap();
214
215 assert!(tables.contains(&"tasks".to_string()));
216 assert!(tables.contains(&"events".to_string()));
217 assert!(tables.contains(&"workspace_state".to_string()));
218 }
219
220 #[tokio::test]
221 async fn test_run_migrations_creates_fts_tables() {
222 let temp_dir = TempDir::new().unwrap();
223 let db_path = temp_dir.path().join("test.db");
224 let pool = create_pool(&db_path).await.unwrap();
225
226 run_migrations(&pool).await.unwrap();
227
228 let tables: Vec<String> = sqlx::query_scalar(
230 "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts'",
231 )
232 .fetch_all(&pool)
233 .await
234 .unwrap();
235
236 assert!(tables.contains(&"tasks_fts".to_string()));
237 assert!(tables.contains(&"events_fts".to_string()));
238 }
239
240 #[tokio::test]
241 async fn test_run_migrations_creates_triggers() {
242 let temp_dir = TempDir::new().unwrap();
243 let db_path = temp_dir.path().join("test.db");
244 let pool = create_pool(&db_path).await.unwrap();
245
246 run_migrations(&pool).await.unwrap();
247
248 let triggers: Vec<String> =
250 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='trigger'")
251 .fetch_all(&pool)
252 .await
253 .unwrap();
254
255 assert!(triggers.contains(&"tasks_ai".to_string()));
256 assert!(triggers.contains(&"tasks_ad".to_string()));
257 assert!(triggers.contains(&"tasks_au".to_string()));
258 assert!(triggers.contains(&"events_ai".to_string()));
259 assert!(triggers.contains(&"events_ad".to_string()));
260 assert!(triggers.contains(&"events_au".to_string()));
261 }
262
263 #[tokio::test]
264 async fn test_run_migrations_idempotent() {
265 let temp_dir = TempDir::new().unwrap();
266 let db_path = temp_dir.path().join("test.db");
267 let pool = create_pool(&db_path).await.unwrap();
268
269 run_migrations(&pool).await.unwrap();
271 run_migrations(&pool).await.unwrap();
272
273 let tables: Vec<String> =
275 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table'")
276 .fetch_all(&pool)
277 .await
278 .unwrap();
279
280 assert!(tables.len() >= 3);
281 }
282
283 #[tokio::test]
284 async fn test_fts_triggers_work() {
285 let temp_dir = TempDir::new().unwrap();
286 let db_path = temp_dir.path().join("test.db");
287 let pool = create_pool(&db_path).await.unwrap();
288 run_migrations(&pool).await.unwrap();
289
290 sqlx::query("INSERT INTO tasks (name, spec, status) VALUES (?, ?, ?)")
292 .bind("Test task")
293 .bind("Test spec")
294 .bind("todo")
295 .execute(&pool)
296 .await
297 .unwrap();
298
299 let count: i64 =
301 sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'Test'")
302 .fetch_one(&pool)
303 .await
304 .unwrap();
305
306 assert_eq!(count, 1);
307 }
308
309 #[tokio::test]
310 async fn test_workspace_state_table_structure() {
311 let temp_dir = TempDir::new().unwrap();
312 let db_path = temp_dir.path().join("test.db");
313 let pool = create_pool(&db_path).await.unwrap();
314 run_migrations(&pool).await.unwrap();
315
316 sqlx::query("INSERT INTO workspace_state (key, value) VALUES (?, ?)")
318 .bind("test_key")
319 .bind("test_value")
320 .execute(&pool)
321 .await
322 .unwrap();
323
324 let value: String = sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = ?")
325 .bind("test_key")
326 .fetch_one(&pool)
327 .await
328 .unwrap();
329
330 assert_eq!(value, "test_value");
331 }
332
333 #[tokio::test]
334 async fn test_task_status_constraint() {
335 let temp_dir = TempDir::new().unwrap();
336 let db_path = temp_dir.path().join("test.db");
337 let pool = create_pool(&db_path).await.unwrap();
338 run_migrations(&pool).await.unwrap();
339
340 let result = sqlx::query("INSERT INTO tasks (name, status) VALUES (?, ?)")
342 .bind("Test")
343 .bind("invalid_status")
344 .execute(&pool)
345 .await;
346
347 assert!(result.is_err());
349 }
350}