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 fn root_already_covered(&self, root: &PathBuf) -> bool {
584 let mounts = self.mounts.read();
585 mounts.values().any(|m| {
586 m.paths
587 .iter()
588 .any(|p| root.starts_with(p) || p.starts_with(root))
589 })
590 }
591}
592
593fn markers_drifted(
596 stored: &HashMap<PathBuf, SystemTime>,
597 current: &[(std::path::PathBuf, SystemTime)],
598) -> bool {
599 if stored.len() != current.len() {
600 return true; }
602 for (path, mtime) in current {
603 match stored.get(path) {
604 Some(stored_time) if stored_time == mtime => continue,
605 _ => return true, }
607 }
608 false
609}
610
611fn validate_mount_name(name: &str) -> Result<String> {
614 let trimmed = name.trim();
615 if trimmed.is_empty() {
616 return Err(MountManagerError::Invalid("Mount name must not be empty".to_string()).into());
617 }
618 if trimmed.chars().count() > 64 {
619 return Err(MountManagerError::Invalid(
620 "Mount name must be at most 64 characters".to_string(),
621 )
622 .into());
623 }
624 if trimmed.chars().any(|c| c.is_control()) {
625 return Err(MountManagerError::Invalid(
626 "Mount name must not contain control characters".to_string(),
627 )
628 .into());
629 }
630 Ok(trimmed.to_string())
631}
632
633fn validate_mount_path(path: &Path) -> Result<()> {
639 let forbidden = [
640 "", "/etc", "/dev", "/proc", "/sys", "/var", "/usr", "/bin", "/sbin", "/boot",
641 ];
642 let normalized = path.to_str().map(|s| s.trim_end_matches('/')).unwrap_or("");
643 if forbidden.contains(&normalized) {
644 return Err(MountManagerError::Invalid(format!(
645 "Mount path '{}' is a system directory; refusing to create an overly broad Mount",
646 path.display()
647 ))
648 .into());
649 }
650 Ok(())
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 fn open_manager() -> MountManager {
658 let db = Arc::new(MemoryDatabase::open_in_memory(64).expect("db"));
659 MountManager::new(db, None).expect("manager")
660 }
661
662 #[test]
663 fn test_create_and_get() {
664 let mgr = open_manager();
665 let m = mgr
666 .create_mount(
667 "oxios".to_string(),
668 vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
669 MountSource::Manual,
670 )
671 .expect("create");
672 assert_eq!(mgr.get_mount(m.id).unwrap().name, "oxios");
673 assert_eq!(mgr.get_mount_by_name("oxios").unwrap().id, m.id);
674 }
675
676 #[test]
677 fn test_duplicate_name_rejected() {
678 let mgr = open_manager();
679 mgr.create_mount(
680 "oxios".to_string(),
681 vec![PathBuf::from("/a")],
682 MountSource::Manual,
683 )
684 .expect("first");
685 let err = mgr
686 .create_mount(
687 "oxios".to_string(),
688 vec![PathBuf::from("/b")],
689 MountSource::Manual,
690 )
691 .unwrap_err();
692 assert!(err.to_string().contains("already exists"));
693 }
694
695 #[test]
696 fn test_empty_paths_rejected() {
697 let mgr = open_manager();
698 let err = mgr
699 .create_mount("x".to_string(), vec![], MountSource::Manual)
700 .unwrap_err();
701 assert!(err.to_string().contains("at least one path"));
702 }
703
704 #[test]
705 fn test_system_directory_path_rejected() {
706 let mgr = open_manager();
707 for bad in ["/", "/etc", "/dev", "/proc", "/usr"] {
708 let err = mgr
709 .create_mount(
710 "bad".to_string(),
711 vec![PathBuf::from(bad)],
712 MountSource::Manual,
713 )
714 .unwrap_err();
715 assert!(
716 err.to_string().contains("system directory"),
717 "expected system directory rejection for {bad}"
718 );
719 }
720 }
721
722 #[test]
723 fn test_update_enrichment_bounds_description() {
724 let mgr = open_manager();
725 let m = mgr
726 .create_mount(
727 "oxios".to_string(),
728 vec![PathBuf::from("/a")],
729 MountSource::Manual,
730 )
731 .expect("create");
732 let long = "x".repeat(800);
733 let updated = mgr
734 .update_enrichment(m.id, Some(long.clone()), None)
735 .expect("update");
736 assert_eq!(updated.auto_description.chars().count(), 500);
737 assert!(updated.last_enriched_at.is_some());
738 assert!(!updated.enrichment_pending);
739 }
740
741 #[test]
742 fn test_update_renames_mount() {
743 let mgr = open_manager();
744 let m = mgr
745 .create_mount(
746 "oxios".to_string(),
747 vec![PathBuf::from("/a")],
748 MountSource::Manual,
749 )
750 .expect("create");
751 let updated = mgr
752 .update(m.id, Some("new-name".to_string()), None)
753 .expect("rename");
754 assert_eq!(updated.name, "new-name");
755 assert!(mgr.get_mount_by_name("oxios").is_none());
757 assert_eq!(mgr.get_mount_by_name("new-name").unwrap().id, m.id);
758 }
759
760 #[test]
761 fn test_update_rejects_duplicate_name() {
762 let mgr = open_manager();
763 mgr.create_mount(
764 "alpha".to_string(),
765 vec![PathBuf::from("/a")],
766 MountSource::Manual,
767 )
768 .expect("first");
769 let b = mgr
770 .create_mount(
771 "beta".to_string(),
772 vec![PathBuf::from("/b")],
773 MountSource::Manual,
774 )
775 .expect("second");
776 let err = mgr
777 .update(b.id, Some("alpha".to_string()), None)
778 .unwrap_err();
779 assert!(err.to_string().contains("already exists"));
780 }
781
782 #[test]
783 fn test_update_paths_invalidates_enrichment() {
784 let mgr = open_manager();
785 let m = mgr
786 .create_mount(
787 "oxios".to_string(),
788 vec![PathBuf::from("/old")],
789 MountSource::Manual,
790 )
791 .expect("create");
792 let enriched = mgr
794 .update_enrichment(m.id, Some("a rust project".to_string()), None)
795 .expect("enrich");
796 assert!(!enriched.enrichment_pending);
797 {
799 let mut mounts = mgr.mounts.write();
800 mounts
801 .get_mut(&m.id)
802 .unwrap()
803 .last_marker_snapshot
804 .insert(PathBuf::from("/old/Cargo.toml"), SystemTime::now());
805 }
806 let updated = mgr
808 .update(m.id, None, Some(vec![PathBuf::from("/new")]))
809 .expect("update paths");
810 assert_eq!(updated.paths, vec![PathBuf::from("/new")]);
811 assert!(updated.enrichment_pending);
813 assert!(updated.last_marker_snapshot.is_empty());
814 }
815
816 #[test]
817 fn test_update_no_op_preserves_enrichment() {
818 let mgr = open_manager();
819 let m = mgr
820 .create_mount(
821 "oxios".to_string(),
822 vec![PathBuf::from("/a")],
823 MountSource::Manual,
824 )
825 .expect("create");
826 mgr.update_enrichment(m.id, Some("desc".to_string()), None)
827 .expect("enrich");
828 let updated = mgr
830 .update(
831 m.id,
832 Some("oxios".to_string()),
833 Some(vec![PathBuf::from("/a")]),
834 )
835 .expect("noop");
836 assert!(!updated.enrichment_pending);
837 }
838
839 #[test]
840 fn test_remove_mount() {
841 let mgr = open_manager();
842 let m = mgr
843 .create_mount(
844 "temp".to_string(),
845 vec![PathBuf::from("/t")],
846 MountSource::Manual,
847 )
848 .expect("create");
849 mgr.remove_mount(m.id).expect("remove");
850 assert!(mgr.get_mount(m.id).is_none());
851 assert!(mgr.get_mount_by_name("temp").is_none());
852 }
853
854 #[test]
855 fn test_get_mounts_ordered_skips_missing() {
856 let mgr = open_manager();
857 let m1 = mgr
858 .create_mount(
859 "a".to_string(),
860 vec![PathBuf::from("/a")],
861 MountSource::Manual,
862 )
863 .unwrap();
864 let m2 = mgr
865 .create_mount(
866 "b".to_string(),
867 vec![PathBuf::from("/b")],
868 MountSource::Manual,
869 )
870 .unwrap();
871 let missing = MountId::new_v4();
872 let got = mgr.get_mounts_ordered(&[m1.id, missing, m2.id]);
873 assert_eq!(got.len(), 2);
874 assert_eq!(got[0].name, "a");
875 assert_eq!(got[1].name, "b");
876 }
877
878 #[test]
879 fn test_promote_frequent_paths_creates_mount() {
880 use crate::state_store::{Session, UserMessage};
881 use chrono::Utc;
882
883 let mgr = open_manager();
884 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
887 let file = root.join("src/lib.rs");
888
889 let sessions: Vec<Session> = (0..3)
893 .map(|_| {
894 let mut session = Session::new("test");
895 session.user_messages.push(UserMessage {
896 content: format!("fix {} please", file.display()),
897 timestamp: Utc::now(),
898 });
899 session
900 })
901 .collect();
902
903 let config = path_promotion::PromotionConfig::default();
904 let created = mgr.promote_frequent_paths(&sessions, &config);
905 assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
906
907 let mount = mgr.get_mount(created[0]).expect("promoted mount exists");
908 assert_eq!(mount.source, MountSource::AutoPromoted);
909 assert_eq!(mount.name, "oxios-kernel");
910 assert!(mount.auto_meta.languages.contains(&"rust".to_string()));
912 }
913
914 fn sessions_mentioning(root: &Path, n: u32) -> Vec<crate::state_store::Session> {
918 use crate::state_store::{Session, UserMessage};
919 use chrono::Utc;
920 (0..n)
921 .map(|_| {
922 let mut s = Session::new("test");
923 s.user_messages.push(UserMessage {
924 content: format!("work on {}/src/lib.rs", root.display()),
925 timestamp: Utc::now(),
926 });
927 s
928 })
929 .collect()
930 }
931
932 #[test]
933 fn test_promote_skips_already_covered_root() {
934 let mgr = open_manager();
935 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
936 mgr.create_mount(
938 "manual-kernel".to_string(),
939 vec![root.clone()],
940 MountSource::Manual,
941 )
942 .unwrap();
943
944 let sessions = sessions_mentioning(&root, 3);
948 let config = path_promotion::PromotionConfig::default();
949 let created = mgr.promote_frequent_paths(&sessions, &config);
950 assert!(
951 created.is_empty(),
952 "should not promote an already-covered root"
953 );
954 }
955
956 #[test]
957 fn test_promote_respects_threshold() {
958 let mgr = open_manager();
959 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
960
961 let sessions = sessions_mentioning(&root, 2);
963 let config = path_promotion::PromotionConfig::default();
964 let created = mgr.promote_frequent_paths(&sessions, &config);
965 assert!(created.is_empty(), "should not promote below threshold");
966 }
967
968 #[test]
969 fn test_promote_skips_dismissed_root() {
970 let mgr = open_manager();
973 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
974 let sessions = sessions_mentioning(&root, 3);
975 let config = path_promotion::PromotionConfig::default();
976
977 let created = mgr.promote_frequent_paths(&sessions, &config);
979 assert_eq!(created.len(), 1, "expected exactly one promoted Mount");
980 let promoted_id = created[0];
981 assert_eq!(
982 mgr.get_mount(promoted_id).unwrap().source,
983 MountSource::AutoPromoted
984 );
985
986 mgr.remove_mount(promoted_id).expect("remove");
988 assert!(mgr.get_mount(promoted_id).is_none());
989
990 let recreated = mgr.promote_frequent_paths(&sessions, &config);
992 assert!(
993 recreated.is_empty(),
994 "dismissed root must not be re-promoted (got {:?})",
995 recreated
996 );
997 }
998
999 #[test]
1000 fn test_dismissal_only_for_auto_promoted() {
1001 let mgr = open_manager();
1004 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1005
1006 let m = mgr
1008 .create_mount(
1009 "manual-kernel".to_string(),
1010 vec![root.clone()],
1011 MountSource::Manual,
1012 )
1013 .unwrap();
1014 mgr.remove_mount(m.id).expect("remove manual");
1015
1016 assert!(
1018 mgr.dismissed_roots.read().is_empty(),
1019 "manual removal must not tombstone"
1020 );
1021
1022 let sessions = sessions_mentioning(&root, 3);
1024 let config = path_promotion::PromotionConfig::default();
1025 let created = mgr.promote_frequent_paths(&sessions, &config);
1026 assert_eq!(
1027 created.len(),
1028 1,
1029 "promotion must still work after manual removal"
1030 );
1031 }
1032}