Skip to main content

oxios_kernel/mount/
manager.rs

1//! MountManager: CRUD + detection for Mounts (RFC-025).
2//!
3//! Mirrors `ProjectManager`'s structure for consistency. Mounts are persisted
4//! in the `mounts` SQLite table (same `memory.db`).
5
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::SystemTime;
10
11use anyhow::Result;
12use chrono::Utc;
13use parking_lot::RwLock;
14
15use oxios_memory::memory::sqlite::MemoryDatabase;
16
17use super::mount_db;
18use super::path_promotion;
19use super::{DetectionResult, Mount, MountId, MountMeta, MountSource, detect_mounts};
20use crate::event_bus::{EventBus, KernelEvent};
21
22/// Errors from MountManager operations.
23#[derive(thiserror::Error, Debug)]
24pub enum MountManagerError {
25    /// Mount not found.
26    #[error("Mount not found: {0}")]
27    NotFound(MountId),
28    /// Mount name already taken.
29    #[error("Mount name already exists: {0}")]
30    DuplicateName(String),
31    /// Invalid operation.
32    #[error("Invalid operation: {0}")]
33    Invalid(String),
34}
35
36/// Manages Mounts: CRUD, lookup, and detection.
37///
38/// Mounts are persisted in the `mounts` SQLite table
39/// (same `memory.db` as memories and the legacy `projects` table).
40pub struct MountManager {
41    /// In-memory index of all Mounts (loaded at startup).
42    mounts: RwLock<HashMap<MountId, Mount>>,
43    /// Name → ID index for fast name lookup.
44    name_index: RwLock<HashMap<String, MountId>>,
45    /// RFC-025 Phase 5: roots the user has explicitly dismissed (Promo-3).
46    ///
47    /// When an `AutoPromoted` Mount is removed, its canonicalized root paths
48    /// are recorded here (and in `mount_dismissals`) so the scanner never
49    /// re-creates a Mount the user has rejected. Canonicalized form is used
50    /// so that the comparison is path-stable across symlinks.
51    dismissed_roots: RwLock<HashSet<PathBuf>>,
52    /// SQLite database for persistence.
53    db: Arc<MemoryDatabase>,
54    /// Event bus for publishing Mount events.
55    event_bus: Option<EventBus>,
56}
57
58impl MountManager {
59    /// Create a new MountManager, loading existing Mounts from SQLite.
60    pub fn new(db: Arc<MemoryDatabase>, event_bus: Option<EventBus>) -> Result<Self> {
61        // Ensure the schema exists (idempotent).
62        mount_db::ensure_mount_schema(&db.conn())?;
63
64        let mut mounts = HashMap::new();
65        let mut name_index = HashMap::new();
66        for mount in mount_db::list_mounts(&db.conn())? {
67            name_index.insert(mount.name.clone(), mount.id);
68            mounts.insert(mount.id, mount);
69        }
70
71        // Promo-3: load dismissal tombstones so re-promoted mounts stay dead.
72        let dismissed_roots = mount_db::list_dismissed_roots(&db.conn())?
73            .into_iter()
74            .collect::<HashSet<_>>();
75
76        tracing::info!(
77            count = mounts.len(),
78            dismissed = dismissed_roots.len(),
79            "MountManager initialized"
80        );
81
82        Ok(Self {
83            mounts: RwLock::new(mounts),
84            name_index: RwLock::new(name_index),
85            dismissed_roots: RwLock::new(dismissed_roots),
86            db,
87            event_bus,
88        })
89    }
90
91    /// List all Mounts.
92    pub fn list_mounts(&self) -> Vec<Mount> {
93        self.mounts.read().values().cloned().collect()
94    }
95
96    /// Get a Mount by ID.
97    pub fn get_mount(&self, id: MountId) -> Option<Mount> {
98        self.mounts.read().get(&id).cloned()
99    }
100
101    /// Get a Mount by name.
102    pub fn get_mount_by_name(&self, name: &str) -> Option<Mount> {
103        let name_index = self.name_index.read();
104        let id = name_index.get(name)?;
105        self.mounts.read().get(id).cloned()
106    }
107
108    /// Get several Mounts by ID, preserving the request order. Missing IDs
109    /// are skipped (they may have been deleted).
110    pub fn get_mounts_ordered(&self, ids: &[MountId]) -> Vec<Mount> {
111        let mounts = self.mounts.read();
112        ids.iter()
113            .filter_map(|id| mounts.get(id).cloned())
114            .collect()
115    }
116
117    /// Create a new Mount with the minimal RFC-025 input (name + paths).
118    pub fn create_mount(
119        &self,
120        name: String,
121        paths: Vec<PathBuf>,
122        source: MountSource,
123    ) -> Result<Mount> {
124        let name = validate_mount_name(&name)?;
125        if paths.is_empty() {
126            return Err(MountManagerError::Invalid(
127                "a Mount requires at least one path".to_string(),
128            )
129            .into());
130        }
131        // Reject overly broad system paths (lightweight safety, not a sandbox).
132        for p in &paths {
133            validate_mount_path(p)?;
134        }
135
136        let mut mount = Mount::new(&name, source);
137        mount.paths = paths;
138
139        // Hold the write locks across the uniqueness check, the DB write, and
140        // the in-memory insert. The previous version used a *read* lock for the
141        // name check and a *separate* write lock for the insert, leaving a
142        // TOCTOU window in which two concurrent `create_mount` calls with the
143        // same name both passed the check. Acquiring both locks in the
144        // consistent order used throughout the manager (mounts → name_index)
145        // closes the window entirely.
146        let mut mounts = self.mounts.write();
147        let mut name_index = self.name_index.write();
148        if name_index.contains_key(&name) {
149            return Err(MountManagerError::DuplicateName(name).into());
150        }
151
152        mount_db::save_mount(&self.db.conn(), &mount)?;
153
154        name_index.insert(mount.name.clone(), mount.id);
155        mounts.insert(mount.id, mount.clone());
156        drop(name_index);
157        drop(mounts);
158
159        if let Some(ref event_bus) = self.event_bus {
160            let _ = event_bus.publish(KernelEvent::ProjectCreated {
161                // Reuse ProjectCreated for now; a MountCreated variant can be
162                // added when the frontend needs to distinguish them.
163                project_id: mount.id,
164                name: mount.name.clone(),
165                source: source.to_string(),
166            });
167        }
168
169        tracing::info!(name = %mount.name, id = %mount.id, "Mount created");
170        Ok(mount)
171    }
172
173    /// Update a Mount's auto-enriched fields (agent-driven, RFC-025 Phase 3).
174    ///
175    /// Only `auto_description` and `auto_meta` are writable here — `name` and
176    /// `paths` are user-level and go through [`Self::update`].
177    pub fn update_enrichment(
178        &self,
179        id: MountId,
180        auto_description: Option<String>,
181        auto_meta: Option<MountMeta>,
182    ) -> Result<Mount> {
183        let mut mounts = self.mounts.write();
184        let mount = mounts.get_mut(&id).ok_or(MountManagerError::NotFound(id))?;
185
186        if let Some(desc) = auto_description {
187            // Bounded per RFC-025 cost guard (≤ 500 chars).
188            mount.auto_description = desc.chars().take(500).collect();
189        }
190        if let Some(meta) = auto_meta {
191            mount.auto_meta = meta;
192        }
193        mount.last_enriched_at = Some(Utc::now());
194        mount.enrichment_pending = false;
195        mount.updated_at = Utc::now();
196
197        let mount_clone = mount.clone();
198        drop(mounts);
199        mount_db::save_mount(&self.db.conn(), &mount_clone)?;
200        tracing::info!(name = %mount_clone.name, id = %id, "Mount enriched");
201        Ok(mount_clone)
202    }
203
204    /// Update a Mount's user-level fields: name and/or paths.
205    ///
206    /// Replaces the former rename-only path. When `paths` changes, the cached
207    /// enrichment (description, tech-stack, marker mtimes) is invalidated:
208    /// `enrichment_pending` is set so rescan or agent enrichment re-seeds it.
209    pub fn update(
210        &self,
211        id: MountId,
212        name: Option<String>,
213        paths: Option<Vec<PathBuf>>,
214    ) -> Result<Mount> {
215        // Validate before taking locks.
216        let new_name = match &name {
217            Some(n) => Some(validate_mount_name(n)?),
218            None => None,
219        };
220        if let Some(new_paths) = &paths {
221            if new_paths.is_empty() {
222                return Err(MountManagerError::Invalid(
223                    "a Mount requires at least one path".to_string(),
224                )
225                .into());
226            }
227            for p in new_paths {
228                validate_mount_path(p)?;
229            }
230        }
231
232        let mut mounts = self.mounts.write();
233        let mut name_index = self.name_index.write();
234        let mount = mounts.get_mut(&id).ok_or(MountManagerError::NotFound(id))?;
235        let mut changed = false;
236
237        if let Some(new_name) = new_name
238            && new_name != mount.name
239        {
240            if name_index.contains_key(&new_name) {
241                return Err(MountManagerError::DuplicateName(new_name).into());
242            }
243            name_index.remove(&mount.name);
244            name_index.insert(new_name.clone(), id);
245            mount.name = new_name;
246            changed = true;
247        }
248
249        if let Some(new_paths) = paths {
250            // A path change invalidates the cached enrichment: the description,
251            // tech-stack tags, and marker mtimes all pointed at the old roots.
252            if new_paths != mount.paths {
253                mount.paths = new_paths;
254                mount.last_marker_snapshot.clear();
255                mount.enrichment_pending = true;
256                changed = true;
257            }
258        }
259
260        if changed {
261            mount.updated_at = Utc::now();
262            let mount_clone = mount.clone();
263            drop(mounts);
264            drop(name_index);
265            mount_db::save_mount(&self.db.conn(), &mount_clone)?;
266            tracing::info!(name = %mount_clone.name, id = %id, "Mount updated");
267            return Ok(mount_clone);
268        }
269        // No-op: skip the DB write.
270        let mount_clone = mount.clone();
271        drop(mounts);
272        drop(name_index);
273        Ok(mount_clone)
274    }
275
276    /// Remove a Mount.
277    ///
278    /// DB-first ordering (matches `create_mount`): if the DB delete fails the
279    /// in-memory state is left untouched so the caller can retry and the Mount
280    /// doesn't silently reappear on restart.
281    ///
282    /// If the Mount was `AutoPromoted`, its canonicalized root paths are
283    /// recorded as dismissals (tombstones) so the background scanner does
284    /// not immediately re-promote them (Promo-3). Manual mounts are removed
285    /// without recording a tombstone (the user may still want auto-promotion
286    /// for that root).
287    pub fn remove_mount(&self, id: MountId) -> Result<()> {
288        // Preserve NotFound semantics + capture the Mount for tombstoning.
289        let removed = {
290            let mounts = self.mounts.read();
291            mounts
292                .get(&id)
293                .cloned()
294                .ok_or(MountManagerError::NotFound(id))?
295        };
296        // Delete from the DB before touching memory.
297        mount_db::delete_mount(&self.db.conn(), &id.to_string())?;
298        {
299            let mut mounts = self.mounts.write();
300            let mut name_index = self.name_index.write();
301            if let Some(mount) = mounts.remove(&id) {
302                name_index.remove(&mount.name);
303            }
304        }
305
306        // Promo-3: tombstone auto-promoted roots so they aren't re-created.
307        if removed.source == MountSource::AutoPromoted {
308            self.record_dismissal(&removed.paths);
309        }
310
311        tracing::info!(id = %id, "Mount removed");
312        Ok(())
313    }
314
315    /// Canonicalize each path and record it as a dismissed root, both
316    /// in-memory and in SQLite (Promo-3). Best-effort: paths that fail to
317    /// canonicalize are stored in their raw form so the tombstone still
318    /// matches the exact string the scanner would normalize to.
319    fn record_dismissal(&self, paths: &[PathBuf]) {
320        let to_record: Vec<PathBuf> = paths
321            .iter()
322            .map(|p| Self::canonicalize_for_index(p))
323            .collect();
324
325        {
326            let mut dismissed = self.dismissed_roots.write();
327            for p in &to_record {
328                dismissed.insert(p.clone());
329            }
330        }
331        for p in &to_record {
332            if let Err(e) = mount_db::add_dismissed_root(&self.db.conn(), p) {
333                tracing::warn!(
334                    path = %p.display(),
335                    error = %e,
336                    "failed to persist mount dismissal"
337                );
338            }
339        }
340        tracing::debug!(count = to_record.len(), "recorded mount dismissals");
341    }
342
343    /// Canonicalize a path for index comparison, falling back to the raw
344    /// path when canonicalization fails (e.g. the path was removed).
345    fn canonicalize_for_index(path: &Path) -> PathBuf {
346        std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
347    }
348
349    /// RFC-025 Phase 5: is `root` in the dismissed set (Promo-3)?
350    ///
351    /// Compares against both the canonicalized and raw forms of stored
352    /// tombstones so that a root matches regardless of symlink resolution.
353    fn is_dismissed(&self, root: &Path) -> bool {
354        let dismissed = self.dismissed_roots.read();
355        if dismissed.contains(root) {
356            return true;
357        }
358        let canonical = Self::canonicalize_for_index(root);
359        dismissed.contains(&canonical)
360    }
361
362    /// Record that a Mount was used in a session.
363    pub fn touch(&self, id: MountId) {
364        let to_save = {
365            let mut mounts = self.mounts.write();
366            if let Some(mount) = mounts.get_mut(&id) {
367                mount.touch();
368                Some(mount.clone())
369            } else {
370                None
371            }
372        };
373        if let Some(mount) = to_save
374            && let Err(e) = mount_db::save_mount(&self.db.conn(), &mount)
375        {
376            tracing::warn!(id = %id, error = %e, "touch: failed to save Mount");
377        }
378    }
379
380    /// Try to detect a Mount from a user message.
381    pub fn detect(&self, message: &str) -> DetectionResult {
382        let mounts = self.list_mounts();
383        detect_mounts(message, &mounts)
384    }
385
386    /// Seed `auto_meta` from the filesystem (RFC-025 §Auto-Meta).
387    ///
388    /// Cheap heuristic detection on marker files. The agent refines this
389    /// during enrichment. Idempotent — safe to call multiple times.
390    pub fn seed_auto_meta(&self, id: MountId) -> Result<()> {
391        let mount = {
392            let mounts = self.mounts.read();
393            mounts
394                .get(&id)
395                .ok_or(MountManagerError::NotFound(id))?
396                .clone()
397        };
398        let Some(primary) = mount.primary_path().cloned() else {
399            return Ok(()); // nothing to scan
400        };
401        if !primary.exists() {
402            tracing::debug!(path = %primary.display(), "Mount path missing, skip meta seed");
403            return Ok(());
404        }
405        // detect_meta is cheap heuristics only — it must NOT clear the
406        // enrichment nudge. Route it directly (not through update_enrichment,
407        // which would stamp `last_enriched_at` and clear `enrichment_pending`),
408        // so the agent is still prompted to do real enrichment.
409        let meta = super::meta_detection::detect_meta(&primary);
410        let to_save = {
411            let mut mounts = self.mounts.write();
412            let Some(mount) = mounts.get_mut(&id) else {
413                return Ok(()); // removed while detecting
414            };
415            mount.auto_meta = meta;
416            mount.enrichment_pending = true;
417            mount.last_enriched_at = None;
418            mount.updated_at = Utc::now();
419            mount.clone()
420        };
421        if let Err(e) = mount_db::save_mount(&self.db.conn(), &to_save) {
422            tracing::warn!(id = %id, error = %e, "seed_auto_meta: failed to save Mount");
423        }
424        tracing::info!(name = %to_save.name, id = %id, "Mount auto_meta seeded");
425        Ok(())
426    }
427
428    /// Check marker-file drift and set `enrichment_pending` (RFC-025 §Enrichment).
429    ///
430    /// Compares current marker mtimes against the stored snapshot. Returns
431    /// `true` if any marker drifted (and the flag was set). Cheap: a handful
432    /// of `stat()` calls.
433    pub fn check_drift(&self, id: MountId) -> Result<bool> {
434        // Acquire a read lock only to clone the primary path and the current
435        // snapshot, then drop it so the filesystem I/O (snapshot_markers) runs
436        // lock-free (M8: don't do I/O under the write lock).
437        let (primary, old_snapshot) = {
438            let mounts = self.mounts.read();
439            let mount = mounts.get(&id).ok_or(MountManagerError::NotFound(id))?;
440            let Some(primary) = mount.primary_path().cloned() else {
441                return Ok(false);
442            };
443            (primary, mount.last_marker_snapshot.clone())
444        };
445
446        // Filesystem I/O — no lock held.
447        let current = super::meta_detection::snapshot_markers(&primary);
448        let drifted = markers_drifted(&old_snapshot, &current);
449        let current_map: HashMap<PathBuf, SystemTime> = current.into_iter().collect();
450
451        // Re-acquire a write lock to apply results (re-checking the Mount
452        // still exists — it may have been removed while we read the fs).
453        let to_save = {
454            let mut mounts = self.mounts.write();
455            let Some(mount) = mounts.get_mut(&id) else {
456                return Ok(drifted);
457            };
458            // Skip the mutation + DB write when nothing drifted and the
459            // snapshot is unchanged (m4: don't write on every drift check).
460            if !drifted && mount.last_marker_snapshot == current_map {
461                None
462            } else {
463                if drifted {
464                    mount.enrichment_pending = true;
465                    mount.updated_at = Utc::now();
466                }
467                // Refresh the snapshot so the next comparison is accurate.
468                mount.last_marker_snapshot = current_map;
469                Some(mount.clone())
470            }
471        };
472
473        if let Some(mount) = to_save
474            && let Err(e) = mount_db::save_mount(&self.db.conn(), &mount)
475        {
476            tracing::warn!(id = %id, error = %e, "check_drift: failed to save Mount");
477        }
478        Ok(drifted)
479    }
480
481    /// Check drift for all Mounts (Dream-time refresh, RFC-025).
482    ///
483    /// Returns the IDs of Mounts whose content drifted.
484    pub fn check_all_drift(&self) -> Vec<MountId> {
485        let ids: Vec<MountId> = self.mounts.read().keys().copied().collect();
486        let mut drifted = Vec::new();
487        for id in ids {
488            match self.check_drift(id) {
489                Ok(true) => drifted.push(id),
490                Ok(false) => {}
491                Err(e) => tracing::warn!(error = %e, %id, "check_drift failed for mount"),
492            }
493        }
494        drifted
495    }
496
497    /// RFC-025 Phase 5: scan session history and auto-create Mounts for paths
498    /// that cross the frequency threshold.
499    ///
500    /// Returns the IDs of newly-created Mounts (empty if none promoted). Safe
501    /// to call repeatedly — paths already covered by an existing Mount are
502    /// skipped, as are name collisions.
503    pub fn promote_frequent_paths(
504        &self,
505        sessions: &[crate::state_store::Session],
506        config: &path_promotion::PromotionConfig,
507    ) -> Vec<MountId> {
508        if !config.enabled {
509            return Vec::new();
510        }
511
512        let freqs = path_promotion::tally_frequencies(sessions, config);
513        // Sort deterministically: most frequent first, then alphabetically by
514        // path. This guarantees that when two roots derive the same name the
515        // most-used one wins consistently across runs (HashMap iteration order
516        // is otherwise non-deterministic).
517        let mut sorted_freqs: Vec<_> = freqs.into_iter().collect();
518        sorted_freqs.sort_by(|a, b| b.1.count.cmp(&a.1.count).then_with(|| a.0.cmp(&b.0)));
519        let mut created = Vec::new();
520
521        for (root, freq) in sorted_freqs {
522            if freq.count < config.threshold {
523                continue;
524            }
525            // Skip if any existing Mount already covers this root.
526            if self.root_already_covered(&root) {
527                continue;
528            }
529            // Promo-3: skip roots the user has explicitly dismissed, so a
530            // deleted AutoPromoted Mount is not immediately re-created.
531            if self.is_dismissed(&root) {
532                tracing::debug!(
533                    path = %root.display(),
534                    "auto-promotion skipped: root was dismissed"
535                );
536                continue;
537            }
538            // Derive a name from the final path component.
539            let Some(name) = root
540                .file_name()
541                .and_then(|n| n.to_str())
542                .map(|s| s.to_string())
543            else {
544                continue;
545            };
546            // Skip if the name is already taken (collision → leave for the
547            // user to resolve, rather than inventing "name-2").
548            if self.get_mount_by_name(&name).is_some() {
549                continue;
550            }
551
552            match self.create_mount(
553                name.clone(),
554                vec![root.clone()],
555                super::MountSource::AutoPromoted,
556            ) {
557                Ok(mount) => {
558                    tracing::info!(
559                        name = %mount.name,
560                        path = %root.display(),
561                        count = freq.count,
562                        "RFC-025: auto-promoted frequent path to Mount"
563                    );
564                    // Seed auto_meta immediately so the new Mount is useful.
565                    let _ = self.seed_auto_meta(mount.id);
566                    created.push(mount.id);
567                }
568                Err(e) => {
569                    tracing::debug!(
570                        path = %root.display(),
571                        error = %e,
572                        "auto-promotion skipped"
573                    );
574                }
575            }
576        }
577
578        created
579    }
580
581    /// Returns `true` if some existing Mount's `paths` already includes (or is
582    /// an ancestor of) `root`, meaning the root is already covered.
583    fn root_already_covered(&self, root: &PathBuf) -> bool {
584        let mounts = self.mounts.read();
585        mounts.values().any(|m| {
586            m.paths
587                .iter()
588                .any(|p| root.starts_with(p) || p.starts_with(root))
589        })
590    }
591}
592
593/// Compare a stored marker snapshot against the current state.
594/// Returns `true` if any marker was added, removed, or changed mtime.
595fn markers_drifted(
596    stored: &HashMap<PathBuf, SystemTime>,
597    current: &[(std::path::PathBuf, SystemTime)],
598) -> bool {
599    if stored.len() != current.len() {
600        return true; // marker added or removed
601    }
602    for (path, mtime) in current {
603        match stored.get(path) {
604            Some(stored_time) if stored_time == mtime => continue,
605            _ => return true, // new, removed, or changed
606        }
607    }
608    false
609}
610
611/// Validate a Mount name (RFC-025): non-empty after trim, ≤ 64 chars (by char
612/// count), no control characters. Returns the trimmed name on success.
613fn validate_mount_name(name: &str) -> Result<String> {
614    let trimmed = name.trim();
615    if trimmed.is_empty() {
616        return Err(MountManagerError::Invalid("Mount name must not be empty".to_string()).into());
617    }
618    if trimmed.chars().count() > 64 {
619        return Err(MountManagerError::Invalid(
620            "Mount name must be at most 64 characters".to_string(),
621        )
622        .into());
623    }
624    if trimmed.chars().any(|c| c.is_control()) {
625        return Err(MountManagerError::Invalid(
626            "Mount name must not contain control characters".to_string(),
627        )
628        .into());
629    }
630    Ok(trimmed.to_string())
631}
632
633/// Reject paths that are too broad to be a meaningful project root.
634///
635/// This is a lightweight safety check, not a full sandbox — AccessManager
636/// RBAC enforcement is the real boundary. We only reject the filesystem root
637/// and a few system directories that would make detection hijack every path.
638fn validate_mount_path(path: &Path) -> Result<()> {
639    let forbidden = [
640        "", "/etc", "/dev", "/proc", "/sys", "/var", "/usr", "/bin", "/sbin", "/boot",
641    ];
642    let normalized = path.to_str().map(|s| s.trim_end_matches('/')).unwrap_or("");
643    if forbidden.contains(&normalized) {
644        return Err(MountManagerError::Invalid(format!(
645            "Mount path '{}' is a system directory; refusing to create an overly broad Mount",
646            path.display()
647        ))
648        .into());
649    }
650    Ok(())
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn open_manager() -> MountManager {
658        let db = Arc::new(MemoryDatabase::open_in_memory(64).expect("db"));
659        MountManager::new(db, None).expect("manager")
660    }
661
662    #[test]
663    fn test_create_and_get() {
664        let mgr = open_manager();
665        let m = mgr
666            .create_mount(
667                "oxios".to_string(),
668                vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
669                MountSource::Manual,
670            )
671            .expect("create");
672        assert_eq!(mgr.get_mount(m.id).unwrap().name, "oxios");
673        assert_eq!(mgr.get_mount_by_name("oxios").unwrap().id, m.id);
674    }
675
676    #[test]
677    fn test_duplicate_name_rejected() {
678        let mgr = open_manager();
679        mgr.create_mount(
680            "oxios".to_string(),
681            vec![PathBuf::from("/a")],
682            MountSource::Manual,
683        )
684        .expect("first");
685        let err = mgr
686            .create_mount(
687                "oxios".to_string(),
688                vec![PathBuf::from("/b")],
689                MountSource::Manual,
690            )
691            .unwrap_err();
692        assert!(err.to_string().contains("already exists"));
693    }
694
695    #[test]
696    fn test_empty_paths_rejected() {
697        let mgr = open_manager();
698        let err = mgr
699            .create_mount("x".to_string(), vec![], MountSource::Manual)
700            .unwrap_err();
701        assert!(err.to_string().contains("at least one path"));
702    }
703
704    #[test]
705    fn test_system_directory_path_rejected() {
706        let mgr = open_manager();
707        for bad in ["/", "/etc", "/dev", "/proc", "/usr"] {
708            let err = mgr
709                .create_mount(
710                    "bad".to_string(),
711                    vec![PathBuf::from(bad)],
712                    MountSource::Manual,
713                )
714                .unwrap_err();
715            assert!(
716                err.to_string().contains("system directory"),
717                "expected system directory rejection for {bad}"
718            );
719        }
720    }
721
722    #[test]
723    fn test_update_enrichment_bounds_description() {
724        let mgr = open_manager();
725        let m = mgr
726            .create_mount(
727                "oxios".to_string(),
728                vec![PathBuf::from("/a")],
729                MountSource::Manual,
730            )
731            .expect("create");
732        let long = "x".repeat(800);
733        let updated = mgr
734            .update_enrichment(m.id, Some(long.clone()), None)
735            .expect("update");
736        assert_eq!(updated.auto_description.chars().count(), 500);
737        assert!(updated.last_enriched_at.is_some());
738        assert!(!updated.enrichment_pending);
739    }
740
741    #[test]
742    fn test_update_renames_mount() {
743        let mgr = open_manager();
744        let m = mgr
745            .create_mount(
746                "oxios".to_string(),
747                vec![PathBuf::from("/a")],
748                MountSource::Manual,
749            )
750            .expect("create");
751        let updated = mgr
752            .update(m.id, Some("new-name".to_string()), None)
753            .expect("rename");
754        assert_eq!(updated.name, "new-name");
755        // name_index follows the rename.
756        assert!(mgr.get_mount_by_name("oxios").is_none());
757        assert_eq!(mgr.get_mount_by_name("new-name").unwrap().id, m.id);
758    }
759
760    #[test]
761    fn test_update_rejects_duplicate_name() {
762        let mgr = open_manager();
763        mgr.create_mount(
764            "alpha".to_string(),
765            vec![PathBuf::from("/a")],
766            MountSource::Manual,
767        )
768        .expect("first");
769        let b = mgr
770            .create_mount(
771                "beta".to_string(),
772                vec![PathBuf::from("/b")],
773                MountSource::Manual,
774            )
775            .expect("second");
776        let err = mgr
777            .update(b.id, Some("alpha".to_string()), None)
778            .unwrap_err();
779        assert!(err.to_string().contains("already exists"));
780    }
781
782    #[test]
783    fn test_update_paths_invalidates_enrichment() {
784        let mgr = open_manager();
785        let m = mgr
786            .create_mount(
787                "oxios".to_string(),
788                vec![PathBuf::from("/old")],
789                MountSource::Manual,
790            )
791            .expect("create");
792        // Seed enrichment so we can observe the invalidation.
793        let enriched = mgr
794            .update_enrichment(m.id, Some("a rust project".to_string()), None)
795            .expect("enrich");
796        assert!(!enriched.enrichment_pending);
797        // Seed a stale marker snapshot pointing at the old root.
798        {
799            let mut mounts = mgr.mounts.write();
800            mounts
801                .get_mut(&m.id)
802                .unwrap()
803                .last_marker_snapshot
804                .insert(PathBuf::from("/old/Cargo.toml"), SystemTime::now());
805        }
806        // Repoint the mount at a new path.
807        let updated = mgr
808            .update(m.id, None, Some(vec![PathBuf::from("/new")]))
809            .expect("update paths");
810        assert_eq!(updated.paths, vec![PathBuf::from("/new")]);
811        // The cached description/tech-stack/markers are now stale.
812        assert!(updated.enrichment_pending);
813        assert!(updated.last_marker_snapshot.is_empty());
814    }
815
816    #[test]
817    fn test_update_no_op_preserves_enrichment() {
818        let mgr = open_manager();
819        let m = mgr
820            .create_mount(
821                "oxios".to_string(),
822                vec![PathBuf::from("/a")],
823                MountSource::Manual,
824            )
825            .expect("create");
826        mgr.update_enrichment(m.id, Some("desc".to_string()), None)
827            .expect("enrich");
828        // Passing the unchanged name and paths must NOT mark enrichment stale.
829        let updated = mgr
830            .update(
831                m.id,
832                Some("oxios".to_string()),
833                Some(vec![PathBuf::from("/a")]),
834            )
835            .expect("noop");
836        assert!(!updated.enrichment_pending);
837    }
838
839    #[test]
840    fn test_remove_mount() {
841        let mgr = open_manager();
842        let m = mgr
843            .create_mount(
844                "temp".to_string(),
845                vec![PathBuf::from("/t")],
846                MountSource::Manual,
847            )
848            .expect("create");
849        mgr.remove_mount(m.id).expect("remove");
850        assert!(mgr.get_mount(m.id).is_none());
851        assert!(mgr.get_mount_by_name("temp").is_none());
852    }
853
854    #[test]
855    fn test_get_mounts_ordered_skips_missing() {
856        let mgr = open_manager();
857        let m1 = mgr
858            .create_mount(
859                "a".to_string(),
860                vec![PathBuf::from("/a")],
861                MountSource::Manual,
862            )
863            .unwrap();
864        let m2 = mgr
865            .create_mount(
866                "b".to_string(),
867                vec![PathBuf::from("/b")],
868                MountSource::Manual,
869            )
870            .unwrap();
871        let missing = MountId::new_v4();
872        let got = mgr.get_mounts_ordered(&[m1.id, missing, m2.id]);
873        assert_eq!(got.len(), 2);
874        assert_eq!(got[0].name, "a");
875        assert_eq!(got[1].name, "b");
876    }
877
878    #[test]
879    fn test_promote_frequent_paths_creates_mount() {
880        use crate::state_store::{Session, UserMessage};
881        use chrono::Utc;
882
883        let mgr = open_manager();
884        // Use this crate's own source dir — it has Cargo.toml at its root,
885        // so normalize_to_root will collapse to the oxios-kernel root.
886        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
887        let file = root.join("src/lib.rs");
888
889        // Frequency is counted per distinct root per session (Promo-7): one
890        // session's repeated mentions count once. So we need three separate
891        // sessions to cross the default threshold of 3.
892        let sessions: Vec<Session> = (0..3)
893            .map(|_| {
894                let mut session = Session::new("test");
895                session.user_messages.push(UserMessage {
896                    content: format!("fix {} please", file.display()),
897                    timestamp: Utc::now(),
898                });
899                session
900            })
901            .collect();
902
903        let config = path_promotion::PromotionConfig::default();
904        let created = mgr.promote_frequent_paths(&sessions, &config);
905        assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
906
907        let mount = mgr.get_mount(created[0]).expect("promoted mount exists");
908        assert_eq!(mount.source, MountSource::AutoPromoted);
909        assert_eq!(mount.name, "oxios-kernel");
910        // auto_meta should be seeded (Cargo.toml → rust).
911        assert!(mount.auto_meta.languages.contains(&"rust".to_string()));
912    }
913
914    /// Build `n` sessions each mentioning `root` once (Promo-7: frequency is
915    /// per distinct root per session, so we vary the *session* count, not
916    /// the message count within one session).
917    fn sessions_mentioning(root: &Path, n: u32) -> Vec<crate::state_store::Session> {
918        use crate::state_store::{Session, UserMessage};
919        use chrono::Utc;
920        (0..n)
921            .map(|_| {
922                let mut s = Session::new("test");
923                s.user_messages.push(UserMessage {
924                    content: format!("work on {}/src/lib.rs", root.display()),
925                    timestamp: Utc::now(),
926                });
927                s
928            })
929            .collect()
930    }
931
932    #[test]
933    fn test_promote_skips_already_covered_root() {
934        let mgr = open_manager();
935        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
936        // Pre-create a Mount covering this root.
937        mgr.create_mount(
938            "manual-kernel".to_string(),
939            vec![root.clone()],
940            MountSource::Manual,
941        )
942        .unwrap();
943
944        // Promo-7: 3 separate sessions (count=3) cross the default threshold,
945        // so this exercises the coverage-skip path rather than trivially
946        // passing because the count is below threshold.
947        let sessions = sessions_mentioning(&root, 3);
948        let config = path_promotion::PromotionConfig::default();
949        let created = mgr.promote_frequent_paths(&sessions, &config);
950        assert!(
951            created.is_empty(),
952            "should not promote an already-covered root"
953        );
954    }
955
956    #[test]
957    fn test_promote_respects_threshold() {
958        let mgr = open_manager();
959        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
960
961        // Promo-7: 2 sessions → count=2, below the default threshold of 3.
962        let sessions = sessions_mentioning(&root, 2);
963        let config = path_promotion::PromotionConfig::default();
964        let created = mgr.promote_frequent_paths(&sessions, &config);
965        assert!(created.is_empty(), "should not promote below threshold");
966    }
967
968    #[test]
969    fn test_promote_skips_dismissed_root() {
970        // Promo-3: removing an AutoPromoted Mount must tombstone its root so
971        // the scanner never re-creates it.
972        let mgr = open_manager();
973        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
974        let sessions = sessions_mentioning(&root, 3);
975        let config = path_promotion::PromotionConfig::default();
976
977        // First scan: promotes the root to an AutoPromoted Mount.
978        let created = mgr.promote_frequent_paths(&sessions, &config);
979        assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
980        let promoted_id = created[0];
981        assert_eq!(
982            mgr.get_mount(promoted_id).unwrap().source,
983            MountSource::AutoPromoted
984        );
985
986        // User dismisses it.
987        mgr.remove_mount(promoted_id).expect("remove");
988        assert!(mgr.get_mount(promoted_id).is_none());
989
990        // Second scan with the same evidence must NOT re-create it.
991        let recreated = mgr.promote_frequent_paths(&sessions, &config);
992        assert!(
993            recreated.is_empty(),
994            "dismissed root must not be re-promoted (got {:?})",
995            recreated
996        );
997    }
998
999    #[test]
1000    fn test_dismissal_only_for_auto_promoted() {
1001        // Promo-3: dismissing a *Manual* Mount must not tombstone the root,
1002        // since the user may still want auto-promotion for it later.
1003        let mgr = open_manager();
1004        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1005
1006        // Manual mount.
1007        let m = mgr
1008            .create_mount(
1009                "manual-kernel".to_string(),
1010                vec![root.clone()],
1011                MountSource::Manual,
1012            )
1013            .unwrap();
1014        mgr.remove_mount(m.id).expect("remove manual");
1015
1016        // Dismissed set should be empty — no tombstone for manual mounts.
1017        assert!(
1018            mgr.dismissed_roots.read().is_empty(),
1019            "manual removal must not tombstone"
1020        );
1021
1022        // Subsequent promotion is still possible. Promo-7: 3 sessions.
1023        let sessions = sessions_mentioning(&root, 3);
1024        let config = path_promotion::PromotionConfig::default();
1025        let created = mgr.promote_frequent_paths(&sessions, &config);
1026        assert_eq!(
1027            created.len(),
1028            1,
1029            "promotion must still work after manual removal"
1030        );
1031    }
1032}