Skip to main content

cooklang_find/search/
mod.rs

1//! Recipe searching functionality.
2//!
3//! This module provides full-text search capabilities for recipe files,
4//! supporting both filename and content matching with relevance scoring.
5
6use 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/// Errors that can occur during recipe searching.
17#[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
32/// Searches for recipes in a directory tree that match a query string.
33///
34/// This function performs a comprehensive search through all .cook and .menu files
35/// in the specified directory and its subdirectories. The search algorithm:
36///
37/// 1. Searches for exact and partial filename matches (highest priority)
38/// 2. Searches for query terms within file contents
39/// 3. Scores and ranks results by relevance
40///
41/// # Arguments
42///
43/// * `base_dir` - The root directory to search in
44/// * `query` - The search query (can contain multiple terms separated by spaces)
45///
46/// # Returns
47///
48/// Returns a vector of `RecipeEntry` objects sorted by relevance score,
49/// with the most relevant recipes first.
50///
51/// # Examples
52///
53/// ```no_run
54/// use cooklang_find::search;
55/// use camino::Utf8Path;
56///
57/// // Search for recipes containing "chocolate"
58/// let results = search(Utf8Path::new("./recipes"), "chocolate")?;
59///
60/// // Search with multiple terms
61/// let results = search(Utf8Path::new("./recipes"), "chocolate cake")?;
62/// # Ok::<(), Box<dyn std::error::Error>>(())
63/// ```
64pub 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
78/// Search for .cook and .menu files in a directory and return scored results
79fn 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    // Search for both .cook and .menu files
85    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            // Score based on filename match (using full query)
102            let filename_score = score_filename_match(&result.path, &query_lower);
103            result.add_score(filename_score);
104
105            // Score based on content matches (using individual terms)
106            if let Ok(content_score) = score_content_matches(&result.path, &terms) {
107                result.add_score(content_score);
108            }
109
110            // Include result if it has any score
111            if result.score > 0.0 {
112                scored_results.push(result);
113            }
114        }
115    }
116
117    // Sort results by score
118    sort_results(&mut scored_results);
119    // Return only the paths in sorted order
120    Ok(scored_results.into_iter().map(|r| r.path).collect())
121}
122
123/// Calculate score for filename matches
124fn 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 // Highest score for exact match
131            } else if name.contains(&query) {
132                10.0 // High score for partial match
133            } else {
134                0.0
135            }
136        })
137        .unwrap_or(0.0)
138}
139
140/// Calculate score for content matches
141fn score_content_matches(path: &Utf8Path, terms: &[String]) -> io::Result<f64> {
142    let matches = count_matches(path, terms)?;
143    if matches > 0 {
144        // Base score for having any match
145        let mut score = 1.0;
146        // Additional score for multiple matches (capped)
147        score += f64::min(0.1 * matches as f64, 5.0);
148        Ok(score)
149    } else {
150        Ok(0.0)
151    }
152}
153
154/// Count how many times the terms appear in the file
155fn 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
171/// Sort search results by score in descending order
172fn sort_results(results: &mut [SearchResult]) {
173    results.sort_unstable_by(|a, b| {
174        // First sort by score (highest first)
175        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        // If scores are equal, sort by filename
185        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 some test recipes
209        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        // Create nested directories with recipes
232        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        // Should be sorted by score first (highest first), then by name
309        assert_eq!(results[0].path, Utf8PathBuf::from("c.cook")); // Highest score
310        assert_eq!(results[1].path, Utf8PathBuf::from("a.cook")); // Same score, alphabetically first
311        assert_eq!(results[2].path, Utf8PathBuf::from("b.cook")); // Same score, alphabetically second
312    }
313
314    #[test]
315    fn test_invalid_directory() {
316        let result = search(Utf8Path::new("/nonexistent/directory"), "query");
317        assert!(result.is_ok()); // Search should succeed but return empty results
318        assert!(result.unwrap().is_empty());
319    }
320}