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("~/") && cleaned.len() > 2 {
94            if let Some(home) = std::env::var_os("HOME") {
95                let expanded = cleaned.replacen("~", &home.to_string_lossy(), 1);
96                return Some(PathBuf::from(expanded));
97            }
98        }
99    }
100
101    None
102}
103
104/// Find a project by exact ID.
105pub fn find_by_id(projects: &[Project], id: ProjectId) -> Option<&Project> {
106    projects.iter().find(|p| p.id == id)
107}
108
109/// Find a project by name (case-insensitive).
110pub fn find_by_name<'a>(projects: &'a [Project], name: &str) -> Option<&'a Project> {
111    let lower = name.to_lowercase();
112    projects.iter().find(|p| p.name.to_lowercase() == lower)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn make_projects() -> Vec<Project> {
120        let mut oxios = Project::new("oxios", ProjectSource::Manual);
121        oxios
122            .paths
123            .push(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
124        oxios.add_tag("agent-os");
125
126        let mut oxi = Project::new("oxi", ProjectSource::Manual);
127        oxi.paths
128            .push(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxi"));
129        oxi.add_tag("sdk");
130
131        let mut blog = Project::new("my-blog", ProjectSource::Manual);
132        blog.add_tag("writing");
133        blog.add_tag("content");
134
135        vec![oxios, oxi, blog]
136    }
137
138    #[test]
139    fn test_detect_by_name() {
140        let projects = make_projects();
141        let result = detect_project("oxios 코드리뷰해줘", &projects);
142        assert!(matches!(result, DetectionResult::Found(id) if id == projects[0].id));
143    }
144
145    #[test]
146    fn test_detect_by_path() {
147        let projects = make_projects();
148        let result = detect_project("/Volumes/MERCURY/PROJECTS/oxios에서 작업", &projects);
149        assert!(matches!(result, DetectionResult::Found(id) if id == projects[0].id));
150    }
151
152    #[test]
153    fn test_detect_by_tag() {
154        let projects = make_projects();
155        let result = detect_project("writing 관련 도움이 필요해", &projects);
156        assert!(matches!(result, DetectionResult::Found(id) if id == projects[2].id));
157    }
158
159    #[test]
160    fn test_detect_no_match_with_path() {
161        let projects = make_projects();
162        let result = detect_project("/Volumes/MERCURY/PROJECTS/unknown 에서 작업", &projects);
163        assert!(matches!(
164            result,
165            DetectionResult::NoMatch {
166                detected_path: Some(_)
167            }
168        ));
169    }
170
171    #[test]
172    fn test_detect_no_match() {
173        let projects = make_projects();
174        let result = detect_project("오늘 점심 뭐 먹지?", &projects);
175        assert!(matches!(
176            result,
177            DetectionResult::NoMatch {
178                detected_path: None
179            }
180        ));
181    }
182
183    #[test]
184    fn test_extract_path() {
185        assert_eq!(
186            extract_path("/Volumes/MERCURY/PROJECTS/oxios"),
187            Some(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
188        );
189        assert_eq!(extract_path("no path here"), None);
190    }
191
192    #[test]
193    fn test_find_by_name() {
194        let projects = make_projects();
195        assert!(find_by_name(&projects, "oxios").is_some());
196        assert!(find_by_name(&projects, "Oxios").is_some()); // case-insensitive
197        assert!(find_by_name(&projects, "nonexistent").is_none());
198    }
199}