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::rename`] / the web API.
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    /// Rename a Mount.
205    pub fn rename(&self, id: MountId, new_name: String) -> Result<Mount> {
206        let new_name = validate_mount_name(&new_name)?;
207        let mut mounts = self.mounts.write();
208        let mut name_index = self.name_index.write();
209        let mount = mounts.get_mut(&id).ok_or(MountManagerError::NotFound(id))?;
210
211        if new_name != mount.name {
212            if name_index.contains_key(&new_name) {
213                return Err(MountManagerError::DuplicateName(new_name).into());
214            }
215            name_index.remove(&mount.name);
216            name_index.insert(new_name.clone(), id);
217            mount.name = new_name;
218            mount.updated_at = Utc::now();
219        }
220
221        let mount_clone = mount.clone();
222        drop(mounts);
223        drop(name_index);
224        mount_db::save_mount(&self.db.conn(), &mount_clone)?;
225        Ok(mount_clone)
226    }
227
228    /// Remove a Mount.
229    ///
230    /// DB-first ordering (matches `create_mount`): if the DB delete fails the
231    /// in-memory state is left untouched so the caller can retry and the Mount
232    /// doesn't silently reappear on restart.
233    ///
234    /// If the Mount was `AutoPromoted`, its canonicalized root paths are
235    /// recorded as dismissals (tombstones) so the background scanner does
236    /// not immediately re-promote them (Promo-3). Manual mounts are removed
237    /// without recording a tombstone (the user may still want auto-promotion
238    /// for that root).
239    pub fn remove_mount(&self, id: MountId) -> Result<()> {
240        // Preserve NotFound semantics + capture the Mount for tombstoning.
241        let removed = {
242            let mounts = self.mounts.read();
243            mounts
244                .get(&id)
245                .cloned()
246                .ok_or(MountManagerError::NotFound(id))?
247        };
248        // Delete from the DB before touching memory.
249        mount_db::delete_mount(&self.db.conn(), &id.to_string())?;
250        {
251            let mut mounts = self.mounts.write();
252            let mut name_index = self.name_index.write();
253            if let Some(mount) = mounts.remove(&id) {
254                name_index.remove(&mount.name);
255            }
256        }
257
258        // Promo-3: tombstone auto-promoted roots so they aren't re-created.
259        if removed.source == MountSource::AutoPromoted {
260            self.record_dismissal(&removed.paths);
261        }
262
263        tracing::info!(id = %id, "Mount removed");
264        Ok(())
265    }
266
267    /// Canonicalize each path and record it as a dismissed root, both
268    /// in-memory and in SQLite (Promo-3). Best-effort: paths that fail to
269    /// canonicalize are stored in their raw form so the tombstone still
270    /// matches the exact string the scanner would normalize to.
271    fn record_dismissal(&self, paths: &[PathBuf]) {
272        let to_record: Vec<PathBuf> = paths
273            .iter()
274            .map(|p| Self::canonicalize_for_index(p))
275            .collect();
276
277        {
278            let mut dismissed = self.dismissed_roots.write();
279            for p in &to_record {
280                dismissed.insert(p.clone());
281            }
282        }
283        for p in &to_record {
284            if let Err(e) = mount_db::add_dismissed_root(&self.db.conn(), p) {
285                tracing::warn!(
286                    path = %p.display(),
287                    error = %e,
288                    "failed to persist mount dismissal"
289                );
290            }
291        }
292        tracing::debug!(count = to_record.len(), "recorded mount dismissals");
293    }
294
295    /// Canonicalize a path for index comparison, falling back to the raw
296    /// path when canonicalization fails (e.g. the path was removed).
297    fn canonicalize_for_index(path: &Path) -> PathBuf {
298        std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
299    }
300
301    /// RFC-025 Phase 5: is `root` in the dismissed set (Promo-3)?
302    ///
303    /// Compares against both the canonicalized and raw forms of stored
304    /// tombstones so that a root matches regardless of symlink resolution.
305    fn is_dismissed(&self, root: &Path) -> bool {
306        let dismissed = self.dismissed_roots.read();
307        if dismissed.contains(root) {
308            return true;
309        }
310        let canonical = Self::canonicalize_for_index(root);
311        dismissed.contains(&canonical)
312    }
313
314    /// Record that a Mount was used in a session.
315    pub fn touch(&self, id: MountId) {
316        let to_save = {
317            let mut mounts = self.mounts.write();
318            if let Some(mount) = mounts.get_mut(&id) {
319                mount.touch();
320                Some(mount.clone())
321            } else {
322                None
323            }
324        };
325        if let Some(mount) = to_save
326            && let Err(e) = mount_db::save_mount(&self.db.conn(), &mount)
327        {
328            tracing::warn!(id = %id, error = %e, "touch: failed to save Mount");
329        }
330    }
331
332    /// Try to detect a Mount from a user message.
333    pub fn detect(&self, message: &str) -> DetectionResult {
334        let mounts = self.list_mounts();
335        detect_mounts(message, &mounts)
336    }
337
338    /// Seed `auto_meta` from the filesystem (RFC-025 §Auto-Meta).
339    ///
340    /// Cheap heuristic detection on marker files. The agent refines this
341    /// during enrichment. Idempotent — safe to call multiple times.
342    pub fn seed_auto_meta(&self, id: MountId) -> Result<()> {
343        let mount = {
344            let mounts = self.mounts.read();
345            mounts
346                .get(&id)
347                .ok_or(MountManagerError::NotFound(id))?
348                .clone()
349        };
350        let Some(primary) = mount.primary_path().cloned() else {
351            return Ok(()); // nothing to scan
352        };
353        if !primary.exists() {
354            tracing::debug!(path = %primary.display(), "Mount path missing, skip meta seed");
355            return Ok(());
356        }
357        // detect_meta is cheap heuristics only — it must NOT clear the
358        // enrichment nudge. Route it directly (not through update_enrichment,
359        // which would stamp `last_enriched_at` and clear `enrichment_pending`),
360        // so the agent is still prompted to do real enrichment.
361        let meta = super::meta_detection::detect_meta(&primary);
362        let to_save = {
363            let mut mounts = self.mounts.write();
364            let Some(mount) = mounts.get_mut(&id) else {
365                return Ok(()); // removed while detecting
366            };
367            mount.auto_meta = meta;
368            mount.enrichment_pending = true;
369            mount.last_enriched_at = None;
370            mount.updated_at = Utc::now();
371            mount.clone()
372        };
373        if let Err(e) = mount_db::save_mount(&self.db.conn(), &to_save) {
374            tracing::warn!(id = %id, error = %e, "seed_auto_meta: failed to save Mount");
375        }
376        tracing::info!(name = %to_save.name, id = %id, "Mount auto_meta seeded");
377        Ok(())
378    }
379
380    /// Check marker-file drift and set `enrichment_pending` (RFC-025 §Enrichment).
381    ///
382    /// Compares current marker mtimes against the stored snapshot. Returns
383    /// `true` if any marker drifted (and the flag was set). Cheap: a handful
384    /// of `stat()` calls.
385    pub fn check_drift(&self, id: MountId) -> Result<bool> {
386        // Acquire a read lock only to clone the primary path and the current
387        // snapshot, then drop it so the filesystem I/O (snapshot_markers) runs
388        // lock-free (M8: don't do I/O under the write lock).
389        let (primary, old_snapshot) = {
390            let mounts = self.mounts.read();
391            let mount = mounts.get(&id).ok_or(MountManagerError::NotFound(id))?;
392            let Some(primary) = mount.primary_path().cloned() else {
393                return Ok(false);
394            };
395            (primary, mount.last_marker_snapshot.clone())
396        };
397
398        // Filesystem I/O — no lock held.
399        let current = super::meta_detection::snapshot_markers(&primary);
400        let drifted = markers_drifted(&old_snapshot, &current);
401        let current_map: HashMap<PathBuf, SystemTime> = current.into_iter().collect();
402
403        // Re-acquire a write lock to apply results (re-checking the Mount
404        // still exists — it may have been removed while we read the fs).
405        let to_save = {
406            let mut mounts = self.mounts.write();
407            let Some(mount) = mounts.get_mut(&id) else {
408                return Ok(drifted);
409            };
410            // Skip the mutation + DB write when nothing drifted and the
411            // snapshot is unchanged (m4: don't write on every drift check).
412            if !drifted && mount.last_marker_snapshot == current_map {
413                None
414            } else {
415                if drifted {
416                    mount.enrichment_pending = true;
417                    mount.updated_at = Utc::now();
418                }
419                // Refresh the snapshot so the next comparison is accurate.
420                mount.last_marker_snapshot = current_map;
421                Some(mount.clone())
422            }
423        };
424
425        if let Some(mount) = to_save
426            && let Err(e) = mount_db::save_mount(&self.db.conn(), &mount)
427        {
428            tracing::warn!(id = %id, error = %e, "check_drift: failed to save Mount");
429        }
430        Ok(drifted)
431    }
432
433    /// Check drift for all Mounts (Dream-time refresh, RFC-025).
434    ///
435    /// Returns the IDs of Mounts whose content drifted.
436    pub fn check_all_drift(&self) -> Vec<MountId> {
437        let ids: Vec<MountId> = self.mounts.read().keys().copied().collect();
438        let mut drifted = Vec::new();
439        for id in ids {
440            match self.check_drift(id) {
441                Ok(true) => drifted.push(id),
442                Ok(false) => {}
443                Err(e) => tracing::warn!(error = %e, %id, "check_drift failed for mount"),
444            }
445        }
446        drifted
447    }
448
449    /// RFC-025 Phase 5: scan session history and auto-create Mounts for paths
450    /// that cross the frequency threshold.
451    ///
452    /// Returns the IDs of newly-created Mounts (empty if none promoted). Safe
453    /// to call repeatedly — paths already covered by an existing Mount are
454    /// skipped, as are name collisions.
455    pub fn promote_frequent_paths(
456        &self,
457        sessions: &[crate::state_store::Session],
458        config: &path_promotion::PromotionConfig,
459    ) -> Vec<MountId> {
460        if !config.enabled {
461            return Vec::new();
462        }
463
464        let freqs = path_promotion::tally_frequencies(sessions, config);
465        // Sort deterministically: most frequent first, then alphabetically by
466        // path. This guarantees that when two roots derive the same name the
467        // most-used one wins consistently across runs (HashMap iteration order
468        // is otherwise non-deterministic).
469        let mut sorted_freqs: Vec<_> = freqs.into_iter().collect();
470        sorted_freqs.sort_by(|a, b| b.1.count.cmp(&a.1.count).then_with(|| a.0.cmp(&b.0)));
471        let mut created = Vec::new();
472
473        for (root, freq) in sorted_freqs {
474            if freq.count < config.threshold {
475                continue;
476            }
477            // Skip if any existing Mount already covers this root.
478            if self.root_already_covered(&root) {
479                continue;
480            }
481            // Promo-3: skip roots the user has explicitly dismissed, so a
482            // deleted AutoPromoted Mount is not immediately re-created.
483            if self.is_dismissed(&root) {
484                tracing::debug!(
485                    path = %root.display(),
486                    "auto-promotion skipped: root was dismissed"
487                );
488                continue;
489            }
490            // Derive a name from the final path component.
491            let Some(name) = root
492                .file_name()
493                .and_then(|n| n.to_str())
494                .map(|s| s.to_string())
495            else {
496                continue;
497            };
498            // Skip if the name is already taken (collision → leave for the
499            // user to resolve, rather than inventing "name-2").
500            if self.get_mount_by_name(&name).is_some() {
501                continue;
502            }
503
504            match self.create_mount(
505                name.clone(),
506                vec![root.clone()],
507                super::MountSource::AutoPromoted,
508            ) {
509                Ok(mount) => {
510                    tracing::info!(
511                        name = %mount.name,
512                        path = %root.display(),
513                        count = freq.count,
514                        "RFC-025: auto-promoted frequent path to Mount"
515                    );
516                    // Seed auto_meta immediately so the new Mount is useful.
517                    let _ = self.seed_auto_meta(mount.id);
518                    created.push(mount.id);
519                }
520                Err(e) => {
521                    tracing::debug!(
522                        path = %root.display(),
523                        error = %e,
524                        "auto-promotion skipped"
525                    );
526                }
527            }
528        }
529
530        created
531    }
532
533    /// Returns `true` if some existing Mount's `paths` already includes (or is
534    /// an ancestor of) `root`, meaning the root is already covered.
535    fn root_already_covered(&self, root: &PathBuf) -> bool {
536        let mounts = self.mounts.read();
537        mounts.values().any(|m| {
538            m.paths
539                .iter()
540                .any(|p| root.starts_with(p) || p.starts_with(root))
541        })
542    }
543}
544
545/// Compare a stored marker snapshot against the current state.
546/// Returns `true` if any marker was added, removed, or changed mtime.
547fn markers_drifted(
548    stored: &HashMap<PathBuf, SystemTime>,
549    current: &[(std::path::PathBuf, SystemTime)],
550) -> bool {
551    if stored.len() != current.len() {
552        return true; // marker added or removed
553    }
554    for (path, mtime) in current {
555        match stored.get(path) {
556            Some(stored_time) if stored_time == mtime => continue,
557            _ => return true, // new, removed, or changed
558        }
559    }
560    false
561}
562
563/// Validate a Mount name (RFC-025): non-empty after trim, ≤ 64 chars (by char
564/// count), no control characters. Returns the trimmed name on success.
565fn validate_mount_name(name: &str) -> Result<String> {
566    let trimmed = name.trim();
567    if trimmed.is_empty() {
568        return Err(MountManagerError::Invalid("Mount name must not be empty".to_string()).into());
569    }
570    if trimmed.chars().count() > 64 {
571        return Err(MountManagerError::Invalid(
572            "Mount name must be at most 64 characters".to_string(),
573        )
574        .into());
575    }
576    if trimmed.chars().any(|c| c.is_control()) {
577        return Err(MountManagerError::Invalid(
578            "Mount name must not contain control characters".to_string(),
579        )
580        .into());
581    }
582    Ok(trimmed.to_string())
583}
584
585/// Reject paths that are too broad to be a meaningful project root.
586///
587/// This is a lightweight safety check, not a full sandbox — AccessManager
588/// RBAC enforcement is the real boundary. We only reject the filesystem root
589/// and a few system directories that would make detection hijack every path.
590fn validate_mount_path(path: &Path) -> Result<()> {
591    let forbidden = [
592        "", "/etc", "/dev", "/proc", "/sys", "/var", "/usr", "/bin", "/sbin", "/boot",
593    ];
594    let normalized = path.to_str().map(|s| s.trim_end_matches('/')).unwrap_or("");
595    if forbidden.contains(&normalized) {
596        return Err(MountManagerError::Invalid(format!(
597            "Mount path '{}' is a system directory; refusing to create an overly broad Mount",
598            path.display()
599        ))
600        .into());
601    }
602    Ok(())
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn open_manager() -> MountManager {
610        let db = Arc::new(MemoryDatabase::open_in_memory(64).expect("db"));
611        MountManager::new(db, None).expect("manager")
612    }
613
614    #[test]
615    fn test_create_and_get() {
616        let mgr = open_manager();
617        let m = mgr
618            .create_mount(
619                "oxios".to_string(),
620                vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
621                MountSource::Manual,
622            )
623            .expect("create");
624        assert_eq!(mgr.get_mount(m.id).unwrap().name, "oxios");
625        assert_eq!(mgr.get_mount_by_name("oxios").unwrap().id, m.id);
626    }
627
628    #[test]
629    fn test_duplicate_name_rejected() {
630        let mgr = open_manager();
631        mgr.create_mount(
632            "oxios".to_string(),
633            vec![PathBuf::from("/a")],
634            MountSource::Manual,
635        )
636        .expect("first");
637        let err = mgr
638            .create_mount(
639                "oxios".to_string(),
640                vec![PathBuf::from("/b")],
641                MountSource::Manual,
642            )
643            .unwrap_err();
644        assert!(err.to_string().contains("already exists"));
645    }
646
647    #[test]
648    fn test_empty_paths_rejected() {
649        let mgr = open_manager();
650        let err = mgr
651            .create_mount("x".to_string(), vec![], MountSource::Manual)
652            .unwrap_err();
653        assert!(err.to_string().contains("at least one path"));
654    }
655
656    #[test]
657    fn test_system_directory_path_rejected() {
658        let mgr = open_manager();
659        for bad in ["/", "/etc", "/dev", "/proc", "/usr"] {
660            let err = mgr
661                .create_mount(
662                    "bad".to_string(),
663                    vec![PathBuf::from(bad)],
664                    MountSource::Manual,
665                )
666                .unwrap_err();
667            assert!(
668                err.to_string().contains("system directory"),
669                "expected system directory rejection for {bad}"
670            );
671        }
672    }
673
674    #[test]
675    fn test_update_enrichment_bounds_description() {
676        let mgr = open_manager();
677        let m = mgr
678            .create_mount(
679                "oxios".to_string(),
680                vec![PathBuf::from("/a")],
681                MountSource::Manual,
682            )
683            .expect("create");
684        let long = "x".repeat(800);
685        let updated = mgr
686            .update_enrichment(m.id, Some(long.clone()), None)
687            .expect("update");
688        assert_eq!(updated.auto_description.chars().count(), 500);
689        assert!(updated.last_enriched_at.is_some());
690        assert!(!updated.enrichment_pending);
691    }
692
693    #[test]
694    fn test_remove_mount() {
695        let mgr = open_manager();
696        let m = mgr
697            .create_mount(
698                "temp".to_string(),
699                vec![PathBuf::from("/t")],
700                MountSource::Manual,
701            )
702            .expect("create");
703        mgr.remove_mount(m.id).expect("remove");
704        assert!(mgr.get_mount(m.id).is_none());
705        assert!(mgr.get_mount_by_name("temp").is_none());
706    }
707
708    #[test]
709    fn test_get_mounts_ordered_skips_missing() {
710        let mgr = open_manager();
711        let m1 = mgr
712            .create_mount(
713                "a".to_string(),
714                vec![PathBuf::from("/a")],
715                MountSource::Manual,
716            )
717            .unwrap();
718        let m2 = mgr
719            .create_mount(
720                "b".to_string(),
721                vec![PathBuf::from("/b")],
722                MountSource::Manual,
723            )
724            .unwrap();
725        let missing = MountId::new_v4();
726        let got = mgr.get_mounts_ordered(&[m1.id, missing, m2.id]);
727        assert_eq!(got.len(), 2);
728        assert_eq!(got[0].name, "a");
729        assert_eq!(got[1].name, "b");
730    }
731
732    #[test]
733    fn test_promote_frequent_paths_creates_mount() {
734        use crate::state_store::{Session, UserMessage};
735        use chrono::Utc;
736
737        let mgr = open_manager();
738        // Use this crate's own source dir — it has Cargo.toml at its root,
739        // so normalize_to_root will collapse to the oxios-kernel root.
740        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
741        let file = root.join("src/lib.rs");
742
743        // Frequency is counted per distinct root per session (Promo-7): one
744        // session's repeated mentions count once. So we need three separate
745        // sessions to cross the default threshold of 3.
746        let sessions: Vec<Session> = (0..3)
747            .map(|_| {
748                let mut session = Session::new("test");
749                session.user_messages.push(UserMessage {
750                    content: format!("fix {} please", file.display()),
751                    timestamp: Utc::now(),
752                });
753                session
754            })
755            .collect();
756
757        let config = path_promotion::PromotionConfig::default();
758        let created = mgr.promote_frequent_paths(&sessions, &config);
759        assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
760
761        let mount = mgr.get_mount(created[0]).expect("promoted mount exists");
762        assert_eq!(mount.source, MountSource::AutoPromoted);
763        assert_eq!(mount.name, "oxios-kernel");
764        // auto_meta should be seeded (Cargo.toml → rust).
765        assert!(mount.auto_meta.languages.contains(&"rust".to_string()));
766    }
767
768    /// Build `n` sessions each mentioning `root` once (Promo-7: frequency is
769    /// per distinct root per session, so we vary the *session* count, not
770    /// the message count within one session).
771    fn sessions_mentioning(root: &PathBuf, n: u32) -> Vec<crate::state_store::Session> {
772        use crate::state_store::{Session, UserMessage};
773        use chrono::Utc;
774        (0..n)
775            .map(|_| {
776                let mut s = Session::new("test");
777                s.user_messages.push(UserMessage {
778                    content: format!("work on {}/src/lib.rs", root.display()),
779                    timestamp: Utc::now(),
780                });
781                s
782            })
783            .collect()
784    }
785
786    #[test]
787    fn test_promote_skips_already_covered_root() {
788        let mgr = open_manager();
789        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
790        // Pre-create a Mount covering this root.
791        mgr.create_mount(
792            "manual-kernel".to_string(),
793            vec![root.clone()],
794            MountSource::Manual,
795        )
796        .unwrap();
797
798        // Promo-7: 3 separate sessions (count=3) cross the default threshold,
799        // so this exercises the coverage-skip path rather than trivially
800        // passing because the count is below threshold.
801        let sessions = sessions_mentioning(&root, 3);
802        let config = path_promotion::PromotionConfig::default();
803        let created = mgr.promote_frequent_paths(&sessions, &config);
804        assert!(
805            created.is_empty(),
806            "should not promote an already-covered root"
807        );
808    }
809
810    #[test]
811    fn test_promote_respects_threshold() {
812        let mgr = open_manager();
813        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
814
815        // Promo-7: 2 sessions → count=2, below the default threshold of 3.
816        let sessions = sessions_mentioning(&root, 2);
817        let config = path_promotion::PromotionConfig::default();
818        let created = mgr.promote_frequent_paths(&sessions, &config);
819        assert!(created.is_empty(), "should not promote below threshold");
820    }
821
822    #[test]
823    fn test_promote_skips_dismissed_root() {
824        // Promo-3: removing an AutoPromoted Mount must tombstone its root so
825        // the scanner never re-creates it.
826        let mgr = open_manager();
827        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
828        let sessions = sessions_mentioning(&root, 3);
829        let config = path_promotion::PromotionConfig::default();
830
831        // First scan: promotes the root to an AutoPromoted Mount.
832        let created = mgr.promote_frequent_paths(&sessions, &config);
833        assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
834        let promoted_id = created[0];
835        assert_eq!(
836            mgr.get_mount(promoted_id).unwrap().source,
837            MountSource::AutoPromoted
838        );
839
840        // User dismisses it.
841        mgr.remove_mount(promoted_id).expect("remove");
842        assert!(mgr.get_mount(promoted_id).is_none());
843
844        // Second scan with the same evidence must NOT re-create it.
845        let recreated = mgr.promote_frequent_paths(&sessions, &config);
846        assert!(
847            recreated.is_empty(),
848            "dismissed root must not be re-promoted (got {:?})",
849            recreated
850        );
851    }
852
853    #[test]
854    fn test_dismissal_only_for_auto_promoted() {
855        // Promo-3: dismissing a *Manual* Mount must not tombstone the root,
856        // since the user may still want auto-promotion for it later.
857        let mgr = open_manager();
858        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
859
860        // Manual mount.
861        let m = mgr
862            .create_mount(
863                "manual-kernel".to_string(),
864                vec![root.clone()],
865                MountSource::Manual,
866            )
867            .unwrap();
868        mgr.remove_mount(m.id).expect("remove manual");
869
870        // Dismissed set should be empty — no tombstone for manual mounts.
871        assert!(
872            mgr.dismissed_roots.read().is_empty(),
873            "manual removal must not tombstone"
874        );
875
876        // Subsequent promotion is still possible. Promo-7: 3 sessions.
877        let sessions = sessions_mentioning(&root, 3);
878        let config = path_promotion::PromotionConfig::default();
879        let created = mgr.promote_frequent_paths(&sessions, &config);
880        assert_eq!(
881            created.len(),
882            1,
883            "promotion must still work after manual removal"
884        );
885    }
886}