1use std::collections::BTreeSet;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15use chrono::Utc;
16use rusqlite::Connection;
17
18use ito_common::match_::nearest_matches;
19use ito_domain::backend::BackendProjectStore;
20use ito_domain::changes::{
21 Change, ChangeLifecycleFilter, ChangeRepository, ChangeSummary, ChangeTargetResolution,
22 ResolveTargetOptions, Spec, parse_change_id, parse_module_id,
23};
24use ito_domain::errors::{DomainError, DomainResult};
25use ito_domain::modules::{Module, ModuleRepository, ModuleSummary};
26use ito_domain::specs::{SpecDocument, SpecRepository, SpecSummary};
27use ito_domain::tasks::{
28 TaskInitResult, TaskMutationResult, TaskMutationService, TaskMutationServiceResult,
29 TaskRepository, TasksParseResult, parse_tasks_tracking_file,
30};
31use regex::Regex;
32
33use crate::errors::{CoreError, CoreResult};
34use crate::repository_runtime::RepositorySet;
35use crate::task_mutations::task_mutation_error_from_core;
36use crate::tasks::{
37 apply_add_task, apply_complete_task, apply_shelve_task, apply_start_task, apply_unshelve_task,
38 enhanced_tasks_template,
39};
40
41#[path = "sqlite_project_store_backend.rs"]
42mod backend_store;
43#[path = "sqlite_project_store_mutations.rs"]
44mod task_mutations_impl;
45
46#[path = "sqlite_project_store_repositories.rs"]
47mod repositories;
48
49use repositories::{
50 SqliteChangeRepository, SqliteModuleRepository, SqliteSpecRepository, SqliteTaskRepository,
51};
52use task_mutations_impl::SqliteTaskMutationService;
53
54pub struct UpsertChangeParams<'a> {
56 pub org: &'a str,
58 pub repo: &'a str,
60 pub change_id: &'a str,
62 pub module_id: Option<&'a str>,
64 pub proposal: Option<&'a str>,
66 pub design: Option<&'a str>,
68 pub tasks_md: Option<&'a str>,
70 pub specs: &'a [(&'a str, &'a str)],
72}
73
74pub struct SqliteBackendProjectStore {
79 conn: Arc<Mutex<Connection>>,
80}
81
82impl SqliteBackendProjectStore {
83 pub fn open(db_path: &Path) -> Result<Self, CoreError> {
85 if let Some(parent) = db_path.parent() {
86 std::fs::create_dir_all(parent)
87 .map_err(|e| CoreError::io("creating sqlite database directory", e))?;
88 }
89
90 let conn = Connection::open(db_path)
91 .map_err(|e| CoreError::sqlite(format!("opening database: {e}")))?;
92
93 let store = Self {
94 conn: Arc::new(Mutex::new(conn)),
95 };
96 store.initialize_schema()?;
97 Ok(store)
98 }
99
100 pub fn open_in_memory() -> Result<Self, CoreError> {
102 let conn = Connection::open_in_memory()
103 .map_err(|e| CoreError::sqlite(format!("opening in-memory database: {e}")))?;
104 let store = Self {
105 conn: Arc::new(Mutex::new(conn)),
106 };
107 store.initialize_schema()?;
108 Ok(store)
109 }
110
111 fn initialize_schema(&self) -> Result<(), CoreError> {
112 let conn = self.lock_conn()?;
113 conn.execute_batch(
114 "CREATE TABLE IF NOT EXISTS projects (
115 org TEXT NOT NULL,
116 repo TEXT NOT NULL,
117 created_at TEXT NOT NULL,
118 PRIMARY KEY (org, repo)
119 );
120
121 CREATE TABLE IF NOT EXISTS changes (
122 org TEXT NOT NULL,
123 repo TEXT NOT NULL,
124 change_id TEXT NOT NULL,
125 module_id TEXT,
126 proposal TEXT,
127 design TEXT,
128 tasks_md TEXT,
129 archived_at TEXT,
130 created_at TEXT NOT NULL,
131 updated_at TEXT NOT NULL,
132 PRIMARY KEY (org, repo, change_id),
133 FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
134 );
135
136 CREATE TABLE IF NOT EXISTS change_specs (
137 org TEXT NOT NULL,
138 repo TEXT NOT NULL,
139 change_id TEXT NOT NULL,
140 capability TEXT NOT NULL,
141 content TEXT NOT NULL,
142 PRIMARY KEY (org, repo, change_id, capability),
143 FOREIGN KEY (org, repo, change_id)
144 REFERENCES changes(org, repo, change_id)
145 );
146
147 CREATE TABLE IF NOT EXISTS modules (
148 org TEXT NOT NULL,
149 repo TEXT NOT NULL,
150 module_id TEXT NOT NULL,
151 name TEXT NOT NULL,
152 description TEXT,
153 created_at TEXT NOT NULL,
154 updated_at TEXT NOT NULL,
155 PRIMARY KEY (org, repo, module_id),
156 FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
157 );
158
159 CREATE TABLE IF NOT EXISTS promoted_specs (
160 org TEXT NOT NULL,
161 repo TEXT NOT NULL,
162 spec_id TEXT NOT NULL,
163 markdown TEXT NOT NULL,
164 updated_at TEXT NOT NULL,
165 PRIMARY KEY (org, repo, spec_id),
166 FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
167 );",
168 )
169 .map_err(|e| CoreError::sqlite(format!("initializing schema: {e}")))
170 }
171
172 pub fn upsert_change(&self, params: &UpsertChangeParams<'_>) -> Result<(), CoreError> {
174 let UpsertChangeParams {
175 org,
176 repo,
177 change_id,
178 module_id,
179 proposal,
180 design,
181 tasks_md,
182 specs,
183 } = params;
184 let conn = self.lock_conn()?;
185 let now = Utc::now().to_rfc3339();
186
187 conn.execute(
188 "INSERT OR REPLACE INTO changes
189 (org, repo, change_id, module_id, proposal, design, tasks_md, created_at, updated_at)
190 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
191 rusqlite::params![
192 org, repo, change_id, module_id, proposal, design, tasks_md, now, now
193 ],
194 )
195 .map_err(|e| CoreError::sqlite(format!("upserting change: {e}")))?;
196
197 conn.execute(
199 "DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
200 rusqlite::params![org, repo, change_id],
201 )
202 .map_err(|e| CoreError::sqlite(format!("deleting old specs: {e}")))?;
203
204 for (capability, content) in *specs {
205 conn.execute(
206 "INSERT INTO change_specs (org, repo, change_id, capability, content)
207 VALUES (?1, ?2, ?3, ?4, ?5)",
208 rusqlite::params![org, repo, change_id, capability, content],
209 )
210 .map_err(|e| CoreError::sqlite(format!("inserting spec: {e}")))?;
211 }
212
213 Ok(())
214 }
215
216 pub fn upsert_module(
218 &self,
219 org: &str,
220 repo: &str,
221 module_id: &str,
222 name: &str,
223 description: Option<&str>,
224 ) -> Result<(), CoreError> {
225 let conn = self.lock_conn()?;
226 let now = Utc::now().to_rfc3339();
227
228 conn.execute(
229 "INSERT OR REPLACE INTO modules
230 (org, repo, module_id, name, description, created_at, updated_at)
231 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
232 rusqlite::params![org, repo, module_id, name, description, now, now],
233 )
234 .map_err(|e| CoreError::sqlite(format!("upserting module: {e}")))?;
235
236 Ok(())
237 }
238
239 pub(crate) fn repository_set(&self, org: &str, repo: &str) -> CoreResult<RepositorySet> {
240 let conn = self.lock_conn()?;
241 let changes = load_changes_from_db(&conn, org, repo)?;
242 let modules = load_modules_from_db(&conn, org, repo)?;
243 let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
244 let specs = load_promoted_specs_from_db(&conn, org, repo)?;
245
246 Ok(RepositorySet {
247 changes: Arc::new(SqliteChangeRepository { changes }),
248 modules: Arc::new(SqliteModuleRepository { modules }),
249 tasks: Arc::new(SqliteTaskRepository { tasks_data }),
250 task_mutations: Arc::new(SqliteTaskMutationService {
251 conn: Arc::clone(&self.conn),
252 org: org.to_string(),
253 repo: repo.to_string(),
254 }),
255 specs: Arc::new(SqliteSpecRepository { specs }),
256 })
257 }
258
259 fn lock_conn(&self) -> DomainResult<std::sync::MutexGuard<'_, Connection>> {
260 self.conn.lock().map_err(|e| {
261 DomainError::io(
262 "locking sqlite connection",
263 std::io::Error::other(e.to_string()),
264 )
265 })
266 }
267}
268
269fn load_changes_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ChangeRow>> {
272 let mut stmt = conn
273 .prepare(
274 "SELECT change_id, module_id, proposal, design, tasks_md, created_at, updated_at, archived_at
275 FROM changes WHERE org = ?1 AND repo = ?2",
276 )
277 .map_err(|e| map_sqlite_err("preparing change query", e))?;
278
279 let rows = stmt
280 .query_map(rusqlite::params![org, repo], |row| {
281 Ok(ChangeRow {
282 change_id: row.get(0)?,
283 module_id: row.get(1)?,
284 proposal: row.get(2)?,
285 design: row.get(3)?,
286 tasks_md: row.get(4)?,
287 created_at: row.get(5)?,
288 updated_at: row.get(6)?,
289 archived_at: row.get(7)?,
290 specs: Vec::new(), })
292 })
293 .map_err(|e| map_sqlite_err("querying changes", e))?;
294
295 let mut changes = Vec::new();
296 for row in rows {
297 let mut change = row.map_err(|e| map_sqlite_err("reading change row", e))?;
298
299 let mut spec_stmt = conn
301 .prepare(
302 "SELECT capability, content FROM change_specs
303 WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
304 )
305 .map_err(|e| map_sqlite_err("preparing spec query", e))?;
306
307 let spec_rows = spec_stmt
308 .query_map(rusqlite::params![org, repo, &change.change_id], |row| {
309 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
310 })
311 .map_err(|e| map_sqlite_err("querying specs", e))?;
312
313 for spec_row in spec_rows {
314 let (capability, content) =
315 spec_row.map_err(|e| map_sqlite_err("reading spec row", e))?;
316 change.specs.push((capability, content));
317 }
318
319 changes.push(change);
320 }
321
322 Ok(changes)
323}
324
325fn load_modules_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ModuleRow>> {
326 let mut stmt = conn
327 .prepare(
328 "SELECT module_id, name, description FROM modules
329 WHERE org = ?1 AND repo = ?2",
330 )
331 .map_err(|e| map_sqlite_err("preparing module query", e))?;
332
333 let rows = stmt
334 .query_map(rusqlite::params![org, repo], |row| {
335 Ok(ModuleRow {
336 module_id: row.get(0)?,
337 name: row.get(1)?,
338 description: row.get(2)?,
339 })
340 })
341 .map_err(|e| map_sqlite_err("querying modules", e))?;
342
343 let mut modules = Vec::new();
344 for row in rows {
345 modules.push(row.map_err(|e| map_sqlite_err("reading module row", e))?);
346 }
347
348 Ok(modules)
349}
350
351fn load_tasks_data_from_db(
353 conn: &Connection,
354 org: &str,
355 repo: &str,
356) -> DomainResult<Vec<(String, Option<String>)>> {
357 let mut stmt = conn
358 .prepare("SELECT change_id, tasks_md FROM changes WHERE org = ?1 AND repo = ?2")
359 .map_err(|e| map_sqlite_err("preparing tasks query", e))?;
360
361 let rows = stmt
362 .query_map(rusqlite::params![org, repo], |row| {
363 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
364 })
365 .map_err(|e| map_sqlite_err("querying tasks data", e))?;
366
367 let mut data = Vec::new();
368 for row in rows {
369 data.push(row.map_err(|e| map_sqlite_err("reading tasks row", e))?);
370 }
371
372 Ok(data)
373}
374
375fn load_promoted_specs_from_db(
376 conn: &Connection,
377 org: &str,
378 repo: &str,
379) -> DomainResult<Vec<SpecDocument>> {
380 let mut stmt = conn
381 .prepare(
382 "SELECT spec_id, markdown, updated_at FROM promoted_specs WHERE org = ?1 AND repo = ?2",
383 )
384 .map_err(|e| map_sqlite_err("preparing promoted specs query", e))?;
385
386 let rows = stmt
387 .query_map(rusqlite::params![org, repo], |row| {
388 Ok((
389 row.get::<_, String>(0)?,
390 row.get::<_, String>(1)?,
391 row.get::<_, String>(2)?,
392 ))
393 })
394 .map_err(|e| map_sqlite_err("querying promoted specs", e))?;
395
396 let mut specs = Vec::new();
397 for row in rows {
398 let (id, markdown, updated_at) =
399 row.map_err(|e| map_sqlite_err("reading promoted spec row", e))?;
400 let last_modified = chrono::DateTime::parse_from_rfc3339(&updated_at)
401 .map(|dt| dt.with_timezone(&Utc))
402 .unwrap_or_else(|_| Utc::now());
403 specs.push(SpecDocument {
404 id: id.clone(),
405 path: PathBuf::from(format!(".ito/specs/{id}/spec.md")),
406 markdown,
407 last_modified,
408 });
409 }
410 specs.sort_by(|left, right| left.id.cmp(&right.id));
411 Ok(specs)
412}
413
414fn map_sqlite_err(context: &'static str, err: rusqlite::Error) -> DomainError {
415 DomainError::io(context, std::io::Error::other(err.to_string()))
416}
417
418#[derive(Debug)]
421struct ChangeRow {
422 change_id: String,
423 module_id: Option<String>,
424 proposal: Option<String>,
425 design: Option<String>,
426 tasks_md: Option<String>,
427 #[allow(dead_code)]
428 created_at: String,
429 updated_at: String,
430 archived_at: Option<String>,
431 specs: Vec<(String, String)>,
432}
433
434#[derive(Debug)]
435struct ModuleRow {
436 module_id: String,
437 name: String,
438 description: Option<String>,
439}