Skip to main content

oxios_kernel/mount/
mount_db.rs

1//! Mount SQLite persistence (RFC-025).
2//!
3//! The `mounts` table lives in the same `memory.db` as memories and the
4//! legacy `projects` table. Coexists with RFC-011's `projects` table during
5//! the migration window.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::SystemTime;
10
11use anyhow::Result;
12
13use super::{Mount, MountMeta, MountSource};
14
15/// Schema DDL for the `mounts` table.
16pub const MOUNT_SCHEMA: &str = r#"
17-- ─────────────────────────────────────────────
18-- Mounts (RFC-025) — path aliases
19-- ─────────────────────────────────────────────
20CREATE TABLE IF NOT EXISTS mounts (
21    id                     TEXT PRIMARY KEY,
22    name                   TEXT NOT NULL UNIQUE,
23    paths                  TEXT NOT NULL,            -- JSON array of path strings
24    auto_description       TEXT NOT NULL DEFAULT '',
25    auto_meta              TEXT NOT NULL DEFAULT '{}', -- JSON MountMeta
26    source                 TEXT NOT NULL DEFAULT 'manual',
27    last_marker_snapshot   TEXT NOT NULL DEFAULT '{}', -- JSON {path_str: rfc3339_or_secs}
28    enrichment_pending     INTEGER NOT NULL DEFAULT 0,
29    last_enriched_at       TEXT,
30    created_at             TEXT NOT NULL,
31    updated_at             TEXT NOT NULL,
32    last_active_at         TEXT NOT NULL
33);
34
35CREATE INDEX IF NOT EXISTS idx_mounts_name ON mounts(name);
36
37-- ─────────────────────────────────────────────
38-- Mount dismissals (RFC-025 Phase 5) — tombstones for deleted
39-- AutoPromoted Mounts, so the scanner does not re-create them.
40-- ─────────────────────────────────────────────
41CREATE TABLE IF NOT EXISTS mount_dismissals (
42    root_path TEXT PRIMARY KEY
43);
44"#;
45
46/// Ensure the `mounts` table exists.
47pub fn ensure_mount_schema(conn: &rusqlite::Connection) -> Result<()> {
48    conn.execute_batch(MOUNT_SCHEMA)?;
49    Ok(())
50}
51
52/// Save (upsert) a Mount.
53pub fn save_mount(conn: &rusqlite::Connection, mount: &Mount) -> Result<()> {
54    conn.execute(
55        "INSERT OR REPLACE INTO mounts
56         (id, name, paths, auto_description, auto_meta, source,
57          last_marker_snapshot, enrichment_pending, last_enriched_at,
58          created_at, updated_at, last_active_at)
59         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
60        rusqlite::params![
61            mount.id.to_string(),
62            mount.name,
63            serde_json::to_string(&mount.paths)?,
64            mount.auto_description,
65            serde_json::to_string(&mount.auto_meta)?,
66            mount.source.to_string(),
67            serde_json::to_string(&serialize_snapshot(&mount.last_marker_snapshot))?,
68            mount.enrichment_pending as i32,
69            mount.last_enriched_at.map(|t| t.to_rfc3339()),
70            mount.created_at.to_rfc3339(),
71            mount.updated_at.to_rfc3339(),
72            mount.last_active_at.to_rfc3339(),
73        ],
74    )?;
75    Ok(())
76}
77
78/// List all Mounts, ordered by name.
79pub fn list_mounts(conn: &rusqlite::Connection) -> Result<Vec<Mount>> {
80    let mut stmt = conn.prepare(
81        "SELECT id, name, paths, auto_description, auto_meta, source,
82                last_marker_snapshot, enrichment_pending, last_enriched_at,
83                created_at, updated_at, last_active_at
84         FROM mounts ORDER BY name",
85    )?;
86    let rows = stmt.query_map([], row_to_mount)?;
87    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
88}
89
90/// Delete a Mount by ID.
91pub fn delete_mount(conn: &rusqlite::Connection, id: &str) -> Result<()> {
92    conn.execute("DELETE FROM mounts WHERE id = ?1", rusqlite::params![id])?;
93    Ok(())
94}
95
96/// RFC-025 Phase 5: load all dismissed root paths (tombstones).
97///
98/// These are roots the user explicitly removed after the scanner
99/// auto-promoted them. The scanner skips them so it never re-creates a
100/// Mount the user has rejected (Promo-3).
101pub fn list_dismissed_roots(conn: &rusqlite::Connection) -> Result<Vec<PathBuf>> {
102    let mut stmt = conn.prepare("SELECT root_path FROM mount_dismissals")?;
103    let rows = stmt.query_map([], |row| {
104        let s: String = row.get(0)?;
105        Ok(PathBuf::from(s))
106    })?;
107    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
108}
109
110/// RFC-025 Phase 5: record a dismissed root path (tombstone).
111pub fn add_dismissed_root(conn: &rusqlite::Connection, root: &Path) -> Result<()> {
112    conn.execute(
113        "INSERT OR IGNORE INTO mount_dismissals (root_path) VALUES (?1)",
114        rusqlite::params![root.to_string_lossy()],
115    )?;
116    Ok(())
117}
118
119/// Convert a SQLite row into a [`Mount`].
120fn row_to_mount(row: &rusqlite::Row<'_>) -> rusqlite::Result<Mount> {
121    use chrono::{DateTime, Utc};
122
123    let id_str: String = row.get(0)?;
124    let name: String = row.get(1)?;
125    let paths_str: String = row
126        .get::<_, Option<String>>(2)?
127        .unwrap_or_else(|| "[]".to_string());
128    let auto_description: String = row.get::<_, Option<String>>(3)?.unwrap_or_default();
129    let auto_meta_str: String = row
130        .get::<_, Option<String>>(4)?
131        .unwrap_or_else(|| "{}".to_string());
132    let source_str: String = row
133        .get::<_, Option<String>>(5)?
134        .unwrap_or_else(|| "manual".to_string());
135    let snapshot_str: String = row
136        .get::<_, Option<String>>(6)?
137        .unwrap_or_else(|| "{}".to_string());
138    let enrichment_pending: bool = row.get::<_, Option<i32>>(7)?.unwrap_or(0) != 0;
139    let last_enriched_str: Option<String> = row.get(8)?;
140    let created_at: String = row.get(9)?;
141    let updated_at: String = row.get(10)?;
142    let last_active_at: String = row.get(11)?;
143
144    let id = uuid::Uuid::parse_str(&id_str).map_err(|e| {
145        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
146    })?;
147    let paths: Vec<PathBuf> = serde_json::from_str(&paths_str).unwrap_or_default();
148    let auto_meta: MountMeta = serde_json::from_str(&auto_meta_str).unwrap_or_default();
149    let last_marker_snapshot = deserialize_snapshot(&snapshot_str);
150    let source = match source_str.as_str() {
151        "auto_detected" => MountSource::AutoDetected,
152        "auto_promoted" => MountSource::AutoPromoted,
153        _ => MountSource::Manual,
154    };
155    let last_enriched_at = last_enriched_str
156        .as_deref()
157        .and_then(|s| s.parse::<DateTime<Utc>>().ok());
158
159    Ok(Mount {
160        id,
161        name,
162        paths,
163        auto_description,
164        auto_meta,
165        source,
166        last_marker_snapshot,
167        enrichment_pending,
168        last_enriched_at,
169        created_at: created_at
170            .parse::<DateTime<Utc>>()
171            .unwrap_or_else(|_| Utc::now()),
172        updated_at: updated_at
173            .parse::<DateTime<Utc>>()
174            .unwrap_or_else(|_| Utc::now()),
175        last_active_at: last_active_at
176            .parse::<DateTime<Utc>>()
177            .unwrap_or_else(|_| Utc::now()),
178    })
179}
180
181// ── SystemTime snapshot (de)serialization helpers ──────────────────────
182//
183// SystemTime isn't directly JSON-serializable in a stable way, so we store
184// the snapshot as {path_string: seconds_since_epoch}. This is enough for
185// drift comparison (we only need to detect change, not exact timestamps).
186
187fn serialize_snapshot(snap: &HashMap<PathBuf, SystemTime>) -> HashMap<String, u64> {
188    snap.iter()
189        .map(|(k, v)| {
190            let secs = v
191                .duration_since(SystemTime::UNIX_EPOCH)
192                .map(|d| d.as_secs())
193                .unwrap_or(0);
194            (k.to_string_lossy().to_string(), secs)
195        })
196        .collect()
197}
198
199fn deserialize_snapshot(json: &str) -> HashMap<PathBuf, SystemTime> {
200    let map: HashMap<String, u64> = serde_json::from_str(json).unwrap_or_default();
201    map.into_iter()
202        .map(|(k, secs)| {
203            let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
204            (PathBuf::from(k), time)
205        })
206        .collect()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::MemoryDatabase;
213
214    fn open_db() -> MemoryDatabase {
215        let db = MemoryDatabase::open_in_memory(64).expect("open db");
216        ensure_mount_schema(&db.conn()).expect("schema");
217        db
218    }
219
220    #[test]
221    fn test_save_and_list_mount() {
222        let db = open_db();
223        let mut m =
224            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
225        m.auto_description = "Agent OS".to_string();
226        m.auto_meta.summary = "Rust agent OS".to_string();
227        m.auto_meta.languages = vec!["rust".to_string()];
228        save_mount(&db.conn(), &m).expect("save");
229
230        let listed = list_mounts(&db.conn()).expect("list");
231        assert_eq!(listed.len(), 1);
232        let got = &listed[0];
233        assert_eq!(got.name, "oxios");
234        assert_eq!(got.auto_description, "Agent OS");
235        assert_eq!(got.auto_meta.languages, vec!["rust".to_string()]);
236        assert!(!got.enrichment_pending);
237    }
238
239    #[test]
240    fn test_delete_mount() {
241        let db = open_db();
242        let m = Mount::from_name_and_path("temp", PathBuf::from("/tmp"));
243        save_mount(&db.conn(), &m).expect("save");
244        assert_eq!(list_mounts(&db.conn()).unwrap().len(), 1);
245        delete_mount(&db.conn(), &m.id.to_string()).expect("delete");
246        assert_eq!(list_mounts(&db.conn()).unwrap().len(), 0);
247    }
248
249    #[test]
250    fn test_upsert_replaces() {
251        let db = open_db();
252        let mut m = Mount::from_name_and_path("oxios", PathBuf::from("/a"));
253        save_mount(&db.conn(), &m).expect("save");
254        m.auto_description = "updated".to_string();
255        save_mount(&db.conn(), &m).expect("upsert");
256        let listed = list_mounts(&db.conn()).unwrap();
257        assert_eq!(listed.len(), 1);
258        assert_eq!(listed[0].auto_description, "updated");
259    }
260
261    #[test]
262    fn test_marker_snapshot_roundtrip() {
263        let db = open_db();
264        let mut m = Mount::from_name_and_path("oxios", PathBuf::from("/a"));
265        m.last_marker_snapshot.insert(
266            PathBuf::from("/a/Cargo.toml"),
267            SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
268        );
269        save_mount(&db.conn(), &m).expect("save");
270        let got = list_mounts(&db.conn()).unwrap().pop().unwrap();
271        let stored = got
272            .last_marker_snapshot
273            .get(&PathBuf::from("/a/Cargo.toml"))
274            .expect("snapshot present");
275        let secs = stored
276            .duration_since(SystemTime::UNIX_EPOCH)
277            .unwrap()
278            .as_secs();
279        assert_eq!(secs, 1_700_000_000);
280    }
281
282    #[test]
283    fn test_dismissed_roots_roundtrip() {
284        let db = open_db();
285        // Starts empty.
286        assert!(list_dismissed_roots(&db.conn()).unwrap().is_empty());
287        // Insert two tombstones.
288        add_dismissed_root(&db.conn(), &PathBuf::from("/proj/a")).expect("add");
289        add_dismissed_root(&db.conn(), &PathBuf::from("/proj/b")).expect("add");
290        // Idempotent — re-adding the same root is a no-op.
291        add_dismissed_root(&db.conn(), &PathBuf::from("/proj/a")).expect("re-add");
292        let roots = list_dismissed_roots(&db.conn()).unwrap();
293        assert_eq!(roots.len(), 2);
294        assert!(roots.contains(&PathBuf::from("/proj/a")));
295        assert!(roots.contains(&PathBuf::from("/proj/b")));
296    }
297}