cooklang_find/search/
mod.rs1use crate::model::{RecipeEntry, RecipeEntryError};
7use camino::{Utf8Path, Utf8PathBuf};
8use std::fs::File;
9use std::io::{self, BufRead, BufReader};
10use thiserror::Error;
11
12mod model;
13
14pub use model::SearchResult;
15
16#[derive(Error, Debug)]
18pub enum SearchError {
19 #[error("Failed to read directory: {0}")]
20 GlobError(#[from] glob::GlobError),
21
22 #[error("Failed to create glob pattern: {0}")]
23 PatternError(#[from] glob::PatternError),
24
25 #[error("Failed to process recipe: {0}")]
26 RecipeEntryError(#[from] RecipeEntryError),
27
28 #[error("Failed to read file: {0}")]
29 IoError(#[from] std::io::Error),
30}
31
32pub fn search(base_dir: &Utf8Path, query: &str) -> Result<Vec<RecipeEntry>, SearchError> {
65 let paths = search_paths(base_dir, query)?;
66 let mut recipes = Vec::new();
67
68 for path in paths {
69 match RecipeEntry::from_path(path) {
70 Ok(recipe) => recipes.push(recipe),
71 Err(e) => return Err(SearchError::RecipeEntryError(e)),
72 }
73 }
74
75 Ok(recipes)
76}
77
78fn search_paths(base_dir: &Utf8Path, query: &str) -> Result<Vec<Utf8PathBuf>, SearchError> {
80 let mut scored_results = vec![];
81 let query_lower = query.to_lowercase();
82 let terms: Vec<String> = query_lower.split_whitespace().map(String::from).collect();
83
84 let patterns = vec![
86 base_dir.join("**/*.cook").to_string(),
87 base_dir.join("**/*.menu").to_string(),
88 ];
89
90 for pattern in patterns {
91 for entry in glob::glob(&pattern)? {
92 let path = entry?;
93 let path = Utf8PathBuf::from_path_buf(path).map_err(|_| {
94 SearchError::IoError(std::io::Error::new(
95 std::io::ErrorKind::InvalidData,
96 "Path contains invalid UTF-8",
97 ))
98 })?;
99 let mut result = SearchResult::new(path);
100
101 let filename_score = score_filename_match(&result.path, &query_lower);
103 result.add_score(filename_score);
104
105 if let Ok(content_score) = score_content_matches(&result.path, &terms) {
107 result.add_score(content_score);
108 }
109
110 if result.score > 0.0 {
112 scored_results.push(result);
113 }
114 }
115 }
116
117 sort_results(&mut scored_results);
119 Ok(scored_results.into_iter().map(|r| r.path).collect())
121}
122
123fn score_filename_match(path: &Utf8Path, query: &str) -> f64 {
125 let query = query.to_lowercase();
126 path.file_stem()
127 .map(|name| {
128 let name = name.to_lowercase();
129 if name == query {
130 20.0 } else if name.contains(&query) {
132 10.0 } else {
134 0.0
135 }
136 })
137 .unwrap_or(0.0)
138}
139
140fn score_content_matches(path: &Utf8Path, terms: &[String]) -> io::Result<f64> {
142 let matches = count_matches(path, terms)?;
143 if matches > 0 {
144 let mut score = 1.0;
146 score += f64::min(0.1 * matches as f64, 5.0);
148 Ok(score)
149 } else {
150 Ok(0.0)
151 }
152}
153
154fn count_matches(path: &Utf8Path, terms: &[String]) -> io::Result<usize> {
156 let file = File::open(path)?;
157 let reader = BufReader::new(file);
158 let mut total_matches = 0;
159
160 for line in reader.lines() {
161 let line = line?.to_lowercase();
162 total_matches += terms
163 .iter()
164 .map(|term| line.matches(term).count())
165 .sum::<usize>();
166 }
167
168 Ok(total_matches)
169}
170
171fn sort_results(results: &mut [SearchResult]) {
173 results.sort_unstable_by(|a, b| {
174 let score_cmp = b
176 .score
177 .partial_cmp(&a.score)
178 .unwrap_or(std::cmp::Ordering::Equal);
179
180 if score_cmp != std::cmp::Ordering::Equal {
181 return score_cmp;
182 }
183
184 let a_name = a.path.file_stem().unwrap_or("").to_lowercase();
186 let b_name = b.path.file_stem().unwrap_or("").to_lowercase();
187
188 a_name.cmp(&b_name)
189 });
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::fs;
196 use tempfile::TempDir;
197
198 fn create_test_recipe(dir: &Utf8Path, name: &str, content: &str) -> Utf8PathBuf {
199 let path = dir.join(format!("{name}.cook"));
200 fs::write(&path, content).unwrap();
201 path
202 }
203
204 fn setup_test_recipes() -> TempDir {
205 let temp_dir = TempDir::new().unwrap();
206 let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
207
208 create_test_recipe(
210 &temp_dir_path,
211 "pancakes",
212 r#">> servings: 4
213
214 Make delicious pancakes with @maple syrup{}"#,
215 );
216 create_test_recipe(
217 &temp_dir_path,
218 "waffles",
219 r#">> servings: 2
220
221 Crispy @waffles with @syrup"#,
222 );
223 create_test_recipe(
224 &temp_dir_path,
225 "french_toast",
226 r#">> servings: 3
227
228 Classic french toast recipe"#,
229 );
230
231 let breakfast_dir = temp_dir_path.join("breakfast");
233 fs::create_dir_all(&breakfast_dir).unwrap();
234 create_test_recipe(
235 &breakfast_dir,
236 "omelette",
237 r#">> servings: 1
238
239 @Cheese and @mushroom omelette"#,
240 );
241
242 temp_dir
243 }
244
245 #[test]
246 fn test_search_exact_match() {
247 let temp_dir = setup_test_recipes();
248 let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
249 let results = search(&temp_dir_path, "pancakes").unwrap();
250
251 assert_eq!(results.len(), 1);
252 assert_eq!(results[0].name().as_ref().unwrap(), "pancakes");
253 }
254
255 #[test]
256 fn test_search_partial_match() {
257 let temp_dir = setup_test_recipes();
258 let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
259 let results = search(&temp_dir_path, "pancake").unwrap();
260
261 assert_eq!(results.len(), 1);
262 assert_eq!(results[0].name().as_ref().unwrap(), "pancakes");
263 }
264
265 #[test]
266 fn test_search_content_match() {
267 let temp_dir = setup_test_recipes();
268 let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
269 let results = search(&temp_dir_path, "syrup").unwrap();
270
271 assert_eq!(results.len(), 2);
272 let names: Vec<String> = results
273 .iter()
274 .map(|r| r.name().as_ref().unwrap().clone())
275 .collect();
276 assert!(names.contains(&"pancakes".to_string()));
277 assert!(names.contains(&"waffles".to_string()));
278 }
279
280 #[test]
281 fn test_search_no_matches() {
282 let temp_dir = setup_test_recipes();
283 let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()).unwrap();
284 let results = search(&temp_dir_path, "nonexistent").unwrap();
285
286 assert!(results.is_empty());
287 }
288
289 #[test]
290 fn test_search_result_sorting() {
291 let mut results = vec![
292 SearchResult {
293 path: Utf8PathBuf::from("b.cook"),
294 score: 1.0,
295 },
296 SearchResult {
297 path: Utf8PathBuf::from("a.cook"),
298 score: 1.0,
299 },
300 SearchResult {
301 path: Utf8PathBuf::from("c.cook"),
302 score: 2.0,
303 },
304 ];
305
306 sort_results(&mut results);
307
308 assert_eq!(results[0].path, Utf8PathBuf::from("c.cook")); assert_eq!(results[1].path, Utf8PathBuf::from("a.cook")); assert_eq!(results[2].path, Utf8PathBuf::from("b.cook")); }
313
314 #[test]
315 fn test_invalid_directory() {
316 let result = search(Utf8Path::new("/nonexistent/directory"), "query");
317 assert!(result.is_ok()); assert!(result.unwrap().is_empty());
319 }
320}