Skip to main content

ito_core/
sqlite_project_store.rs

1//! SQLite-backed [`BackendProjectStore`] proof-of-concept implementation.
2//!
3//! Stores project data (changes, modules, tasks) in a single SQLite database
4//! keyed by `{org}/{repo}`. This is a proof-of-concept demonstrating the
5//! storage abstraction — it stores serialized markdown content as blobs
6//! rather than fully normalized relational data.
7//!
8//! Database location: configurable via `BackendSqliteConfig::db_path`, with a
9//! default of `<data_dir>/sqlite/ito-backend.db`.
10
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use chrono::Utc;
15use rusqlite::Connection;
16
17use ito_domain::backend::BackendProjectStore;
18use ito_domain::changes::{
19    Change, ChangeRepository, ChangeSummary, ChangeTargetResolution, ResolveTargetOptions, Spec,
20};
21use ito_domain::errors::{DomainError, DomainResult};
22use ito_domain::modules::{Module, ModuleRepository, ModuleSummary};
23use ito_domain::tasks::{TaskRepository, TasksParseResult, parse_tasks_tracking_file};
24
25use crate::errors::CoreError;
26
27/// Parameters for inserting or updating a change in the SQLite store.
28pub struct UpsertChangeParams<'a> {
29    /// Organization namespace.
30    pub org: &'a str,
31    /// Repository namespace.
32    pub repo: &'a str,
33    /// Change identifier.
34    pub change_id: &'a str,
35    /// Optional module this change belongs to.
36    pub module_id: Option<&'a str>,
37    /// Optional proposal markdown content.
38    pub proposal: Option<&'a str>,
39    /// Optional design markdown content.
40    pub design: Option<&'a str>,
41    /// Optional tasks.md content.
42    pub tasks_md: Option<&'a str>,
43    /// Spec deltas as `(capability, content)` pairs.
44    pub specs: &'a [(&'a str, &'a str)],
45}
46
47/// SQLite-backed project store using a single database file.
48///
49/// All projects share one database, namespaced by `{org}/{repo}`.
50/// The connection is protected by a `Mutex` for thread safety.
51pub struct SqliteBackendProjectStore {
52    conn: Mutex<Connection>,
53}
54
55impl SqliteBackendProjectStore {
56    /// Open (or create) a SQLite project store at the given path.
57    pub fn open(db_path: &Path) -> Result<Self, CoreError> {
58        if let Some(parent) = db_path.parent() {
59            std::fs::create_dir_all(parent)
60                .map_err(|e| CoreError::io("creating sqlite database directory", e))?;
61        }
62
63        let conn = Connection::open(db_path)
64            .map_err(|e| CoreError::sqlite(format!("opening database: {e}")))?;
65
66        let store = Self {
67            conn: Mutex::new(conn),
68        };
69        store.initialize_schema()?;
70        Ok(store)
71    }
72
73    /// Open an in-memory SQLite project store (for testing).
74    #[cfg(test)]
75    pub fn open_in_memory() -> Result<Self, CoreError> {
76        let conn = Connection::open_in_memory()
77            .map_err(|e| CoreError::sqlite(format!("opening in-memory database: {e}")))?;
78        let store = Self {
79            conn: Mutex::new(conn),
80        };
81        store.initialize_schema()?;
82        Ok(store)
83    }
84
85    fn initialize_schema(&self) -> Result<(), CoreError> {
86        let conn = self.conn.lock().unwrap();
87        conn.execute_batch(
88            "CREATE TABLE IF NOT EXISTS projects (
89                org TEXT NOT NULL,
90                repo TEXT NOT NULL,
91                created_at TEXT NOT NULL,
92                PRIMARY KEY (org, repo)
93            );
94
95            CREATE TABLE IF NOT EXISTS changes (
96                org TEXT NOT NULL,
97                repo TEXT NOT NULL,
98                change_id TEXT NOT NULL,
99                module_id TEXT,
100                proposal TEXT,
101                design TEXT,
102                tasks_md TEXT,
103                created_at TEXT NOT NULL,
104                updated_at TEXT NOT NULL,
105                PRIMARY KEY (org, repo, change_id),
106                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
107            );
108
109            CREATE TABLE IF NOT EXISTS change_specs (
110                org TEXT NOT NULL,
111                repo TEXT NOT NULL,
112                change_id TEXT NOT NULL,
113                capability TEXT NOT NULL,
114                content TEXT NOT NULL,
115                PRIMARY KEY (org, repo, change_id, capability),
116                FOREIGN KEY (org, repo, change_id)
117                    REFERENCES changes(org, repo, change_id)
118            );
119
120            CREATE TABLE IF NOT EXISTS modules (
121                org TEXT NOT NULL,
122                repo TEXT NOT NULL,
123                module_id TEXT NOT NULL,
124                name TEXT NOT NULL,
125                description TEXT,
126                created_at TEXT NOT NULL,
127                updated_at TEXT NOT NULL,
128                PRIMARY KEY (org, repo, module_id),
129                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
130            );",
131        )
132        .map_err(|e| CoreError::sqlite(format!("initializing schema: {e}")))
133    }
134
135    /// Insert or update a change in the store (for seeding test data).
136    pub fn upsert_change(&self, params: &UpsertChangeParams<'_>) -> Result<(), CoreError> {
137        let UpsertChangeParams {
138            org,
139            repo,
140            change_id,
141            module_id,
142            proposal,
143            design,
144            tasks_md,
145            specs,
146        } = params;
147        let conn = self.conn.lock().unwrap();
148        let now = Utc::now().to_rfc3339();
149
150        conn.execute(
151            "INSERT OR REPLACE INTO changes
152             (org, repo, change_id, module_id, proposal, design, tasks_md, created_at, updated_at)
153             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
154            rusqlite::params![
155                org, repo, change_id, module_id, proposal, design, tasks_md, now, now
156            ],
157        )
158        .map_err(|e| CoreError::sqlite(format!("upserting change: {e}")))?;
159
160        // Delete old specs and insert new
161        conn.execute(
162            "DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
163            rusqlite::params![org, repo, change_id],
164        )
165        .map_err(|e| CoreError::sqlite(format!("deleting old specs: {e}")))?;
166
167        for (capability, content) in *specs {
168            conn.execute(
169                "INSERT INTO change_specs (org, repo, change_id, capability, content)
170                 VALUES (?1, ?2, ?3, ?4, ?5)",
171                rusqlite::params![org, repo, change_id, capability, content],
172            )
173            .map_err(|e| CoreError::sqlite(format!("inserting spec: {e}")))?;
174        }
175
176        Ok(())
177    }
178
179    /// Insert or update a module in the store (for seeding test data).
180    pub fn upsert_module(
181        &self,
182        org: &str,
183        repo: &str,
184        module_id: &str,
185        name: &str,
186        description: Option<&str>,
187    ) -> Result<(), CoreError> {
188        let conn = self.conn.lock().unwrap();
189        let now = Utc::now().to_rfc3339();
190
191        conn.execute(
192            "INSERT OR REPLACE INTO modules
193             (org, repo, module_id, name, description, created_at, updated_at)
194             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
195            rusqlite::params![org, repo, module_id, name, description, now, now],
196        )
197        .map_err(|e| CoreError::sqlite(format!("upserting module: {e}")))?;
198
199        Ok(())
200    }
201}
202
203impl BackendProjectStore for SqliteBackendProjectStore {
204    fn change_repository(
205        &self,
206        org: &str,
207        repo: &str,
208    ) -> DomainResult<Box<dyn ChangeRepository + Send>> {
209        let conn = self.conn.lock().unwrap();
210        let changes = load_changes_from_db(&conn, org, repo)?;
211        Ok(Box::new(SqliteChangeRepository { changes }))
212    }
213
214    fn module_repository(
215        &self,
216        org: &str,
217        repo: &str,
218    ) -> DomainResult<Box<dyn ModuleRepository + Send>> {
219        let conn = self.conn.lock().unwrap();
220        let modules = load_modules_from_db(&conn, org, repo)?;
221        Ok(Box::new(SqliteModuleRepository { modules }))
222    }
223
224    fn task_repository(
225        &self,
226        org: &str,
227        repo: &str,
228    ) -> DomainResult<Box<dyn TaskRepository + Send>> {
229        let conn = self.conn.lock().unwrap();
230        let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
231        Ok(Box::new(SqliteTaskRepository { tasks_data }))
232    }
233
234    fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()> {
235        let conn = self.conn.lock().unwrap();
236        let now = Utc::now().to_rfc3339();
237        conn.execute(
238            "INSERT OR IGNORE INTO projects (org, repo, created_at) VALUES (?1, ?2, ?3)",
239            rusqlite::params![org, repo, now],
240        )
241        .map_err(|e| {
242            DomainError::io(
243                "creating project in sqlite",
244                std::io::Error::other(e.to_string()),
245            )
246        })?;
247        Ok(())
248    }
249
250    fn project_exists(&self, org: &str, repo: &str) -> bool {
251        let conn = self.conn.lock().unwrap();
252        conn.query_row(
253            "SELECT 1 FROM projects WHERE org = ?1 AND repo = ?2",
254            rusqlite::params![org, repo],
255            |_| Ok(()),
256        )
257        .is_ok()
258    }
259}
260
261// ── Data loading helpers ───────────────────────────────────────────
262
263fn load_changes_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ChangeRow>> {
264    let mut stmt = conn
265        .prepare(
266            "SELECT change_id, module_id, proposal, design, tasks_md, created_at, updated_at
267             FROM changes WHERE org = ?1 AND repo = ?2",
268        )
269        .map_err(|e| map_sqlite_err("preparing change query", e))?;
270
271    let rows = stmt
272        .query_map(rusqlite::params![org, repo], |row| {
273            Ok(ChangeRow {
274                change_id: row.get(0)?,
275                module_id: row.get(1)?,
276                proposal: row.get(2)?,
277                design: row.get(3)?,
278                tasks_md: row.get(4)?,
279                created_at: row.get(5)?,
280                updated_at: row.get(6)?,
281                specs: Vec::new(), // filled below
282            })
283        })
284        .map_err(|e| map_sqlite_err("querying changes", e))?;
285
286    let mut changes = Vec::new();
287    for row in rows {
288        let mut change = row.map_err(|e| map_sqlite_err("reading change row", e))?;
289
290        // Load specs for this change
291        let mut spec_stmt = conn
292            .prepare(
293                "SELECT capability, content FROM change_specs
294                 WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
295            )
296            .map_err(|e| map_sqlite_err("preparing spec query", e))?;
297
298        let spec_rows = spec_stmt
299            .query_map(rusqlite::params![org, repo, &change.change_id], |row| {
300                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
301            })
302            .map_err(|e| map_sqlite_err("querying specs", e))?;
303
304        for spec_row in spec_rows {
305            let (capability, content) =
306                spec_row.map_err(|e| map_sqlite_err("reading spec row", e))?;
307            change.specs.push((capability, content));
308        }
309
310        changes.push(change);
311    }
312
313    Ok(changes)
314}
315
316fn load_modules_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ModuleRow>> {
317    let mut stmt = conn
318        .prepare(
319            "SELECT module_id, name, description FROM modules
320             WHERE org = ?1 AND repo = ?2",
321        )
322        .map_err(|e| map_sqlite_err("preparing module query", e))?;
323
324    let rows = stmt
325        .query_map(rusqlite::params![org, repo], |row| {
326            Ok(ModuleRow {
327                module_id: row.get(0)?,
328                name: row.get(1)?,
329                description: row.get(2)?,
330            })
331        })
332        .map_err(|e| map_sqlite_err("querying modules", e))?;
333
334    let mut modules = Vec::new();
335    for row in rows {
336        modules.push(row.map_err(|e| map_sqlite_err("reading module row", e))?);
337    }
338
339    Ok(modules)
340}
341
342/// Mapping of change_id -> tasks_md content for task lookups.
343fn load_tasks_data_from_db(
344    conn: &Connection,
345    org: &str,
346    repo: &str,
347) -> DomainResult<Vec<(String, Option<String>)>> {
348    let mut stmt = conn
349        .prepare("SELECT change_id, tasks_md FROM changes WHERE org = ?1 AND repo = ?2")
350        .map_err(|e| map_sqlite_err("preparing tasks query", e))?;
351
352    let rows = stmt
353        .query_map(rusqlite::params![org, repo], |row| {
354            Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
355        })
356        .map_err(|e| map_sqlite_err("querying tasks data", e))?;
357
358    let mut data = Vec::new();
359    for row in rows {
360        data.push(row.map_err(|e| map_sqlite_err("reading tasks row", e))?);
361    }
362
363    Ok(data)
364}
365
366fn map_sqlite_err(context: &'static str, err: rusqlite::Error) -> DomainError {
367    DomainError::io(context, std::io::Error::other(err.to_string()))
368}
369
370// ── In-memory row types ────────────────────────────────────────────
371
372#[derive(Debug)]
373struct ChangeRow {
374    change_id: String,
375    module_id: Option<String>,
376    proposal: Option<String>,
377    design: Option<String>,
378    tasks_md: Option<String>,
379    #[allow(dead_code)]
380    created_at: String,
381    updated_at: String,
382    specs: Vec<(String, String)>,
383}
384
385#[derive(Debug)]
386struct ModuleRow {
387    module_id: String,
388    name: String,
389    description: Option<String>,
390}
391
392// ── SQLite-backed repository adapters ──────────────────────────────
393//
394// These hold pre-loaded data from the database snapshot and implement
395// the domain repository traits. They are `Send` because they own all
396// their data (no references to the connection).
397
398/// Change repository backed by pre-loaded SQLite data.
399struct SqliteChangeRepository {
400    changes: Vec<ChangeRow>,
401}
402
403impl ChangeRepository for SqliteChangeRepository {
404    fn resolve_target_with_options(
405        &self,
406        input: &str,
407        _options: ResolveTargetOptions,
408    ) -> ChangeTargetResolution {
409        let mut matches = Vec::new();
410        for c in &self.changes {
411            if c.change_id == input || c.change_id.contains(input) {
412                matches.push(c.change_id.clone());
413            }
414        }
415        match matches.len() {
416            0 => ChangeTargetResolution::NotFound,
417            1 => ChangeTargetResolution::Unique(matches.into_iter().next().unwrap()),
418            _ => ChangeTargetResolution::Ambiguous(matches),
419        }
420    }
421
422    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
423        self.changes
424            .iter()
425            .filter(|c| c.change_id.contains(input))
426            .take(max)
427            .map(|c| c.change_id.clone())
428            .collect()
429    }
430
431    fn exists(&self, id: &str) -> bool {
432        self.changes.iter().any(|c| c.change_id == id)
433    }
434
435    fn get(&self, id: &str) -> DomainResult<Change> {
436        let Some(row) = self.changes.iter().find(|c| c.change_id == id) else {
437            return Err(DomainError::not_found("change", id));
438        };
439
440        let tasks = row
441            .tasks_md
442            .as_deref()
443            .map(parse_tasks_tracking_file)
444            .unwrap_or_else(TasksParseResult::empty);
445
446        let last_modified = chrono::DateTime::parse_from_rfc3339(&row.updated_at)
447            .map(|dt| dt.with_timezone(&Utc))
448            .unwrap_or_else(|_| Utc::now());
449
450        Ok(Change {
451            id: row.change_id.clone(),
452            module_id: row.module_id.clone(),
453            path: PathBuf::new(),
454            proposal: row.proposal.clone(),
455            design: row.design.clone(),
456            specs: row
457                .specs
458                .iter()
459                .map(|(name, content)| Spec {
460                    name: name.clone(),
461                    content: content.clone(),
462                })
463                .collect(),
464            tasks,
465            last_modified,
466        })
467    }
468
469    fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
470        let mut summaries = Vec::with_capacity(self.changes.len());
471        for row in &self.changes {
472            let tasks = row
473                .tasks_md
474                .as_deref()
475                .map(parse_tasks_tracking_file)
476                .unwrap_or_else(TasksParseResult::empty);
477
478            let last_modified = chrono::DateTime::parse_from_rfc3339(&row.updated_at)
479                .map(|dt| dt.with_timezone(&Utc))
480                .unwrap_or_else(|_| Utc::now());
481
482            summaries.push(ChangeSummary {
483                id: row.change_id.clone(),
484                module_id: row.module_id.clone(),
485                completed_tasks: tasks.progress.complete as u32,
486                shelved_tasks: tasks.progress.shelved as u32,
487                in_progress_tasks: tasks.progress.in_progress as u32,
488                pending_tasks: tasks.progress.pending as u32,
489                total_tasks: tasks.progress.total as u32,
490                last_modified,
491                has_proposal: row.proposal.is_some(),
492                has_design: row.design.is_some(),
493                has_specs: !row.specs.is_empty(),
494                has_tasks: row.tasks_md.is_some(),
495            });
496        }
497        Ok(summaries)
498    }
499
500    fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
501        let all = self.list()?;
502        Ok(all
503            .into_iter()
504            .filter(|c| c.module_id.as_deref() == Some(module_id))
505            .collect())
506    }
507
508    fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
509        let all = self.list()?;
510        Ok(all
511            .into_iter()
512            .filter(|c| c.total_tasks > 0 && c.completed_tasks < c.total_tasks)
513            .collect())
514    }
515
516    fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
517        let all = self.list()?;
518        Ok(all
519            .into_iter()
520            .filter(|c| c.total_tasks > 0 && c.completed_tasks >= c.total_tasks)
521            .collect())
522    }
523
524    fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
525        let all = self.list()?;
526        all.into_iter()
527            .find(|c| c.id == id)
528            .ok_or_else(|| DomainError::not_found("change", id))
529    }
530}
531
532/// Module repository backed by pre-loaded SQLite data.
533struct SqliteModuleRepository {
534    modules: Vec<ModuleRow>,
535}
536
537impl ModuleRepository for SqliteModuleRepository {
538    fn exists(&self, id: &str) -> bool {
539        self.modules.iter().any(|m| m.module_id == id)
540    }
541
542    fn get(&self, id_or_name: &str) -> DomainResult<Module> {
543        let Some(row) = self
544            .modules
545            .iter()
546            .find(|m| m.module_id == id_or_name || m.name == id_or_name)
547        else {
548            return Err(DomainError::not_found("module", id_or_name));
549        };
550        Ok(Module {
551            id: row.module_id.clone(),
552            name: row.name.clone(),
553            description: row.description.clone(),
554            path: PathBuf::new(),
555        })
556    }
557
558    fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
559        Ok(self
560            .modules
561            .iter()
562            .map(|m| ModuleSummary {
563                id: m.module_id.clone(),
564                name: m.name.clone(),
565                change_count: 0, // No cross-reference in PoC
566            })
567            .collect())
568    }
569}
570
571/// Task repository backed by pre-loaded SQLite data.
572struct SqliteTaskRepository {
573    tasks_data: Vec<(String, Option<String>)>,
574}
575
576impl TaskRepository for SqliteTaskRepository {
577    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
578        let Some((_id, tasks_md)) = self.tasks_data.iter().find(|(id, _)| id == change_id) else {
579            return Ok(TasksParseResult::empty());
580        };
581
582        let Some(md) = tasks_md else {
583            return Ok(TasksParseResult::empty());
584        };
585
586        Ok(parse_tasks_tracking_file(md))
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn open_in_memory_creates_schema() {
596        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
597        assert!(!store.project_exists("org", "repo"));
598    }
599
600    #[test]
601    fn ensure_project_creates_row() {
602        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
603        store.ensure_project("acme", "widgets").unwrap();
604        assert!(store.project_exists("acme", "widgets"));
605    }
606
607    #[test]
608    fn ensure_project_is_idempotent() {
609        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
610        store.ensure_project("acme", "widgets").unwrap();
611        store.ensure_project("acme", "widgets").unwrap();
612        assert!(store.project_exists("acme", "widgets"));
613    }
614
615    #[test]
616    fn upsert_and_list_changes() {
617        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
618        store.ensure_project("org", "repo").unwrap();
619        store
620            .upsert_change(&UpsertChangeParams {
621                org: "org",
622                repo: "repo",
623                change_id: "001-01_my-change",
624                module_id: Some("001"),
625                proposal: Some("# Proposal"),
626                design: None,
627                tasks_md: Some("## 1. Tasks\n- [x] 1.1 Done\n- [ ] 1.2 Pending"),
628                specs: &[("auth", "## ADDED\n### Requirement: Auth")],
629            })
630            .unwrap();
631
632        let change_repo = store.change_repository("org", "repo").unwrap();
633        let changes = change_repo.list().unwrap();
634        assert_eq!(changes.len(), 1);
635        assert_eq!(changes[0].id, "001-01_my-change");
636        assert_eq!(changes[0].module_id, Some("001".to_string()));
637        assert!(changes[0].has_proposal);
638        assert!(!changes[0].has_design);
639        assert!(changes[0].has_specs);
640    }
641
642    #[test]
643    fn get_change_returns_full_data() {
644        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
645        store.ensure_project("org", "repo").unwrap();
646        store
647            .upsert_change(&UpsertChangeParams {
648                org: "org",
649                repo: "repo",
650                change_id: "002-01_another",
651                module_id: None,
652                proposal: Some("# My Proposal"),
653                design: Some("# Design"),
654                tasks_md: None,
655                specs: &[("config", "## MODIFIED")],
656            })
657            .unwrap();
658
659        let change_repo = store.change_repository("org", "repo").unwrap();
660        let change = change_repo.get("002-01_another").unwrap();
661        assert_eq!(change.id, "002-01_another");
662        assert_eq!(change.proposal, Some("# My Proposal".to_string()));
663        assert_eq!(change.design, Some("# Design".to_string()));
664        assert_eq!(change.specs.len(), 1);
665        assert_eq!(change.specs[0].name, "config");
666    }
667
668    #[test]
669    fn get_missing_change_returns_not_found() {
670        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
671        store.ensure_project("org", "repo").unwrap();
672        let change_repo = store.change_repository("org", "repo").unwrap();
673        let err = change_repo.get("nonexistent").unwrap_err();
674        assert!(err.to_string().contains("not found"));
675    }
676
677    #[test]
678    fn upsert_and_list_modules() {
679        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
680        store.ensure_project("org", "repo").unwrap();
681        store
682            .upsert_module("org", "repo", "001", "Backend", Some("Backend module"))
683            .unwrap();
684
685        let module_repo = store.module_repository("org", "repo").unwrap();
686        let modules = module_repo.list().unwrap();
687        assert_eq!(modules.len(), 1);
688        assert_eq!(modules[0].id, "001");
689        assert_eq!(modules[0].name, "Backend");
690    }
691
692    #[test]
693    fn get_module_by_id() {
694        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
695        store.ensure_project("org", "repo").unwrap();
696        store
697            .upsert_module("org", "repo", "001", "Backend", Some("Desc"))
698            .unwrap();
699
700        let module_repo = store.module_repository("org", "repo").unwrap();
701        let module = module_repo.get("001").unwrap();
702        assert_eq!(module.name, "Backend");
703        assert_eq!(module.description, Some("Desc".to_string()));
704    }
705
706    #[test]
707    fn task_repository_loads_tasks() {
708        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
709        store.ensure_project("org", "repo").unwrap();
710        store
711            .upsert_change(&UpsertChangeParams {
712                org: "org",
713                repo: "repo",
714                change_id: "001-01_change",
715                module_id: None,
716                proposal: None,
717                design: None,
718                tasks_md: Some("## 1. Tasks\n- [x] 1.1 Done\n- [ ] 1.2 Pending"),
719                specs: &[],
720            })
721            .unwrap();
722
723        let task_repo = store.task_repository("org", "repo").unwrap();
724        let result = task_repo.load_tasks("001-01_change").unwrap();
725        assert!(result.progress.total > 0);
726    }
727
728    #[test]
729    fn task_repository_missing_change_returns_empty() {
730        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
731        store.ensure_project("org", "repo").unwrap();
732        let task_repo = store.task_repository("org", "repo").unwrap();
733        let result = task_repo.load_tasks("nonexistent").unwrap();
734        assert_eq!(result.progress.total, 0);
735    }
736
737    #[test]
738    fn two_projects_are_isolated() {
739        let store = SqliteBackendProjectStore::open_in_memory().unwrap();
740        store.ensure_project("org1", "repo1").unwrap();
741        store.ensure_project("org2", "repo2").unwrap();
742
743        store
744            .upsert_change(&UpsertChangeParams {
745                org: "org1",
746                repo: "repo1",
747                change_id: "change-a",
748                module_id: None,
749                proposal: None,
750                design: None,
751                tasks_md: None,
752                specs: &[],
753            })
754            .unwrap();
755        store
756            .upsert_change(&UpsertChangeParams {
757                org: "org2",
758                repo: "repo2",
759                change_id: "change-b",
760                module_id: None,
761                proposal: None,
762                design: None,
763                tasks_md: None,
764                specs: &[],
765            })
766            .unwrap();
767
768        let repo1 = store.change_repository("org1", "repo1").unwrap();
769        let repo2 = store.change_repository("org2", "repo2").unwrap();
770
771        let changes1 = repo1.list().unwrap();
772        let changes2 = repo2.list().unwrap();
773
774        assert_eq!(changes1.len(), 1);
775        assert_eq!(changes1[0].id, "change-a");
776        assert_eq!(changes2.len(), 1);
777        assert_eq!(changes2[0].id, "change-b");
778    }
779
780    #[test]
781    fn store_is_send_sync() {
782        fn assert_send_sync<T: Send + Sync>() {}
783        assert_send_sync::<SqliteBackendProjectStore>();
784    }
785
786    #[test]
787    fn on_disk_database_persists() {
788        let tmp = tempfile::tempdir().unwrap();
789        let db_path = tmp.path().join("test.db");
790
791        // Create and populate
792        {
793            let store = SqliteBackendProjectStore::open(&db_path).unwrap();
794            store.ensure_project("org", "repo").unwrap();
795            store
796                .upsert_change(&UpsertChangeParams {
797                    org: "org",
798                    repo: "repo",
799                    change_id: "change-1",
800                    module_id: None,
801                    proposal: Some("# P"),
802                    design: None,
803                    tasks_md: None,
804                    specs: &[],
805                })
806                .unwrap();
807        }
808
809        // Re-open and verify
810        {
811            let store = SqliteBackendProjectStore::open(&db_path).unwrap();
812            assert!(store.project_exists("org", "repo"));
813            let repo = store.change_repository("org", "repo").unwrap();
814            let changes = repo.list().unwrap();
815            assert_eq!(changes.len(), 1);
816            assert_eq!(changes[0].id, "change-1");
817        }
818    }
819}