use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::Result;
use super::{Mount, MountMeta, MountSource};
pub const MOUNT_SCHEMA: &str = r#"
-- ─────────────────────────────────────────────
-- Mounts (RFC-025) — path aliases
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS mounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
paths TEXT NOT NULL, -- JSON array of path strings
auto_description TEXT NOT NULL DEFAULT '',
auto_meta TEXT NOT NULL DEFAULT '{}', -- JSON MountMeta
source TEXT NOT NULL DEFAULT 'manual',
last_marker_snapshot TEXT NOT NULL DEFAULT '{}', -- JSON {path_str: rfc3339_or_secs}
enrichment_pending INTEGER NOT NULL DEFAULT 0,
last_enriched_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_active_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mounts_name ON mounts(name);
-- ─────────────────────────────────────────────
-- Mount dismissals (RFC-025 Phase 5) — tombstones for deleted
-- AutoPromoted Mounts, so the scanner does not re-create them.
-- ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS mount_dismissals (
root_path TEXT PRIMARY KEY
);
"#;
pub fn ensure_mount_schema(conn: &rusqlite::Connection) -> Result<()> {
conn.execute_batch(MOUNT_SCHEMA)?;
Ok(())
}
pub fn save_mount(conn: &rusqlite::Connection, mount: &Mount) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO mounts
(id, name, paths, auto_description, auto_meta, source,
last_marker_snapshot, enrichment_pending, last_enriched_at,
created_at, updated_at, last_active_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
mount.id.to_string(),
mount.name,
serde_json::to_string(&mount.paths)?,
mount.auto_description,
serde_json::to_string(&mount.auto_meta)?,
mount.source.to_string(),
serde_json::to_string(&serialize_snapshot(&mount.last_marker_snapshot))?,
mount.enrichment_pending as i32,
mount.last_enriched_at.map(|t| t.to_rfc3339()),
mount.created_at.to_rfc3339(),
mount.updated_at.to_rfc3339(),
mount.last_active_at.to_rfc3339(),
],
)?;
Ok(())
}
pub fn list_mounts(conn: &rusqlite::Connection) -> Result<Vec<Mount>> {
let mut stmt = conn.prepare(
"SELECT id, name, paths, auto_description, auto_meta, source,
last_marker_snapshot, enrichment_pending, last_enriched_at,
created_at, updated_at, last_active_at
FROM mounts ORDER BY name",
)?;
let rows = stmt.query_map([], row_to_mount)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn delete_mount(conn: &rusqlite::Connection, id: &str) -> Result<()> {
conn.execute("DELETE FROM mounts WHERE id = ?1", rusqlite::params![id])?;
Ok(())
}
pub fn list_dismissed_roots(conn: &rusqlite::Connection) -> Result<Vec<PathBuf>> {
let mut stmt = conn.prepare("SELECT root_path FROM mount_dismissals")?;
let rows = stmt.query_map([], |row| {
let s: String = row.get(0)?;
Ok(PathBuf::from(s))
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
pub fn add_dismissed_root(conn: &rusqlite::Connection, root: &Path) -> Result<()> {
conn.execute(
"INSERT OR IGNORE INTO mount_dismissals (root_path) VALUES (?1)",
rusqlite::params![root.to_string_lossy()],
)?;
Ok(())
}
fn row_to_mount(row: &rusqlite::Row<'_>) -> rusqlite::Result<Mount> {
use chrono::{DateTime, Utc};
let id_str: String = row.get(0)?;
let name: String = row.get(1)?;
let paths_str: String = row
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| "[]".to_string());
let auto_description: String = row.get::<_, Option<String>>(3)?.unwrap_or_default();
let auto_meta_str: String = row
.get::<_, Option<String>>(4)?
.unwrap_or_else(|| "{}".to_string());
let source_str: String = row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "manual".to_string());
let snapshot_str: String = row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "{}".to_string());
let enrichment_pending: bool = row.get::<_, Option<i32>>(7)?.unwrap_or(0) != 0;
let last_enriched_str: Option<String> = row.get(8)?;
let created_at: String = row.get(9)?;
let updated_at: String = row.get(10)?;
let last_active_at: String = row.get(11)?;
let id = uuid::Uuid::parse_str(&id_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
})?;
let paths: Vec<PathBuf> = serde_json::from_str(&paths_str).unwrap_or_default();
let auto_meta: MountMeta = serde_json::from_str(&auto_meta_str).unwrap_or_default();
let last_marker_snapshot = deserialize_snapshot(&snapshot_str);
let source = match source_str.as_str() {
"auto_detected" => MountSource::AutoDetected,
"auto_promoted" => MountSource::AutoPromoted,
_ => MountSource::Manual,
};
let last_enriched_at = last_enriched_str
.as_deref()
.and_then(|s| s.parse::<DateTime<Utc>>().ok());
Ok(Mount {
id,
name,
paths,
auto_description,
auto_meta,
source,
last_marker_snapshot,
enrichment_pending,
last_enriched_at,
created_at: created_at
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now()),
updated_at: updated_at
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now()),
last_active_at: last_active_at
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now()),
})
}
fn serialize_snapshot(snap: &HashMap<PathBuf, SystemTime>) -> HashMap<String, u64> {
snap.iter()
.map(|(k, v)| {
let secs = v
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
(k.to_string_lossy().to_string(), secs)
})
.collect()
}
fn deserialize_snapshot(json: &str) -> HashMap<PathBuf, SystemTime> {
let map: HashMap<String, u64> = serde_json::from_str(json).unwrap_or_default();
map.into_iter()
.map(|(k, secs)| {
let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
(PathBuf::from(k), time)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MemoryDatabase;
fn open_db() -> MemoryDatabase {
let db = MemoryDatabase::open_in_memory(64).expect("open db");
ensure_mount_schema(&db.conn()).expect("schema");
db
}
#[test]
fn test_save_and_list_mount() {
let db = open_db();
let mut m =
Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
m.auto_description = "Agent OS".to_string();
m.auto_meta.summary = "Rust agent OS".to_string();
m.auto_meta.languages = vec!["rust".to_string()];
save_mount(&db.conn(), &m).expect("save");
let listed = list_mounts(&db.conn()).expect("list");
assert_eq!(listed.len(), 1);
let got = &listed[0];
assert_eq!(got.name, "oxios");
assert_eq!(got.auto_description, "Agent OS");
assert_eq!(got.auto_meta.languages, vec!["rust".to_string()]);
assert!(!got.enrichment_pending);
}
#[test]
fn test_delete_mount() {
let db = open_db();
let m = Mount::from_name_and_path("temp", PathBuf::from("/tmp"));
save_mount(&db.conn(), &m).expect("save");
assert_eq!(list_mounts(&db.conn()).unwrap().len(), 1);
delete_mount(&db.conn(), &m.id.to_string()).expect("delete");
assert_eq!(list_mounts(&db.conn()).unwrap().len(), 0);
}
#[test]
fn test_upsert_replaces() {
let db = open_db();
let mut m = Mount::from_name_and_path("oxios", PathBuf::from("/a"));
save_mount(&db.conn(), &m).expect("save");
m.auto_description = "updated".to_string();
save_mount(&db.conn(), &m).expect("upsert");
let listed = list_mounts(&db.conn()).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].auto_description, "updated");
}
#[test]
fn test_marker_snapshot_roundtrip() {
let db = open_db();
let mut m = Mount::from_name_and_path("oxios", PathBuf::from("/a"));
m.last_marker_snapshot.insert(
PathBuf::from("/a/Cargo.toml"),
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
);
save_mount(&db.conn(), &m).expect("save");
let got = list_mounts(&db.conn()).unwrap().pop().unwrap();
let stored = got
.last_marker_snapshot
.get(&PathBuf::from("/a/Cargo.toml"))
.expect("snapshot present");
let secs = stored
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(secs, 1_700_000_000);
}
#[test]
fn test_dismissed_roots_roundtrip() {
let db = open_db();
assert!(list_dismissed_roots(&db.conn()).unwrap().is_empty());
add_dismissed_root(&db.conn(), &PathBuf::from("/proj/a")).expect("add");
add_dismissed_root(&db.conn(), &PathBuf::from("/proj/b")).expect("add");
add_dismissed_root(&db.conn(), &PathBuf::from("/proj/a")).expect("re-add");
let roots = list_dismissed_roots(&db.conn()).unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.contains(&PathBuf::from("/proj/a")));
assert!(roots.contains(&PathBuf::from("/proj/b")));
}
}