1use std::cmp::Reverse;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use kimetsu_core::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
6use rusqlite::Connection;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SchemaNeedsMigration {
14 pub from: i64,
15 pub to: i64,
16}
17
18impl std::fmt::Display for SchemaNeedsMigration {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 write!(
21 f,
22 "brain.db schema version {} is older than this binary's {}; open it read-write once to migrate",
23 self.from, self.to
24 )
25 }
26}
27
28impl std::error::Error for SchemaNeedsMigration {}
29
30pub struct Migration {
35 pub version: i64,
36 pub description: &'static str,
37 pub up: fn(&Connection) -> KimetsuResult<()>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct MigrationOutcome {
42 pub from: i64,
43 pub to: i64,
44 pub applied: Vec<i64>,
45 pub backup_path: Option<PathBuf>,
48}
49
50fn migrations() -> &'static [Migration] {
56 &[
57 Migration {
58 version: 2,
59 description: "fold additive columns, citations/conflicts tables, and FTS reshapes",
60 up: crate::schema::migrate_v1_to_v2,
61 },
62 Migration {
63 version: 3,
64 description: "add superseded_by column + index for near-duplicate merge (Story 3.1)",
65 up: crate::schema::migrate_v2_to_v3,
66 },
67 Migration {
68 version: 4,
69 description: "add memory_edges typed-edge projection table (S5.2 graph-lite backend)",
70 up: crate::schema::migrate_v3_to_v4,
71 },
72 Migration {
73 version: 5,
74 description: "add work_episodes projection table (Flagship 1 episodic resume, Story 1.3)",
75 up: crate::schema::migrate_v4_to_v5,
76 },
77 Migration {
78 version: 6,
79 description: "add skill_proposals table (Flagship 2 Memory → Skill synthesis)",
80 up: crate::schema::migrate_v5_to_v6,
81 },
82 Migration {
83 version: 7,
84 description: "add valid_from + valid_to columns for temporal validity (Flagship 1 Pass A)",
85 up: crate::schema::migrate_v6_to_v7,
86 },
87 Migration {
88 version: 8,
89 description: "add per-event origin column (v3.0 #3 fleet write-safety / provenance)",
90 up: crate::schema::migrate_v7_to_v8,
91 },
92 Migration {
93 version: 9,
94 description: "add per-event HLC column + backfill (v3.0 #3 Slice B convergent team sync)",
95 up: crate::schema::migrate_v8_to_v9,
96 },
97 Migration {
98 version: 10,
99 description: "add memory_citations.query + query_routes table (v2.5.2 consolidation v1)",
100 up: crate::schema::migrate_v9_to_v10,
101 },
102 ]
103}
104
105pub fn target_version() -> i64 {
107 KIMETSU_SCHEMA_VERSION
108}
109
110pub fn current_version(conn: &Connection) -> KimetsuResult<i64> {
112 Ok(conn.query_row(
113 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
114 [],
115 |row| row.get(0),
116 )?)
117}
118
119pub fn run_migrations(conn: &Connection) -> KimetsuResult<MigrationOutcome> {
121 run_with(conn, migrations(), target_version())
122}
123
124fn db_file_path(conn: &Connection) -> Option<PathBuf> {
131 match conn.path() {
132 Some(p) if !p.is_empty() && p != ":memory:" => Some(PathBuf::from(p)),
133 _ => None,
134 }
135}
136
137fn durable_row_count(conn: &Connection) -> i64 {
141 conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get::<_, i64>(0))
142 .unwrap_or(0)
143}
144
145fn unique_default_backup_path(candidate: PathBuf) -> PathBuf {
146 if !candidate.exists() {
147 return candidate;
148 }
149 let (Some(parent), Some(file_name)) = (
150 candidate.parent(),
151 candidate.file_name().and_then(|n| n.to_str()),
152 ) else {
153 return candidate;
154 };
155 for suffix in 1..1000 {
156 let next = parent.join(format!("{file_name}-{suffix}"));
157 if !next.exists() {
158 return next;
159 }
160 }
161 candidate
162}
163
164fn backup_before_migrate(conn: &Connection, from: i64, to: i64) -> KimetsuResult<Option<PathBuf>> {
172 let db_path = match db_file_path(conn) {
173 Some(p) => p,
174 None => return Ok(None), };
176
177 if durable_row_count(conn) == 0 {
180 return Ok(None);
181 }
182
183 let ts = SystemTime::now()
184 .duration_since(UNIX_EPOCH)
185 .map(|d| d.as_nanos())
186 .unwrap_or(0);
187
188 let file_name = format!(
190 "{}.bak-{from}-{to}-{ts}",
191 db_path
192 .file_name()
193 .and_then(|n| n.to_str())
194 .unwrap_or("brain.db")
195 );
196 let dest_path = unique_default_backup_path(db_path.with_file_name(file_name));
197
198 let mut dest = Connection::open(&dest_path)?;
200 let backup = rusqlite::backup::Backup::new(conn, &mut dest)?;
201 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
204 drop(backup);
205
206 Ok(Some(dest_path))
207}
208
209fn prune_backups(db_path: &Path, keep: usize) {
217 let (Some(dir), Some(stem)) = (
218 db_path.parent(),
219 db_path.file_name().and_then(|n| n.to_str()),
220 ) else {
221 return;
222 };
223
224 let prefix = format!("{stem}.bak-");
225
226 let mut backups: Vec<PathBuf> = match std::fs::read_dir(dir) {
227 Ok(rd) => rd
228 .filter_map(|e| e.ok().map(|e| e.path()))
229 .filter(|p| {
230 p.file_name()
231 .and_then(|n| n.to_str())
232 .map(|n| n.starts_with(&prefix))
233 .unwrap_or(false)
234 })
235 .collect(),
236 Err(_) => return,
237 };
238
239 if backups.len() <= keep {
240 return;
241 }
242
243 backups.sort_by_key(|p| {
246 Reverse(
247 p.file_name()
248 .and_then(|n| n.to_str())
249 .and_then(|n| n.rsplit('-').next())
250 .and_then(|ts| ts.parse::<u64>().ok())
251 .unwrap_or(0),
252 )
253 });
254
255 for old in backups.into_iter().skip(keep) {
256 let _ = std::fs::remove_file(old);
257 }
258}
259
260pub(crate) fn run_with(
271 conn: &Connection,
272 migs: &[Migration],
273 target: i64,
274) -> KimetsuResult<MigrationOutcome> {
275 debug_assert!(
277 migs.windows(2).all(|w| w[1].version == w[0].version + 1),
278 "migrations must be strictly ascending and contiguous"
279 );
280 debug_assert!(
281 migs.iter().all(|m| m.version <= target),
282 "no migration may exceed the target version"
283 );
284
285 let current = current_version(conn)?;
286
287 if current == target {
288 return Ok(MigrationOutcome {
289 from: current,
290 to: current,
291 applied: Vec::new(),
292 backup_path: None,
293 });
294 }
295
296 if current > target {
297 return Err(format!(
298 "brain.db schema version {current} was written by a newer Kimetsu \
299 (this binary expects {target}); upgrade Kimetsu"
300 )
301 .into());
302 }
303
304 let backup_path = backup_before_migrate(conn, current, target)?;
306
307 let mut applied = Vec::new();
308
309 for m in migs
310 .iter()
311 .filter(|m| m.version > current && m.version <= target)
312 {
313 conn.execute_batch("BEGIN IMMEDIATE")?;
319
320 let result = (|| -> KimetsuResult<bool> {
321 if m.version <= current_version(conn)? {
324 return Ok(false); }
326 (m.up)(conn)?;
327 conn.execute(
328 "UPDATE schema_info SET value = ?1 WHERE key = 'kimetsu_schema_version'",
329 [m.version],
330 )?;
331 Ok(true)
332 })();
333
334 match result {
335 Ok(did_apply) => {
336 conn.execute_batch("COMMIT")?;
337 if did_apply {
338 applied.push(m.version);
339 }
340 }
341 Err(e) => {
342 let _ = conn.execute_batch("ROLLBACK");
343 return Err(e);
344 }
345 }
346 }
347
348 if let Some(ref bp) = backup_path {
350 if let Some(parent) = bp.parent() {
351 let db_ref = db_file_path(conn).unwrap_or_else(|| parent.join("brain.db"));
352 prune_backups(&db_ref, 3);
353 }
354 }
355
356 if !applied.is_empty() {
359 tracing::info!(
360 from = current,
361 to = target,
362 backup = ?backup_path,
363 "migrated brain.db schema"
364 );
365 }
366
367 Ok(MigrationOutcome {
368 from: current,
369 to: target,
370 applied,
371 backup_path,
372 })
373}
374
375pub fn backup_brain(
392 brain_db_path: &std::path::Path,
393 dest: Option<&std::path::Path>,
394) -> KimetsuResult<(std::path::PathBuf, u64)> {
395 let ts = SystemTime::now()
396 .duration_since(UNIX_EPOCH)
397 .map(|d| d.as_nanos())
398 .unwrap_or(0);
399
400 let dest_path = match dest {
401 Some(p) => p.to_path_buf(),
402 None => {
403 let file_name = format!(
404 "{}.backup-{ts}",
405 brain_db_path
406 .file_name()
407 .and_then(|n| n.to_str())
408 .unwrap_or("brain.db")
409 );
410 unique_default_backup_path(brain_db_path.with_file_name(file_name))
411 }
412 };
413
414 let src = Connection::open_with_flags(
416 brain_db_path,
417 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
418 )?;
419
420 let mut dst = Connection::open(&dest_path)?;
422 let backup = rusqlite::backup::Backup::new(&src, &mut dst)?;
423 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
424 drop(backup);
425 drop(dst);
426 drop(src);
427
428 let size = std::fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
429
430 Ok((dest_path, size))
431}
432
433#[cfg(test)]
438mod tests {
439 use super::*;
440 use rusqlite::Connection;
441
442 fn make_db(version: i64) -> Connection {
446 let conn = Connection::open_in_memory().expect("open_in_memory");
447 conn.execute_batch(&format!(
448 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
449 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
450 ))
451 .expect("seed schema_info");
452 conn
453 }
454
455 fn make_file_db(path: &Path, version: i64) -> Connection {
457 let conn = Connection::open(path).expect("open file db");
458 conn.execute_batch(&format!(
459 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
460 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
461 ))
462 .expect("seed schema_info");
463 conn
464 }
465
466 fn make_file_db_with_memory(path: &Path, version: i64) -> Connection {
470 let conn = make_file_db(path, version);
471 conn.execute_batch(
472 "CREATE TABLE memories (
473 memory_id TEXT PRIMARY KEY,
474 scope TEXT NOT NULL,
475 kind TEXT NOT NULL,
476 text TEXT NOT NULL
477 );
478 INSERT INTO memories VALUES ('test-mem-id', 'repo', 'preference', 'test memory');",
479 )
480 .expect("seed memories table");
481 conn
482 }
483
484 #[test]
489 fn migrate_v7_forward_adds_origin_and_hlc() {
490 let conn = Connection::open_in_memory().expect("open");
491 conn.execute_batch(
492 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
493 INSERT INTO schema_info VALUES ('kimetsu_schema_version', 7);
494 CREATE TABLE events (
495 event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, ts TEXT NOT NULL,
496 kind TEXT NOT NULL, schema_version INTEGER NOT NULL, payload_json TEXT NOT NULL);
497 INSERT INTO events VALUES
498 ('e1','r1','2024-01-01T00:00:00Z','memory.accepted',1,'{}'),
499 ('e2','r1','2024-01-02T00:00:00Z','memory.cited',1,'{}');",
500 )
501 .expect("seed v7 events");
502
503 let target = target_version();
504 let outcome = run_with(&conn, migrations(), target).expect("migrate v7->current");
505 assert!(outcome.applied.contains(&8), "v8 migration must apply");
506 assert!(outcome.applied.contains(&9), "v9 migration must apply");
507
508 let cols: Vec<String> = {
509 let mut stmt = conn.prepare("PRAGMA table_info(events)").unwrap();
510 stmt.query_map([], |r| r.get::<_, String>(1))
511 .unwrap()
512 .filter_map(Result::ok)
513 .collect()
514 };
515 assert!(
516 cols.iter().any(|c| c == "origin"),
517 "events.origin must exist"
518 );
519 assert!(cols.iter().any(|c| c == "hlc"), "events.hlc must exist");
520
521 let origin: Option<String> = conn
523 .query_row("SELECT origin FROM events WHERE event_id='e1'", [], |r| {
524 r.get(0)
525 })
526 .expect("read origin");
527 assert_eq!(origin, None, "old event rows must read origin = NULL");
528
529 let hlc1: String = conn
531 .query_row("SELECT hlc FROM events WHERE event_id='e1'", [], |r| {
532 r.get(0)
533 })
534 .expect("read hlc1");
535 let hlc2: String = conn
536 .query_row("SELECT hlc FROM events WHERE event_id='e2'", [], |r| {
537 r.get(0)
538 })
539 .expect("read hlc2");
540 assert!(
541 hlc1.starts_with("0000000000000."),
542 "backfilled wall=0: {hlc1}"
543 );
544 assert!(hlc1 < hlc2, "backfilled HLC preserves insertion order");
545 }
546
547 fn table_exists(conn: &Connection, name: &str) -> bool {
549 let count: i64 = conn
550 .query_row(
551 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
552 [name],
553 |r| r.get(0),
554 )
555 .unwrap_or(0);
556 count > 0
557 }
558
559 fn up_create_m2(conn: &Connection) -> KimetsuResult<()> {
565 conn.execute_batch("CREATE TABLE IF NOT EXISTS m2 (x INTEGER);")?;
566 Ok(())
567 }
568
569 fn up_create_m3(conn: &Connection) -> KimetsuResult<()> {
570 conn.execute_batch("CREATE TABLE IF NOT EXISTS m3 (x INTEGER);")?;
571 Ok(())
572 }
573
574 fn up_fail_partial(conn: &Connection) -> KimetsuResult<()> {
575 conn.execute_batch("CREATE TABLE IF NOT EXISTS partial_table (x INTEGER);")?;
578 Err("intentional migration failure".into())
579 }
580
581 fn up_create_t(conn: &Connection) -> KimetsuResult<()> {
582 conn.execute_batch("CREATE TABLE IF NOT EXISTS t (x INTEGER);")?;
583 Ok(())
584 }
585
586 #[test]
590 fn noop_when_at_target() {
591 let conn = make_db(7);
592 let outcome = run_with(&conn, &[], 7).expect("run_with");
593 assert_eq!(
594 outcome,
595 MigrationOutcome {
596 from: 7,
597 to: 7,
598 applied: vec![],
599 backup_path: None,
600 }
601 );
602 assert_eq!(current_version(&conn).unwrap(), 7);
604 }
605
606 #[test]
610 fn rejects_newer_db() {
611 let conn = make_db(999);
612 let err = run_with(&conn, &[], 1).expect_err("should error on newer DB");
613 let msg = err.to_string();
614 assert!(
615 msg.contains("newer"),
616 "error message should mention 'newer', got: {msg}"
617 );
618 assert_eq!(current_version(&conn).unwrap(), 999);
620 }
621
622 #[test]
626 fn applies_single_migration() {
627 let conn = make_db(1);
628 let migs = [Migration {
629 version: 2,
630 description: "create m2",
631 up: up_create_m2,
632 }];
633 let outcome = run_with(&conn, &migs, 2).expect("run_with");
634 assert_eq!(outcome.from, 1);
635 assert_eq!(outcome.to, 2);
636 assert_eq!(outcome.applied, vec![2]);
637 assert!(outcome.backup_path.is_none());
639 assert_eq!(current_version(&conn).unwrap(), 2);
641 assert!(table_exists(&conn, "m2"), "m2 table should exist");
643 }
644
645 #[test]
649 fn idempotent_rerun() {
650 let conn = make_db(1);
651 let migs = [Migration {
652 version: 2,
653 description: "create m2",
654 up: up_create_m2,
655 }];
656 run_with(&conn, &migs, 2).expect("first run");
658 let outcome = run_with(&conn, &migs, 2).expect("second run");
660 assert_eq!(
661 outcome.applied,
662 Vec::<i64>::new(),
663 "second run must apply nothing"
664 );
665 assert_eq!(current_version(&conn).unwrap(), 2);
666 }
667
668 #[test]
672 fn rollback_on_failing_migration() {
673 let conn = make_db(1);
674 let migs = [Migration {
675 version: 2,
676 description: "fail",
677 up: up_fail_partial,
678 }];
679 let err = run_with(&conn, &migs, 2).expect_err("should propagate migration error");
680 assert!(
681 err.to_string().contains("intentional"),
682 "propagated error should contain original message, got: {err}"
683 );
684 assert_eq!(
686 current_version(&conn).unwrap(),
687 1,
688 "version must be unchanged after rollback"
689 );
690 assert!(
692 !table_exists(&conn, "partial_table"),
693 "partial_table must not exist after rollback"
694 );
695 }
696
697 #[test]
701 fn multi_step_chain() {
702 let conn = make_db(1);
703 let migs = [
704 Migration {
705 version: 2,
706 description: "create m2",
707 up: up_create_m2,
708 },
709 Migration {
710 version: 3,
711 description: "create m3",
712 up: up_create_m3,
713 },
714 ];
715 let outcome = run_with(&conn, &migs, 3).expect("run_with");
716 assert_eq!(outcome.from, 1);
717 assert_eq!(outcome.to, 3);
718 assert_eq!(outcome.applied, vec![2, 3]);
719 assert_eq!(current_version(&conn).unwrap(), 3);
720 assert!(table_exists(&conn, "m2"), "m2 should exist");
721 assert!(table_exists(&conn, "m3"), "m3 should exist");
722 }
723
724 #[test]
728 fn backup_created_for_file_db() {
729 let tmp_id = std::time::SystemTime::now()
730 .duration_since(std::time::UNIX_EPOCH)
731 .map(|d| d.as_nanos())
732 .unwrap_or(0);
733 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-{tmp_id}"));
734 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
735
736 let db_path = tmp_dir.join("brain.db");
737 {
738 let conn = make_file_db_with_memory(&db_path, 1);
740
741 let migs = [Migration {
742 version: 2,
743 description: "create t",
744 up: up_create_t,
745 }];
746
747 let outcome = run_with(&conn, &migs, 2).expect("run_with");
748
749 let bak_path = outcome
751 .backup_path
752 .expect("backup_path should be Some for file DB");
753 assert!(
754 bak_path.exists(),
755 "backup file should exist at {bak_path:?}"
756 );
757
758 let bak_name = bak_path
760 .file_name()
761 .and_then(|n| n.to_str())
762 .expect("backup has a filename");
763 assert!(
764 bak_name.starts_with("brain.db.bak-1-2-"),
765 "backup name should be brain.db.bak-1-2-<ts>, got: {bak_name}"
766 );
767
768 let bak_conn = Connection::open(&bak_path).expect("open backup db");
770 let bak_version: i64 = bak_conn
771 .query_row(
772 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
773 [],
774 |r| r.get(0),
775 )
776 .expect("read backup version");
777 assert_eq!(
778 bak_version, 1,
779 "backup should capture pre-migration version 1"
780 );
781
782 assert_eq!(current_version(&conn).unwrap(), 2);
784 }
785
786 let _ = std::fs::remove_dir_all(&tmp_dir);
787 }
788
789 #[test]
793 fn no_backup_for_in_memory_db() {
794 let conn = make_db(1);
795 let migs = [Migration {
796 version: 2,
797 description: "create t",
798 up: up_create_t,
799 }];
800 let outcome = run_with(&conn, &migs, 2).expect("run_with");
801 assert!(
802 outcome.backup_path.is_none(),
803 "in-memory DB must not produce a backup"
804 );
805 assert_eq!(current_version(&conn).unwrap(), 2);
807 assert!(table_exists(&conn, "t"), "table t should exist");
808 }
809
810 #[test]
814 fn no_backup_for_noop() {
815 let tmp_id = std::time::SystemTime::now()
816 .duration_since(std::time::UNIX_EPOCH)
817 .map(|d| d.as_nanos())
818 .unwrap_or(0);
819 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-noop-{tmp_id}"));
820 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
821
822 let db_path = tmp_dir.join("brain.db");
823 {
824 let conn = make_file_db(&db_path, 2);
825 let outcome = run_with(&conn, &[], 2).expect("run_with");
826
827 assert!(
828 outcome.backup_path.is_none(),
829 "no-op run must not produce a backup"
830 );
831
832 let bak_files: Vec<_> = std::fs::read_dir(&tmp_dir)
834 .expect("read_dir")
835 .filter_map(|e| e.ok())
836 .filter(|e| {
837 e.file_name()
838 .to_str()
839 .map(|n| n.contains(".bak-"))
840 .unwrap_or(false)
841 })
842 .collect();
843 assert!(
844 bak_files.is_empty(),
845 "no backup files should exist after no-op, found: {bak_files:?}"
846 );
847 }
848
849 let _ = std::fs::remove_dir_all(&tmp_dir);
850 }
851
852 #[test]
856 fn retention_keep_3() {
857 let tmp_id = std::time::SystemTime::now()
858 .duration_since(std::time::UNIX_EPOCH)
859 .map(|d| d.as_nanos())
860 .unwrap_or(0);
861 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-retention-{tmp_id}"));
862 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
863
864 let sidecar_names = [
868 "brain.db.bak-1-2-1000",
869 "brain.db.bak-1-2-2000",
870 "brain.db.bak-1-2-3000",
871 "brain.db.bak-1-2-4000",
872 ];
873 for name in &sidecar_names {
874 let p = tmp_dir.join(name);
875 std::fs::write(&p, b"fake backup").expect("write fake sidecar");
876 }
877
878 let db_path = tmp_dir.join("brain.db");
879 prune_backups(&db_path, 3);
880
881 let remaining: Vec<_> = std::fs::read_dir(&tmp_dir)
883 .expect("read_dir")
884 .filter_map(|e| e.ok())
885 .filter(|e| {
886 e.file_name()
887 .to_str()
888 .map(|n| n.starts_with("brain.db.bak-"))
889 .unwrap_or(false)
890 })
891 .map(|e| e.file_name().to_str().unwrap_or("").to_owned())
892 .collect();
893
894 assert_eq!(
895 remaining.len(),
896 3,
897 "exactly 3 backups should remain after pruning, found: {remaining:?}"
898 );
899
900 assert!(
902 !tmp_dir.join("brain.db.bak-1-2-1000").exists(),
903 "oldest backup (ts=1000) should have been pruned"
904 );
905 assert!(
907 tmp_dir.join("brain.db.bak-1-2-2000").exists(),
908 "backup ts=2000 should survive"
909 );
910 assert!(
911 tmp_dir.join("brain.db.bak-1-2-3000").exists(),
912 "backup ts=3000 should survive"
913 );
914 assert!(
915 tmp_dir.join("brain.db.bak-1-2-4000").exists(),
916 "backup ts=4000 should survive"
917 );
918
919 let _ = std::fs::remove_dir_all(&tmp_dir);
920 }
921
922 fn make_full_brain_db(path: &Path) -> Connection {
929 let conn = Connection::open(path).expect("open brain db");
930 conn.execute_batch(&format!(
932 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
933 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {});
934 CREATE TABLE memories (
935 memory_id TEXT PRIMARY KEY,
936 scope TEXT NOT NULL,
937 kind TEXT NOT NULL,
938 text TEXT NOT NULL
939 );
940 INSERT INTO memories VALUES ('bk-mem-1', 'repo', 'fact', 'backup test memory');",
941 target_version(),
942 ))
943 .expect("seed brain db");
944 conn
945 }
946
947 #[test]
948 fn backup_brain_default_path_exists_and_valid() {
949 let tmp_id = std::time::SystemTime::now()
950 .duration_since(std::time::UNIX_EPOCH)
951 .map(|d| d.as_nanos())
952 .unwrap_or(0);
953 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain-{tmp_id}"));
954 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
955
956 let db_path = tmp_dir.join("brain.db");
957 {
958 let _conn = make_full_brain_db(&db_path);
959 } let (dest, size) = backup_brain(&db_path, None).expect("backup_brain");
962
963 assert!(dest.exists(), "backup file should exist at {dest:?}");
965 assert!(size > 0, "backup size should be > 0, got {size}");
967 let name = dest
969 .file_name()
970 .and_then(|n| n.to_str())
971 .expect("backup has a filename");
972 assert!(
973 name.starts_with("brain.db.backup-"),
974 "backup name should start with 'brain.db.backup-', got: {name}"
975 );
976
977 let bak_conn = Connection::open(&dest).expect("open backup");
979 let count: i64 = bak_conn
980 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
981 .expect("count memories in backup");
982 assert_eq!(count, 1, "backup should contain 1 memory row");
983
984 let _ = std::fs::remove_dir_all(&tmp_dir);
985 }
986
987 #[test]
988 fn backup_brain_default_path_does_not_overwrite_existing_backup() {
989 let tmp_id = std::time::SystemTime::now()
990 .duration_since(std::time::UNIX_EPOCH)
991 .map(|d| d.as_nanos())
992 .unwrap_or(0);
993 let tmp_dir =
994 std::env::temp_dir().join(format!("kimetsu-test-backup-brain-unique-{tmp_id}"));
995 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
996
997 let db_path = tmp_dir.join("brain.db");
998 {
999 let _conn = make_full_brain_db(&db_path);
1000 }
1001
1002 let (first, _) = backup_brain(&db_path, None).expect("first backup");
1003 let (second, _) = backup_brain(&db_path, None).expect("second backup");
1004
1005 assert_ne!(first, second, "default backups must not overwrite");
1006 assert!(first.exists(), "first backup should still exist");
1007 assert!(second.exists(), "second backup should exist");
1008
1009 let _ = std::fs::remove_dir_all(&tmp_dir);
1010 }
1011
1012 #[test]
1013 fn backup_brain_custom_path() {
1014 let tmp_id = std::time::SystemTime::now()
1015 .duration_since(std::time::UNIX_EPOCH)
1016 .map(|d| d.as_nanos())
1017 .unwrap_or(0);
1018 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain2-{tmp_id}"));
1019 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1020
1021 let db_path = tmp_dir.join("brain.db");
1022 let custom = tmp_dir.join("my-custom-backup.db");
1023 {
1024 let _conn = make_full_brain_db(&db_path);
1025 }
1026
1027 let (dest, size) = backup_brain(&db_path, Some(&custom)).expect("backup_brain custom");
1028
1029 assert_eq!(dest, custom, "dest should be the custom path");
1030 assert!(custom.exists(), "custom backup file should exist");
1031 assert!(size > 0);
1032
1033 let bak_conn = Connection::open(&custom).expect("open custom backup");
1035 let count: i64 = bak_conn
1036 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
1037 .expect("count memories in custom backup");
1038 assert_eq!(count, 1, "custom backup should contain 1 memory row");
1039
1040 let _ = std::fs::remove_dir_all(&tmp_dir);
1041 }
1042}