1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::SystemTime;
10
11use anyhow::Result;
12
13use super::{Mount, MountMeta, MountSource};
14
15pub 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
46pub fn ensure_mount_schema(conn: &rusqlite::Connection) -> Result<()> {
48 conn.execute_batch(MOUNT_SCHEMA)?;
49 Ok(())
50}
51
52pub 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
78pub 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
90pub 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
96pub 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
110pub 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
119fn 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
181fn 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 assert!(list_dismissed_roots(&db.conn()).unwrap().is_empty());
287 add_dismissed_root(&db.conn(), &PathBuf::from("/proj/a")).expect("add");
289 add_dismissed_root(&db.conn(), &PathBuf::from("/proj/b")).expect("add");
290 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}