Skip to main content

cookcli_core/
find.rs

1//! Resolving a recipe name or path to a file on disk, and walking a whole
2//! collection of them.
3
4use 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
10/// Re-exported from [`cooklang_format`], which is where the writers that
11/// depend on this spelling now live.
12pub use cooklang_format::REFERENCE_SEPARATOR;
13
14/// Look `name` up under `base_path`, returning the file it resolves to.
15///
16/// `name` may be a path or a bare recipe name, with or without an extension —
17/// `cooklang-find` tries `.cook` and `.menu` for the latter. A leading `./` is
18/// stripped first, because `cooklang-find` does not expect it.
19///
20/// # Errors
21///
22/// - [`CoreError::RecipeNotFound`] if nothing matches. Reserved for genuine
23///   absence, so that a caller told "not found" does not go looking for a file
24///   that is sitting right there.
25/// - [`CoreError::Io`] if a match was found but could not be opened or its
26///   front matter could not be understood. The cause is in `source`.
27pub fn get_recipe(base_path: &Utf8Path, name: &str) -> Result<RecipeEntry, CoreError> {
28    // Remove the `./` prefix if present before passing to cooklang_find, which
29    // does not expect it.
30    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
36/// Map a lookup failure onto the error that describes what actually happened.
37///
38/// Only `FetchError::InvalidPath` means the recipe is absent;
39/// the rest mean it was found and could not be opened or understood.
40pub(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
60/// Unwrap a `cooklang-find` entry error to the underlying [`std::io::Error`].
61///
62/// Its other variants are front matter problems rather than I/O; they keep
63/// their message as the source so nothing is lost, and travel as
64/// [`CoreError::Io`] because they too mean "found, but unusable".
65pub(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
72/// Map a tree-building failure onto the error that describes what happened.
73///
74/// Everything except a listing failure is about the root itself: it is missing,
75/// it is a file, or its name cannot be turned into a glob pattern. None of them
76/// mean a recipe was read and rejected.
77///
78/// Shared by every command that walks a collection — `doctor::validate`,
79/// `pantry::recipes` and `pantry::plan` — so that a mistyped root is reported
80/// the same way whichever of them was asked.
81pub(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        // The variants' own `Display` repeats the path, which the `Search`
88        // rendering already names.
89        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        // What a root spelled `./recipes` gets: the walk finds
93        // `recipes/soup.cook`, which does not start with `./recipes`. Reached
94        // by `cook doctor validate -b ./recipes`, so it is worth wording for a
95        // person.
96        TreeError::StripPrefixError(what) => {
97            search(format!("cannot express {what} relative to it"))
98        }
99        // Carries the file it failed on, which is more use than the root.
100        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        // Unreachable through `build_tree`, which skips an entry it cannot
107        // load rather than failing. Mapped rather than ignored so that the
108        // match stops compiling if that changes.
109        TreeError::RecipeEntryError(source) => CoreError::Io {
110            path: base_dir.to_owned(),
111            source: entry_error(source),
112        },
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Walking a collection
118// ---------------------------------------------------------------------------
119
120/// Build the recipe tree under `base_dir`, in this crate's error wording.
121pub(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
126/// Every recipe in the tree, depth first, in path order.
127///
128/// Sorted because `cooklang-find` holds a directory's children in a `HashMap`,
129/// so the walk itself yields them differently from run to run. Several callers
130/// sort their own results anyway — `pantry::recipes` does, and `pantry::plan`
131/// breaks its ties alphabetically — but the diagnostics depend on this:
132/// without it, a collection with two unreadable recipes reports them in a
133/// different order each time.
134pub(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
150/// Parse one recipe, or note that it was left out.
151///
152/// Nothing here fails the walk: one unreadable file in a collection must not
153/// cost the caller the answer for the rest of it.
154///
155/// **Only the skip is reported.** A recipe that parses with warnings — the
156/// deprecated `>>` metadata syntax, say — contributes its ingredients and none
157/// of its warnings, because they cannot change any answer a caller of this is
158/// computing. `doctor::validate` is the one command that reports them, and the
159/// reason `cook doctor aisle` and `cook doctor pantry` no longer log a recipe's
160/// parse warnings the way they did before they went through here.
161pub(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    // Unscaled: scaling by one would only re-fit units, and no caller of this
188    // reads a quantity.
189    match parse_unscaled(&content, &display, entry.path().map(Utf8PathBuf::as_path)) {
190        Ok(outcome) => Some(outcome.value),
191        Err(_) => skipped("parse"),
192    }
193}
194
195/// The ingredients a recipe asks the reader to have, as it writes them.
196///
197/// A set, so a recipe using flour twice wants flour once.
198///
199/// References to other recipes are left out: they are a recipe to make, not a
200/// thing to have in. Ingredients the parser marks as not to be listed are left
201/// out too, though with [`PARSER`](crate::PARSER)'s extensions there are none
202/// — `@-salt{}` parses as an ingredient *named* `-salt` rather than a hidden
203/// one, so it counts like any other. The filter is kept for the day that
204/// changes.
205pub(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    /// The `./` strip is what makes a reference like `@./sub/stew{}` resolve.
245    #[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            // The `./` must not survive into the reported name, or the message
257            // names something the caller never asked for.
258            Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
259            other => panic!("expected RecipeNotFound, got {other:?}"),
260        }
261    }
262
263    /// Present but unusable is an I/O error, not absence.
264    #[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    /// Pins all three lookup outcomes, including the one `cooklang-find` does
275    /// not currently produce: only genuine absence may be reported as absence.
276    #[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    /// `entry_error` is the shared mapping used both when the lookup fails and
321    /// when a later read does. Reaching the latter needs the file to become
322    /// unreadable *between* two reads, which cannot be arranged without a race,
323    /// so pin the mapping directly instead.
324    #[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}