use crate::{find, Context, CoreError, Outcome};
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct SearchRequest {
pub query: String,
pub base_dir: Option<Utf8PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct SearchHit {
pub relative_path: Utf8PathBuf,
pub path: Utf8PathBuf,
pub name: Option<String>,
}
pub fn search(ctx: &Context, req: SearchRequest) -> Result<Outcome<Vec<SearchHit>>, CoreError> {
let base_dir = req
.base_dir
.unwrap_or_else(|| ctx.base_path().to_path_buf());
tracing::trace!("searching {base_dir} for {:?}", req.query);
let entries =
cooklang_find::search(&base_dir, &req.query).map_err(|e| search_error(e, &base_dir))?;
let terms: Vec<String> = req
.query
.split_whitespace()
.map(str::to_lowercase)
.collect();
let mut hits = Vec::new();
for entry in &entries {
let Some(path) = entry.path() else { continue };
if !matches_every_term(path, &terms)? {
continue;
}
hits.push(SearchHit {
relative_path: relative_to(&base_dir, path),
path: path.clone(),
name: entry.name().clone(),
});
}
Ok(Outcome::new(hits))
}
fn matches_every_term(path: &Utf8Path, terms: &[String]) -> Result<bool, CoreError> {
if terms.is_empty() {
return Ok(true);
}
let bytes = std::fs::read(path).map_err(|source| CoreError::Io {
path: path.to_owned(),
source,
})?;
let contents = String::from_utf8_lossy(&bytes).to_lowercase();
let stem = path.file_stem().unwrap_or_default().to_lowercase();
Ok(terms
.iter()
.all(|term| contents.contains(term) || stem.contains(term)))
}
fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
path.strip_prefix(base_dir).unwrap_or(path).to_owned()
}
fn search_error(error: cooklang_find::search::SearchError, base_dir: &Utf8Path) -> CoreError {
use cooklang_find::search::SearchError;
match error {
SearchError::PatternError(source) => CoreError::Search {
base_dir: base_dir.to_owned(),
message: source.to_string(),
},
SearchError::GlobError(source) => CoreError::Io {
path: Utf8Path::from_path(source.path())
.map(Utf8Path::to_owned)
.unwrap_or_else(|| base_dir.to_owned()),
source: source.into_error(),
},
SearchError::IoError(source) => CoreError::Io {
path: base_dir.to_owned(),
source,
},
SearchError::RecipeEntryError(source) => CoreError::Io {
path: base_dir.to_owned(),
source: find::entry_error(source),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
std::fs::create_dir(base.join("Breakfast")).unwrap();
write(
&base.join("Breakfast").join("pancakes.cook"),
"---\ntitle: Fluffy Pancakes\n---\n\nMix @flour{2%cups} with @milk{1%cup}.\n",
);
write(
&base.join("curry.cook"),
"Fry @chicken{1} in @oil{1%tbsp}.\n",
);
write(
&base.join("pilaf.cook"),
"Boil @rice{200%g} in @water{1%l}.\n",
);
dir
}
fn write(path: &Utf8Path, text: &str) {
std::fs::write(path, text).unwrap();
}
fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
}
fn run(base_dir: &Utf8Path, query: &str) -> Vec<SearchHit> {
search(
&Context::new(base_dir.to_owned()),
SearchRequest {
query: query.to_string(),
base_dir: None,
},
)
.expect("search succeeds")
.into_value()
}
fn relative_paths(hits: &[SearchHit]) -> Vec<Utf8PathBuf> {
hits.iter().map(|h| h.relative_path.clone()).collect()
}
#[test]
fn finds_a_recipe_whose_content_matches_a_term() {
let dir = fixture();
let hits = run(&base(&dir), "flour");
assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
}
#[test]
fn finds_a_recipe_by_its_file_name() {
let dir = fixture();
let hits = run(&base(&dir), "pilaf");
assert_eq!(relative_paths(&hits), ["pilaf.cook"]);
}
#[test]
fn a_query_that_matches_nothing_returns_no_hits() {
let dir = fixture();
assert!(run(&base(&dir), "kohlrabi").is_empty());
}
#[test]
fn multiple_terms_are_anded() {
let dir = fixture();
assert!(
run(&base(&dir), "chicken rice").is_empty(),
"no recipe has both: {:?}",
relative_paths(&run(&base(&dir), "chicken rice"))
);
}
#[test]
fn adding_a_term_narrows_the_result_set() {
let dir = fixture();
let base = base(&dir);
write(
&base.join("stir-fry.cook"),
"Fry @chicken{1} and @rice{1}.\n",
);
let one = relative_paths(&run(&base, "chicken"));
let two = relative_paths(&run(&base, "chicken rice"));
let mut sorted = one.clone();
sorted.sort();
assert_eq!(sorted, ["curry.cook", "stir-fry.cook"]);
assert_eq!(two, ["stir-fry.cook"], "the second term must filter");
assert!(two.len() < one.len(), "adding a term must not widen");
}
#[test]
fn a_term_matching_only_the_file_name_satisfies_the_and() {
let dir = fixture();
assert_eq!(
relative_paths(&run(&base(&dir), "pilaf rice")),
["pilaf.cook"]
);
}
#[test]
fn a_single_term_query_is_unchanged() {
let dir = fixture();
assert_eq!(relative_paths(&run(&base(&dir), "chicken")), ["curry.cook"]);
assert_eq!(
relative_paths(&run(&base(&dir), "flour")),
["Breakfast/pancakes.cook"]
);
}
#[test]
fn terms_match_regardless_of_case() {
let dir = fixture();
assert_eq!(
relative_paths(&run(&base(&dir), "CHICKEN Oil")),
["curry.cook"]
);
}
#[test]
fn hits_are_relative_to_the_search_root() {
let dir = fixture();
let hits = run(&base(&dir), "flour");
let hit = hits.first().expect("one hit");
assert_eq!(hit.relative_path, "Breakfast/pancakes.cook");
assert!(
hit.relative_path.is_relative(),
"relative_path must not be absolute: {}",
hit.relative_path
);
}
#[test]
fn hits_carry_the_path_the_search_found_them_at() {
let dir = fixture();
let base = base(&dir);
let hits = run(&base, "flour");
let hit = hits.first().expect("one hit");
assert_eq!(hit.path, base.join("Breakfast").join("pancakes.cook"));
assert!(hit.path.is_file(), "path must be openable: {}", hit.path);
}
#[test]
fn a_hit_is_named_by_its_title_when_it_has_one() {
let dir = fixture();
let hits = run(&base(&dir), "flour");
assert_eq!(
hits.first().expect("one hit").name.as_deref(),
Some("Fluffy Pancakes")
);
}
#[test]
fn a_hit_with_no_title_is_named_by_its_file_stem() {
let dir = fixture();
let hits = run(&base(&dir), "pilaf");
assert_eq!(
hits.first().expect("one hit").name.as_deref(),
Some("pilaf")
);
}
#[test]
fn base_dir_overrides_the_context_base_path() {
let searched = fixture();
let ignored = tempfile::TempDir::new().unwrap();
write(&base(&ignored).join("decoy.cook"), "Mix @flour{1%cup}.\n");
let hits = search(
&Context::new(base(&ignored)),
SearchRequest {
query: "flour".to_string(),
base_dir: Some(base(&searched)),
},
)
.expect("search succeeds")
.into_value();
assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
}
#[test]
fn without_a_base_dir_the_context_base_path_is_searched() {
let searched = fixture();
let hits = run(&base(&searched), "flour");
assert_eq!(relative_paths(&hits), ["Breakfast/pancakes.cook"]);
}
#[test]
fn hits_come_back_best_first() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
write(&base.join("aaa-mentions-pilaf.cook"), "Serve with pilaf.\n");
write(&base.join("pilaf.cook"), "Boil @rice{200%g}.\n");
assert_eq!(
relative_paths(&run(&base, "pilaf")),
["pilaf.cook", "aaa-mentions-pilaf.cook"]
);
}
#[test]
fn a_missing_search_root_yields_no_hits() {
let dir = tempfile::TempDir::new().unwrap();
assert!(run(&base(&dir).join("nope"), "flour").is_empty());
}
#[test]
fn a_path_that_does_not_start_with_the_root_is_left_alone() {
assert_eq!(
relative_to(
Utf8Path::new("./recipes"),
Utf8Path::new("recipes/soup.cook")
),
"recipes/soup.cook"
);
assert_eq!(
relative_to(
Utf8Path::new("/recipes"),
Utf8Path::new("/elsewhere/soup.cook")
),
"/elsewhere/soup.cook"
);
}
#[test]
fn a_path_under_the_root_is_stripped_to_the_remainder() {
assert_eq!(
relative_to(
Utf8Path::new("/recipes"),
Utf8Path::new("/recipes/Breakfast/pancakes.cook")
),
"Breakfast/pancakes.cook"
);
}
#[test]
fn a_search_root_that_is_not_a_valid_glob_pattern_is_reported() {
let dir = tempfile::TempDir::new().unwrap();
let root = base(&dir).join("re[ci");
std::fs::create_dir(&root).unwrap();
write(&root.join("soup.cook"), "Boil @water{1%l}.\n");
match search(
&Context::new(root.clone()),
SearchRequest {
query: "water".to_string(),
base_dir: None,
},
) {
Err(CoreError::Search { base_dir, message }) => {
assert_eq!(base_dir, root);
assert!(
message.contains("attern"),
"the cause must survive: {message}"
);
}
other => panic!(
"expected CoreError::Search, got {:?}",
other.map(|o| o.value)
),
}
}
#[test]
fn a_recipe_that_is_not_valid_utf8_is_still_searchable() {
let dir = fixture();
let base = base(&dir);
std::fs::write(
base.join("tuna mornay.cook"),
b"---\ntitle: Tuna Mornay\n---\n\nBake @tuna{1%can} with cr\xe8me.\n",
)
.unwrap();
std::fs::write(
base.join("salmon.cook"),
b"---\ntitle: Saumon \xe9tuv\xe9\n---\n\nSteam @salmon{2} with @dill{}.\n",
)
.unwrap();
assert_eq!(
relative_paths(&run(&base, "tuna")),
["tuna mornay.cook"],
"a bad byte in the body must not fail the search"
);
assert_eq!(
relative_paths(&run(&base, "salmon")),
["salmon.cook"],
"nor must one in the front matter"
);
assert_eq!(
relative_paths(&run(&base, "dill")),
["salmon.cook"],
"a term only in the contents must still match"
);
assert_eq!(
relative_paths(&run(&base, "steam dill")),
["salmon.cook"],
"and must still satisfy every term of an AND query"
);
let hit = run(&base, "salmon");
assert_eq!(hit[0].name.as_deref(), Some("Saumon \u{fffd}tuv\u{fffd}"));
assert_eq!(
relative_paths(&run(&base, "flour")),
["Breakfast/pancakes.cook"]
);
}
#[test]
fn text_around_an_invalid_byte_still_matches() {
let dir = tempfile::TempDir::new().unwrap();
let path = base(&dir).join("tuna mornay.cook");
std::fs::write(&path, b"Bake @tuna{1%can} with cr\xe8me.\n").unwrap();
let matches = |query: &str| {
let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
matches_every_term(&path, &terms).expect("a bad byte is not an i/o failure")
};
assert!(matches("bake"), "text before the bad byte");
assert!(matches("me."), "text after the bad byte");
assert!(matches("bake mornay"), "body and file name together");
assert!(matches("bake me."), "both sides of the bad byte");
assert!(
!matches("kohlrabi"),
"and a term that is absent still misses"
);
assert!(
!matches("bake kohlrabi"),
"AND still narrows: one missing term is enough"
);
}
#[test]
fn a_file_with_no_valid_text_matches_nothing_and_fails_nothing() {
let dir = tempfile::TempDir::new().unwrap();
let path = base(&dir).join("junk.cook");
std::fs::write(&path, [0xff, 0xfe, 0xff, 0xfe, 0x80, 0x81]).unwrap();
assert!(
!matches_every_term(&path, &["tuna".to_string()]).expect("must not fail"),
"there is no text in it to match"
);
assert!(
matches_every_term(&path, &["junk".to_string()]).expect("must not fail"),
"but the file name is still a surface to match on"
);
}
#[test]
fn an_unreadable_file_is_still_an_io_error() {
let dir = tempfile::TempDir::new().unwrap();
let missing = base(&dir).join("gone.cook");
match matches_every_term(&missing, &["tuna".to_string()]) {
Err(CoreError::Io { path, source }) => {
assert_eq!(path, missing);
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected CoreError::Io, got {other:?}"),
}
}
#[test]
fn a_file_that_cannot_be_read_is_an_io_error_not_a_search_error() {
use cooklang_find::search::SearchError;
let root = Utf8Path::new("/recipes");
let unreadable = search_error(
SearchError::IoError(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
)),
root,
);
match unreadable {
CoreError::Io { path, source } => {
assert_eq!(path, root);
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
}
other => panic!("expected CoreError::Io, got {other:?}"),
}
let unusable = search_error(
SearchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
"bad front matter".to_string(),
)),
root,
);
match unusable {
CoreError::Io { path, source } => {
assert_eq!(path, root);
assert!(
source.to_string().contains("bad front matter"),
"the cause must survive: {source}"
);
}
other => panic!("expected CoreError::Io, got {other:?}"),
}
}
}