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}