rust_filesearch/px/
search.rs

1//! Fuzzy search for projects
2//!
3//! Provides fuzzy matching on project names and paths,
4//! combined with frecency scoring for intelligent ranking.
5
6use crate::px::project::Project;
7use fuzzy_matcher::skim::SkimMatcherV2;
8use fuzzy_matcher::FuzzyMatcher;
9
10/// Project fuzzy searcher with integrated frecency ranking
11pub struct ProjectSearcher {
12    matcher: SkimMatcherV2,
13}
14
15impl ProjectSearcher {
16    /// Create a new project searcher with default configuration
17    pub fn new() -> Self {
18        Self {
19            matcher: SkimMatcherV2::default(),
20        }
21    }
22
23    /// Search projects by fuzzy matching name/path
24    ///
25    /// Returns projects sorted by combined fuzzy match score + frecency.
26    ///
27    /// The ranking formula is:
28    /// - Fuzzy match score (0-100): how well the query matches the project
29    /// - Frecency score (0-∞): how frequently and recently the project was accessed
30    /// - Combined: fuzzy_score * 0.7 + frecency_score * 0.3
31    ///
32    /// This prioritizes good matches while still surfacing frequently-used projects.
33    pub fn search<'a>(&self, projects: &'a [Project], query: &str) -> Vec<&'a Project> {
34        if query.trim().is_empty() {
35            // No query - return all sorted by frecency
36            return self.sort_by_frecency(projects);
37        }
38
39        let mut matches: Vec<(&Project, i64)> = projects
40            .iter()
41            .filter_map(|project| {
42                // Try matching against both name and path
43                let name_score = self.matcher.fuzzy_match(&project.name, query).unwrap_or(0);
44                let path_score = self
45                    .matcher
46                    .fuzzy_match(&project.path.to_string_lossy(), query)
47                    .unwrap_or(0);
48
49                // Take the better of the two scores
50                let fuzzy_score = name_score.max(path_score);
51
52                if fuzzy_score > 0 {
53                    // Combine fuzzy score with frecency
54                    // Fuzzy scores are typically 0-100, frecency can be 0-150+
55                    // Weight fuzzy matching more heavily (70%) but keep frecency influence (30%)
56                    let combined_score =
57                        (fuzzy_score as f64 * 0.7) + (project.frecency_score * 0.3);
58
59                    Some((project, combined_score as i64))
60                } else {
61                    None
62                }
63            })
64            .collect();
65
66        // Sort by combined score (highest first)
67        matches.sort_by(|a, b| b.1.cmp(&a.1));
68
69        matches.into_iter().map(|(project, _)| project).collect()
70    }
71
72    /// Search for exact match (case-insensitive contains)
73    ///
74    /// Useful for when the user knows exactly what they want
75    pub fn exact_search<'a>(&self, projects: &'a [Project], query: &str) -> Vec<&'a Project> {
76        let query_lower = query.to_lowercase();
77        let mut matches: Vec<&Project> = projects
78            .iter()
79            .filter(|p| {
80                p.name.to_lowercase().contains(&query_lower)
81                    || p.path
82                        .to_string_lossy()
83                        .to_lowercase()
84                        .contains(&query_lower)
85            })
86            .collect();
87
88        // Sort by frecency
89        matches.sort_by(|a, b| {
90            b.frecency_score
91                .partial_cmp(&a.frecency_score)
92                .unwrap_or(std::cmp::Ordering::Equal)
93        });
94
95        matches
96    }
97
98    /// Helper to sort projects by frecency only
99    fn sort_by_frecency<'a>(&self, projects: &'a [Project]) -> Vec<&'a Project> {
100        let mut sorted: Vec<&Project> = projects.iter().collect();
101        sorted.sort_by(|a, b| {
102            b.frecency_score
103                .partial_cmp(&a.frecency_score)
104                .unwrap_or(std::cmp::Ordering::Equal)
105        });
106        sorted
107    }
108}
109
110impl Default for ProjectSearcher {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::px::project::{Project, ProjectGitStatus};
120    use chrono::Utc;
121    use std::path::PathBuf;
122
123    fn create_test_project(name: &str, frecency: f64) -> Project {
124        Project {
125            path: PathBuf::from(format!("/test/{}", name)),
126            name: name.to_string(),
127            last_modified: Utc::now(),
128            git_status: ProjectGitStatus {
129                current_branch: "main".to_string(),
130                has_uncommitted: false,
131                ahead: 0,
132                behind: 0,
133                last_commit: None,
134            },
135            frecency_score: frecency,
136            last_accessed: None,
137            access_count: 0,
138            readme_excerpt: None,
139        }
140    }
141
142    #[test]
143    fn test_empty_query() {
144        let searcher = ProjectSearcher::new();
145        let projects = vec![
146            create_test_project("low-frecency", 10.0),
147            create_test_project("high-frecency", 100.0),
148        ];
149
150        let results = searcher.search(&projects, "");
151        assert_eq!(results.len(), 2);
152        // Should be sorted by frecency
153        assert_eq!(results[0].name, "high-frecency");
154    }
155
156    #[test]
157    fn test_fuzzy_match() {
158        let searcher = ProjectSearcher::new();
159        let projects = vec![
160            create_test_project("rust-filesearch", 50.0),
161            create_test_project("python-script", 50.0),
162            create_test_project("rust-analyzer", 50.0),
163        ];
164
165        let results = searcher.search(&projects, "rust");
166        // Should match "rust-filesearch" and "rust-analyzer"
167        assert!(results.len() >= 2);
168        assert!(results.iter().any(|p| p.name.contains("rust")));
169    }
170
171    #[test]
172    fn test_frecency_influences_ranking() {
173        let searcher = ProjectSearcher::new();
174        let projects = vec![
175            create_test_project("rust-project", 10.0),   // Low frecency
176            create_test_project("rust-awesome", 100.0),  // High frecency
177        ];
178
179        let results = searcher.search(&projects, "rust");
180        assert_eq!(results.len(), 2);
181        // Both match "rust", but high-frecency should rank higher
182        assert_eq!(results[0].name, "rust-awesome");
183    }
184
185    #[test]
186    fn test_exact_search() {
187        let searcher = ProjectSearcher::new();
188        let projects = vec![
189            create_test_project("whatsgood-homepage", 50.0),
190            create_test_project("rust-filesearch", 50.0),
191        ];
192
193        let results = searcher.exact_search(&projects, "whatsgood");
194        assert_eq!(results.len(), 1);
195        assert_eq!(results[0].name, "whatsgood-homepage");
196    }
197}
198