1use 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#[derive(thiserror::Error, Debug)]
24pub enum MountManagerError {
25 #[error("Mount not found: {0}")]
27 NotFound(MountId),
28 #[error("Mount name already exists: {0}")]
30 DuplicateName(String),
31 #[error("Invalid operation: {0}")]
33 Invalid(String),
34}
35
36pub struct MountManager {
41 mounts: RwLock<HashMap<MountId, Mount>>,
43 name_index: RwLock<HashMap<String, MountId>>,
45 dismissed_roots: RwLock<HashSet<PathBuf>>,
52 db: Arc<MemoryDatabase>,
54 event_bus: Option<EventBus>,
56}
57
58impl MountManager {
59 pub fn new(db: Arc<MemoryDatabase>, event_bus: Option<EventBus>) -> Result<Self> {
61 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 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 pub fn list_mounts(&self) -> Vec<Mount> {
93 self.mounts.read().values().cloned().collect()
94 }
95
96 pub fn get_mount(&self, id: MountId) -> Option<Mount> {
98 self.mounts.read().get(&id).cloned()
99 }
100
101 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 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 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 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 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 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 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 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 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 pub fn remove_mount(&self, id: MountId) -> Result<()> {
240 let removed = {
242 let mounts = self.mounts.read();
243 mounts
244 .get(&id)
245 .cloned()
246 .ok_or(MountManagerError::NotFound(id))?
247 };
248 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 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 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 fn canonicalize_for_index(path: &Path) -> PathBuf {
298 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
299 }
300
301 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 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 pub fn detect(&self, message: &str) -> DetectionResult {
334 let mounts = self.list_mounts();
335 detect_mounts(message, &mounts)
336 }
337
338 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(()); };
353 if !primary.exists() {
354 tracing::debug!(path = %primary.display(), "Mount path missing, skip meta seed");
355 return Ok(());
356 }
357 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(()); };
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 pub fn check_drift(&self, id: MountId) -> Result<bool> {
386 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 let current = super::meta_detection::snapshot_markers(&primary);
400 let drifted = markers_drifted(&old_snapshot, ¤t);
401 let current_map: HashMap<PathBuf, SystemTime> = current.into_iter().collect();
402
403 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 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 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 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 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 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 if self.root_already_covered(&root) {
479 continue;
480 }
481 if self.is_dismissed(&root) {
484 tracing::debug!(
485 path = %root.display(),
486 "auto-promotion skipped: root was dismissed"
487 );
488 continue;
489 }
490 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 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 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 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
545fn markers_drifted(
548 stored: &HashMap<PathBuf, SystemTime>,
549 current: &[(std::path::PathBuf, SystemTime)],
550) -> bool {
551 if stored.len() != current.len() {
552 return true; }
554 for (path, mtime) in current {
555 match stored.get(path) {
556 Some(stored_time) if stored_time == mtime => continue,
557 _ => return true, }
559 }
560 false
561}
562
563fn 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
585fn 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 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
741 let file = root.join("src/lib.rs");
742
743 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 assert!(mount.auto_meta.languages.contains(&"rust".to_string()));
766 }
767
768 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 mgr.create_mount(
792 "manual-kernel".to_string(),
793 vec![root.clone()],
794 MountSource::Manual,
795 )
796 .unwrap();
797
798 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 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 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 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 mgr.remove_mount(promoted_id).expect("remove");
842 assert!(mgr.get_mount(promoted_id).is_none());
843
844 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 let mgr = open_manager();
858 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
859
860 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 assert!(
872 mgr.dismissed_roots.read().is_empty(),
873 "manual removal must not tombstone"
874 );
875
876 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}