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. Bytes
100///   that are not valid UTF-8 are not such a failure; they are decoded as
101///   U+FFFD, as `matches_every_term` describes.
102pub fn search(ctx: &Context, req: SearchRequest) -> Result<Outcome<Vec<SearchHit>>, CoreError> {
103    let base_dir = req
104        .base_dir
105        .unwrap_or_else(|| ctx.base_path().to_path_buf());
106
107    tracing::trace!("searching {base_dir} for {:?}", req.query);
108
109    let entries =
110        cooklang_find::search(&base_dir, &req.query).map_err(|e| search_error(e, &base_dir))?;
111
112    // `cooklang-find` returns the union over the terms, best first. Narrowing
113    // to the intersection keeps that ranking — it only removes rows.
114    let terms: Vec<String> = req
115        .query
116        .split_whitespace()
117        .map(str::to_lowercase)
118        .collect();
119
120    let mut hits = Vec::new();
121    for entry in &entries {
122        // A search only ever yields file-backed entries, so this skips nothing
123        // today. It is a `continue` rather than an unwrap because a
124        // `RecipeEntry` need not have a path, and inventing one for an entry
125        // that lacks it would be worse than leaving it out.
126        let Some(path) = entry.path() else { continue };
127        if !matches_every_term(path, &terms)? {
128            continue;
129        }
130        hits.push(SearchHit {
131            relative_path: relative_to(&base_dir, path),
132            path: path.clone(),
133            name: entry.name().clone(),
134        });
135    }
136
137    Ok(Outcome::new(hits))
138}
139
140/// Whether every term appears in `path`'s text or in its file name.
141///
142/// Terms are expected already lowercased. Matching is a case-insensitive
143/// substring test against the two surfaces `cooklang-find` scores — the file
144/// stem and the contents — so that intersecting cannot drop a recipe the
145/// library matched on a term it would have counted.
146///
147/// An empty term list matches everything, which is what an all-whitespace query
148/// should do: `cooklang-find` returns nothing for it anyway.
149///
150/// Bytes that are not valid UTF-8 are decoded as U+FFFD rather than refused.
151/// A recipe saved from a Latin-1 editor is still a recipe, and rejecting it
152/// here failed the whole search over one stray byte in one file
153/// (<https://github.com/cooklang/cookcli/issues/498>). Only the bad bytes
154/// themselves stop matching — a term either side of one still does. A genuine
155/// I/O failure is a different thing and is still an error, because an
156/// unreadable file is not an empty one.
157fn matches_every_term(path: &Utf8Path, terms: &[String]) -> Result<bool, CoreError> {
158    if terms.is_empty() {
159        return Ok(true);
160    }
161
162    let bytes = std::fs::read(path).map_err(|source| CoreError::Io {
163        path: path.to_owned(),
164        source,
165    })?;
166    let contents = String::from_utf8_lossy(&bytes).to_lowercase();
167    let stem = path.file_stem().unwrap_or_default().to_lowercase();
168
169    Ok(terms
170        .iter()
171        .all(|term| contents.contains(term) || stem.contains(term)))
172}
173
174/// Express `path` relative to `base_dir`, leaving it whole when it does not
175/// start with it.
176///
177/// The fallback is load-bearing rather than defensive, because the root is
178/// matched as written rather than resolved. A root of `./recipes` has the walk
179/// produce `recipes/soup.cook`, which does not begin with `./recipes`, so the
180/// hit keeps the root in it. Pass a root without a `./` — or an absolute one —
181/// to get the stripping the name promises.
182fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
183    path.strip_prefix(base_dir).unwrap_or(path).to_owned()
184}
185
186/// Map a search failure onto the error that describes what actually happened.
187///
188/// Only a bad glob pattern means the root itself was unsearchable; the rest
189/// mean a file under it was found and could not be read or understood.
190fn search_error(error: cooklang_find::search::SearchError, base_dir: &Utf8Path) -> CoreError {
191    use cooklang_find::search::SearchError;
192    match error {
193        SearchError::PatternError(source) => CoreError::Search {
194            base_dir: base_dir.to_owned(),
195            message: source.to_string(),
196        },
197        // Carries the file it failed on, which is more use than the root.
198        SearchError::GlobError(source) => CoreError::Io {
199            path: Utf8Path::from_path(source.path())
200                .map(Utf8Path::to_owned)
201                .unwrap_or_else(|| base_dir.to_owned()),
202            source: source.into_error(),
203        },
204        // These two lose the file on the way out of `cooklang-find`, so the
205        // root is the most specific thing left to name.
206        SearchError::IoError(source) => CoreError::Io {
207            path: base_dir.to_owned(),
208            source,
209        },
210        SearchError::RecipeEntryError(source) => CoreError::Io {
211            path: base_dir.to_owned(),
212            source: find::entry_error(source),
213        },
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    /// A fixture whose recipes overlap deliberately: `chicken` and `rice`
222    /// appear in one recipe each, so a two-term query tells AND and OR apart.
223    fn fixture() -> tempfile::TempDir {
224        let dir = tempfile::TempDir::new().unwrap();
225        let base = base(&dir);
226        std::fs::create_dir(base.join("Breakfast")).unwrap();
227        write(
228            &base.join("Breakfast").join("pancakes.cook"),
229            "---\ntitle: Fluffy Pancakes\n---\n\nMix @flour{2%cups} with @milk{1%cup}.\n",
230        );
231        write(
232            &base.join("curry.cook"),
233            "Fry @chicken{1} in @oil{1%tbsp}.\n",
234        );
235        write(
236            &base.join("pilaf.cook"),
237            "Boil @rice{200%g} in @water{1%l}.\n",
238        );
239        dir
240    }
241
242    fn write(path: &Utf8Path, text: &str) {
243        std::fs::write(path, text).unwrap();
244    }
245
246    fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
247        Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
248    }
249
250    /// Run a search rooted at the context base path.
251    fn run(base_dir: &Utf8Path, query: &str) -> Vec<SearchHit> {
252        search(
253            &Context::new(base_dir.to_owned()),
254            SearchRequest {
255                query: query.to_string(),
256                base_dir: None,
257            },
258        )
259        .expect("search succeeds")
260        .into_value()
261    }
262
263    /// The hits' paths, left as paths rather than stringified.
264    ///
265    /// That is what makes the assertions below portable: camino compares a
266    /// path component by component, so the `Breakfast\pancakes.cook` the walk
267    /// produces on Windows equals the `Breakfast/pancakes.cook` written here,
268    /// while comparing the two as strings would not. Nothing else is
269    /// loosened — a hit under a different directory, or with a different file
270    /// name, still fails.
271    fn relative_paths(hits: &[SearchHit]) -> Vec<Utf8PathBuf> {
272        hits.iter().map(|h| h.relative_path.clone()).collect()
273    }
274
275    #[test]
276    fn finds_a_recipe_whose_content_matches_a_term() {
277        let dir = fixture();
278        let hits = run(&base(&dir), "flour");
279        assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
280    }
281
282    #[test]
283    fn finds_a_recipe_by_its_file_name() {
284        let dir = fixture();
285        // "pilaf" appears nowhere in any recipe's text.
286        let hits = run(&base(&dir), "pilaf");
287        assert_eq!(relative_paths(&hits), ["pilaf.cook"]);
288    }
289
290    #[test]
291    fn a_query_that_matches_nothing_returns_no_hits() {
292        let dir = fixture();
293        assert!(run(&base(&dir), "kohlrabi").is_empty());
294    }
295
296    /// Every term must match. `curry.cook` has no rice in it and `pilaf.cook`
297    /// has no chicken, so "chicken rice" matches neither.
298    ///
299    /// This used to return both: `cooklang-find` scores a file for *any* term
300    /// and keeps everything above zero, so a multi-term query was a union
301    /// (<https://github.com/cooklang/cookcli/issues/425>).
302    #[test]
303    fn multiple_terms_are_anded() {
304        let dir = fixture();
305        assert!(
306            run(&base(&dir), "chicken rice").is_empty(),
307            "no recipe has both: {:?}",
308            relative_paths(&run(&base(&dir), "chicken rice"))
309        );
310    }
311
312    /// The point of AND: a second term narrows rather than widens.
313    #[test]
314    fn adding_a_term_narrows_the_result_set() {
315        let dir = fixture();
316        let base = base(&dir);
317        write(
318            &base.join("stir-fry.cook"),
319            "Fry @chicken{1} and @rice{1}.\n",
320        );
321
322        let one = relative_paths(&run(&base, "chicken"));
323        let two = relative_paths(&run(&base, "chicken rice"));
324
325        let mut sorted = one.clone();
326        sorted.sort();
327        assert_eq!(sorted, ["curry.cook", "stir-fry.cook"]);
328        assert_eq!(two, ["stir-fry.cook"], "the second term must filter");
329        assert!(two.len() < one.len(), "adding a term must not widen");
330    }
331
332    /// A term may match the file name rather than the contents, and still
333    /// count towards the AND — `pilaf` appears in no recipe's text.
334    #[test]
335    fn a_term_matching_only_the_file_name_satisfies_the_and() {
336        let dir = fixture();
337        assert_eq!(
338            relative_paths(&run(&base(&dir), "pilaf rice")),
339            ["pilaf.cook"]
340        );
341    }
342
343    /// A single-term query means the same thing as it always did: AND over one
344    /// term is that term. This is the compatibility guarantee for the change.
345    #[test]
346    fn a_single_term_query_is_unchanged() {
347        let dir = fixture();
348        assert_eq!(relative_paths(&run(&base(&dir), "chicken")), ["curry.cook"]);
349        assert_eq!(
350            relative_paths(&run(&base(&dir), "flour")),
351            ["Breakfast/pancakes.cook"]
352        );
353    }
354
355    /// Matching ignores case on both sides, as the underlying scoring does.
356    #[test]
357    fn terms_match_regardless_of_case() {
358        let dir = fixture();
359        assert_eq!(
360            relative_paths(&run(&base(&dir), "CHICKEN Oil")),
361            ["curry.cook"]
362        );
363    }
364
365    #[test]
366    fn hits_are_relative_to_the_search_root() {
367        let dir = fixture();
368        let hits = run(&base(&dir), "flour");
369        let hit = hits.first().expect("one hit");
370        assert_eq!(hit.relative_path, "Breakfast/pancakes.cook");
371        assert!(
372            hit.relative_path.is_relative(),
373            "relative_path must not be absolute: {}",
374            hit.relative_path
375        );
376    }
377
378    #[test]
379    fn hits_carry_the_path_the_search_found_them_at() {
380        let dir = fixture();
381        let base = base(&dir);
382        let hits = run(&base, "flour");
383        let hit = hits.first().expect("one hit");
384        assert_eq!(hit.path, base.join("Breakfast").join("pancakes.cook"));
385        assert!(hit.path.is_file(), "path must be openable: {}", hit.path);
386    }
387
388    #[test]
389    fn a_hit_is_named_by_its_title_when_it_has_one() {
390        let dir = fixture();
391        let hits = run(&base(&dir), "flour");
392        assert_eq!(
393            hits.first().expect("one hit").name.as_deref(),
394            Some("Fluffy Pancakes")
395        );
396    }
397
398    #[test]
399    fn a_hit_with_no_title_is_named_by_its_file_stem() {
400        let dir = fixture();
401        let hits = run(&base(&dir), "pilaf");
402        assert_eq!(
403            hits.first().expect("one hit").name.as_deref(),
404            Some("pilaf")
405        );
406    }
407
408    #[test]
409    fn base_dir_overrides_the_context_base_path() {
410        let searched = fixture();
411        let ignored = tempfile::TempDir::new().unwrap();
412        write(&base(&ignored).join("decoy.cook"), "Mix @flour{1%cup}.\n");
413
414        let hits = search(
415            &Context::new(base(&ignored)),
416            SearchRequest {
417                query: "flour".to_string(),
418                base_dir: Some(base(&searched)),
419            },
420        )
421        .expect("search succeeds")
422        .into_value();
423
424        assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
425    }
426
427    #[test]
428    fn without_a_base_dir_the_context_base_path_is_searched() {
429        let searched = fixture();
430        let hits = run(&base(&searched), "flour");
431        assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
432    }
433
434    /// A filename match outranks a content-only match, so the caller can print
435    /// the list as it comes and have the likeliest recipe first.
436    #[test]
437    fn hits_come_back_best_first() {
438        let dir = tempfile::TempDir::new().unwrap();
439        let base = base(&dir);
440        write(&base.join("aaa-mentions-pilaf.cook"), "Serve with pilaf.\n");
441        write(&base.join("pilaf.cook"), "Boil @rice{200%g}.\n");
442
443        // Alphabetically the mention sorts first, so ordering by score is the
444        // only thing that can put `pilaf.cook` in front.
445        assert_eq!(
446            relative_paths(&run(&base, "pilaf")),
447            ["pilaf.cook", "aaa-mentions-pilaf.cook"]
448        );
449    }
450
451    /// Searching somewhere that does not exist is empty, not an error.
452    #[test]
453    fn a_missing_search_root_yields_no_hits() {
454        let dir = tempfile::TempDir::new().unwrap();
455        assert!(run(&base(&dir).join("nope"), "flour").is_empty());
456    }
457
458    /// `relative_to` is exercised directly for the case that cannot be reached
459    /// from a test without changing the process working directory: a search
460    /// root spelled relatively.
461    ///
462    /// The walk resolves `./recipes/**/*.cook` to paths like
463    /// `recipes/soup.cook`, dropping the `./` that `strip_prefix` would then
464    /// need to find. The root therefore survives into the result. This is
465    /// long-standing `cook search --base-dir ./recipes` behaviour, pinned here
466    /// rather than endorsed.
467    #[test]
468    fn a_path_that_does_not_start_with_the_root_is_left_alone() {
469        assert_eq!(
470            relative_to(
471                Utf8Path::new("./recipes"),
472                Utf8Path::new("recipes/soup.cook")
473            ),
474            "recipes/soup.cook"
475        );
476        assert_eq!(
477            relative_to(
478                Utf8Path::new("/recipes"),
479                Utf8Path::new("/elsewhere/soup.cook")
480            ),
481            "/elsewhere/soup.cook"
482        );
483    }
484
485    #[test]
486    fn a_path_under_the_root_is_stripped_to_the_remainder() {
487        assert_eq!(
488            relative_to(
489                Utf8Path::new("/recipes"),
490                Utf8Path::new("/recipes/Breakfast/pancakes.cook")
491            ),
492            "Breakfast/pancakes.cook"
493        );
494    }
495
496    /// A search root whose name contains glob syntax cannot be turned into a
497    /// pattern. It is a real directory that a user can really have, so it gets
498    /// an error naming it rather than a silent empty result.
499    #[test]
500    fn a_search_root_that_is_not_a_valid_glob_pattern_is_reported() {
501        let dir = tempfile::TempDir::new().unwrap();
502        let root = base(&dir).join("re[ci");
503        std::fs::create_dir(&root).unwrap();
504        write(&root.join("soup.cook"), "Boil @water{1%l}.\n");
505
506        match search(
507            &Context::new(root.clone()),
508            SearchRequest {
509                query: "water".to_string(),
510                base_dir: None,
511            },
512        ) {
513            Err(CoreError::Search { base_dir, message }) => {
514                assert_eq!(base_dir, root);
515                assert!(
516                    message.contains("attern"),
517                    "the cause must survive: {message}"
518                );
519            }
520            other => panic!(
521                "expected CoreError::Search, got {:?}",
522                other.map(|o| o.value)
523            ),
524        }
525    }
526
527    /// A recipe carrying a byte that is not valid UTF-8 is still a recipe.
528    ///
529    /// `cook search tuna` used to die with "Failed to read '<root>' / stream
530    /// did not contain valid UTF-8" over one Latin-1 file, taking every other
531    /// recipe's results with it
532    /// (<https://github.com/cooklang/cookcli/issues/498>). The bad bytes are
533    /// decoded as U+FFFD, so the recipe is found and the text around them still
534    /// matches.
535    ///
536    /// Both files here are needed, because the report had two causes in two
537    /// crates. A bad byte in the **body** was this crate's:
538    /// [`matches_every_term`] read candidates with `read_to_string`. A bad byte
539    /// in the **front matter** was `cooklang-find`'s, which propagated it out
540    /// of its own walk instead of skipping the file the way `build_tree` does —
541    /// that is the arm that named the search root rather than the file, and it
542    /// needs 0.7.1 (cooklang/cooklang-find#13).
543    #[test]
544    fn a_recipe_that_is_not_valid_utf8_is_still_searchable() {
545        let dir = fixture();
546        let base = base(&dir);
547        // Latin-1: 0xe8 and 0xe9 are an "è" and an "é" that never made it to
548        // UTF-8. One file carries its bad byte in the body, the other in the
549        // front matter.
550        std::fs::write(
551            base.join("tuna mornay.cook"),
552            b"---\ntitle: Tuna Mornay\n---\n\nBake @tuna{1%can} with cr\xe8me.\n",
553        )
554        .unwrap();
555        std::fs::write(
556            base.join("salmon.cook"),
557            b"---\ntitle: Saumon \xe9tuv\xe9\n---\n\nSteam @salmon{2} with @dill{}.\n",
558        )
559        .unwrap();
560
561        assert_eq!(
562            relative_paths(&run(&base, "tuna")),
563            ["tuna mornay.cook"],
564            "a bad byte in the body must not fail the search"
565        );
566        assert_eq!(
567            relative_paths(&run(&base, "salmon")),
568            ["salmon.cook"],
569            "nor must one in the front matter"
570        );
571
572        // Found by an ingredient that only appears in the body, so the hit
573        // cannot be coming from the file name. This is what needed 0.7.1:
574        // 0.7.0 could not score such a file's contents at all.
575        assert_eq!(
576            relative_paths(&run(&base, "dill")),
577            ["salmon.cook"],
578            "a term only in the contents must still match"
579        );
580        assert_eq!(
581            relative_paths(&run(&base, "steam dill")),
582            ["salmon.cook"],
583            "and must still satisfy every term of an AND query"
584        );
585
586        // The title survives, bad byte and all, rather than the entry being
587        // dropped or left nameless.
588        let hit = run(&base, "salmon");
589        assert_eq!(hit[0].name.as_deref(), Some("Saumon \u{fffd}tuv\u{fffd}"));
590
591        // The part the bug was really about: one bad file used to fail every
592        // query, not just the ones that matched it.
593        assert_eq!(
594            relative_paths(&run(&base, "flour")),
595            ["Breakfast/pancakes.cook"]
596        );
597    }
598
599    /// The bad bytes are the only thing that stops matching: the readable text
600    /// on either side of one is still searchable, and still counts towards an
601    /// AND query.
602    ///
603    /// Exercised through [`matches_every_term`] directly, so that the AND
604    /// filter's own reading of a malformed file is pinned here rather than
605    /// only through a whole search, where `cooklang-find`'s scoring decides
606    /// what the filter ever sees.
607    #[test]
608    fn text_around_an_invalid_byte_still_matches() {
609        let dir = tempfile::TempDir::new().unwrap();
610        let path = base(&dir).join("tuna mornay.cook");
611        std::fs::write(&path, b"Bake @tuna{1%can} with cr\xe8me.\n").unwrap();
612
613        let matches = |query: &str| {
614            let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
615            matches_every_term(&path, &terms).expect("a bad byte is not an i/o failure")
616        };
617
618        assert!(matches("bake"), "text before the bad byte");
619        assert!(matches("me."), "text after the bad byte");
620        assert!(matches("bake mornay"), "body and file name together");
621        assert!(matches("bake me."), "both sides of the bad byte");
622        assert!(
623            !matches("kohlrabi"),
624            "and a term that is absent still misses"
625        );
626        assert!(
627            !matches("bake kohlrabi"),
628            "AND still narrows: one missing term is enough"
629        );
630    }
631
632    /// A file with no valid text in it at all — a binary that landed under a
633    /// `.cook` name — is the degenerate case of the same thing: nothing to
634    /// match, but nothing to fail over either.
635    #[test]
636    fn a_file_with_no_valid_text_matches_nothing_and_fails_nothing() {
637        let dir = tempfile::TempDir::new().unwrap();
638        let path = base(&dir).join("junk.cook");
639        std::fs::write(&path, [0xff, 0xfe, 0xff, 0xfe, 0x80, 0x81]).unwrap();
640
641        assert!(
642            !matches_every_term(&path, &["tuna".to_string()]).expect("must not fail"),
643            "there is no text in it to match"
644        );
645        assert!(
646            matches_every_term(&path, &["junk".to_string()]).expect("must not fail"),
647            "but the file name is still a surface to match on"
648        );
649    }
650
651    /// A file that cannot be read at all is still an error. Lossy decoding is
652    /// for bytes that are there and malformed, not for bytes that never
653    /// arrived — silently treating an unreadable recipe as an empty one would
654    /// turn a fixable problem into results that are quietly wrong.
655    #[test]
656    fn an_unreadable_file_is_still_an_io_error() {
657        let dir = tempfile::TempDir::new().unwrap();
658        let missing = base(&dir).join("gone.cook");
659
660        match matches_every_term(&missing, &["tuna".to_string()]) {
661            Err(CoreError::Io { path, source }) => {
662                assert_eq!(path, missing);
663                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
664            }
665            other => panic!("expected CoreError::Io, got {other:?}"),
666        }
667    }
668
669    /// The remaining failures mean a file under the root was unusable, not that
670    /// the root was. They are pinned through `search_error` directly, because
671    /// provoking them needs a file that breaks between the walk finding it and
672    /// the walk reading it.
673    ///
674    /// `SearchError::GlobError` is absent only because `glob` exposes no way to
675    /// construct one; its arm is the one that names the failing file rather
676    /// than the root.
677    #[test]
678    fn a_file_that_cannot_be_read_is_an_io_error_not_a_search_error() {
679        use cooklang_find::search::SearchError;
680        let root = Utf8Path::new("/recipes");
681
682        let unreadable = search_error(
683            SearchError::IoError(std::io::Error::new(
684                std::io::ErrorKind::PermissionDenied,
685                "denied",
686            )),
687            root,
688        );
689        match unreadable {
690            CoreError::Io { path, source } => {
691                assert_eq!(path, root);
692                assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
693            }
694            other => panic!("expected CoreError::Io, got {other:?}"),
695        }
696
697        let unusable = search_error(
698            SearchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
699                "bad front matter".to_string(),
700            )),
701            root,
702        );
703        match unusable {
704            CoreError::Io { path, source } => {
705                assert_eq!(path, root);
706                assert!(
707                    source.to_string().contains("bad front matter"),
708                    "the cause must survive: {source}"
709                );
710            }
711            other => panic!("expected CoreError::Io, got {other:?}"),
712        }
713    }
714}