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 update(
210 &self,
211 id: MountId,
212 name: Option<String>,
213 paths: Option<Vec<PathBuf>>,
214 ) -> Result<Mount> {
215 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 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 let mount_clone = mount.clone();
271 drop(mounts);
272 drop(name_index);
273 Ok(mount_clone)
274 }
275
276 pub fn remove_mount(&self, id: MountId) -> Result<()> {
288 let removed = {
290 let mounts = self.mounts.read();
291 mounts
292 .get(&id)
293 .cloned()
294 .ok_or(MountManagerError::NotFound(id))?
295 };
296 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 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 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 fn canonicalize_for_index(path: &Path) -> PathBuf {
346 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
347 }
348
349 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 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 pub fn detect(&self, message: &str) -> DetectionResult {
382 let mounts = self.list_mounts();
383 detect_mounts(message, &mounts)
384 }
385
386 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(()); };
401 if !primary.exists() {
402 tracing::debug!(path = %primary.display(), "Mount path missing, skip meta seed");
403 return Ok(());
404 }
405 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(()); };
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 pub fn check_drift(&self, id: MountId) -> Result<bool> {
434 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 let current = super::meta_detection::snapshot_markers(&primary);
448 let drifted = markers_drifted(&old_snapshot, ¤t);
449 let current_map: HashMap<PathBuf, SystemTime> = current.into_iter().collect();
450
451 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 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 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 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 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 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 if self.root_already_covered(&root) {
527 continue;
528 }
529 if self.is_dismissed(&root) {
532 tracing::debug!(
533 path = %root.display(),
534 "auto-promotion skipped: root was dismissed"
535 );
536 continue;
537 }
538 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 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 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 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 fn root_already_covered(&self, root: &PathBuf) -> bool {
598 self.covering_mount_id(root).is_some()
599 }
600}
601
602fn markers_drifted(
605 stored: &HashMap<PathBuf, SystemTime>,
606 current: &[(std::path::PathBuf, SystemTime)],
607) -> bool {
608 if stored.len() != current.len() {
609 return true; }
611 for (path, mtime) in current {
612 match stored.get(path) {
613 Some(stored_time) if stored_time == mtime => continue,
614 _ => return true, }
616 }
617 false
618}
619
620fn 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
642fn 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 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 let enriched = mgr
803 .update_enrichment(m.id, Some("a rust project".to_string()), None)
804 .expect("enrich");
805 assert!(!enriched.enrichment_pending);
806 {
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 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 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 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 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
896 let file = root.join("src/lib.rs");
897
898 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 assert!(mount.auto_meta.languages.contains(&"rust".to_string()));
921 }
922
923 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 mgr.create_mount(
947 "manual-kernel".to_string(),
948 vec![root.clone()],
949 MountSource::Manual,
950 )
951 .unwrap();
952
953 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 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 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 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 mgr.remove_mount(promoted_id).expect("remove");
997 assert!(mgr.get_mount(promoted_id).is_none());
998
999 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 let mgr = open_manager();
1013 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1014
1015 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 assert!(
1027 mgr.dismissed_roots.read().is_empty(),
1028 "manual removal must not tombstone"
1029 );
1030
1031 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}