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 sub_module_id: Option<&'a str>,
66 pub proposal: Option<&'a str>,
68 pub design: Option<&'a str>,
70 pub tasks_md: Option<&'a str>,
72 pub specs: &'a [(&'a str, &'a str)],
74}
75
76pub struct SqliteBackendProjectStore {
81 conn: Arc<Mutex<Connection>>,
82}
83
84impl SqliteBackendProjectStore {
85 pub fn open(db_path: &Path) -> Result<Self, CoreError> {
87 if let Some(parent) = db_path.parent() {
88 std::fs::create_dir_all(parent)
89 .map_err(|e| CoreError::io("creating sqlite database directory", e))?;
90 }
91
92 let conn = Connection::open(db_path)
93 .map_err(|e| CoreError::sqlite(format!("opening database: {e}")))?;
94
95 let store = Self {
96 conn: Arc::new(Mutex::new(conn)),
97 };
98 store.initialize_schema()?;
99 Ok(store)
100 }
101
102 pub fn open_in_memory() -> Result<Self, CoreError> {
104 let conn = Connection::open_in_memory()
105 .map_err(|e| CoreError::sqlite(format!("opening in-memory database: {e}")))?;
106 let store = Self {
107 conn: Arc::new(Mutex::new(conn)),
108 };
109 store.initialize_schema()?;
110 Ok(store)
111 }
112
113 fn initialize_schema(&self) -> Result<(), CoreError> {
114 let conn = self.lock_conn()?;
115 conn.execute_batch(
116 "CREATE TABLE IF NOT EXISTS projects (
117 org TEXT NOT NULL,
118 repo TEXT NOT NULL,
119 created_at TEXT NOT NULL,
120 PRIMARY KEY (org, repo)
121 );
122
123 CREATE TABLE IF NOT EXISTS changes (
124 org TEXT NOT NULL,
125 repo TEXT NOT NULL,
126 change_id TEXT NOT NULL,
127 module_id TEXT,
128 sub_module_id TEXT,
129 proposal TEXT,
130 design TEXT,
131 tasks_md TEXT,
132 archived_at TEXT,
133 created_at TEXT NOT NULL,
134 updated_at TEXT NOT NULL,
135 PRIMARY KEY (org, repo, change_id),
136 FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
137 );
138
139 CREATE TABLE IF NOT EXISTS change_specs (
140 org TEXT NOT NULL,
141 repo TEXT NOT NULL,
142 change_id TEXT NOT NULL,
143 capability TEXT NOT NULL,
144 content TEXT NOT NULL,
145 PRIMARY KEY (org, repo, change_id, capability),
146 FOREIGN KEY (org, repo, change_id)
147 REFERENCES changes(org, repo, change_id)
148 );
149
150 CREATE TABLE IF NOT EXISTS modules (
151 org TEXT NOT NULL,
152 repo TEXT NOT NULL,
153 module_id TEXT NOT NULL,
154 name TEXT NOT NULL,
155 description TEXT,
156 created_at TEXT NOT NULL,
157 updated_at TEXT NOT NULL,
158 PRIMARY KEY (org, repo, module_id),
159 FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
160 );
161
162 CREATE TABLE IF NOT EXISTS promoted_specs (
163 org TEXT NOT NULL,
164 repo TEXT NOT NULL,
165 spec_id TEXT NOT NULL,
166 markdown TEXT NOT NULL,
167 updated_at TEXT NOT NULL,
168 PRIMARY KEY (org, repo, spec_id),
169 FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
170 );",
171 )
172 .map_err(|e| CoreError::sqlite(format!("initializing schema: {e}")))?;
173
174 let has_col = conn
178 .query_row(
179 "SELECT COUNT(*) FROM pragma_table_info('changes') WHERE name = 'sub_module_id'",
180 [],
181 |row| row.get::<_, i64>(0),
182 )
183 .map_err(|e| CoreError::sqlite(format!("checking schema migration: {e}")))?
184 > 0;
185 if !has_col {
186 conn.execute_batch("ALTER TABLE changes ADD COLUMN sub_module_id TEXT")
187 .map_err(|e| CoreError::sqlite(format!("migrating schema (sub_module_id): {e}")))?;
188 }
189
190 Ok(())
191 }
192
193 pub fn upsert_change(&self, params: &UpsertChangeParams<'_>) -> Result<(), CoreError> {
195 let UpsertChangeParams {
196 org,
197 repo,
198 change_id,
199 module_id,
200 sub_module_id,
201 proposal,
202 design,
203 tasks_md,
204 specs,
205 } = params;
206 let conn = self.lock_conn()?;
207 let now = Utc::now().to_rfc3339();
208
209 conn.execute(
210 "INSERT OR REPLACE INTO changes
211 (org, repo, change_id, module_id, sub_module_id, proposal, design, tasks_md, created_at, updated_at)
212 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
213 rusqlite::params![
214 org, repo, change_id, module_id, sub_module_id, proposal, design, tasks_md, now, now
215 ],
216 )
217 .map_err(|e| CoreError::sqlite(format!("upserting change: {e}")))?;
218
219 conn.execute(
221 "DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
222 rusqlite::params![org, repo, change_id],
223 )
224 .map_err(|e| CoreError::sqlite(format!("deleting old specs: {e}")))?;
225
226 for (capability, content) in *specs {
227 conn.execute(
228 "INSERT INTO change_specs (org, repo, change_id, capability, content)
229 VALUES (?1, ?2, ?3, ?4, ?5)",
230 rusqlite::params![org, repo, change_id, capability, content],
231 )
232 .map_err(|e| CoreError::sqlite(format!("inserting spec: {e}")))?;
233 }
234
235 Ok(())
236 }
237
238 pub fn upsert_module(
240 &self,
241 org: &str,
242 repo: &str,
243 module_id: &str,
244 name: &str,
245 description: Option<&str>,
246 ) -> Result<(), CoreError> {
247 let conn = self.lock_conn()?;
248 let now = Utc::now().to_rfc3339();
249
250 conn.execute(
251 "INSERT OR REPLACE INTO modules
252 (org, repo, module_id, name, description, created_at, updated_at)
253 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
254 rusqlite::params![org, repo, module_id, name, description, now, now],
255 )
256 .map_err(|e| CoreError::sqlite(format!("upserting module: {e}")))?;
257
258 Ok(())
259 }
260
261 pub(crate) fn repository_set(&self, org: &str, repo: &str) -> CoreResult<RepositorySet> {
262 let conn = self.lock_conn()?;
263 let changes = load_changes_from_db(&conn, org, repo)?;
264 let modules = load_modules_from_db(&conn, org, repo)?;
265 let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
266 let specs = load_promoted_specs_from_db(&conn, org, repo)?;
267
268 Ok(RepositorySet {
269 changes: Arc::new(SqliteChangeRepository { changes }),
270 modules: Arc::new(SqliteModuleRepository { modules }),
271 tasks: Arc::new(SqliteTaskRepository { tasks_data }),
272 task_mutations: Arc::new(SqliteTaskMutationService {
273 conn: Arc::clone(&self.conn),
274 org: org.to_string(),
275 repo: repo.to_string(),
276 }),
277 specs: Arc::new(SqliteSpecRepository { specs }),
278 })
279 }
280
281 fn lock_conn(&self) -> DomainResult<std::sync::MutexGuard<'_, Connection>> {
282 self.conn.lock().map_err(|e| {
283 DomainError::io(
284 "locking sqlite connection",
285 std::io::Error::other(e.to_string()),
286 )
287 })
288 }
289}
290
291fn load_changes_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ChangeRow>> {
294 let mut stmt = conn
295 .prepare(
296 "SELECT change_id, module_id, sub_module_id, proposal, design, tasks_md, created_at, updated_at, archived_at
297 FROM changes WHERE org = ?1 AND repo = ?2",
298 )
299 .map_err(|e| map_sqlite_err("preparing change query", e))?;
300
301 let rows = stmt
302 .query_map(rusqlite::params![org, repo], |row| {
303 Ok(ChangeRow {
304 change_id: row.get(0)?,
305 module_id: row.get(1)?,
306 sub_module_id: row.get(2)?,
307 proposal: row.get(3)?,
308 design: row.get(4)?,
309 tasks_md: row.get(5)?,
310 created_at: row.get(6)?,
311 updated_at: row.get(7)?,
312 archived_at: row.get(8)?,
313 specs: Vec::new(), })
315 })
316 .map_err(|e| map_sqlite_err("querying changes", e))?;
317
318 let mut changes = Vec::new();
319 for row in rows {
320 let mut change = row.map_err(|e| map_sqlite_err("reading change row", e))?;
321
322 let mut spec_stmt = conn
324 .prepare(
325 "SELECT capability, content FROM change_specs
326 WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
327 )
328 .map_err(|e| map_sqlite_err("preparing spec query", e))?;
329
330 let spec_rows = spec_stmt
331 .query_map(rusqlite::params![org, repo, &change.change_id], |row| {
332 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
333 })
334 .map_err(|e| map_sqlite_err("querying specs", e))?;
335
336 for spec_row in spec_rows {
337 let (capability, content) =
338 spec_row.map_err(|e| map_sqlite_err("reading spec row", e))?;
339 change.specs.push((capability, content));
340 }
341
342 changes.push(change);
343 }
344
345 Ok(changes)
346}
347
348fn load_modules_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ModuleRow>> {
349 let mut stmt = conn
350 .prepare(
351 "SELECT module_id, name, description FROM modules
352 WHERE org = ?1 AND repo = ?2",
353 )
354 .map_err(|e| map_sqlite_err("preparing module query", e))?;
355
356 let rows = stmt
357 .query_map(rusqlite::params![org, repo], |row| {
358 Ok(ModuleRow {
359 module_id: row.get(0)?,
360 name: row.get(1)?,
361 description: row.get(2)?,
362 })
363 })
364 .map_err(|e| map_sqlite_err("querying modules", e))?;
365
366 let mut modules = Vec::new();
367 for row in rows {
368 modules.push(row.map_err(|e| map_sqlite_err("reading module row", e))?);
369 }
370
371 Ok(modules)
372}
373
374fn load_tasks_data_from_db(
376 conn: &Connection,
377 org: &str,
378 repo: &str,
379) -> DomainResult<Vec<(String, Option<String>)>> {
380 let mut stmt = conn
381 .prepare("SELECT change_id, tasks_md FROM changes WHERE org = ?1 AND repo = ?2")
382 .map_err(|e| map_sqlite_err("preparing tasks query", e))?;
383
384 let rows = stmt
385 .query_map(rusqlite::params![org, repo], |row| {
386 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
387 })
388 .map_err(|e| map_sqlite_err("querying tasks data", e))?;
389
390 let mut data = Vec::new();
391 for row in rows {
392 data.push(row.map_err(|e| map_sqlite_err("reading tasks row", e))?);
393 }
394
395 Ok(data)
396}
397
398fn load_promoted_specs_from_db(
399 conn: &Connection,
400 org: &str,
401 repo: &str,
402) -> DomainResult<Vec<SpecDocument>> {
403 let mut stmt = conn
404 .prepare(
405 "SELECT spec_id, markdown, updated_at FROM promoted_specs WHERE org = ?1 AND repo = ?2",
406 )
407 .map_err(|e| map_sqlite_err("preparing promoted specs query", e))?;
408
409 let rows = stmt
410 .query_map(rusqlite::params![org, repo], |row| {
411 Ok((
412 row.get::<_, String>(0)?,
413 row.get::<_, String>(1)?,
414 row.get::<_, String>(2)?,
415 ))
416 })
417 .map_err(|e| map_sqlite_err("querying promoted specs", e))?;
418
419 let mut specs = Vec::new();
420 for row in rows {
421 let (id, markdown, updated_at) =
422 row.map_err(|e| map_sqlite_err("reading promoted spec row", e))?;
423 let last_modified = chrono::DateTime::parse_from_rfc3339(&updated_at)
424 .map(|dt| dt.with_timezone(&Utc))
425 .unwrap_or_else(|_| Utc::now());
426 specs.push(SpecDocument {
427 id: id.clone(),
428 path: PathBuf::from(format!(".ito/specs/{id}/spec.md")),
429 markdown,
430 last_modified,
431 });
432 }
433 specs.sort_by(|left, right| left.id.cmp(&right.id));
434 Ok(specs)
435}
436
437fn map_sqlite_err(context: &'static str, err: rusqlite::Error) -> DomainError {
438 DomainError::io(context, std::io::Error::other(err.to_string()))
439}
440
441#[derive(Debug)]
444struct ChangeRow {
445 change_id: String,
446 module_id: Option<String>,
447 sub_module_id: Option<String>,
448 proposal: Option<String>,
449 design: Option<String>,
450 tasks_md: Option<String>,
451 #[allow(dead_code)]
452 created_at: String,
453 updated_at: String,
454 archived_at: Option<String>,
455 specs: Vec<(String, String)>,
456}
457
458#[derive(Debug)]
459struct ModuleRow {
460 module_id: String,
461 name: String,
462 description: Option<String>,
463}