Skip to main content

cookcli_core/
search.rs

1//! Full-text recipe search.
2//!
3//! [`search`] walks the `.cook` and `.menu` files under a root, scores each
4//! against a query, and returns the matches best first.
5//!
6//! # What counts as a match
7//!
8//! **Every term must match.** A recipe is a hit when each whitespace-separated
9//! term of the query appears somewhere in its text or in its file name, matched
10//! case-insensitively as a substring. Adding a term narrows the results.
11//!
12//! Ranking is `cooklang-find`'s, and is a separate question from matching:
13//!
14//! - The file name is matched against the **whole query**, spaces included.
15//!   An exact stem match ranks highest, a substring match next.
16//! - The contents are matched against each term, and every occurrence adds a
17//!   little.
18//!
19//! # Why matching is not simply `cooklang-find`'s scoring
20//!
21//! `cooklang-find` keeps anything scoring above zero, and it scores a file for
22//! *any* term it contains — so a multi-term query was a union there, and
23//! `cook search chicken rice` returned recipes with no rice in them. That
24//! contradicted CookCLI's own help text, which had always promised AND
25//! (<https://github.com/cooklang/cookcli/issues/425>), and it made a query of
26//! common words match most of a collection.
27//!
28//! The union is still what the library returns; [`search`] intersects it here.
29//! A single-term query is unaffected, since AND over one term is that term.
30
31use crate::{find, Context, CoreError, Outcome};
32use camino::{Utf8Path, Utf8PathBuf};
33use serde::Serialize;
34
35/// A search to run.
36#[derive(Debug, Clone)]
37pub struct SearchRequest {
38    /// The query, as a single string.
39    ///
40    /// Whitespace splits it into terms for the content match, while the whole
41    /// string is what the file name match looks for — so the spacing is part of
42    /// the query rather than a separator this crate is free to normalise.
43    ///
44    /// Callers holding a list of words, such as a command line's arguments,
45    /// join them with a space. That loses nothing: `["olive", "oil"]` and
46    /// `["olive oil"]` are the same query, and no scoring rule below could tell
47    /// them apart if this field kept them separate.
48    pub query: String,
49    /// Directory to search. Defaults to the context base path.
50    ///
51    /// Every hit's [`SearchHit::relative_path`] is expressed against this, so
52    /// it doubles as the root that results are reported relative to.
53    pub base_dir: Option<Utf8PathBuf>,
54}
55
56/// One matching recipe.
57#[derive(Debug, Clone, Serialize)]
58#[non_exhaustive]
59pub struct SearchHit {
60    /// Where the recipe sits under the search root.
61    ///
62    /// This is [`SearchHit::path`] with the root stripped off, and is what
63    /// `cook search` prints. A path that does not begin with the root is kept
64    /// whole rather than mangled; see [`path`](SearchHit::path) for when that
65    /// happens.
66    pub relative_path: Utf8PathBuf,
67    /// The path the search found the recipe at, ready to open.
68    ///
69    /// Absolute when the search root is absolute, which is the case worth
70    /// designing for. When the root is relative the path is too, and is
71    /// interpreted against the *process* working directory — for an in-process
72    /// editor integration that is the editor's, not the project's. This mirrors
73    /// the caveat on [`Context::base_path`] and is the reason a relative root
74    /// can leave `relative_path` and `path` equal.
75    pub path: Utf8PathBuf,
76    /// The recipe's title, falling back to its file stem when it has none.
77    ///
78    /// Read from front matter that the search has already parsed, so it costs
79    /// nothing to carry. `None` only if the entry has neither, which a file
80    /// found on disk cannot manage.
81    pub name: Option<String>,
82}
83
84/// Search the recipes under `req`'s root, best match first.
85///
86/// The root is [`SearchRequest::base_dir`], or [`Context::base_path`] when that
87/// is unset. Nothing else on the context is consulted. A root that does not
88/// exist is not an error: there is simply nothing under it to match.
89///
90/// Every term must match; see the [module documentation](self).
91///
92/// # Errors
93///
94/// - [`CoreError::Search`] if the root cannot be searched at all, which in
95///   practice means its name contains glob syntax.
96/// - [`CoreError::Io`] if a file under the root turned up in the walk but could
97///   not be read, or its front matter could not be understood. One such file
98///   fails the whole search rather than being skipped — that is
99///   `cooklang-find`'s behaviour and this crate does not paper over it.
100pub fn search(ctx: &Context, req: SearchRequest) -> Result<Outcome<Vec<SearchHit>>, CoreError> {
101    let base_dir = req
102        .base_dir
103        .unwrap_or_else(|| ctx.base_path().to_path_buf());
104
105    tracing::trace!("searching {base_dir} for {:?}", req.query);
106
107    let entries =
108        cooklang_find::search(&base_dir, &req.query).map_err(|e| search_error(e, &base_dir))?;
109
110    // `cooklang-find` returns the union over the terms, best first. Narrowing
111    // to the intersection keeps that ranking — it only removes rows.
112    let terms: Vec<String> = req
113        .query
114        .split_whitespace()
115        .map(str::to_lowercase)
116        .collect();
117
118    let mut hits = Vec::new();
119    for entry in &entries {
120        // A search only ever yields file-backed entries, so this skips nothing
121        // today. It is a `continue` rather than an unwrap because a
122        // `RecipeEntry` need not have a path, and inventing one for an entry
123        // that lacks it would be worse than leaving it out.
124        let Some(path) = entry.path() else { continue };
125        if !matches_every_term(path, &terms)? {
126            continue;
127        }
128        hits.push(SearchHit {
129            relative_path: relative_to(&base_dir, path),
130            path: path.clone(),
131            name: entry.name().clone(),
132        });
133    }
134
135    Ok(Outcome::new(hits))
136}
137
138/// Whether every term appears in `path`'s text or in its file name.
139///
140/// Terms are expected already lowercased. Matching is a case-insensitive
141/// substring test against the two surfaces `cooklang-find` scores — the file
142/// stem and the contents — so that intersecting cannot drop a recipe the
143/// library matched on a term it would have counted.
144///
145/// An empty term list matches everything, which is what an all-whitespace query
146/// should do: `cooklang-find` returns nothing for it anyway.
147fn matches_every_term(path: &Utf8Path, terms: &[String]) -> Result<bool, CoreError> {
148    if terms.is_empty() {
149        return Ok(true);
150    }
151
152    let contents = std::fs::read_to_string(path)
153        .map_err(|source| CoreError::Io {
154            path: path.to_owned(),
155            source,
156        })?
157        .to_lowercase();
158    let stem = path.file_stem().unwrap_or_default().to_lowercase();
159
160    Ok(terms
161        .iter()
162        .all(|term| contents.contains(term) || stem.contains(term)))
163}
164
165/// Express `path` relative to `base_dir`, leaving it whole when it does not
166/// start with it.
167///
168/// The fallback is load-bearing rather than defensive, because the root is
169/// matched as written rather than resolved. A root of `./recipes` has the walk
170/// produce `recipes/soup.cook`, which does not begin with `./recipes`, so the
171/// hit keeps the root in it. Pass a root without a `./` — or an absolute one —
172/// to get the stripping the name promises.
173fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
174    path.strip_prefix(base_dir).unwrap_or(path).to_owned()
175}
176
177/// Map a search failure onto the error that describes what actually happened.
178///
179/// Only a bad glob pattern means the root itself was unsearchable; the rest
180/// mean a file under it was found and could not be read or understood.
181fn search_error(error: cooklang_find::search::SearchError, base_dir: &Utf8Path) -> CoreError {
182    use cooklang_find::search::SearchError;
183    match error {
184        SearchError::PatternError(source) => CoreError::Search {
185            base_dir: base_dir.to_owned(),
186            message: source.to_string(),
187        },
188        // Carries the file it failed on, which is more use than the root.
189        SearchError::GlobError(source) => CoreError::Io {
190            path: Utf8Path::from_path(source.path())
191                .map(Utf8Path::to_owned)
192                .unwrap_or_else(|| base_dir.to_owned()),
193            source: source.into_error(),
194        },
195        // These two lose the file on the way out of `cooklang-find`, so the
196        // root is the most specific thing left to name.
197        SearchError::IoError(source) => CoreError::Io {
198            path: base_dir.to_owned(),
199            source,
200        },
201        SearchError::RecipeEntryError(source) => CoreError::Io {
202            path: base_dir.to_owned(),
203            source: find::entry_error(source),
204        },
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    /// A fixture whose recipes overlap deliberately: `chicken` and `rice`
213    /// appear in one recipe each, so a two-term query tells AND and OR apart.
214    fn fixture() -> tempfile::TempDir {
215        let dir = tempfile::TempDir::new().unwrap();
216        let base = base(&dir);
217        std::fs::create_dir(base.join("Breakfast")).unwrap();
218        write(
219            &base.join("Breakfast").join("pancakes.cook"),
220            "---\ntitle: Fluffy Pancakes\n---\n\nMix @flour{2%cups} with @milk{1%cup}.\n",
221        );
222        write(
223            &base.join("curry.cook"),
224            "Fry @chicken{1} in @oil{1%tbsp}.\n",
225        );
226        write(
227            &base.join("pilaf.cook"),
228            "Boil @rice{200%g} in @water{1%l}.\n",
229        );
230        dir
231    }
232
233    fn write(path: &Utf8Path, text: &str) {
234        std::fs::write(path, text).unwrap();
235    }
236
237    fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
238        Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
239    }
240
241    /// Run a search rooted at the context base path.
242    fn run(base_dir: &Utf8Path, query: &str) -> Vec<SearchHit> {
243        search(
244            &Context::new(base_dir.to_owned()),
245            SearchRequest {
246                query: query.to_string(),
247                base_dir: None,
248            },
249        )
250        .expect("search succeeds")
251        .into_value()
252    }
253
254    /// The hits' paths, left as paths rather than stringified.
255    ///
256    /// That is what makes the assertions below portable: camino compares a
257    /// path component by component, so the `Breakfast\pancakes.cook` the walk
258    /// produces on Windows equals the `Breakfast/pancakes.cook` written here,
259    /// while comparing the two as strings would not. Nothing else is
260    /// loosened — a hit under a different directory, or with a different file
261    /// name, still fails.
262    fn relative_paths(hits: &[SearchHit]) -> Vec<Utf8PathBuf> {
263        hits.iter().map(|h| h.relative_path.clone()).collect()
264    }
265
266    #[test]
267    fn finds_a_recipe_whose_content_matches_a_term() {
268        let dir = fixture();
269        let hits = run(&base(&dir), "flour");
270        assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
271    }
272
273    #[test]
274    fn finds_a_recipe_by_its_file_name() {
275        let dir = fixture();
276        // "pilaf" appears nowhere in any recipe's text.
277        let hits = run(&base(&dir), "pilaf");
278        assert_eq!(relative_paths(&hits), ["pilaf.cook"]);
279    }
280
281    #[test]
282    fn a_query_that_matches_nothing_returns_no_hits() {
283        let dir = fixture();
284        assert!(run(&base(&dir), "kohlrabi").is_empty());
285    }
286
287    /// Every term must match. `curry.cook` has no rice in it and `pilaf.cook`
288    /// has no chicken, so "chicken rice" matches neither.
289    ///
290    /// This used to return both: `cooklang-find` scores a file for *any* term
291    /// and keeps everything above zero, so a multi-term query was a union
292    /// (<https://github.com/cooklang/cookcli/issues/425>).
293    #[test]
294    fn multiple_terms_are_anded() {
295        let dir = fixture();
296        assert!(
297            run(&base(&dir), "chicken rice").is_empty(),
298            "no recipe has both: {:?}",
299            relative_paths(&run(&base(&dir), "chicken rice"))
300        );
301    }
302
303    /// The point of AND: a second term narrows rather than widens.
304    #[test]
305    fn adding_a_term_narrows_the_result_set() {
306        let dir = fixture();
307        let base = base(&dir);
308        write(
309            &base.join("stir-fry.cook"),
310            "Fry @chicken{1} and @rice{1}.\n",
311        );
312
313        let one = relative_paths(&run(&base, "chicken"));
314        let two = relative_paths(&run(&base, "chicken rice"));
315
316        let mut sorted = one.clone();
317        sorted.sort();
318        assert_eq!(sorted, ["curry.cook", "stir-fry.cook"]);
319        assert_eq!(two, ["stir-fry.cook"], "the second term must filter");
320        assert!(two.len() < one.len(), "adding a term must not widen");
321    }
322
323    /// A term may match the file name rather than the contents, and still
324    /// count towards the AND — `pilaf` appears in no recipe's text.
325    #[test]
326    fn a_term_matching_only_the_file_name_satisfies_the_and() {
327        let dir = fixture();
328        assert_eq!(
329            relative_paths(&run(&base(&dir), "pilaf rice")),
330            ["pilaf.cook"]
331        );
332    }
333
334    /// A single-term query means the same thing as it always did: AND over one
335    /// term is that term. This is the compatibility guarantee for the change.
336    #[test]
337    fn a_single_term_query_is_unchanged() {
338        let dir = fixture();
339        assert_eq!(relative_paths(&run(&base(&dir), "chicken")), ["curry.cook"]);
340        assert_eq!(
341            relative_paths(&run(&base(&dir), "flour")),
342            ["Breakfast/pancakes.cook"]
343        );
344    }
345
346    /// Matching ignores case on both sides, as the underlying scoring does.
347    #[test]
348    fn terms_match_regardless_of_case() {
349        let dir = fixture();
350        assert_eq!(
351            relative_paths(&run(&base(&dir), "CHICKEN Oil")),
352            ["curry.cook"]
353        );
354    }
355
356    #[test]
357    fn hits_are_relative_to_the_search_root() {
358        let dir = fixture();
359        let hits = run(&base(&dir), "flour");
360        let hit = hits.first().expect("one hit");
361        assert_eq!(hit.relative_path, "Breakfast/pancakes.cook");
362        assert!(
363            hit.relative_path.is_relative(),
364            "relative_path must not be absolute: {}",
365            hit.relative_path
366        );
367    }
368
369    #[test]
370    fn hits_carry_the_path_the_search_found_them_at() {
371        let dir = fixture();
372        let base = base(&dir);
373        let hits = run(&base, "flour");
374        let hit = hits.first().expect("one hit");
375        assert_eq!(hit.path, base.join("Breakfast").join("pancakes.cook"));
376        assert!(hit.path.is_file(), "path must be openable: {}", hit.path);
377    }
378
379    #[test]
380    fn a_hit_is_named_by_its_title_when_it_has_one() {
381        let dir = fixture();
382        let hits = run(&base(&dir), "flour");
383        assert_eq!(
384            hits.first().expect("one hit").name.as_deref(),
385            Some("Fluffy Pancakes")
386        );
387    }
388
389    #[test]
390    fn a_hit_with_no_title_is_named_by_its_file_stem() {
391        let dir = fixture();
392        let hits = run(&base(&dir), "pilaf");
393        assert_eq!(
394            hits.first().expect("one hit").name.as_deref(),
395            Some("pilaf")
396        );
397    }
398
399    #[test]
400    fn base_dir_overrides_the_context_base_path() {
401        let searched = fixture();
402        let ignored = tempfile::TempDir::new().unwrap();
403        write(&base(&ignored).join("decoy.cook"), "Mix @flour{1%cup}.\n");
404
405        let hits = search(
406            &Context::new(base(&ignored)),
407            SearchRequest {
408                query: "flour".to_string(),
409                base_dir: Some(base(&searched)),
410            },
411        )
412        .expect("search succeeds")
413        .into_value();
414
415        assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
416    }
417
418    #[test]
419    fn without_a_base_dir_the_context_base_path_is_searched() {
420        let searched = fixture();
421        let hits = run(&base(&searched), "flour");
422        assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
423    }
424
425    /// A filename match outranks a content-only match, so the caller can print
426    /// the list as it comes and have the likeliest recipe first.
427    #[test]
428    fn hits_come_back_best_first() {
429        let dir = tempfile::TempDir::new().unwrap();
430        let base = base(&dir);
431        write(&base.join("aaa-mentions-pilaf.cook"), "Serve with pilaf.\n");
432        write(&base.join("pilaf.cook"), "Boil @rice{200%g}.\n");
433
434        // Alphabetically the mention sorts first, so ordering by score is the
435        // only thing that can put `pilaf.cook` in front.
436        assert_eq!(
437            relative_paths(&run(&base, "pilaf")),
438            ["pilaf.cook", "aaa-mentions-pilaf.cook"]
439        );
440    }
441
442    /// Searching somewhere that does not exist is empty, not an error.
443    #[test]
444    fn a_missing_search_root_yields_no_hits() {
445        let dir = tempfile::TempDir::new().unwrap();
446        assert!(run(&base(&dir).join("nope"), "flour").is_empty());
447    }
448
449    /// `relative_to` is exercised directly for the case that cannot be reached
450    /// from a test without changing the process working directory: a search
451    /// root spelled relatively.
452    ///
453    /// The walk resolves `./recipes/**/*.cook` to paths like
454    /// `recipes/soup.cook`, dropping the `./` that `strip_prefix` would then
455    /// need to find. The root therefore survives into the result. This is
456    /// long-standing `cook search --base-dir ./recipes` behaviour, pinned here
457    /// rather than endorsed.
458    #[test]
459    fn a_path_that_does_not_start_with_the_root_is_left_alone() {
460        assert_eq!(
461            relative_to(
462                Utf8Path::new("./recipes"),
463                Utf8Path::new("recipes/soup.cook")
464            ),
465            "recipes/soup.cook"
466        );
467        assert_eq!(
468            relative_to(
469                Utf8Path::new("/recipes"),
470                Utf8Path::new("/elsewhere/soup.cook")
471            ),
472            "/elsewhere/soup.cook"
473        );
474    }
475
476    #[test]
477    fn a_path_under_the_root_is_stripped_to_the_remainder() {
478        assert_eq!(
479            relative_to(
480                Utf8Path::new("/recipes"),
481                Utf8Path::new("/recipes/Breakfast/pancakes.cook")
482            ),
483            "Breakfast/pancakes.cook"
484        );
485    }
486
487    /// A search root whose name contains glob syntax cannot be turned into a
488    /// pattern. It is a real directory that a user can really have, so it gets
489    /// an error naming it rather than a silent empty result.
490    #[test]
491    fn a_search_root_that_is_not_a_valid_glob_pattern_is_reported() {
492        let dir = tempfile::TempDir::new().unwrap();
493        let root = base(&dir).join("re[ci");
494        std::fs::create_dir(&root).unwrap();
495        write(&root.join("soup.cook"), "Boil @water{1%l}.\n");
496
497        match search(
498            &Context::new(root.clone()),
499            SearchRequest {
500                query: "water".to_string(),
501                base_dir: None,
502            },
503        ) {
504            Err(CoreError::Search { base_dir, message }) => {
505                assert_eq!(base_dir, root);
506                assert!(
507                    message.contains("attern"),
508                    "the cause must survive: {message}"
509                );
510            }
511            other => panic!(
512                "expected CoreError::Search, got {:?}",
513                other.map(|o| o.value)
514            ),
515        }
516    }
517
518    /// The remaining failures mean a file under the root was unusable, not that
519    /// the root was. They are pinned through `search_error` directly, because
520    /// provoking them needs a file that breaks between the walk finding it and
521    /// the walk reading it.
522    ///
523    /// `SearchError::GlobError` is absent only because `glob` exposes no way to
524    /// construct one; its arm is the one that names the failing file rather
525    /// than the root.
526    #[test]
527    fn a_file_that_cannot_be_read_is_an_io_error_not_a_search_error() {
528        use cooklang_find::search::SearchError;
529        let root = Utf8Path::new("/recipes");
530
531        let unreadable = search_error(
532            SearchError::IoError(std::io::Error::new(
533                std::io::ErrorKind::PermissionDenied,
534                "denied",
535            )),
536            root,
537        );
538        match unreadable {
539            CoreError::Io { path, source } => {
540                assert_eq!(path, root);
541                assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
542            }
543            other => panic!("expected CoreError::Io, got {other:?}"),
544        }
545
546        let unusable = search_error(
547            SearchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
548                "bad front matter".to_string(),
549            )),
550            root,
551        );
552        match unusable {
553            CoreError::Io { path, source } => {
554                assert_eq!(path, root);
555                assert!(
556                    source.to_string().contains("bad front matter"),
557                    "the cause must survive: {source}"
558                );
559            }
560            other => panic!("expected CoreError::Io, got {other:?}"),
561        }
562    }
563}