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 (v2.6 #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 (v2.6 #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 Migration {
103 version: 11,
104 description: "add memory_entities projection (v2.6 first-class tags + ingest-time edges)",
105 up: crate::schema::migrate_v10_to_v11,
106 },
107 Migration {
108 version: 12,
109 description: "durable correction revisions and corpus freshness",
110 up: crate::schema::migrate_v11_to_v12,
111 },
112 Migration {
113 version: 13,
114 description: "preserve proposal temporal applicability",
115 up: crate::schema::migrate_v12_to_v13,
116 },
117 Migration {
118 version: 14,
119 description: "scope work episodes by explicit identity",
120 up: crate::schema::migrate_v13_to_v14,
121 },
122 Migration {
123 version: 15,
124 description: "derive structured fact evidence from redacted memories",
125 up: crate::schema::migrate_v14_to_v15,
126 },
127 ]
128}
129
130pub fn target_version() -> i64 {
132 KIMETSU_SCHEMA_VERSION
133}
134
135pub fn current_version(conn: &Connection) -> KimetsuResult<i64> {
137 Ok(conn.query_row(
138 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
139 [],
140 |row| row.get(0),
141 )?)
142}
143
144pub fn run_migrations(conn: &Connection) -> KimetsuResult<MigrationOutcome> {
146 run_with(conn, migrations(), target_version())
147}
148
149fn db_file_path(conn: &Connection) -> Option<PathBuf> {
156 match conn.path() {
157 Some(p) if !p.is_empty() && p != ":memory:" => Some(PathBuf::from(p)),
158 _ => None,
159 }
160}
161
162fn durable_row_count(conn: &Connection) -> i64 {
166 conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get::<_, i64>(0))
167 .unwrap_or(0)
168}
169
170fn unique_default_backup_path(candidate: PathBuf) -> PathBuf {
171 if !candidate.exists() {
172 return candidate;
173 }
174 let (Some(parent), Some(file_name)) = (
175 candidate.parent(),
176 candidate.file_name().and_then(|n| n.to_str()),
177 ) else {
178 return candidate;
179 };
180 for suffix in 1..1000 {
181 let next = parent.join(format!("{file_name}-{suffix}"));
182 if !next.exists() {
183 return next;
184 }
185 }
186 candidate
187}
188
189fn backup_before_migrate(conn: &Connection, from: i64, to: i64) -> KimetsuResult<Option<PathBuf>> {
197 let db_path = match db_file_path(conn) {
198 Some(p) => p,
199 None => return Ok(None), };
201
202 if durable_row_count(conn) == 0 {
205 return Ok(None);
206 }
207
208 let ts = SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .map(|d| d.as_nanos())
211 .unwrap_or(0);
212
213 let file_name = format!(
215 "{}.bak-{from}-{to}-{ts}",
216 db_path
217 .file_name()
218 .and_then(|n| n.to_str())
219 .unwrap_or("brain.db")
220 );
221 let dest_path = unique_default_backup_path(db_path.with_file_name(file_name));
222
223 let mut dest = Connection::open(&dest_path)?;
225 let backup = rusqlite::backup::Backup::new(conn, &mut dest)?;
226 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
229 drop(backup);
230
231 Ok(Some(dest_path))
232}
233
234fn prune_backups(db_path: &Path, keep: usize) {
242 let (Some(dir), Some(stem)) = (
243 db_path.parent(),
244 db_path.file_name().and_then(|n| n.to_str()),
245 ) else {
246 return;
247 };
248
249 let prefix = format!("{stem}.bak-");
250
251 let mut backups: Vec<PathBuf> = match std::fs::read_dir(dir) {
252 Ok(rd) => rd
253 .filter_map(|e| e.ok().map(|e| e.path()))
254 .filter(|p| {
255 p.file_name()
256 .and_then(|n| n.to_str())
257 .map(|n| n.starts_with(&prefix))
258 .unwrap_or(false)
259 })
260 .collect(),
261 Err(_) => return,
262 };
263
264 if backups.len() <= keep {
265 return;
266 }
267
268 backups.sort_by_key(|p| {
271 Reverse(
272 p.file_name()
273 .and_then(|n| n.to_str())
274 .and_then(|n| n.rsplit('-').next())
275 .and_then(|ts| ts.parse::<u64>().ok())
276 .unwrap_or(0),
277 )
278 });
279
280 for old in backups.into_iter().skip(keep) {
281 let _ = std::fs::remove_file(old);
282 }
283}
284
285pub(crate) fn run_with(
296 conn: &Connection,
297 migs: &[Migration],
298 target: i64,
299) -> KimetsuResult<MigrationOutcome> {
300 debug_assert!(
302 migs.windows(2).all(|w| w[1].version == w[0].version + 1),
303 "migrations must be strictly ascending and contiguous"
304 );
305 debug_assert!(
306 migs.iter().all(|m| m.version <= target),
307 "no migration may exceed the target version"
308 );
309
310 let current = current_version(conn)?;
311
312 if current == target {
313 return Ok(MigrationOutcome {
314 from: current,
315 to: current,
316 applied: Vec::new(),
317 backup_path: None,
318 });
319 }
320
321 if current > target {
322 return Err(format!(
323 "brain.db schema version {current} was written by a newer Kimetsu \
324 (this binary expects {target}); upgrade Kimetsu"
325 )
326 .into());
327 }
328
329 let backup_path = backup_before_migrate(conn, current, target)?;
331
332 let mut applied = Vec::new();
333
334 for m in migs
335 .iter()
336 .filter(|m| m.version > current && m.version <= target)
337 {
338 conn.execute_batch("BEGIN IMMEDIATE")?;
344
345 let result = (|| -> KimetsuResult<bool> {
346 if m.version <= current_version(conn)? {
349 return Ok(false); }
351 (m.up)(conn)?;
352 conn.execute(
353 "UPDATE schema_info SET value = ?1 WHERE key = 'kimetsu_schema_version'",
354 [m.version],
355 )?;
356 Ok(true)
357 })();
358
359 match result {
360 Ok(did_apply) => {
361 conn.execute_batch("COMMIT")?;
362 if did_apply {
363 applied.push(m.version);
364 }
365 }
366 Err(e) => {
367 let _ = conn.execute_batch("ROLLBACK");
368 return Err(e);
369 }
370 }
371 }
372
373 if let Some(ref bp) = backup_path {
375 if let Some(parent) = bp.parent() {
376 let db_ref = db_file_path(conn).unwrap_or_else(|| parent.join("brain.db"));
377 prune_backups(&db_ref, 3);
378 }
379 }
380
381 if !applied.is_empty() {
384 tracing::info!(
385 from = current,
386 to = target,
387 backup = ?backup_path,
388 "migrated brain.db schema"
389 );
390 }
391
392 Ok(MigrationOutcome {
393 from: current,
394 to: target,
395 applied,
396 backup_path,
397 })
398}
399
400pub fn backup_brain(
417 brain_db_path: &std::path::Path,
418 dest: Option<&std::path::Path>,
419) -> KimetsuResult<(std::path::PathBuf, u64)> {
420 let ts = SystemTime::now()
421 .duration_since(UNIX_EPOCH)
422 .map(|d| d.as_nanos())
423 .unwrap_or(0);
424
425 let dest_path = match dest {
426 Some(p) => p.to_path_buf(),
427 None => {
428 let file_name = format!(
429 "{}.backup-{ts}",
430 brain_db_path
431 .file_name()
432 .and_then(|n| n.to_str())
433 .unwrap_or("brain.db")
434 );
435 unique_default_backup_path(brain_db_path.with_file_name(file_name))
436 }
437 };
438
439 let src = Connection::open_with_flags(
441 brain_db_path,
442 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
443 )?;
444
445 let mut dst = Connection::open(&dest_path)?;
447 let backup = rusqlite::backup::Backup::new(&src, &mut dst)?;
448 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
449 drop(backup);
450 drop(dst);
451 drop(src);
452
453 let size = std::fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
454
455 Ok((dest_path, size))
456}
457
458#[cfg(test)]
463mod tests {
464 use super::*;
465 use rusqlite::Connection;
466
467 fn make_db(version: i64) -> Connection {
471 let conn = Connection::open_in_memory().expect("open_in_memory");
472 conn.execute_batch(&format!(
473 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
474 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
475 ))
476 .expect("seed schema_info");
477 conn
478 }
479
480 fn make_file_db(path: &Path, version: i64) -> Connection {
482 let conn = Connection::open(path).expect("open file db");
483 conn.execute_batch(&format!(
484 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
485 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
486 ))
487 .expect("seed schema_info");
488 conn
489 }
490
491 fn make_file_db_with_memory(path: &Path, version: i64) -> Connection {
495 let conn = make_file_db(path, version);
496 conn.execute_batch(
497 "CREATE TABLE memories (
498 memory_id TEXT PRIMARY KEY,
499 scope TEXT NOT NULL,
500 kind TEXT NOT NULL,
501 text TEXT NOT NULL
502 );
503 INSERT INTO memories VALUES ('test-mem-id', 'repo', 'preference', 'test memory');",
504 )
505 .expect("seed memories table");
506 conn
507 }
508
509 #[test]
514 fn migrate_v7_forward_adds_origin_and_hlc() {
515 let conn = Connection::open_in_memory().expect("open");
516 conn.execute_batch(
517 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
518 INSERT INTO schema_info VALUES ('kimetsu_schema_version', 7);
519 CREATE TABLE events (
520 event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, ts TEXT NOT NULL,
521 kind TEXT NOT NULL, schema_version INTEGER NOT NULL, payload_json TEXT NOT NULL);
522 INSERT INTO events VALUES
523 ('e1','r1','2024-01-01T00:00:00Z','memory.accepted',1,'{}'),
524 ('e2','r1','2024-01-02T00:00:00Z','memory.cited',1,'{}');",
525 )
526 .expect("seed v7 events");
527 conn.execute_batch("CREATE TABLE memories(memory_id TEXT PRIMARY KEY, text TEXT, embedding BLOB, embedding_model TEXT, invalidated_at TEXT, superseded_by TEXT);").unwrap();
529
530 let target = target_version();
531 let outcome = run_with(&conn, migrations(), target).expect("migrate v7->current");
532 assert!(outcome.applied.contains(&8), "v8 migration must apply");
533 assert!(outcome.applied.contains(&9), "v9 migration must apply");
534
535 let cols: Vec<String> = {
536 let mut stmt = conn.prepare("PRAGMA table_info(events)").unwrap();
537 stmt.query_map([], |r| r.get::<_, String>(1))
538 .unwrap()
539 .filter_map(Result::ok)
540 .collect()
541 };
542 assert!(
543 cols.iter().any(|c| c == "origin"),
544 "events.origin must exist"
545 );
546 assert!(cols.iter().any(|c| c == "hlc"), "events.hlc must exist");
547
548 let origin: Option<String> = conn
550 .query_row("SELECT origin FROM events WHERE event_id='e1'", [], |r| {
551 r.get(0)
552 })
553 .expect("read origin");
554 assert_eq!(origin, None, "old event rows must read origin = NULL");
555
556 let hlc1: String = conn
558 .query_row("SELECT hlc FROM events WHERE event_id='e1'", [], |r| {
559 r.get(0)
560 })
561 .expect("read hlc1");
562 let hlc2: String = conn
563 .query_row("SELECT hlc FROM events WHERE event_id='e2'", [], |r| {
564 r.get(0)
565 })
566 .expect("read hlc2");
567 assert!(
568 hlc1.starts_with("0000000000000."),
569 "backfilled wall=0: {hlc1}"
570 );
571 assert!(hlc1 < hlc2, "backfilled HLC preserves insertion order");
572 }
573
574 fn table_exists(conn: &Connection, name: &str) -> bool {
576 let count: i64 = conn
577 .query_row(
578 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
579 [name],
580 |r| r.get(0),
581 )
582 .unwrap_or(0);
583 count > 0
584 }
585
586 fn up_create_m2(conn: &Connection) -> KimetsuResult<()> {
592 conn.execute_batch("CREATE TABLE IF NOT EXISTS m2 (x INTEGER);")?;
593 Ok(())
594 }
595
596 fn up_create_m3(conn: &Connection) -> KimetsuResult<()> {
597 conn.execute_batch("CREATE TABLE IF NOT EXISTS m3 (x INTEGER);")?;
598 Ok(())
599 }
600
601 fn up_fail_partial(conn: &Connection) -> KimetsuResult<()> {
602 conn.execute_batch("CREATE TABLE IF NOT EXISTS partial_table (x INTEGER);")?;
605 Err("intentional migration failure".into())
606 }
607
608 fn up_create_t(conn: &Connection) -> KimetsuResult<()> {
609 conn.execute_batch("CREATE TABLE IF NOT EXISTS t (x INTEGER);")?;
610 Ok(())
611 }
612
613 #[test]
617 fn noop_when_at_target() {
618 let conn = make_db(7);
619 let outcome = run_with(&conn, &[], 7).expect("run_with");
620 assert_eq!(
621 outcome,
622 MigrationOutcome {
623 from: 7,
624 to: 7,
625 applied: vec![],
626 backup_path: None,
627 }
628 );
629 assert_eq!(current_version(&conn).unwrap(), 7);
631 }
632
633 #[test]
637 fn rejects_newer_db() {
638 let conn = make_db(999);
639 let err = run_with(&conn, &[], 1).expect_err("should error on newer DB");
640 let msg = err.to_string();
641 assert!(
642 msg.contains("newer"),
643 "error message should mention 'newer', got: {msg}"
644 );
645 assert_eq!(current_version(&conn).unwrap(), 999);
647 }
648
649 #[test]
653 fn applies_single_migration() {
654 let conn = make_db(1);
655 let migs = [Migration {
656 version: 2,
657 description: "create m2",
658 up: up_create_m2,
659 }];
660 let outcome = run_with(&conn, &migs, 2).expect("run_with");
661 assert_eq!(outcome.from, 1);
662 assert_eq!(outcome.to, 2);
663 assert_eq!(outcome.applied, vec![2]);
664 assert!(outcome.backup_path.is_none());
666 assert_eq!(current_version(&conn).unwrap(), 2);
668 assert!(table_exists(&conn, "m2"), "m2 table should exist");
670 }
671
672 #[test]
676 fn idempotent_rerun() {
677 let conn = make_db(1);
678 let migs = [Migration {
679 version: 2,
680 description: "create m2",
681 up: up_create_m2,
682 }];
683 run_with(&conn, &migs, 2).expect("first run");
685 let outcome = run_with(&conn, &migs, 2).expect("second run");
687 assert_eq!(
688 outcome.applied,
689 Vec::<i64>::new(),
690 "second run must apply nothing"
691 );
692 assert_eq!(current_version(&conn).unwrap(), 2);
693 }
694
695 #[test]
699 fn rollback_on_failing_migration() {
700 let conn = make_db(1);
701 let migs = [Migration {
702 version: 2,
703 description: "fail",
704 up: up_fail_partial,
705 }];
706 let err = run_with(&conn, &migs, 2).expect_err("should propagate migration error");
707 assert!(
708 err.to_string().contains("intentional"),
709 "propagated error should contain original message, got: {err}"
710 );
711 assert_eq!(
713 current_version(&conn).unwrap(),
714 1,
715 "version must be unchanged after rollback"
716 );
717 assert!(
719 !table_exists(&conn, "partial_table"),
720 "partial_table must not exist after rollback"
721 );
722 }
723
724 #[test]
728 fn multi_step_chain() {
729 let conn = make_db(1);
730 let migs = [
731 Migration {
732 version: 2,
733 description: "create m2",
734 up: up_create_m2,
735 },
736 Migration {
737 version: 3,
738 description: "create m3",
739 up: up_create_m3,
740 },
741 ];
742 let outcome = run_with(&conn, &migs, 3).expect("run_with");
743 assert_eq!(outcome.from, 1);
744 assert_eq!(outcome.to, 3);
745 assert_eq!(outcome.applied, vec![2, 3]);
746 assert_eq!(current_version(&conn).unwrap(), 3);
747 assert!(table_exists(&conn, "m2"), "m2 should exist");
748 assert!(table_exists(&conn, "m3"), "m3 should exist");
749 }
750
751 #[test]
755 fn backup_created_for_file_db() {
756 let tmp_id = std::time::SystemTime::now()
757 .duration_since(std::time::UNIX_EPOCH)
758 .map(|d| d.as_nanos())
759 .unwrap_or(0);
760 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-{tmp_id}"));
761 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
762
763 let db_path = tmp_dir.join("brain.db");
764 {
765 let conn = make_file_db_with_memory(&db_path, 1);
767
768 let migs = [Migration {
769 version: 2,
770 description: "create t",
771 up: up_create_t,
772 }];
773
774 let outcome = run_with(&conn, &migs, 2).expect("run_with");
775
776 let bak_path = outcome
778 .backup_path
779 .expect("backup_path should be Some for file DB");
780 assert!(
781 bak_path.exists(),
782 "backup file should exist at {bak_path:?}"
783 );
784
785 let bak_name = bak_path
787 .file_name()
788 .and_then(|n| n.to_str())
789 .expect("backup has a filename");
790 assert!(
791 bak_name.starts_with("brain.db.bak-1-2-"),
792 "backup name should be brain.db.bak-1-2-<ts>, got: {bak_name}"
793 );
794
795 let bak_conn = Connection::open(&bak_path).expect("open backup db");
797 let bak_version: i64 = bak_conn
798 .query_row(
799 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
800 [],
801 |r| r.get(0),
802 )
803 .expect("read backup version");
804 assert_eq!(
805 bak_version, 1,
806 "backup should capture pre-migration version 1"
807 );
808
809 assert_eq!(current_version(&conn).unwrap(), 2);
811 }
812
813 let _ = std::fs::remove_dir_all(&tmp_dir);
814 }
815
816 #[test]
820 fn no_backup_for_in_memory_db() {
821 let conn = make_db(1);
822 let migs = [Migration {
823 version: 2,
824 description: "create t",
825 up: up_create_t,
826 }];
827 let outcome = run_with(&conn, &migs, 2).expect("run_with");
828 assert!(
829 outcome.backup_path.is_none(),
830 "in-memory DB must not produce a backup"
831 );
832 assert_eq!(current_version(&conn).unwrap(), 2);
834 assert!(table_exists(&conn, "t"), "table t should exist");
835 }
836
837 #[test]
841 fn no_backup_for_noop() {
842 let tmp_id = std::time::SystemTime::now()
843 .duration_since(std::time::UNIX_EPOCH)
844 .map(|d| d.as_nanos())
845 .unwrap_or(0);
846 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-noop-{tmp_id}"));
847 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
848
849 let db_path = tmp_dir.join("brain.db");
850 {
851 let conn = make_file_db(&db_path, 2);
852 let outcome = run_with(&conn, &[], 2).expect("run_with");
853
854 assert!(
855 outcome.backup_path.is_none(),
856 "no-op run must not produce a backup"
857 );
858
859 let bak_files: Vec<_> = std::fs::read_dir(&tmp_dir)
861 .expect("read_dir")
862 .filter_map(|e| e.ok())
863 .filter(|e| {
864 e.file_name()
865 .to_str()
866 .map(|n| n.contains(".bak-"))
867 .unwrap_or(false)
868 })
869 .collect();
870 assert!(
871 bak_files.is_empty(),
872 "no backup files should exist after no-op, found: {bak_files:?}"
873 );
874 }
875
876 let _ = std::fs::remove_dir_all(&tmp_dir);
877 }
878
879 #[test]
883 fn retention_keep_3() {
884 let tmp_id = std::time::SystemTime::now()
885 .duration_since(std::time::UNIX_EPOCH)
886 .map(|d| d.as_nanos())
887 .unwrap_or(0);
888 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-retention-{tmp_id}"));
889 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
890
891 let sidecar_names = [
895 "brain.db.bak-1-2-1000",
896 "brain.db.bak-1-2-2000",
897 "brain.db.bak-1-2-3000",
898 "brain.db.bak-1-2-4000",
899 ];
900 for name in &sidecar_names {
901 let p = tmp_dir.join(name);
902 std::fs::write(&p, b"fake backup").expect("write fake sidecar");
903 }
904
905 let db_path = tmp_dir.join("brain.db");
906 prune_backups(&db_path, 3);
907
908 let remaining: Vec<_> = std::fs::read_dir(&tmp_dir)
910 .expect("read_dir")
911 .filter_map(|e| e.ok())
912 .filter(|e| {
913 e.file_name()
914 .to_str()
915 .map(|n| n.starts_with("brain.db.bak-"))
916 .unwrap_or(false)
917 })
918 .map(|e| e.file_name().to_str().unwrap_or("").to_owned())
919 .collect();
920
921 assert_eq!(
922 remaining.len(),
923 3,
924 "exactly 3 backups should remain after pruning, found: {remaining:?}"
925 );
926
927 assert!(
929 !tmp_dir.join("brain.db.bak-1-2-1000").exists(),
930 "oldest backup (ts=1000) should have been pruned"
931 );
932 assert!(
934 tmp_dir.join("brain.db.bak-1-2-2000").exists(),
935 "backup ts=2000 should survive"
936 );
937 assert!(
938 tmp_dir.join("brain.db.bak-1-2-3000").exists(),
939 "backup ts=3000 should survive"
940 );
941 assert!(
942 tmp_dir.join("brain.db.bak-1-2-4000").exists(),
943 "backup ts=4000 should survive"
944 );
945
946 let _ = std::fs::remove_dir_all(&tmp_dir);
947 }
948
949 fn make_full_brain_db(path: &Path) -> Connection {
956 let conn = Connection::open(path).expect("open brain db");
957 conn.execute_batch(&format!(
959 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
960 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {});
961 CREATE TABLE memories (
962 memory_id TEXT PRIMARY KEY,
963 scope TEXT NOT NULL,
964 kind TEXT NOT NULL,
965 text TEXT NOT NULL
966 );
967 INSERT INTO memories VALUES ('bk-mem-1', 'repo', 'fact', 'backup test memory');",
968 target_version(),
969 ))
970 .expect("seed brain db");
971 conn
972 }
973
974 #[test]
975 fn backup_brain_default_path_exists_and_valid() {
976 let tmp_id = std::time::SystemTime::now()
977 .duration_since(std::time::UNIX_EPOCH)
978 .map(|d| d.as_nanos())
979 .unwrap_or(0);
980 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain-{tmp_id}"));
981 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
982
983 let db_path = tmp_dir.join("brain.db");
984 {
985 let _conn = make_full_brain_db(&db_path);
986 } let (dest, size) = backup_brain(&db_path, None).expect("backup_brain");
989
990 assert!(dest.exists(), "backup file should exist at {dest:?}");
992 assert!(size > 0, "backup size should be > 0, got {size}");
994 let name = dest
996 .file_name()
997 .and_then(|n| n.to_str())
998 .expect("backup has a filename");
999 assert!(
1000 name.starts_with("brain.db.backup-"),
1001 "backup name should start with 'brain.db.backup-', got: {name}"
1002 );
1003
1004 let bak_conn = Connection::open(&dest).expect("open backup");
1006 let count: i64 = bak_conn
1007 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
1008 .expect("count memories in backup");
1009 assert_eq!(count, 1, "backup should contain 1 memory row");
1010
1011 let _ = std::fs::remove_dir_all(&tmp_dir);
1012 }
1013
1014 #[test]
1015 fn backup_brain_default_path_does_not_overwrite_existing_backup() {
1016 let tmp_id = std::time::SystemTime::now()
1017 .duration_since(std::time::UNIX_EPOCH)
1018 .map(|d| d.as_nanos())
1019 .unwrap_or(0);
1020 let tmp_dir =
1021 std::env::temp_dir().join(format!("kimetsu-test-backup-brain-unique-{tmp_id}"));
1022 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1023
1024 let db_path = tmp_dir.join("brain.db");
1025 {
1026 let _conn = make_full_brain_db(&db_path);
1027 }
1028
1029 let (first, _) = backup_brain(&db_path, None).expect("first backup");
1030 let (second, _) = backup_brain(&db_path, None).expect("second backup");
1031
1032 assert_ne!(first, second, "default backups must not overwrite");
1033 assert!(first.exists(), "first backup should still exist");
1034 assert!(second.exists(), "second backup should exist");
1035
1036 let _ = std::fs::remove_dir_all(&tmp_dir);
1037 }
1038
1039 #[test]
1040 fn backup_brain_custom_path() {
1041 let tmp_id = std::time::SystemTime::now()
1042 .duration_since(std::time::UNIX_EPOCH)
1043 .map(|d| d.as_nanos())
1044 .unwrap_or(0);
1045 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain2-{tmp_id}"));
1046 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1047
1048 let db_path = tmp_dir.join("brain.db");
1049 let custom = tmp_dir.join("my-custom-backup.db");
1050 {
1051 let _conn = make_full_brain_db(&db_path);
1052 }
1053
1054 let (dest, size) = backup_brain(&db_path, Some(&custom)).expect("backup_brain custom");
1055
1056 assert_eq!(dest, custom, "dest should be the custom path");
1057 assert!(custom.exists(), "custom backup file should exist");
1058 assert!(size > 0);
1059
1060 let bak_conn = Connection::open(&custom).expect("open custom backup");
1062 let count: i64 = bak_conn
1063 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
1064 .expect("count memories in custom backup");
1065 assert_eq!(count, 1, "custom backup should contain 1 memory row");
1066
1067 let _ = std::fs::remove_dir_all(&tmp_dir);
1068 }
1069}