1use crate::{parser::parse_unscaled, CoreError, Diagnostic};
5use camino::{Utf8Path, Utf8PathBuf};
6use cooklang::Recipe;
7use cooklang_find::{tree::TreeError, RecipeEntry, RecipeTree};
8use std::collections::BTreeSet;
9
10pub use cooklang_format::REFERENCE_SEPARATOR;
13
14pub fn get_recipe(base_path: &Utf8Path, name: &str) -> Result<RecipeEntry, CoreError> {
28 let clean_name = name.strip_prefix("./").unwrap_or(name);
31
32 cooklang_find::get_recipe(vec![base_path.to_path_buf()], clean_name.into())
33 .map_err(|e| fetch_error(e, Utf8Path::new(clean_name)))
34}
35
36pub(crate) fn fetch_error(
41 error: cooklang_find::fetcher::FetchError,
42 lookup: &Utf8Path,
43) -> CoreError {
44 use cooklang_find::fetcher::FetchError;
45 match error {
46 FetchError::InvalidPath(name) => CoreError::RecipeNotFound {
47 name: name.to_string(),
48 },
49 FetchError::IoError(source) => CoreError::Io {
50 path: lookup.to_owned(),
51 source,
52 },
53 FetchError::RecipeEntryError(source) => CoreError::Io {
54 path: lookup.to_owned(),
55 source: entry_error(source),
56 },
57 }
58}
59
60pub(crate) fn entry_error(error: cooklang_find::RecipeEntryError) -> std::io::Error {
66 match error {
67 cooklang_find::RecipeEntryError::IoError(e) => e,
68 other => std::io::Error::other(other.to_string()),
69 }
70}
71
72pub(crate) fn tree_error(error: TreeError, base_dir: &Utf8Path) -> CoreError {
82 let search = |message: String| CoreError::Search {
83 base_dir: base_dir.to_owned(),
84 message,
85 };
86 match error {
87 TreeError::DirectoryNotFound(_) => search("no such directory".to_string()),
90 TreeError::NotADirectory(_) => search("not a directory".to_string()),
91 TreeError::PatternError(source) => search(source.to_string()),
92 TreeError::StripPrefixError(what) => {
97 search(format!("cannot express {what} relative to it"))
98 }
99 TreeError::GlobError(source) => CoreError::Io {
101 path: Utf8Path::from_path(source.path())
102 .map(Utf8Path::to_owned)
103 .unwrap_or_else(|| base_dir.to_owned()),
104 source: source.into_error(),
105 },
106 TreeError::RecipeEntryError(source) => CoreError::Io {
110 path: base_dir.to_owned(),
111 source: entry_error(source),
112 },
113 }
114}
115
116pub(crate) fn build_tree(base_dir: &Utf8Path) -> Result<RecipeTree, CoreError> {
122 tracing::trace!("walking recipes under {base_dir}");
123 cooklang_find::build_tree(base_dir).map_err(|e| tree_error(e, base_dir))
124}
125
126pub(crate) fn walk(tree: &RecipeTree) -> Vec<&RecipeEntry> {
135 fn collect<'a>(tree: &'a RecipeTree, out: &mut Vec<&'a RecipeEntry>) {
136 if let Some(entry) = &tree.recipe {
137 out.push(entry);
138 }
139 for subtree in tree.children.values() {
140 collect(subtree, out);
141 }
142 }
143
144 let mut entries = Vec::new();
145 collect(tree, &mut entries);
146 entries.sort_by_key(|entry| (entry.path().cloned(), entry.name().clone()));
147 entries
148}
149
150pub(crate) fn parse_or_skip(
162 entry: &RecipeEntry,
163 diagnostics: &mut Vec<Diagnostic>,
164) -> Option<Recipe> {
165 let display = entry
166 .path()
167 .map(ToString::to_string)
168 .or_else(|| entry.name().clone())
169 .unwrap_or_else(|| "unknown".to_string());
170
171 let mut skipped = |reason: &str| {
172 let diagnostic = Diagnostic::warning(format!(
173 "could not {reason} {display}, so it was not considered"
174 ));
175 diagnostics.push(match entry.path() {
176 Some(path) => diagnostic.at_file(path),
177 None => diagnostic,
178 });
179 None
180 };
181
182 let content = match entry.content() {
183 Ok(content) => content,
184 Err(_) => return skipped("read"),
185 };
186
187 match parse_unscaled(&content, &display, entry.path().map(Utf8PathBuf::as_path)) {
190 Ok(outcome) => Some(outcome.value),
191 Err(_) => skipped("parse"),
192 }
193}
194
195pub(crate) fn listed_ingredients(recipe: &Recipe) -> BTreeSet<String> {
206 recipe
207 .ingredients
208 .iter()
209 .filter(|ingredient| ingredient.reference.is_none())
210 .filter(|ingredient| ingredient.modifiers().should_be_listed())
211 .map(|ingredient| ingredient.display_name().to_string())
212 .collect()
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn fixture() -> tempfile::TempDir {
220 let dir = tempfile::TempDir::new().unwrap();
221 std::fs::create_dir(dir.path().join("sub")).unwrap();
222 std::fs::write(dir.path().join("soup.cook"), "Boil @water{1%l}.\n").unwrap();
223 std::fs::write(
224 dir.path().join("sub").join("stew.cook"),
225 "Boil @water{1%l}.\n",
226 )
227 .unwrap();
228 dir
229 }
230
231 fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
232 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
233 }
234
235 #[test]
236 fn finds_a_recipe_by_name_path_and_extension() {
237 let dir = fixture();
238 for name in ["soup", "soup.cook", "./soup.cook"] {
239 let entry = get_recipe(&base(&dir), name).unwrap_or_else(|e| panic!("{name}: {e}"));
240 assert_eq!(entry.path().and_then(|p| p.file_name()), Some("soup.cook"));
241 }
242 }
243
244 #[test]
246 fn a_leading_dot_slash_is_stripped_from_nested_paths() {
247 let dir = fixture();
248 let entry = get_recipe(&base(&dir), "./sub/stew.cook").expect("resolves");
249 assert_eq!(entry.path().and_then(|p| p.file_name()), Some("stew.cook"));
250 }
251
252 #[test]
253 fn a_missing_recipe_is_not_found() {
254 let dir = fixture();
255 match get_recipe(&base(&dir), "./absent.cook") {
256 Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
259 other => panic!("expected RecipeNotFound, got {other:?}"),
260 }
261 }
262
263 #[test]
265 fn a_directory_named_like_a_recipe_is_an_io_error() {
266 let dir = tempfile::TempDir::new().unwrap();
267 std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
268 match get_recipe(&base(&dir), "adir.cook") {
269 Err(CoreError::Io { path, .. }) => assert_eq!(path.file_name(), Some("adir.cook")),
270 other => panic!("expected CoreError::Io, got {other:?}"),
271 }
272 }
273
274 #[test]
277 fn only_a_missing_file_maps_to_recipe_not_found() {
278 use cooklang_find::fetcher::FetchError;
279 let lookup = Utf8Path::new("recipes/pancakes.cook");
280
281 let absent = fetch_error(
282 FetchError::InvalidPath(Utf8PathBuf::from("pancakes.cook")),
283 lookup,
284 );
285 assert!(
286 matches!(absent, CoreError::RecipeNotFound { ref name } if name == "pancakes.cook"),
287 "an absent file is not found, got {absent:?}"
288 );
289
290 let unreadable = fetch_error(
291 FetchError::IoError(std::io::Error::new(
292 std::io::ErrorKind::PermissionDenied,
293 "denied",
294 )),
295 lookup,
296 );
297 match unreadable {
298 CoreError::Io { path, source } => {
299 assert_eq!(path, lookup);
300 assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
301 }
302 other => panic!("an unreadable file is an I/O error, got {other:?}"),
303 }
304
305 let unusable = fetch_error(
306 FetchError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
307 "bad front matter".to_string(),
308 )),
309 lookup,
310 );
311 match unusable {
312 CoreError::Io { path, source } => {
313 assert_eq!(path, lookup);
314 assert!(source.to_string().contains("bad front matter"));
315 }
316 other => panic!("an unusable file is an I/O error, got {other:?}"),
317 }
318 }
319
320 #[test]
325 fn entry_errors_keep_their_io_kind_and_never_lose_their_message() {
326 let io = entry_error(cooklang_find::RecipeEntryError::IoError(
327 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
328 ));
329 assert_eq!(
330 io.kind(),
331 std::io::ErrorKind::PermissionDenied,
332 "an I/O cause must keep its kind, so callers can match on it"
333 );
334
335 let other = entry_error(cooklang_find::RecipeEntryError::MetadataError(
336 "bad front matter".to_string(),
337 ));
338 assert!(
339 other.to_string().contains("bad front matter"),
340 "a non-I/O cause must keep its message: {other}"
341 );
342 }
343}