Skip to main content

oxios_kernel/project/
detection.rs

1//! Project detection: find a Project matching user input.
2//!
3//! Simplified from Space's 3-layer detection. Phase 1 uses:
4//! 1. Direct name match
5//! 2. Path extraction + match
6//! 3. Tag/keyword match
7//!
8//! AI-based classification is deferred to Phase 2.
9
10use std::path::PathBuf;
11
12#[cfg(test)]
13use super::ProjectSource;
14use super::{Project, ProjectId};
15
16/// Result of a project lookup attempt.
17#[derive(Debug)]
18pub enum DetectionResult {
19    /// Found a matching project.
20    Found(ProjectId),
21    /// No project matched. Optionally, a path was detected.
22    NoMatch { detected_path: Option<PathBuf> },
23}
24
25/// Try to detect a project from a user message.
26///
27/// Detection layers:
28/// 1. Direct name match ("oxios" → project with name "oxios")
29/// 2. Path extraction ("/Volumes/MERCURY/PROJECTS/oxios" → project with matching path)
30/// 3. Tag match (keywords → project tags)
31pub fn detect_project(message: &str, projects: &[Project]) -> DetectionResult {
32    // Layer 1: Direct name match (case-insensitive)
33    let lower = message.to_lowercase();
34    for project in projects {
35        if lower.contains(&project.name.to_lowercase()) {
36            return DetectionResult::Found(project.id);
37        }
38    }
39
40    // Layer 2: Path extraction
41    if let Some(path) = extract_path(message) {
42        for project in projects {
43            if project
44                .paths
45                .iter()
46                .any(|p| path.starts_with(p) || p.starts_with(&path))
47            {
48                return DetectionResult::Found(project.id);
49            }
50        }
51        return DetectionResult::NoMatch {
52            detected_path: Some(path),
53        };
54    }
55
56    // Layer 3: Tag match
57    for project in projects {
58        for tag in &project.tags {
59            if lower.contains(&tag.to_lowercase()) {
60                return DetectionResult::Found(project.id);
61            }
62        }
63    }
64
65    DetectionResult::NoMatch {
66        detected_path: None,
67    }
68}
69
70/// Extract a filesystem path from a message string.
71///
72/// Looks for patterns like `/path/to/something`.
73pub fn extract_path(message: &str) -> Option<PathBuf> {
74    // Find substrings that look like absolute paths
75    for word in message.split_whitespace() {
76        let cleaned = word.trim_matches(|c: char| {
77            !c.is_alphanumeric() && c != '/' && c != '.' && c != '-' && c != '_'
78        });
79        if cleaned.starts_with('/') && cleaned.len() > 2 {
80            let path = PathBuf::from(cleaned);
81            // Check it looks like a real path (has at least one directory component)
82            if path.parent().is_some() {
83                return Some(path);
84            }
85        }
86    }
87
88    // Check for ~-prefixed paths
89    for word in message.split_whitespace() {
90        let cleaned = word.trim_matches(|c: char| {
91            !c.is_alphanumeric() && c != '/' && c != '.' && c != '-' && c != '_' && c != '~'
92        });
93        if cleaned.starts_with("~/")
94            && cleaned.len() > 2
95            && let Some(home) = std::env::var_os("HOME")
96        {
97            let expanded = cleaned.replacen("~", &home.to_string_lossy(), 1);
98            return Some(PathBuf::from(expanded));
99        }
100    }
101
102    None
103}
104
105/// Find a project by exact ID.
106pub fn find_by_id(projects: &[Project], id: ProjectId) -> Option<&Project> {
107    projects.iter().find(|p| p.id == id)
108}
109
110/// Find a project by name (case-insensitive).
111pub fn find_by_name<'a>(projects: &'a [Project], name: &str) -> Option<&'a Project> {
112    let lower = name.to_lowercase();
113    projects.iter().find(|p| p.name.to_lowercase() == lower)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn make_projects() -> Vec<Project> {
121        let mut oxios = Project::new("oxios", ProjectSource::Manual);
122        oxios
123            .paths
124            .push(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
125        oxios.add_tag("agent-os");
126
127        let mut oxi = Project::new("oxi", ProjectSource::Manual);
128        oxi.paths
129            .push(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxi"));
130        oxi.add_tag("sdk");
131
132        let mut blog = Project::new("my-blog", ProjectSource::Manual);
133        blog.add_tag("writing");
134        blog.add_tag("content");
135
136        vec![oxios, oxi, blog]
137    }
138
139    #[test]
140    fn test_detect_by_name() {
141        let projects = make_projects();
142        let result = detect_project("oxios 코드리뷰해줘", &projects);
143        assert!(matches!(result, DetectionResult::Found(id) if id == projects[0].id));
144    }
145
146    #[test]
147    fn test_detect_by_path() {
148        let projects = make_projects();
149        let result = detect_project("/Volumes/MERCURY/PROJECTS/oxios에서 작업", &projects);
150        assert!(matches!(result, DetectionResult::Found(id) if id == projects[0].id));
151    }
152
153    #[test]
154    fn test_detect_by_tag() {
155        let projects = make_projects();
156        let result = detect_project("writing 관련 도움이 필요해", &projects);
157        assert!(matches!(result, DetectionResult::Found(id) if id == projects[2].id));
158    }
159
160    #[test]
161    fn test_detect_no_match_with_path() {
162        let projects = make_projects();
163        let result = detect_project("/Volumes/MERCURY/PROJECTS/unknown 에서 작업", &projects);
164        assert!(matches!(
165            result,
166            DetectionResult::NoMatch {
167                detected_path: Some(_)
168            }
169        ));
170    }
171
172    #[test]
173    fn test_detect_no_match() {
174        let projects = make_projects();
175        let result = detect_project("오늘 점심 뭐 먹지?", &projects);
176        assert!(matches!(
177            result,
178            DetectionResult::NoMatch {
179                detected_path: None
180            }
181        ));
182    }
183
184    #[test]
185    fn test_extract_path() {
186        assert_eq!(
187            extract_path("/Volumes/MERCURY/PROJECTS/oxios"),
188            Some(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
189        );
190        assert_eq!(extract_path("no path here"), None);
191    }
192
193    #[test]
194    fn test_find_by_name() {
195        let projects = make_projects();
196        assert!(find_by_name(&projects, "oxios").is_some());
197        assert!(find_by_name(&projects, "Oxios").is_some()); // case-insensitive
198        assert!(find_by_name(&projects, "nonexistent").is_none());
199    }
200}