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 the id of an existing Mount whose `paths` includes (or is an
582    /// ancestor/descendant of) `root`, if any. Used by the RFC-025 migration
583    /// to avoid creating a duplicate Mount for a path the user already
584    /// registered under a different name.
585    pub fn covering_mount_id(&self, root: &PathBuf) -> Option<MountId> {
586        let mounts = self.mounts.read();
587        mounts.values().find_map(|m| {
588            m.paths
589                .iter()
590                .any(|p| root.starts_with(p) || p.starts_with(root))
591                .then_some(m.id)
592        })
593    }
594
595    /// Returns `true` if some existing Mount's `paths` already includes (or is
596    /// an ancestor of) `root`, meaning the root is already covered.
597    fn root_already_covered(&self, root: &PathBuf) -> bool {
598        self.covering_mount_id(root).is_some()
599    }
600}
601
602/// Compare a stored marker snapshot against the current state.
603/// Returns `true` if any marker was added, removed, or changed mtime.
604fn markers_drifted(
605    stored: &HashMap<PathBuf, SystemTime>,
606    current: &[(std::path::PathBuf, SystemTime)],
607) -> bool {
608    if stored.len() != current.len() {
609        return true; // marker added or removed
610    }
611    for (path, mtime) in current {
612        match stored.get(path) {
613            Some(stored_time) if stored_time == mtime => continue,
614            _ => return true, // new, removed, or changed
615        }
616    }
617    false
618}
619
620/// Validate a Mount name (RFC-025): non-empty after trim, ≤ 64 chars (by char
621/// count), no control characters. Returns the trimmed name on success.
622fn validate_mount_name(name: &str) -> Result<String> {
623    let trimmed = name.trim();
624    if trimmed.is_empty() {
625        return Err(MountManagerError::Invalid("Mount name must not be empty".to_string()).into());
626    }
627    if trimmed.chars().count() > 64 {
628        return Err(MountManagerError::Invalid(
629            "Mount name must be at most 64 characters".to_string(),
630        )
631        .into());
632    }
633    if trimmed.chars().any(|c| c.is_control()) {
634        return Err(MountManagerError::Invalid(
635            "Mount name must not contain control characters".to_string(),
636        )
637        .into());
638    }
639    Ok(trimmed.to_string())
640}
641
642/// Reject paths that are too broad to be a meaningful project root.
643///
644/// This is a lightweight safety check, not a full sandbox — AccessManager
645/// RBAC enforcement is the real boundary. We only reject the filesystem root
646/// and a few system directories that would make detection hijack every path.
647fn validate_mount_path(path: &Path) -> Result<()> {
648    let forbidden = [
649        "", "/etc", "/dev", "/proc", "/sys", "/var", "/usr", "/bin", "/sbin", "/boot",
650    ];
651    let normalized = path.to_str().map(|s| s.trim_end_matches('/')).unwrap_or("");
652    if forbidden.contains(&normalized) {
653        return Err(MountManagerError::Invalid(format!(
654            "Mount path '{}' is a system directory; refusing to create an overly broad Mount",
655            path.display()
656        ))
657        .into());
658    }
659    Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn open_manager() -> MountManager {
667        let db = Arc::new(MemoryDatabase::open_in_memory(64).expect("db"));
668        MountManager::new(db, None).expect("manager")
669    }
670
671    #[test]
672    fn test_create_and_get() {
673        let mgr = open_manager();
674        let m = mgr
675            .create_mount(
676                "oxios".to_string(),
677                vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
678                MountSource::Manual,
679            )
680            .expect("create");
681        assert_eq!(mgr.get_mount(m.id).unwrap().name, "oxios");
682        assert_eq!(mgr.get_mount_by_name("oxios").unwrap().id, m.id);
683    }
684
685    #[test]
686    fn test_duplicate_name_rejected() {
687        let mgr = open_manager();
688        mgr.create_mount(
689            "oxios".to_string(),
690            vec![PathBuf::from("/a")],
691            MountSource::Manual,
692        )
693        .expect("first");
694        let err = mgr
695            .create_mount(
696                "oxios".to_string(),
697                vec![PathBuf::from("/b")],
698                MountSource::Manual,
699            )
700            .unwrap_err();
701        assert!(err.to_string().contains("already exists"));
702    }
703
704    #[test]
705    fn test_empty_paths_rejected() {
706        let mgr = open_manager();
707        let err = mgr
708            .create_mount("x".to_string(), vec![], MountSource::Manual)
709            .unwrap_err();
710        assert!(err.to_string().contains("at least one path"));
711    }
712
713    #[test]
714    fn test_system_directory_path_rejected() {
715        let mgr = open_manager();
716        for bad in ["/", "/etc", "/dev", "/proc", "/usr"] {
717            let err = mgr
718                .create_mount(
719                    "bad".to_string(),
720                    vec![PathBuf::from(bad)],
721                    MountSource::Manual,
722                )
723                .unwrap_err();
724            assert!(
725                err.to_string().contains("system directory"),
726                "expected system directory rejection for {bad}"
727            );
728        }
729    }
730
731    #[test]
732    fn test_update_enrichment_bounds_description() {
733        let mgr = open_manager();
734        let m = mgr
735            .create_mount(
736                "oxios".to_string(),
737                vec![PathBuf::from("/a")],
738                MountSource::Manual,
739            )
740            .expect("create");
741        let long = "x".repeat(800);
742        let updated = mgr
743            .update_enrichment(m.id, Some(long.clone()), None)
744            .expect("update");
745        assert_eq!(updated.auto_description.chars().count(), 500);
746        assert!(updated.last_enriched_at.is_some());
747        assert!(!updated.enrichment_pending);
748    }
749
750    #[test]
751    fn test_update_renames_mount() {
752        let mgr = open_manager();
753        let m = mgr
754            .create_mount(
755                "oxios".to_string(),
756                vec![PathBuf::from("/a")],
757                MountSource::Manual,
758            )
759            .expect("create");
760        let updated = mgr
761            .update(m.id, Some("new-name".to_string()), None)
762            .expect("rename");
763        assert_eq!(updated.name, "new-name");
764        // name_index follows the rename.
765        assert!(mgr.get_mount_by_name("oxios").is_none());
766        assert_eq!(mgr.get_mount_by_name("new-name").unwrap().id, m.id);
767    }
768
769    #[test]
770    fn test_update_rejects_duplicate_name() {
771        let mgr = open_manager();
772        mgr.create_mount(
773            "alpha".to_string(),
774            vec![PathBuf::from("/a")],
775            MountSource::Manual,
776        )
777        .expect("first");
778        let b = mgr
779            .create_mount(
780                "beta".to_string(),
781                vec![PathBuf::from("/b")],
782                MountSource::Manual,
783            )
784            .expect("second");
785        let err = mgr
786            .update(b.id, Some("alpha".to_string()), None)
787            .unwrap_err();
788        assert!(err.to_string().contains("already exists"));
789    }
790
791    #[test]
792    fn test_update_paths_invalidates_enrichment() {
793        let mgr = open_manager();
794        let m = mgr
795            .create_mount(
796                "oxios".to_string(),
797                vec![PathBuf::from("/old")],
798                MountSource::Manual,
799            )
800            .expect("create");
801        // Seed enrichment so we can observe the invalidation.
802        let enriched = mgr
803            .update_enrichment(m.id, Some("a rust project".to_string()), None)
804            .expect("enrich");
805        assert!(!enriched.enrichment_pending);
806        // Seed a stale marker snapshot pointing at the old root.
807        {
808            let mut mounts = mgr.mounts.write();
809            mounts
810                .get_mut(&m.id)
811                .unwrap()
812                .last_marker_snapshot
813                .insert(PathBuf::from("/old/Cargo.toml"), SystemTime::now());
814        }
815        // Repoint the mount at a new path.
816        let updated = mgr
817            .update(m.id, None, Some(vec![PathBuf::from("/new")]))
818            .expect("update paths");
819        assert_eq!(updated.paths, vec![PathBuf::from("/new")]);
820        // The cached description/tech-stack/markers are now stale.
821        assert!(updated.enrichment_pending);
822        assert!(updated.last_marker_snapshot.is_empty());
823    }
824
825    #[test]
826    fn test_update_no_op_preserves_enrichment() {
827        let mgr = open_manager();
828        let m = mgr
829            .create_mount(
830                "oxios".to_string(),
831                vec![PathBuf::from("/a")],
832                MountSource::Manual,
833            )
834            .expect("create");
835        mgr.update_enrichment(m.id, Some("desc".to_string()), None)
836            .expect("enrich");
837        // Passing the unchanged name and paths must NOT mark enrichment stale.
838        let updated = mgr
839            .update(
840                m.id,
841                Some("oxios".to_string()),
842                Some(vec![PathBuf::from("/a")]),
843            )
844            .expect("noop");
845        assert!(!updated.enrichment_pending);
846    }
847
848    #[test]
849    fn test_remove_mount() {
850        let mgr = open_manager();
851        let m = mgr
852            .create_mount(
853                "temp".to_string(),
854                vec![PathBuf::from("/t")],
855                MountSource::Manual,
856            )
857            .expect("create");
858        mgr.remove_mount(m.id).expect("remove");
859        assert!(mgr.get_mount(m.id).is_none());
860        assert!(mgr.get_mount_by_name("temp").is_none());
861    }
862
863    #[test]
864    fn test_get_mounts_ordered_skips_missing() {
865        let mgr = open_manager();
866        let m1 = mgr
867            .create_mount(
868                "a".to_string(),
869                vec![PathBuf::from("/a")],
870                MountSource::Manual,
871            )
872            .unwrap();
873        let m2 = mgr
874            .create_mount(
875                "b".to_string(),
876                vec![PathBuf::from("/b")],
877                MountSource::Manual,
878            )
879            .unwrap();
880        let missing = MountId::new_v4();
881        let got = mgr.get_mounts_ordered(&[m1.id, missing, m2.id]);
882        assert_eq!(got.len(), 2);
883        assert_eq!(got[0].name, "a");
884        assert_eq!(got[1].name, "b");
885    }
886
887    #[test]
888    fn test_promote_frequent_paths_creates_mount() {
889        use crate::state_store::{Session, UserMessage};
890        use chrono::Utc;
891
892        let mgr = open_manager();
893        // Use this crate's own source dir — it has Cargo.toml at its root,
894        // so normalize_to_root will collapse to the oxios-kernel root.
895        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
896        let file = root.join("src/lib.rs");
897
898        // Frequency is counted per distinct root per session (Promo-7): one
899        // session's repeated mentions count once. So we need three separate
900        // sessions to cross the default threshold of 3.
901        let sessions: Vec<Session> = (0..3)
902            .map(|_| {
903                let mut session = Session::new("test");
904                session.user_messages.push(UserMessage {
905                    content: format!("fix {} please", file.display()),
906                    timestamp: Utc::now(),
907                });
908                session
909            })
910            .collect();
911
912        let config = path_promotion::PromotionConfig::default();
913        let created = mgr.promote_frequent_paths(&sessions, &config);
914        assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
915
916        let mount = mgr.get_mount(created[0]).expect("promoted mount exists");
917        assert_eq!(mount.source, MountSource::AutoPromoted);
918        assert_eq!(mount.name, "oxios-kernel");
919        // auto_meta should be seeded (Cargo.toml → rust).
920        assert!(mount.auto_meta.languages.contains(&"rust".to_string()));
921    }
922
923    /// Build `n` sessions each mentioning `root` once (Promo-7: frequency is
924    /// per distinct root per session, so we vary the *session* count, not
925    /// the message count within one session).
926    fn sessions_mentioning(root: &Path, n: u32) -> Vec<crate::state_store::Session> {
927        use crate::state_store::{Session, UserMessage};
928        use chrono::Utc;
929        (0..n)
930            .map(|_| {
931                let mut s = Session::new("test");
932                s.user_messages.push(UserMessage {
933                    content: format!("work on {}/src/lib.rs", root.display()),
934                    timestamp: Utc::now(),
935                });
936                s
937            })
938            .collect()
939    }
940
941    #[test]
942    fn test_promote_skips_already_covered_root() {
943        let mgr = open_manager();
944        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
945        // Pre-create a Mount covering this root.
946        mgr.create_mount(
947            "manual-kernel".to_string(),
948            vec![root.clone()],
949            MountSource::Manual,
950        )
951        .unwrap();
952
953        // Promo-7: 3 separate sessions (count=3) cross the default threshold,
954        // so this exercises the coverage-skip path rather than trivially
955        // passing because the count is below threshold.
956        let sessions = sessions_mentioning(&root, 3);
957        let config = path_promotion::PromotionConfig::default();
958        let created = mgr.promote_frequent_paths(&sessions, &config);
959        assert!(
960            created.is_empty(),
961            "should not promote an already-covered root"
962        );
963    }
964
965    #[test]
966    fn test_promote_respects_threshold() {
967        let mgr = open_manager();
968        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
969
970        // Promo-7: 2 sessions → count=2, below the default threshold of 3.
971        let sessions = sessions_mentioning(&root, 2);
972        let config = path_promotion::PromotionConfig::default();
973        let created = mgr.promote_frequent_paths(&sessions, &config);
974        assert!(created.is_empty(), "should not promote below threshold");
975    }
976
977    #[test]
978    fn test_promote_skips_dismissed_root() {
979        // Promo-3: removing an AutoPromoted Mount must tombstone its root so
980        // the scanner never re-creates it.
981        let mgr = open_manager();
982        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
983        let sessions = sessions_mentioning(&root, 3);
984        let config = path_promotion::PromotionConfig::default();
985
986        // First scan: promotes the root to an AutoPromoted Mount.
987        let created = mgr.promote_frequent_paths(&sessions, &config);
988        assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
989        let promoted_id = created[0];
990        assert_eq!(
991            mgr.get_mount(promoted_id).unwrap().source,
992            MountSource::AutoPromoted
993        );
994
995        // User dismisses it.
996        mgr.remove_mount(promoted_id).expect("remove");
997        assert!(mgr.get_mount(promoted_id).is_none());
998
999        // Second scan with the same evidence must NOT re-create it.
1000        let recreated = mgr.promote_frequent_paths(&sessions, &config);
1001        assert!(
1002            recreated.is_empty(),
1003            "dismissed root must not be re-promoted (got {:?})",
1004            recreated
1005        );
1006    }
1007
1008    #[test]
1009    fn test_dismissal_only_for_auto_promoted() {
1010        // Promo-3: dismissing a *Manual* Mount must not tombstone the root,
1011        // since the user may still want auto-promotion for it later.
1012        let mgr = open_manager();
1013        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1014
1015        // Manual mount.
1016        let m = mgr
1017            .create_mount(
1018                "manual-kernel".to_string(),
1019                vec![root.clone()],
1020                MountSource::Manual,
1021            )
1022            .unwrap();
1023        mgr.remove_mount(m.id).expect("remove manual");
1024
1025        // Dismissed set should be empty — no tombstone for manual mounts.
1026        assert!(
1027            mgr.dismissed_roots.read().is_empty(),
1028            "manual removal must not tombstone"
1029        );
1030
1031        // Subsequent promotion is still possible. Promo-7: 3 sessions.
1032        let sessions = sessions_mentioning(&root, 3);
1033        let config = path_promotion::PromotionConfig::default();
1034        let created = mgr.promote_frequent_paths(&sessions, &config);
1035        assert_eq!(
1036            created.len(),
1037            1,
1038            "promotion must still work after manual removal"
1039        );
1040    }
1041}