1use 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
27pub struct UpsertChangeParams<'a> {
29 pub org: &'a str,
31 pub repo: &'a str,
33 pub change_id: &'a str,
35 pub module_id: Option<&'a str>,
37 pub proposal: Option<&'a str>,
39 pub design: Option<&'a str>,
41 pub tasks_md: Option<&'a str>,
43 pub specs: &'a [(&'a str, &'a str)],
45}
46
47pub struct SqliteBackendProjectStore {
52 conn: Mutex<Connection>,
53}
54
55impl SqliteBackendProjectStore {
56 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 #[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 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 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 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
261fn 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(), })
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 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
342fn 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#[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
392struct 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
532struct 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, })
567 .collect())
568 }
569}
570
571struct 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 {
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 {
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}