Skip to main content

meerkat_mobkit/
storage_layout.rs

1//! The single MobKit path authority for storage roots and canonical
2//! top-level database locators.
3//!
4//! [`MobKitStorageLayout`] is constructed once at bootstrap and carried
5//! through composition; no surface derives state-dir file names or resolves
6//! ambient roots on its own. The layout owns **roots and canonical
7//! top-level locators**; feature crates own *relative* names beneath them —
8//! the workgraph admission sidecar's lock name, the blob directory's
9//! internal sharding, and per-realm agent-memory files stay feature-owned.
10//!
11//! The two sanctioned ambient derivations live in this module and nowhere
12//! else (the M5 anti-ambient-resolution gate allowlists exactly this file):
13//! [`default_gateway_home`] (`$XDG_STATE_HOME`/`$HOME` rules) and
14//! [`default_ephemeral_scratch_root`] (per-process root under the OS temp
15//! directory for explicitly declared-ephemeral layouts).
16//!
17//! # Canonical spellings
18//!
19//! Decided here, once (storage-unification plan, Phase M2). Stores shared
20//! with Meerkat keep Meerkat's names; MobKit-owned files converge on the
21//! `*.sqlite3` convention. Legacy spellings remain readable through probing.
22//!
23//! | Slot | Canonical | Legacy spellings |
24//! |---|---|---|
25//! | sessions | `sessions.sqlite3` (Meerkat's realm spelling) | `sessions.db`, `sessions.sqlite` |
26//! | runtime | `runtime.sqlite` | — |
27//! | schedule | [`SCHEDULE_STORE_FILE`] (`schedule.sqlite`) | — |
28//! | workgraph | [`WORKGRAPH_STORE_FILE`] (`workgraph.sqlite3`) | — |
29//! | continuity | `continuity.sqlite3` | `continuity.db`, `identity_continuity.sqlite` |
30//! | metadata | `mobkit_metadata.sqlite3` | `mobkit_metadata.sqlite` |
31//! | console | `mobkit_console.sqlite3` | `mobkit_console.sqlite` |
32//! | agent-memory root | `agent-memory/` | `agent-memory-sqlite/` |
33//! | event log | `event_log.sqlite3` (reserved; nothing opens it pre-M4) | — |
34//! | blob root | `blobs/` | — |
35//! | peer key | `peer_key.ed25519` (gateway home) | — |
36//! | registry | `tux-runtimes.json` (gateway home) | — |
37//!
38//! # Canonical-name-first probing
39//!
40//! [`MobKitStorageLayout::resolve_database`] resolves the canonical name,
41//! then probes the known legacy spellings in the same directory:
42//!
43//! - exactly one spelling exists → use it **where it lies** (no rename at
44//!   open; physical renames arrive only with the M6 migration verb, under
45//!   the maintenance fence, with changelog entries);
46//! - canonical AND a legacy spelling exist, or two legacy spellings exist →
47//!   [`StorageLayoutError::FileNameTwins`], pointing at the storage doctor;
48//! - none exists → the canonical name (a fresh deployment converges).
49//!
50//! The invariant: the resolver never *creates* a twin.
51
52use 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
61/// Canonical sessions database file name (Meerkat's realm spelling).
62pub const CANONICAL_SESSIONS_DB_FILE_NAME: &str = "sessions.sqlite3";
63/// Runtime store file name (all three surfaces already agree; kept).
64pub const RUNTIME_DB_FILE_NAME: &str = "runtime.sqlite";
65/// Canonical identity-continuity database file name.
66pub const CANONICAL_CONTINUITY_DB_FILE_NAME: &str = "continuity.sqlite3";
67/// Canonical runtime-metadata database file name.
68pub const CANONICAL_METADATA_DB_FILE_NAME: &str = "mobkit_metadata.sqlite3";
69/// Canonical console-timeline database file name.
70pub const CANONICAL_CONSOLE_DB_FILE_NAME: &str = "mobkit_console.sqlite3";
71/// Canonical agent-memory root directory name (per-realm files beneath it
72/// stay feature-owned).
73pub const CANONICAL_AGENT_MEMORY_DIR_NAME: &str = "agent-memory";
74/// Reserved event-log database file name. Nothing opens it before the M4
75/// disk factory; the locator exists so the name is decided exactly once.
76pub const EVENT_LOG_DB_FILE_NAME: &str = "event_log.sqlite3";
77/// Blob root directory name (internal sharding stays feature-owned).
78pub const BLOB_ROOT_DIR_NAME: &str = "blobs";
79/// Runtime registry file name under the gateway home.
80pub const RUNTIME_REGISTRY_FILE_NAME: &str = "tux-runtimes.json";
81/// Directory name appended to the XDG state root for the gateway home.
82pub const GATEWAY_HOME_DIR_NAME: &str = "meerkat-mobkit";
83
84/// The nine top-level database locators the layout owns.
85#[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    /// Every slot, in the order the layout summary reports them.
101    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    /// The canonical file (or directory) name for this slot.
114    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    /// The known legacy spellings probed beside the canonical name.
129    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/// How a database locator resolved.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
161pub enum DatabaseProvenance {
162    /// The canonical spelling (existing, or fresh — nothing else existed).
163    Canonical,
164    /// A known legacy spelling, used where it lies (never renamed at open).
165    LegacySpelling(String),
166    /// The caller supplied an explicit database file (the gateway
167    /// `store_path`-with-extension escape hatch); probing is bypassed.
168    ExplicitOverride,
169}
170
171/// A resolved database locator: the path to open plus how it was chosen.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ResolvedDatabase {
174    pub path: PathBuf,
175    pub provenance: DatabaseProvenance,
176}
177
178/// Whether the layout's state root is durable or an explicitly declared
179/// scratch root.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum StateDirDurability {
183    Durable,
184    DeclaredEphemeral,
185}
186
187/// Typed layout refusals.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum StorageLayoutError {
190    /// Two spellings of the same store exist in the state directory. The
191    /// resolver refuses to choose (opening one would fork history; renaming
192    /// at open is the M6 migration verb's job, under the fence).
193    FileNameTwins {
194        slot: DatabaseSlot,
195        paths: Vec<PathBuf>,
196    },
197    /// Neither `$XDG_STATE_HOME` nor `$HOME` is available to derive the
198    /// gateway home.
199    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/// Immutable path authority, constructed once at bootstrap.
237#[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    /// Embedded construction: MobKit runs inside a Meerkat realm and derives
248    /// from Meerkat's already-resolved [`meerkat_core::StorageLayout`] —
249    /// MobKit never re-resolves ambient roots. `state_dir` is the directory
250    /// MobKit's stores live in (the embedder chooses where under the realm);
251    /// the Meerkat state root is carried for the layout summary so health
252    /// surfaces can correlate the two.
253    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    /// Standalone gateway construction from an explicit state directory plus
267    /// the gateway home (registry + peer key). Derive the home with
268    /// [`default_gateway_home`]; nothing else may read `$XDG_STATE_HOME`.
269    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    /// Standalone construction from a gateway `store_path` init parameter:
280    /// a path with a file extension is an explicit session-database override
281    /// (its parent becomes the state directory); otherwise it is the state
282    /// directory itself. This is the one place that interprets the
283    /// `store_path` escape hatch — call sites never sniff extensions.
284    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    /// Explicitly declared-ephemeral construction: no persistent state
302    /// exists, and every store under `scratch_root` is per-process scratch.
303    /// The choice is recorded in the layout summary — never a silent
304    /// call-site fallback into the OS temp directory.
305    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    /// Fully-injected constructor: no ambient reads at all (tests, and
316    /// embedders that own their environment — the runtime builder constructs
317    /// its layout from the explicit `persistent_state()` root this way).
318    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    /// The state directory every database locator resolves under.
329    pub fn state_dir(&self) -> &Path {
330        &self.state_dir
331    }
332
333    /// The XDG gateway home (registry + peer key), when this layout has one.
334    pub fn gateway_home(&self) -> Option<&Path> {
335        self.gateway_home.as_deref()
336    }
337
338    /// The Meerkat realm state root, when embedded via
339    /// [`Self::from_meerkat_layout`].
340    pub fn meerkat_state_root(&self) -> Option<&Path> {
341        self.meerkat_state_root.as_deref()
342    }
343
344    /// The explicit session-database override, when constructed from a
345    /// `store_path` with a file extension.
346    pub fn session_db_override(&self) -> Option<&Path> {
347        self.session_db_override.as_deref()
348    }
349
350    /// Whether the state root is an explicitly declared scratch root.
351    pub fn is_declared_ephemeral(&self) -> bool {
352        self.durability == StateDirDurability::DeclaredEphemeral
353    }
354
355    /// Resolve a database locator by canonical-name-first probing (see the
356    /// module docs). The resolver never creates files and never renames.
357    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    /// The sessions database (probing; honors the explicit override).
398    pub fn session_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
399        self.resolve_database(DatabaseSlot::Sessions)
400    }
401
402    /// The identity-continuity database (probing).
403    pub fn continuity_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
404        self.resolve_database(DatabaseSlot::Continuity)
405    }
406
407    /// The runtime-metadata database (probing).
408    pub fn metadata_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
409        self.resolve_database(DatabaseSlot::Metadata)
410    }
411
412    /// The console-timeline database (probing).
413    pub fn console_db(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
414        self.resolve_database(DatabaseSlot::Console)
415    }
416
417    /// The agent-memory root directory (probing; per-realm files beneath it
418    /// stay feature-owned).
419    pub fn agent_memory_root(&self) -> Result<ResolvedDatabase, StorageLayoutError> {
420        self.resolve_database(DatabaseSlot::AgentMemory)
421    }
422
423    /// The runtime store database. One spelling everywhere — infallible.
424    pub fn runtime_db(&self) -> PathBuf {
425        self.state_dir.join(RUNTIME_DB_FILE_NAME)
426    }
427
428    /// The schedule store database. One spelling everywhere — infallible.
429    pub fn schedule_db(&self) -> PathBuf {
430        self.state_dir.join(SCHEDULE_STORE_FILE)
431    }
432
433    /// The workgraph store database. One spelling everywhere — infallible.
434    pub fn workgraph_db(&self) -> PathBuf {
435        self.state_dir.join(WORKGRAPH_STORE_FILE)
436    }
437
438    /// Canonical Meerkat-level detached-job database inherited by MobKit's
439    /// composite provider. This is realm-owned, not a second MobKit store.
440    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    /// The reserved event-log locator (nothing opens it before M4).
449    pub fn event_log_db(&self) -> PathBuf {
450        self.state_dir.join(EVENT_LOG_DB_FILE_NAME)
451    }
452
453    /// The blob root directory (internal sharding stays feature-owned).
454    pub fn blob_root(&self) -> PathBuf {
455        self.state_dir.join(BLOB_ROOT_DIR_NAME)
456    }
457
458    /// The gateway peer-key file, when this layout has a gateway home.
459    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    /// The runtime registry file, when this layout has a gateway home.
466    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    /// A serializable snapshot of the layout and every slot's resolution,
473    /// for health surfaces (the storage doctor consumes it).
474    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                    // Only twins can fail slot resolution.
487                    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/// Per-slot entry in the layout summary.
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct DatabaseSummary {
508    pub slot: DatabaseSlot,
509    pub resolution: DatabaseResolution,
510}
511
512/// How a slot stands on disk at summary time.
513#[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/// Serializable layout snapshot for health surfaces.
526#[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
538/// The sanctioned ambient derivation of the gateway home:
539/// `$XDG_STATE_HOME/meerkat-mobkit` when `XDG_STATE_HOME` is set and
540/// non-blank, else `$HOME/.local/state/meerkat-mobkit`.
541pub 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
564/// The sanctioned per-process scratch root for
565/// [`MobKitStorageLayout::declared_ephemeral`] layouts: a pid-suffixed
566/// directory under the OS temp directory (per-process, so two gateways on
567/// one host never share scratch identity state).
568pub 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        // The agent-memory slot probes a directory, not a file.
637        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        // The message points operators at the doctor.
682        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        // The override bypasses probing entirely — even a twin pair on disk
704        // does not refuse an explicitly chosen file.
705        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        // Other slots still probe normally.
711        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}