1use std::fmt::{self, Display, Formatter};
53use std::path::{Path, PathBuf};
54
55use serde::{Deserialize, Serialize};
56
57use crate::auth::peer_keys::KEY_FILE_NAME;
58use crate::schedule_wiring::SCHEDULE_STORE_FILE;
59use crate::workgraph_wiring::WORKGRAPH_STORE_FILE;
60
61pub const CANONICAL_SESSIONS_DB_FILE_NAME: &str = "sessions.sqlite3";
63pub const RUNTIME_DB_FILE_NAME: &str = "runtime.sqlite";
65pub const CANONICAL_CONTINUITY_DB_FILE_NAME: &str = "continuity.sqlite3";
67pub const CANONICAL_METADATA_DB_FILE_NAME: &str = "mobkit_metadata.sqlite3";
69pub const CANONICAL_CONSOLE_DB_FILE_NAME: &str = "mobkit_console.sqlite3";
71pub const CANONICAL_AGENT_MEMORY_DIR_NAME: &str = "agent-memory";
74pub const EVENT_LOG_DB_FILE_NAME: &str = "event_log.sqlite3";
77pub const BLOB_ROOT_DIR_NAME: &str = "blobs";
79pub const RUNTIME_REGISTRY_FILE_NAME: &str = "tux-runtimes.json";
81pub const GATEWAY_HOME_DIR_NAME: &str = "meerkat-mobkit";
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum DatabaseSlot {
88 Sessions,
89 Runtime,
90 Schedule,
91 Workgraph,
92 Continuity,
93 Metadata,
94 Console,
95 AgentMemory,
96 EventLog,
97}
98
99impl DatabaseSlot {
100 pub const ALL: [Self; 9] = [
102 Self::Sessions,
103 Self::Runtime,
104 Self::Schedule,
105 Self::Workgraph,
106 Self::Continuity,
107 Self::Metadata,
108 Self::Console,
109 Self::AgentMemory,
110 Self::EventLog,
111 ];
112
113 pub fn canonical_name(self) -> &'static str {
115 match self {
116 Self::Sessions => CANONICAL_SESSIONS_DB_FILE_NAME,
117 Self::Runtime => RUNTIME_DB_FILE_NAME,
118 Self::Schedule => SCHEDULE_STORE_FILE,
119 Self::Workgraph => WORKGRAPH_STORE_FILE,
120 Self::Continuity => CANONICAL_CONTINUITY_DB_FILE_NAME,
121 Self::Metadata => CANONICAL_METADATA_DB_FILE_NAME,
122 Self::Console => CANONICAL_CONSOLE_DB_FILE_NAME,
123 Self::AgentMemory => CANONICAL_AGENT_MEMORY_DIR_NAME,
124 Self::EventLog => EVENT_LOG_DB_FILE_NAME,
125 }
126 }
127
128 pub fn legacy_names(self) -> &'static [&'static str] {
130 match self {
131 Self::Sessions => &["sessions.db", "sessions.sqlite"],
132 Self::Continuity => &["continuity.db", "identity_continuity.sqlite"],
133 Self::Metadata => &["mobkit_metadata.sqlite"],
134 Self::Console => &["mobkit_console.sqlite"],
135 Self::AgentMemory => &["agent-memory-sqlite"],
136 Self::Runtime | Self::Schedule | Self::Workgraph | Self::EventLog => &[],
137 }
138 }
139}
140
141impl Display for DatabaseSlot {
142 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
143 let name = match self {
144 Self::Sessions => "sessions",
145 Self::Runtime => "runtime",
146 Self::Schedule => "schedule",
147 Self::Workgraph => "workgraph",
148 Self::Continuity => "continuity",
149 Self::Metadata => "metadata",
150 Self::Console => "console",
151 Self::AgentMemory => "agent-memory",
152 Self::EventLog => "event-log",
153 };
154 f.write_str(name)
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
161pub enum DatabaseProvenance {
162 Canonical,
164 LegacySpelling(String),
166 ExplicitOverride,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ResolvedDatabase {
174 pub path: PathBuf,
175 pub provenance: DatabaseProvenance,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum StateDirDurability {
183 Durable,
184 DeclaredEphemeral,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum StorageLayoutError {
190 FileNameTwins {
194 slot: DatabaseSlot,
195 paths: Vec<PathBuf>,
196 },
197 GatewayHomeUnavailable,
200}
201
202impl Display for StorageLayoutError {
203 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
204 match self {
205 Self::FileNameTwins { slot, paths } => {
206 write!(
207 f,
208 "file-name twins for the {slot} store: multiple spellings exist in the same \
209 state directory ("
210 )?;
211 for (index, path) in paths.iter().enumerate() {
212 if index > 0 {
213 write!(f, ", ")?;
214 }
215 write!(f, "{}", path.display())?;
216 }
217 write!(
218 f,
219 "); refusing to pick one at open — run the storage doctor \
220 (mobkit/storage/doctor) to inspect, and converge spellings with the \
221 storage migrate verb"
222 )
223 }
224 Self::GatewayHomeUnavailable => {
225 write!(
226 f,
227 "cannot derive the gateway home: neither XDG_STATE_HOME nor HOME is set"
228 )
229 }
230 }
231 }
232}
233
234impl std::error::Error for StorageLayoutError {}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct MobKitStorageLayout {
239 state_dir: PathBuf,
240 gateway_home: Option<PathBuf>,
241 session_db_override: Option<PathBuf>,
242 durability: StateDirDurability,
243 meerkat_state_root: Option<PathBuf>,
244}
245
246impl MobKitStorageLayout {
247 pub fn from_meerkat_layout(
254 meerkat_layout: &meerkat_core::StorageLayout,
255 state_dir: PathBuf,
256 ) -> Self {
257 Self {
258 state_dir,
259 gateway_home: None,
260 session_db_override: None,
261 durability: StateDirDurability::Durable,
262 meerkat_state_root: Some(meerkat_layout.state_root().to_path_buf()),
263 }
264 }
265
266 pub fn standalone(state_dir: PathBuf, gateway_home: PathBuf) -> Self {
270 Self {
271 state_dir,
272 gateway_home: Some(gateway_home),
273 session_db_override: None,
274 durability: StateDirDurability::Durable,
275 meerkat_state_root: None,
276 }
277 }
278
279 pub fn standalone_from_store_path(store_path: &Path, gateway_home: PathBuf) -> Self {
285 if store_path.extension().is_some() {
286 let state_dir = store_path
287 .parent()
288 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
289 Self {
290 state_dir,
291 gateway_home: Some(gateway_home),
292 session_db_override: Some(store_path.to_path_buf()),
293 durability: StateDirDurability::Durable,
294 meerkat_state_root: None,
295 }
296 } else {
297 Self::standalone(store_path.to_path_buf(), gateway_home)
298 }
299 }
300
301 pub fn declared_ephemeral(scratch_root: PathBuf) -> Self {
306 Self {
307 state_dir: scratch_root,
308 gateway_home: None,
309 session_db_override: None,
310 durability: StateDirDurability::DeclaredEphemeral,
311 meerkat_state_root: None,
312 }
313 }
314
315 pub fn with_injected_roots(state_dir: PathBuf, gateway_home: Option<PathBuf>) -> Self {
319 Self {
320 state_dir,
321 gateway_home,
322 session_db_override: None,
323 durability: StateDirDurability::Durable,
324 meerkat_state_root: None,
325 }
326 }
327
328 pub fn state_dir(&self) -> &Path {
330 &self.state_dir
331 }
332
333 pub fn gateway_home(&self) -> Option<&Path> {
335 self.gateway_home.as_deref()
336 }
337
338 pub fn meerkat_state_root(&self) -> Option<&Path> {
341 self.meerkat_state_root.as_deref()
342 }
343
344 pub fn session_db_override(&self) -> Option<&Path> {
347 self.session_db_override.as_deref()
348 }
349
350 pub fn is_declared_ephemeral(&self) -> bool {
352 self.durability == StateDirDurability::DeclaredEphemeral
353 }
354
355 pub fn resolve_database(
358 &self,
359 slot: DatabaseSlot,
360 ) -> Result<ResolvedDatabase, StorageLayoutError> {
361 if slot == DatabaseSlot::Sessions
362 && let Some(override_path) = self.session_db_override.as_ref()
363 {
364 return Ok(ResolvedDatabase {
365 path: override_path.clone(),
366 provenance: DatabaseProvenance::ExplicitOverride,
367 });
368 }
369 let canonical = self.state_dir.join(slot.canonical_name());
370 let existing_legacy: Vec<(&'static str, PathBuf)> = slot
371 .legacy_names()
372 .iter()
373 .map(|name| (*name, self.state_dir.join(name)))
374 .filter(|(_, path)| path.exists())
375 .collect();
376 let canonical_exists = canonical.exists();
377 if (canonical_exists && !existing_legacy.is_empty()) || existing_legacy.len() > 1 {
378 let mut paths = Vec::with_capacity(existing_legacy.len() + 1);
379 if canonical_exists {
380 paths.push(canonical);
381 }
382 paths.extend(existing_legacy.into_iter().map(|(_, path)| path));
383 return Err(StorageLayoutError::FileNameTwins { slot, paths });
384 }
385 if let Some((name, path)) = existing_legacy.into_iter().next() {
386 return Ok(ResolvedDatabase {
387 path,
388 provenance: DatabaseProvenance::LegacySpelling(name.to_string()),
389 });
390 }
391 Ok(ResolvedDatabase {
392 path: canonical,
393 provenance: DatabaseProvenance::Canonical,
394 })
395 }
396
397 pub fn session_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
399 self.resolve_database(DatabaseSlot::Sessions)
400 }
401
402 pub fn continuity_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
404 self.resolve_database(DatabaseSlot::Continuity)
405 }
406
407 pub fn metadata_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
409 self.resolve_database(DatabaseSlot::Metadata)
410 }
411
412 pub fn console_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
414 self.resolve_database(DatabaseSlot::Console)
415 }
416
417 pub fn agent_memory_root(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
420 self.resolve_database(DatabaseSlot::AgentMemory)
421 }
422
423 pub fn runtime_db(&self) -> PathBuf {
425 self.state_dir.join(RUNTIME_DB_FILE_NAME)
426 }
427
428 pub fn schedule_db(&self) -> PathBuf {
430 self.state_dir.join(SCHEDULE_STORE_FILE)
431 }
432
433 pub fn workgraph_db(&self) -> PathBuf {
435 self.state_dir.join(WORKGRAPH_STORE_FILE)
436 }
437
438 pub fn jobs_db(&self) -> PathBuf {
441 meerkat_store::realm_paths_in(
442 &self.state_dir,
443 crate::storage_provider::MEERKAT_LEVEL_REALM_ID,
444 )
445 .jobs_sqlite_path
446 }
447
448 pub fn event_log_db(&self) -> PathBuf {
450 self.state_dir.join(EVENT_LOG_DB_FILE_NAME)
451 }
452
453 pub fn blob_root(&self) -> PathBuf {
455 self.state_dir.join(BLOB_ROOT_DIR_NAME)
456 }
457
458 pub fn peer_key_file(&self) -> Option<PathBuf> {
460 self.gateway_home
461 .as_ref()
462 .map(|home| home.join(KEY_FILE_NAME))
463 }
464
465 pub fn registry_file(&self) -> Option<PathBuf> {
467 self.gateway_home
468 .as_ref()
469 .map(|home| home.join(RUNTIME_REGISTRY_FILE_NAME))
470 }
471
472 pub fn layout_summary(&self) -> StorageLayoutSummary {
475 let databases = DatabaseSlot::ALL
476 .into_iter()
477 .map(|slot| {
478 let resolution = match self.resolve_database(slot) {
479 Ok(resolved) => DatabaseResolution::Resolved {
480 path: resolved.path,
481 provenance: resolved.provenance,
482 },
483 Err(StorageLayoutError::FileNameTwins { paths, .. }) => {
484 DatabaseResolution::Twins { paths }
485 }
486 Err(_) => unreachable!("resolve_database only fails on twins"),
488 };
489 DatabaseSummary { slot, resolution }
490 })
491 .collect();
492 StorageLayoutSummary {
493 state_dir: self.state_dir.clone(),
494 durability: self.durability,
495 gateway_home: self.gateway_home.clone(),
496 meerkat_state_root: self.meerkat_state_root.clone(),
497 blob_root: self.blob_root(),
498 peer_key_file: self.peer_key_file(),
499 registry_file: self.registry_file(),
500 databases,
501 }
502 }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct DatabaseSummary {
508 pub slot: DatabaseSlot,
509 pub resolution: DatabaseResolution,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case", tag = "state")]
515pub enum DatabaseResolution {
516 Resolved {
517 path: PathBuf,
518 provenance: DatabaseProvenance,
519 },
520 Twins {
521 paths: Vec<PathBuf>,
522 },
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct StorageLayoutSummary {
528 pub state_dir: PathBuf,
529 pub durability: StateDirDurability,
530 pub gateway_home: Option<PathBuf>,
531 pub meerkat_state_root: Option<PathBuf>,
532 pub blob_root: PathBuf,
533 pub peer_key_file: Option<PathBuf>,
534 pub registry_file: Option<PathBuf>,
535 pub databases: Vec<DatabaseSummary>,
536}
537
538pub fn default_gateway_home() -> Result<PathBuf, StorageLayoutError> {
542 gateway_home_from(
543 std::env::var("XDG_STATE_HOME").ok().as_deref(),
544 std::env::var("HOME").ok().as_deref(),
545 )
546}
547
548fn gateway_home_from(
549 xdg_state_home: Option<&str>,
550 home: Option<&str>,
551) -> Result<PathBuf, StorageLayoutError> {
552 if let Some(xdg) = xdg_state_home
553 && !xdg.trim().is_empty()
554 {
555 return Ok(PathBuf::from(xdg).join(GATEWAY_HOME_DIR_NAME));
556 }
557 let home = home.ok_or(StorageLayoutError::GatewayHomeUnavailable)?;
558 Ok(PathBuf::from(home)
559 .join(".local")
560 .join("state")
561 .join(GATEWAY_HOME_DIR_NAME))
562}
563
564pub fn default_ephemeral_scratch_root() -> PathBuf {
569 std::env::temp_dir().join(format!("mobkit-scratch-{}", std::process::id()))
570}
571
572#[cfg(test)]
573#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
574mod tests {
575 use super::*;
576
577 fn touch(path: &Path) {
578 std::fs::write(path, b"").expect("touch fixture file");
579 }
580
581 fn durable_layout(dir: &Path) -> MobKitStorageLayout {
582 MobKitStorageLayout::with_injected_roots(dir.to_path_buf(), None)
583 }
584
585 #[test]
586 fn fresh_dir_resolves_every_slot_to_canonical() {
587 let tmp = tempfile::tempdir().expect("tempdir");
588 let layout = durable_layout(tmp.path());
589 for slot in DatabaseSlot::ALL {
590 let resolved = layout.resolve_database(slot).expect("fresh dir resolves");
591 assert_eq!(resolved.provenance, DatabaseProvenance::Canonical);
592 assert_eq!(resolved.path, tmp.path().join(slot.canonical_name()));
593 }
594 assert_eq!(
595 layout.session_db().expect("sessions").path,
596 tmp.path().join("sessions.sqlite3")
597 );
598 assert_eq!(
599 layout.continuity_db().expect("continuity").path,
600 tmp.path().join("continuity.sqlite3")
601 );
602 }
603
604 #[test]
605 fn canonical_only_resolves_canonical() {
606 let tmp = tempfile::tempdir().expect("tempdir");
607 touch(&tmp.path().join("sessions.sqlite3"));
608 let resolved = durable_layout(tmp.path()).session_db().expect("resolve");
609 assert_eq!(resolved.provenance, DatabaseProvenance::Canonical);
610 assert_eq!(resolved.path, tmp.path().join("sessions.sqlite3"));
611 }
612
613 #[test]
614 fn legacy_only_resolves_where_it_lies() {
615 let tmp = tempfile::tempdir().expect("tempdir");
616 let layout = durable_layout(tmp.path());
617 for (slot, legacy) in [
618 (DatabaseSlot::Sessions, "sessions.db"),
619 (DatabaseSlot::Sessions, "sessions.sqlite"),
620 (DatabaseSlot::Continuity, "continuity.db"),
621 (DatabaseSlot::Continuity, "identity_continuity.sqlite"),
622 (DatabaseSlot::Metadata, "mobkit_metadata.sqlite"),
623 (DatabaseSlot::Console, "mobkit_console.sqlite"),
624 ] {
625 let path = tmp.path().join(legacy);
626 touch(&path);
627 let resolved = layout.resolve_database(slot).expect("legacy resolves");
628 assert_eq!(
629 resolved.provenance,
630 DatabaseProvenance::LegacySpelling(legacy.to_string()),
631 "slot {slot} legacy {legacy}"
632 );
633 assert_eq!(resolved.path, path);
634 std::fs::remove_file(&path).expect("cleanup fixture");
635 }
636 let legacy_dir = tmp.path().join("agent-memory-sqlite");
638 std::fs::create_dir(&legacy_dir).expect("legacy memory dir");
639 let resolved = layout.agent_memory_root().expect("legacy dir resolves");
640 assert_eq!(
641 resolved.provenance,
642 DatabaseProvenance::LegacySpelling("agent-memory-sqlite".to_string())
643 );
644 assert_eq!(resolved.path, legacy_dir);
645 }
646
647 #[test]
648 fn canonical_plus_legacy_refuses_as_twins() {
649 let tmp = tempfile::tempdir().expect("tempdir");
650 touch(&tmp.path().join("sessions.sqlite3"));
651 touch(&tmp.path().join("sessions.db"));
652 let err = durable_layout(tmp.path())
653 .session_db()
654 .expect_err("twins refuse");
655 let StorageLayoutError::FileNameTwins { slot, paths } = err else {
656 panic!("expected FileNameTwins");
657 };
658 assert_eq!(slot, DatabaseSlot::Sessions);
659 assert_eq!(
660 paths,
661 vec![
662 tmp.path().join("sessions.sqlite3"),
663 tmp.path().join("sessions.db"),
664 ]
665 );
666 }
667
668 #[test]
669 fn two_legacy_spellings_refuse_as_twins() {
670 let tmp = tempfile::tempdir().expect("tempdir");
671 touch(&tmp.path().join("continuity.db"));
672 touch(&tmp.path().join("identity_continuity.sqlite"));
673 let err = durable_layout(tmp.path())
674 .continuity_db()
675 .expect_err("legacy twins refuse");
676 let StorageLayoutError::FileNameTwins { slot, paths } = err else {
677 panic!("expected FileNameTwins");
678 };
679 assert_eq!(slot, DatabaseSlot::Continuity);
680 assert_eq!(paths.len(), 2);
681 assert!(err_to_string(&slot, &paths).contains("mobkit/storage/doctor"));
683 }
684
685 fn err_to_string(slot: &DatabaseSlot, paths: &[PathBuf]) -> String {
686 StorageLayoutError::FileNameTwins {
687 slot: *slot,
688 paths: paths.to_vec(),
689 }
690 .to_string()
691 }
692
693 #[test]
694 fn store_path_with_extension_is_an_explicit_session_override() {
695 let tmp = tempfile::tempdir().expect("tempdir");
696 let db_file = tmp.path().join("custom-sessions.db");
697 let layout = MobKitStorageLayout::standalone_from_store_path(
698 &db_file,
699 tmp.path().join("gateway-home"),
700 );
701 assert_eq!(layout.state_dir(), tmp.path());
702 assert_eq!(layout.session_db_override(), Some(db_file.as_path()));
703 touch(&tmp.path().join("sessions.sqlite3"));
706 touch(&tmp.path().join("sessions.db"));
707 let resolved = layout.session_db().expect("override resolves");
708 assert_eq!(resolved.provenance, DatabaseProvenance::ExplicitOverride);
709 assert_eq!(resolved.path, db_file);
710 assert!(layout.continuity_db().is_ok());
712 }
713
714 #[test]
715 fn store_path_without_extension_is_the_state_dir() {
716 let tmp = tempfile::tempdir().expect("tempdir");
717 let dir = tmp.path().join("state");
718 let layout = MobKitStorageLayout::standalone_from_store_path(&dir, tmp.path().join("home"));
719 assert_eq!(layout.state_dir(), dir);
720 assert_eq!(layout.session_db_override(), None);
721 }
722
723 #[test]
724 fn gateway_home_prefers_non_blank_xdg_state_home() {
725 assert_eq!(
726 gateway_home_from(Some("/xdg/state"), Some("/home/user")).expect("xdg"),
727 PathBuf::from("/xdg/state").join("meerkat-mobkit")
728 );
729 assert_eq!(
730 gateway_home_from(Some(" "), Some("/home/user")).expect("blank xdg falls back"),
731 PathBuf::from("/home/user")
732 .join(".local")
733 .join("state")
734 .join("meerkat-mobkit")
735 );
736 assert_eq!(
737 gateway_home_from(None, Some("/home/user")).expect("home fallback"),
738 PathBuf::from("/home/user")
739 .join(".local")
740 .join("state")
741 .join("meerkat-mobkit")
742 );
743 assert_eq!(
744 gateway_home_from(None, None).expect_err("no roots"),
745 StorageLayoutError::GatewayHomeUnavailable
746 );
747 }
748
749 #[test]
750 fn embedded_layout_has_no_gateway_home_and_records_the_meerkat_root() {
751 let tmp = tempfile::tempdir().expect("tempdir");
752 let meerkat = meerkat_core::StorageLayout::with_injected_roots(
753 tmp.path().to_path_buf(),
754 None,
755 None,
756 tmp.path().join("realm-root"),
757 );
758 let layout =
759 MobKitStorageLayout::from_meerkat_layout(&meerkat, tmp.path().join("mobkit-state"));
760 assert_eq!(layout.state_dir(), tmp.path().join("mobkit-state"));
761 assert_eq!(
762 layout.meerkat_state_root(),
763 Some(tmp.path().join("realm-root").as_path())
764 );
765 assert_eq!(layout.gateway_home(), None);
766 assert_eq!(layout.peer_key_file(), None);
767 assert_eq!(layout.registry_file(), None);
768 assert!(!layout.is_declared_ephemeral());
769 }
770
771 #[test]
772 fn standalone_layout_owns_the_gateway_home_files() {
773 let tmp = tempfile::tempdir().expect("tempdir");
774 let home = tmp.path().join("gw-home");
775 let layout = MobKitStorageLayout::standalone(tmp.path().join("state"), home.clone());
776 assert_eq!(layout.gateway_home(), Some(home.as_path()));
777 assert_eq!(layout.peer_key_file(), Some(home.join("peer_key.ed25519")));
778 assert_eq!(layout.registry_file(), Some(home.join("tux-runtimes.json")));
779 }
780
781 #[test]
782 fn declared_ephemeral_layout_is_recorded_in_the_summary() {
783 let tmp = tempfile::tempdir().expect("tempdir");
784 let layout = MobKitStorageLayout::declared_ephemeral(tmp.path().join("scratch"));
785 assert!(layout.is_declared_ephemeral());
786 let summary = layout.layout_summary();
787 assert_eq!(summary.durability, StateDirDurability::DeclaredEphemeral);
788 assert_eq!(summary.state_dir, tmp.path().join("scratch"));
789 }
790
791 #[test]
792 fn layout_summary_round_trips_through_serde_and_reports_twins() {
793 let tmp = tempfile::tempdir().expect("tempdir");
794 touch(&tmp.path().join("sessions.sqlite3"));
795 touch(&tmp.path().join("sessions.db"));
796 touch(&tmp.path().join("mobkit_metadata.sqlite"));
797 let layout =
798 MobKitStorageLayout::standalone(tmp.path().to_path_buf(), tmp.path().join("home"));
799 let summary = layout.layout_summary();
800 let sessions = summary
801 .databases
802 .iter()
803 .find(|entry| entry.slot == DatabaseSlot::Sessions)
804 .expect("sessions entry");
805 assert!(matches!(
806 sessions.resolution,
807 DatabaseResolution::Twins { ref paths } if paths.len() == 2
808 ));
809 let metadata = summary
810 .databases
811 .iter()
812 .find(|entry| entry.slot == DatabaseSlot::Metadata)
813 .expect("metadata entry");
814 assert!(matches!(
815 metadata.resolution,
816 DatabaseResolution::Resolved {
817 provenance: DatabaseProvenance::LegacySpelling(ref name),
818 ..
819 } if name == "mobkit_metadata.sqlite"
820 ));
821 let json = serde_json::to_string(&summary).expect("serialize summary");
822 let restored: StorageLayoutSummary =
823 serde_json::from_str(&json).expect("deserialize summary");
824 assert_eq!(restored, summary);
825 }
826
827 #[test]
828 fn fixed_name_slots_agree_with_the_shared_wiring_constants() {
829 let tmp = tempfile::tempdir().expect("tempdir");
830 let layout = durable_layout(tmp.path());
831 assert_eq!(layout.runtime_db(), tmp.path().join("runtime.sqlite"));
832 assert_eq!(
833 layout.schedule_db(),
834 tmp.path().join(crate::schedule_wiring::SCHEDULE_STORE_FILE)
835 );
836 assert_eq!(
837 layout.workgraph_db(),
838 tmp.path()
839 .join(crate::workgraph_wiring::WORKGRAPH_STORE_FILE)
840 );
841 assert_eq!(
842 layout.jobs_db(),
843 tmp.path()
844 .join(crate::storage_provider::MEERKAT_LEVEL_REALM_ID)
845 .join("jobs.sqlite3")
846 );
847 assert_eq!(layout.blob_root(), tmp.path().join("blobs"));
848 assert_eq!(layout.event_log_db(), tmp.path().join("event_log.sqlite3"));
849 }
850}