use crate::{parser::parse_unscaled, CoreError, Diagnostic};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang::Recipe;
use cooklang_find::{tree::TreeError, RecipeEntry, RecipeTree};
use std::collections::BTreeSet;
pub use cooklang_format::REFERENCE_SEPARATOR;
pub fn get_recipe(base_path: &Utf8Path, name: &str) -> Result<RecipeEntry, CoreError> {
let clean_name = name.strip_prefix("./").unwrap_or(name);
cooklang_find::get_recipe(vec![base_path.to_path_buf()], clean_name.into())
.map_err(|e| fetch_error(e, Utf8Path::new(clean_name)))
}
pub(crate) fn fetch_error(
error: cooklang_find::fetcher::FetchError,
lookup: &Utf8Path,
) -> CoreError {
use cooklang_find::fetcher::FetchError;
match error {
FetchError::InvalidPath(name) => CoreError::RecipeNotFound {
name: name.to_string(),
},
FetchError::IoError(source) => CoreError::Io {
path: lookup.to_owned(),
source,
},
FetchError::RecipeEntryError(source) => CoreError::Io {
path: lookup.to_owned(),
source: entry_error(source),
},
}
}
pub(crate) fn entry_error(error: cooklang_find::RecipeEntryError) -> std::io::Error {
match error {
cooklang_find::RecipeEntryError::IoError(e) => e,
other => std::io::Error::other(other.to_string()),
}
}
pub(crate) fn tree_error(error: TreeError, base_dir: &Utf8Path) -> CoreError {
let search = |message: String| CoreError::Search {
base_dir: base_dir.to_owned(),
message,
};
match error {
TreeError::DirectoryNotFound(_) => search("no such directory".to_string()),
TreeError::NotADirectory(_) => search("not a directory".to_string()),
TreeError::PatternError(source) => search(source.to_string()),
TreeError::StripPrefixError(what) => {
search(format!("cannot express {what} relative to it"))
}
TreeError::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(),
},
TreeError::RecipeEntryError(source) => CoreError::Io {
path: base_dir.to_owned(),
source: entry_error(source),
},
}
}
pub(crate) fn build_tree(base_dir: &Utf8Path) -> Result<RecipeTree, CoreError> {
tracing::trace!("walking recipes under {base_dir}");
cooklang_find::build_tree(base_dir).map_err(|e| tree_error(e, base_dir))
}
pub(crate) fn walk(tree: &RecipeTree) -> Vec<&RecipeEntry> {
fn collect<'a>(tree: &'a RecipeTree, out: &mut Vec<&'a RecipeEntry>) {
if let Some(entry) = &tree.recipe {
out.push(entry);
}
for subtree in tree.children.values() {
collect(subtree, out);
}
}
let mut entries = Vec::new();
collect(tree, &mut entries);
entries.sort_by_key(|entry| (entry.path().cloned(), entry.name().clone()));
entries
}
pub(crate) fn parse_or_skip(
entry: &RecipeEntry,
diagnostics: &mut Vec<Diagnostic>,
) -> Option<Recipe> {
let display = entry
.path()
.map(ToString::to_string)
.or_else(|| entry.name().clone())
.unwrap_or_else(|| "unknown".to_string());
let mut skipped = |reason: &str| {
let diagnostic = Diagnostic::warning(format!(
"could not {reason} {display}, so it was not considered"
));
diagnostics.push(match entry.path() {
Some(path) => diagnostic.at_file(path),
None => diagnostic,
});
None
};
let content = match entry.content() {
Ok(content) => content,
Err(_) => return skipped("read"),
};
match parse_unscaled(&content, &display, entry.path().map(Utf8PathBuf::as_path)) {
Ok(outcome) => Some(outcome.value),
Err(_) => skipped("parse"),
}
}
pub(crate) fn listed_ingredients(recipe: &Recipe) -> BTreeSet<String> {
recipe
.ingredients
.iter()
.filter(|ingredient| ingredient.reference.is_none())
.filter(|ingredient| ingredient.modifiers().should_be_listed())
.map(|ingredient| ingredient.display_name().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("soup.cook"), "Boil @water{1%l}.\n").unwrap();
std::fs::write(
dir.path().join("sub").join("stew.cook"),
"Boil @water{1%l}.\n",
)
.unwrap();
dir
}
fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
}
#[test]
fn finds_a_recipe_by_name_path_and_extension() {
let dir = fixture();
for name in ["soup", "soup.cook", "./soup.cook"] {
let entry = get_recipe(&base(&dir), name).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(entry.path().and_then(|p| p.file_name()), Some("soup.cook"));
}
}
#[test]
fn a_leading_dot_slash_is_stripped_from_nested_paths() {
let dir = fixture();
let entry = get_recipe(&base(&dir), "./sub/stew.cook").expect("resolves");
assert_eq!(entry.path().and_then(|p| p.file_name()), Some("stew.cook"));
}
#[test]
fn a_missing_recipe_is_not_found() {
let dir = fixture();
match get_recipe(&base(&dir), "./absent.cook") {
Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
other => panic!("expected RecipeNotFound, got {other:?}"),
}
}
#[test]
fn a_directory_named_like_a_recipe_is_an_io_error() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
match get_recipe(&base(&dir), "adir.cook") {
Err(CoreError::Io { path, .. }) => assert_eq!(path.file_name(), Some("adir.cook")),
other => panic!("expected CoreError::Io, got {other:?}"),
}
}
#[test]
fn only_a_missing_file_maps_to_recipe_not_found() {
use cooklang_find::fetcher::FetchError;
let lookup = Utf8Path::new("recipes/pancakes.cook");
let absent = fetch_error(
FetchError::InvalidPath(Utf8PathBuf::from("pancakes.cook")),
lookup,
);
assert!(
matches!(absent, CoreError::RecipeNotFound { ref name } if name == "pancakes.cook"),
"an absent file is not found, got {absent:?}"
);
let unreadable = fetch_error(
FetchError::IoError(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
)),
lookup,
);
match unreadable {
CoreError::Io { path, source } => {
assert_eq!(path, lookup);
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
}
other => panic!("an unreadable file is an I/O error, got {other:?}"),
}
let unusable = fetch_error(
FetchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
"bad front matter".to_string(),
)),
lookup,
);
match unusable {
CoreError::Io { path, source } => {
assert_eq!(path, lookup);
assert!(source.to_string().contains("bad front matter"));
}
other => panic!("an unusable file is an I/O error, got {other:?}"),
}
}
#[test]
fn entry_errors_keep_their_io_kind_and_never_lose_their_message() {
let io = entry_error(cooklang_find::RecipeEntryError::IoError(
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
));
assert_eq!(
io.kind(),
std::io::ErrorKind::PermissionDenied,
"an I/O cause must keep its kind, so callers can match on it"
);
let other = entry_error(cooklang_find::RecipeEntryError::MetadataError(
"bad front matter".to_string(),
));
assert!(
other.to_string().contains("bad front matter"),
"a non-I/O cause must keep its message: {other}"
);
}
}