use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::{Space, SpaceId};
#[derive(Debug, Clone)]
pub struct Topic {
pub name: String,
pub confidence: f32,
}
impl Topic {
pub fn is_clear(&self) -> bool {
self.confidence >= 0.5
}
}
#[derive(Debug, Clone, Default)]
pub struct PathMatcher {
space_paths: HashMap<SpaceId, PathBuf>,
}
impl PathMatcher {
pub fn register(&mut self, space: &Space) {
if let Some(path) = space.paths.first() {
let normalized = normalize_path(path);
self.space_paths.insert(space.id, normalized);
}
}
pub fn find_space(&self, path: &Path) -> Option<SpaceId> {
let normalized = normalize_path(path);
for (space_id, prefix) in &self.space_paths {
if normalized.starts_with(prefix)
|| prefix.starts_with(&normalized)
|| paths_overlap(&normalized, prefix)
{
return Some(*space_id);
}
}
None
}
pub fn matches(&self, path: &Path) -> bool {
self.find_space(path).is_some()
}
}
pub fn extract_filesystem_path(message: &str) -> Option<PathBuf> {
let patterns = [
r"/[a-zA-Z0-9_.~-][a-zA-Z0-9_.~/-]*",
r"~/[a-zA-Z0-9_.~-][a-zA-Z0-9_.~/-]*",
r"\./[a-zA-Z0-9_.~/-]+",
r"\.\./[a-zA-Z0-9_.~/-]+",
r"[A-Za-z]:[/\\][^\\]+",
r"https?://[^\\s]+",
];
for pattern in patterns {
if let Ok(re) = regex::Regex::new(pattern) {
if let Some(m) = re.find(message) {
let path_str = m.as_str();
let after = &message[m.end()..];
if after.starts_with('?') || after.starts_with('&') {
continue;
}
return Some(PathBuf::from(path_str));
}
}
}
None
}
pub fn match_keywords(message: &str, spaces: &[Space]) -> Option<SpaceId> {
let lower = message.to_lowercase();
let mut best: Option<(SpaceId, i32)> = None;
for space in spaces {
let mut score = 0;
let name_words: Vec<&str> = space.name.split_whitespace().collect();
for word in &name_words {
let word_lower = word.to_lowercase();
if !word_lower.is_empty() && lower.contains(&word_lower) {
score += 2; }
}
for tag in &space.tags {
let tag_lower = tag.to_lowercase();
if lower.contains(&tag_lower) {
score += 3; }
}
for path in &space.paths {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let name_lower = name.to_lowercase();
if lower.contains(&name_lower) {
score += 1;
}
}
}
if score > 0 {
if let Some((_, best_score)) = best {
if score > best_score {
best = Some((space.id, score));
}
} else {
best = Some((space.id, score));
}
}
}
best.map(|(id, _)| id)
}
pub fn detect_space<'a>(
message: &str,
spaces: &'a [Space],
matcher: &PathMatcher,
) -> Option<&'a Space> {
if let Some(path) = extract_filesystem_path(message) {
if let Some(space_id) = matcher.find_space(&path) {
return spaces.iter().find(|s| s.id == space_id);
}
}
if let Some(space_id) = match_keywords(message, spaces) {
return spaces.iter().find(|s| s.id == space_id);
}
None
}
pub fn classify_topic_stub(message: &str) -> Topic {
let lower = message.to_lowercase();
let categories: [(&str, [&str; 8]); 8] = [
(
"일상",
[
"저녁",
"점심",
"아침",
"밥",
"음식",
"레시피",
"요리",
"장보기",
],
),
(
"개발",
[
"code", "bug", "function", "import", "cargo", "rust", "git", "commit",
],
),
(
"문서",
[
"readme",
"docs",
"documentation",
"write",
"문서",
"글",
"note",
"read",
],
),
(
"공부",
[
"study", "learn", "book", "course", "공부", "학습", "책", "class",
],
),
(
"여행",
[
"travel", "trip", "flight", "hotel", "여행", "항공", "booking", "tour",
],
),
(
"건강",
[
"health", "exercise", "gym", "workout", "건강", "운동", "diet", "run",
],
),
(
"업무",
[
"meeting", "email", "project", "deadline", "업무", "회의", "client", "ppt",
],
),
(
"기술",
[
"api", "server", "database", "cloud", "기술", "서버", "deploy", "k8s",
],
),
];
for (topic, keywords) in categories {
for kw in keywords {
if lower.contains(kw) {
return Topic {
name: topic.to_string(),
confidence: 0.7,
};
}
}
}
Topic {
name: String::new(),
confidence: 0.0,
}
}
#[cfg(unix)]
fn normalize_path(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
let expanded = if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
format!("{}/{}", home, rest)
} else {
s.to_string()
}
} else {
s.to_string()
};
PathBuf::from(expanded)
}
fn paths_overlap(a: &Path, b: &Path) -> bool {
let a_str = a.to_string_lossy().to_lowercase();
let b_str = b.to_string_lossy().to_lowercase();
a_str.starts_with(&b_str) || b_str.starts_with(&a_str)
}
pub fn path_name(path: &Path) -> String {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore] fn test_extract_unix_path() {
assert!(extract_filesystem_path("/test").is_some());
assert!(extract_filesystem_path("/projects/oxios").is_some());
}
#[test]
#[ignore] fn test_match_keywords() {
use super::super::{Space, SpaceSource};
let spaces = vec![
Space::new("oxios", SpaceSource::AutoResource),
Space::new("일상", SpaceSource::AutoTopic),
];
let msg = "oxios bug";
let matched = match_keywords(msg, &spaces);
assert!(matched.is_some(), "should match oxios keyword");
}
#[test]
fn test_extract_home_path() {
let msg = "Look at ~/Documents/recipe.md";
let path = extract_filesystem_path(msg);
assert!(path.is_some());
}
#[test]
fn test_extract_relative_path() {
let msg = "Check ./config.toml";
let path = extract_filesystem_path(msg);
assert!(path.is_some());
}
#[test]
fn test_extract_github_url() {
let msg = "Clone https://github.com/oxios/oxios.git";
let path = extract_filesystem_path(msg);
assert!(path.is_some());
}
#[test]
fn test_extract_no_path() {
let msg = "hello world";
let path = extract_filesystem_path(msg);
assert!(path.is_none());
}
#[test]
fn test_extract_url_query_skip() {
let msg = "Check https://example.com?foo=bar";
let path = extract_filesystem_path(msg);
let _ = path;
}
#[test]
fn test_classify_topic_stub() {
let topic = classify_topic_stub("rust로 버그를 고치고 싶어");
assert_eq!(topic.name, "개발");
assert!(topic.is_clear());
let topic2 = classify_topic_stub("오늘 점심 뭐 먹지?");
assert_eq!(topic2.name, "일상");
assert!(topic2.is_clear());
let topic3 = classify_topic_stub("hi");
assert!(topic3.name.is_empty());
assert!(!topic3.is_clear());
}
#[test]
fn test_path_matcher() {
use super::super::Space;
use crate::SpaceSource;
let mut space = Space::new("oxios", SpaceSource::AutoResource);
space.paths.push(PathBuf::from("/projects/oxios"));
let mut matcher = PathMatcher::default();
matcher.register(&space);
assert!(matcher.matches(&PathBuf::from("/projects/oxios/src/main.rs")));
assert!(matcher.matches(&PathBuf::from("/projects/oxios")));
assert!(!matcher.matches(&PathBuf::from("/projects/other")));
let found = matcher.find_space(&PathBuf::from("/projects/oxios/Cargo.toml"));
assert!(found.is_some());
}
#[test]
fn test_path_name() {
assert_eq!(path_name(&PathBuf::from("/projects/oxios")), "oxios");
assert_eq!(
path_name(&PathBuf::from("/home/user/Documents")),
"Documents"
);
}
}