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}