oxios_kernel/mount/
detection.rs1use std::path::PathBuf;
10
11use super::{Mount, MountId};
12
13fn 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 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 let before_ok = abs_pos == 0
51 || h[..abs_pos]
52 .chars()
53 .next_back()
54 .is_none_or(|c| !continues_word(c));
55 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 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#[derive(Debug, Clone)]
78pub enum DetectionResult {
79 Found(MountId),
81 NoMatch { detected_path: Option<PathBuf> },
83}
84
85pub fn detect_mounts(message: &str, mounts: &[Mount]) -> DetectionResult {
90 let lower = message.to_lowercase();
91
92 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 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 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 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 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
186pub fn extract_path(message: &str) -> Option<PathBuf> {
190 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 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
218pub fn find_by_id(mounts: &[Mount], id: MountId) -> Option<&Mount> {
220 mounts.iter().find(|m| m.id == id)
221}
222
223pub 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 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 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 #[test]
341 fn test_short_name_not_substring_matched() {
342 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 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 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 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 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 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 oxi.last_active_at = oxios.last_active_at + chrono::Duration::seconds(60);
398
399 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}