Skip to main content

oxios_kernel/mount/
detection.rs

1//! Mount detection: find a Mount matching user input (RFC-025).
2//!
3//! Replaces RFC-011's tag-based detection layer 3 with `auto_meta` keyword
4//! matching. Layers:
5//! 1. Direct name match ("oxios" → Mount named "oxios")
6//! 2. Path extraction + prefix match (most specific wins)
7//! 3. `auto_meta` keyword match (languages / stack / summary keywords)
8
9use std::path::PathBuf;
10
11use super::{Mount, MountId};
12
13/// Check if `haystack` contains `needle` as a whole word (token),
14/// case-insensitive. A character is considered part of the same word only if
15/// it is an ASCII alphanumeric or `_`. This means:
16///   - Latin substring false-positives are prevented ("go" does not match
17///     "going", "rust" does not match "trust") — the adjacent ASCII letter is
18///     a word continuation, not a boundary.
19///   - A script transition is a boundary, so Korean/Japanese postpositions
20///     written without spaces ("oxios에서", "oxios로") still let the Latin
21///     name match. This codebase is Korean-user-facing, so this is the
22///     desired behaviour.
23///
24/// Unicode-safe: boundary checks examine actual characters (not raw bytes),
25/// and the search cursor is advanced one character at a time so multi-byte
26/// (e.g. CJK) haystacks never slice on a non-char-boundary.
27fn contains_word(haystack: &str, needle: &str) -> bool {
28    if needle.is_empty() {
29        return false;
30    }
31    let h: String = haystack.to_lowercase();
32    let n: String = needle.to_lowercase();
33
34    /// `true` if `c` continues the current word (ASCII alphanumeric or `_`).
35    /// Everything else — punctuation, whitespace, or a non-ASCII script char
36    /// — acts as a word boundary.
37    fn continues_word(c: char) -> bool {
38        c.is_ascii_alphanumeric() || c == '_'
39    }
40
41    let mut start = 0;
42    while start < h.len() {
43        let Some(rel) = h[start..].find(&n) else {
44            break;
45        };
46        let abs_pos = start + rel;
47        let end_pos = abs_pos + n.len();
48
49        // Character immediately before the match (if any) must be a boundary.
50        let before_ok = abs_pos == 0
51            || h[..abs_pos]
52                .chars()
53                .next_back()
54                .is_none_or(|c| !continues_word(c));
55        // Character immediately after the match (if any) must be a boundary.
56        let after_ok = end_pos >= h.len()
57            || h[end_pos..]
58                .chars()
59                .next()
60                .is_none_or(|c| !continues_word(c));
61
62        if before_ok && after_ok {
63            return true;
64        }
65        // Advance past this occurrence by exactly one character so that
66        // overlapping matches are still considered and `start` remains on a
67        // valid char boundary (required for `h[start..]` slicing).
68        start = match h[abs_pos..].char_indices().nth(1) {
69            Some((i, _)) => abs_pos + i,
70            None => h.len(),
71        };
72    }
73    false
74}
75
76/// Result of a Mount lookup attempt.
77#[derive(Debug, Clone)]
78pub enum DetectionResult {
79    /// Found a matching Mount.
80    Found(MountId),
81    /// No Mount matched. Optionally, a path was detected.
82    NoMatch { detected_path: Option<PathBuf> },
83}
84
85/// Try to detect a Mount from a user message.
86///
87/// Detection considers **only Mounts**, never Projects (RFC-025: Projects
88/// always carry user-written instructions and shouldn't be guessed).
89pub fn detect_mounts(message: &str, mounts: &[Mount]) -> DetectionResult {
90    let lower = message.to_lowercase();
91
92    // Layer 1: Direct name match (case-insensitive, whole-word match).
93    // Match the longest name first so "oxios-dev" wins over "oxios".
94    // Names shorter than 3 chars are too ambiguous for Layer 1 ("go", "ai",
95    // "os", "pi") — they are skipped here (mirrors Layer 3's `kw.len() >= 3`).
96    let mut by_name: Vec<&Mount> = mounts
97        .iter()
98        .filter(|m| m.name.len() >= 3 && contains_word(&lower, &m.name))
99        .collect();
100    by_name.sort_by_key(|m| std::cmp::Reverse(m.name.len()));
101    if let Some(m) = by_name.first() {
102        return DetectionResult::Found(m.id);
103    }
104
105    // Layer 2: Path extraction + prefix match (most specific path wins).
106    if let Some(path) = extract_path(message) {
107        let matching: Vec<&Mount> = mounts
108            .iter()
109            .filter(|m| {
110                m.paths
111                    .iter()
112                    .any(|p| path.starts_with(p) || p.starts_with(&path))
113            })
114            .collect();
115        if matching.len() == 1 {
116            return DetectionResult::Found(matching[0].id);
117        }
118        if matching.len() > 1 {
119            // Prefer the most specific path (longest matching prefix).
120            // Audit F-4: matching is non-empty (guarded by len()>1), but if
121            // the max_by_key closure somehow yields no elements (e.g. all
122            // .paths() return None), avoid panicking — fall back to the
123            // first matching entry instead of aborting the daemon.
124            let best = matching
125                .into_iter()
126                .max_by_key(|m| {
127                    m.paths
128                        .iter()
129                        .filter(|p| path.starts_with(p))
130                        .map(|p| p.components().count())
131                        .max()
132                        .unwrap_or(0)
133                })
134                .or_else(|| {
135                    tracing::warn!("mount detection: max_by_key yielded None; using first match");
136                    None
137                });
138            return match best {
139                Some(b) => DetectionResult::Found(b.id),
140                None => DetectionResult::NoMatch {
141                    detected_path: Some(path),
142                },
143            };
144        }
145        return DetectionResult::NoMatch {
146            detected_path: Some(path),
147        };
148    }
149
150    // Layer 3: auto_meta keyword match (languages / stack / summary).
151    //
152    // Iterate in deterministic order: most recently active first, then by
153    // name. The caller-supplied `mounts` slice order is not guaranteed stable
154    // (MountManager builds it from a HashMap), so without sorting the winner
155    // among mounts sharing a keyword would be non-deterministic.
156    let mut sorted: Vec<&Mount> = mounts.iter().collect();
157    sorted.sort_by(|a, b| {
158        b.last_active_at
159            .cmp(&a.last_active_at)
160            .then_with(|| a.name.cmp(&b.name))
161    });
162    for mount in &sorted {
163        // Split the summary into individual words so that a multi-word summary
164        // (e.g. "Agent OS in Rust") does not have to match verbatim.
165        let keywords: Vec<String> = mount
166            .auto_meta
167            .languages
168            .iter()
169            .chain(mount.auto_meta.stack.iter())
170            .cloned()
171            .chain(mount.auto_meta.summary.split_whitespace().map(String::from))
172            .collect();
173        for kw in keywords {
174            let kw = kw.trim().to_lowercase();
175            if kw.len() >= 3 && contains_word(&lower, &kw) {
176                return DetectionResult::Found(mount.id);
177            }
178        }
179    }
180
181    DetectionResult::NoMatch {
182        detected_path: None,
183    }
184}
185
186/// Extract a filesystem path from a message string.
187///
188/// Looks for patterns like `/path/to/something` or `~/path`.
189pub fn extract_path(message: &str) -> Option<PathBuf> {
190    // Absolute paths
191    for word in message.split_whitespace() {
192        let cleaned = word.trim_matches(|c: char| {
193            !c.is_alphanumeric() && c != '/' && c != '.' && c != '-' && c != '_'
194        });
195        if cleaned.starts_with('/') && cleaned.len() > 2 {
196            let path = PathBuf::from(cleaned);
197            if path.parent().is_some() {
198                return Some(path);
199            }
200        }
201    }
202    // ~-prefixed paths
203    for word in message.split_whitespace() {
204        let cleaned = word.trim_matches(|c: char| {
205            !c.is_alphanumeric() && c != '/' && c != '.' && c != '-' && c != '_' && c != '~'
206        });
207        if cleaned.starts_with("~/")
208            && cleaned.len() > 2
209            && let Some(home) = std::env::var_os("HOME")
210        {
211            let expanded = cleaned.replacen("~", &home.to_string_lossy(), 1);
212            return Some(PathBuf::from(expanded));
213        }
214    }
215    None
216}
217
218/// Find a Mount by exact ID.
219pub fn find_by_id(mounts: &[Mount], id: MountId) -> Option<&Mount> {
220    mounts.iter().find(|m| m.id == id)
221}
222
223/// Find a Mount by name (case-insensitive).
224pub fn find_by_name<'a>(mounts: &'a [Mount], name: &str) -> Option<&'a Mount> {
225    let lower = name.to_lowercase();
226    mounts.iter().find(|m| m.name.to_lowercase() == lower)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn make_mounts() -> Vec<Mount> {
234        let mut oxios =
235            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
236        oxios.auto_meta.languages = vec!["rust".to_string()];
237        oxios.auto_meta.stack = vec!["tokio".to_string()];
238
239        let mut oxi =
240            Mount::from_name_and_path("oxi", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxi"));
241        oxi.auto_meta.languages = vec!["rust".to_string()];
242        oxi.auto_meta.summary = "SDK for Oxios agents".to_string();
243
244        let mut blog = Mount::from_name_and_path("my-blog", PathBuf::from("/Users/me/blog"));
245        blog.auto_meta.languages = vec!["typescript".to_string()];
246        blog.auto_meta.stack = vec!["nextjs".to_string()];
247
248        vec![oxios, oxi, blog]
249    }
250
251    #[test]
252    fn test_detect_by_name() {
253        let mounts = make_mounts();
254        let result = detect_mounts("oxios 코드리뷰해줘", &mounts);
255        assert!(matches!(result, DetectionResult::Found(id) if id == mounts[0].id));
256    }
257
258    #[test]
259    fn test_detect_longest_name_wins() {
260        // "oxios-dev" and "oxios" both present; longest name should win.
261        let mut mounts = make_mounts();
262        mounts.push(Mount::from_name_and_path(
263            "oxios-dev",
264            PathBuf::from("/dev"),
265        ));
266        let result = detect_mounts("working on oxios-dev now", &mounts);
267        match result {
268            DetectionResult::Found(id) => {
269                let m = mounts.iter().find(|m| m.id == id).unwrap();
270                assert_eq!(m.name, "oxios-dev");
271            }
272            other => panic!("expected Found, got {other:?}"),
273        }
274    }
275
276    #[test]
277    fn test_detect_by_path() {
278        let mounts = make_mounts();
279        let result = detect_mounts("/Volumes/MERCURY/PROJECTS/oxios에서 작업", &mounts);
280        assert!(matches!(result, DetectionResult::Found(id) if id == mounts[0].id));
281    }
282
283    #[test]
284    fn test_detect_by_meta_keyword() {
285        let mounts = make_mounts();
286        // "nextjs" is a stack keyword on my-blog.
287        let result = detect_mounts("nextjs 관련 도움이 필요해", &mounts);
288        match result {
289            DetectionResult::Found(id) => {
290                let m = mounts.iter().find(|m| m.id == id).unwrap();
291                assert_eq!(m.name, "my-blog");
292            }
293            other => panic!("expected Found (my-blog), got {other:?}"),
294        }
295    }
296
297    #[test]
298    fn test_detect_no_match_with_path() {
299        let mounts = make_mounts();
300        let result = detect_mounts("/Volumes/MERCURY/PROJECTS/unknown 에서 작업", &mounts);
301        assert!(matches!(
302            result,
303            DetectionResult::NoMatch {
304                detected_path: Some(_)
305            }
306        ));
307    }
308
309    #[test]
310    fn test_detect_no_match() {
311        let mounts = make_mounts();
312        let result = detect_mounts("오늘 점심 뭐 먹지?", &mounts);
313        assert!(matches!(
314            result,
315            DetectionResult::NoMatch {
316                detected_path: None
317            }
318        ));
319    }
320
321    #[test]
322    fn test_extract_path() {
323        assert_eq!(
324            extract_path("/Volumes/MERCURY/PROJECTS/oxios"),
325            Some(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
326        );
327        assert_eq!(extract_path("no path here"), None);
328    }
329
330    #[test]
331    fn test_find_by_name() {
332        let mounts = make_mounts();
333        assert!(find_by_name(&mounts, "oxios").is_some());
334        assert!(find_by_name(&mounts, "Oxios").is_some());
335        assert!(find_by_name(&mounts, "nonexistent").is_none());
336    }
337
338    // --- RFC-025 detection hardening (issues M1/M2/M3) ---
339
340    #[test]
341    fn test_short_name_not_substring_matched() {
342        // A mount named "go" (len < 3) must NOT match messages where it only
343        // appears as a substring of a larger word ("going", "again").
344        let mounts = vec![Mount::from_name_and_path("go", PathBuf::from("/p/go"))];
345        let result = detect_mounts("i am going there again", &mounts);
346        assert!(
347            matches!(result, DetectionResult::NoMatch { .. }),
348            "short name 'go' must not substring-match 'going'/'again'"
349        );
350    }
351
352    #[test]
353    fn test_name_word_boundary_no_substring() {
354        // A 3+ char name must not match as a substring of a larger token.
355        // "ring" (len 4) should not match "during", "string", or "brings".
356        let mounts = vec![Mount::from_name_and_path("ring", PathBuf::from("/p/ring"))];
357        let result = detect_mounts("during the string test it brings results", &mounts);
358        assert!(
359            matches!(result, DetectionResult::NoMatch { .. }),
360            "name 'ring' must not substring-match 'during'/'string'/'brings'"
361        );
362        // But it SHOULD match as a standalone word.
363        let result = detect_mounts("let's talk about ring design", &mounts);
364        assert!(matches!(result, DetectionResult::Found(_)));
365    }
366
367    #[test]
368    fn test_keyword_word_boundary_no_substring() {
369        // Layer 3 keyword "rust" must not substring-match "trust".
370        let mounts = make_mounts();
371        let result = detect_mounts("i really trust you on this", &mounts);
372        assert!(
373            matches!(result, DetectionResult::NoMatch { .. }),
374            "keyword 'rust' must not substring-match 'trust'"
375        );
376    }
377
378    #[test]
379    fn test_word_boundary_with_cjk_after() {
380        // A name followed (after a space) by CJK must still match as a word.
381        let mounts = make_mounts();
382        let result = detect_mounts("oxios 코드리뷰", &mounts);
383        assert!(matches!(result, DetectionResult::Found(id) if id == mounts[0].id));
384    }
385
386    #[test]
387    fn test_layer3_most_recent_active_wins() {
388        // Two mounts share the "rust" keyword. The more recently active one
389        // must win regardless of the order they appear in the input slice
390        // (deterministic tie-break on shared keywords — issue M3).
391        let mut oxios = Mount::from_name_and_path("oxios", PathBuf::from("/p/oxios"));
392        oxios.auto_meta.languages = vec!["rust".to_string()];
393
394        let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/p/oxi"));
395        oxi.auto_meta.languages = vec!["rust".to_string()];
396        // Make `oxi` more recently active than `oxios`.
397        oxi.last_active_at = oxios.last_active_at + chrono::Duration::seconds(60);
398
399        // Deliberately pass them in least-recent-first order.
400        let mounts = vec![oxios, oxi];
401        let recent_id = mounts[1].id;
402        let result = detect_mounts("help with a rust project", &mounts);
403        match result {
404            DetectionResult::Found(id) => assert_eq!(
405                id, recent_id,
406                "most recently active mount should win on shared keyword"
407            ),
408            other => panic!("expected Found, got {other:?}"),
409        }
410    }
411}