Skip to main content

cookcli_core/
recipe.rs

1//! Reading and scaling a single recipe.
2
3use crate::{
4    find::{entry_error, fetch_error},
5    parse_recipe, parse_recipe_at, Context, CoreError, Outcome, RecipeSource,
6};
7use camino::Utf8PathBuf;
8use cooklang::Recipe;
9
10/// The character separating a recipe name from an inline scaling factor.
11const SCALING_DELIMITER: char = ':';
12
13/// What to read, and at what scale.
14#[derive(Debug, Clone)]
15pub struct ReadRequest {
16    /// The recipe to read.
17    pub source: RecipeSource,
18    /// Scaling factor applied to all quantities. Pass `1.0` to leave
19    /// quantities alone.
20    ///
21    /// This is the only scaling channel. CookCLI's `name:factor` argument
22    /// convention is a *command-line* spelling, not a property of a path, so
23    /// callers split it themselves with [`split_name_and_scale`] — otherwise
24    /// a path chosen from a file picker could pick up a scaling factor from
25    /// any directory that happens to end in `:2`.
26    pub scale: f64,
27}
28
29/// A parsed recipe together with the title to display for it.
30#[derive(Debug, Clone)]
31#[non_exhaustive]
32pub struct ReadResult {
33    /// The parsed, scaled recipe.
34    pub recipe: Recipe,
35    /// The title to display for the recipe: its metadata `title` when it
36    /// declares one, otherwise the file stem for a path or the caller-supplied
37    /// name for in-memory text. Empty when none of those are known.
38    ///
39    /// Formatters put this in markdown headings and in the `name` of
40    /// schema.org output, so it is the recipe's identity rather than a debug
41    /// label — see [`CoreError::Parse`]'s `name` for the latter.
42    pub title: String,
43    /// The file the recipe was read from, once resolved. `None` for
44    /// [`RecipeSource::Content`].
45    ///
46    /// A bare name like `pancakes` can resolve to any of several directories
47    /// and either extension, so the caller cannot reconstruct this. Callers
48    /// that watch, reveal or write back the file they just read need it, and
49    /// [`Diagnostic::location`](crate::Diagnostic::location) does not serve:
50    /// a clean recipe produces no diagnostics at all.
51    pub path: Option<Utf8PathBuf>,
52}
53
54/// Split a `name:factor` query into its parts.
55///
56/// `"pasta.cook:2"` becomes `("pasta.cook", 2.0)`. Returns `None` when there is
57/// no colon, or when what follows the last one is not a number — so a Windows
58/// path like `C:\recipes\pasta.cook` is left alone.
59///
60/// This is CookCLI's command-line convention for naming a recipe and a scaling
61/// factor in one argument. [`read`] deliberately does not apply it: callers
62/// that accept arguments in that form split them here and fill in
63/// [`ReadRequest::scale`] themselves.
64pub fn split_name_and_scale(query: &str) -> Option<(&str, f64)> {
65    let (name, factor) = query.trim().rsplit_once(SCALING_DELIMITER)?;
66    let factor = factor.parse::<f64>().ok()?;
67    Some((name, factor))
68}
69
70/// Read a recipe, scale it, and report anything the parser had to say.
71///
72/// A [`RecipeSource::Path`] is resolved against [`Context::base_path`], trying
73/// both `.cook` and `.menu` when the name carries no extension. A
74/// [`RecipeSource::Content`] is parsed as given and never touches the
75/// filesystem; its `name` is only a fallback for [`ReadResult::title`], used
76/// when the recipe declares no title of its own.
77///
78/// # Errors
79///
80/// - [`CoreError::RecipeNotFound`] if no file matches the given path or name.
81///   Reserved for genuine absence — a file that exists but cannot be opened is
82///   an [`CoreError::Io`], since telling a caller a file it can see is "not
83///   found" sends it looking for the wrong problem.
84/// - [`CoreError::Io`] if the file is found but cannot be read or its front
85///   matter cannot be understood. The underlying cause is in `source`.
86/// - [`CoreError::Parse`] if the recipe has parse errors. Its `name` is the
87///   recipe's path, matching the file the `rendered` report points at.
88/// - [`CoreError::InvalidScale`] if the scale is not finite.
89pub fn read(ctx: &Context, req: ReadRequest) -> Result<Outcome<ReadResult>, CoreError> {
90    match req.source {
91        RecipeSource::Content { text, name } => {
92            let outcome = parse_recipe(&text, &name, req.scale)?;
93            let title = title_for(&outcome.value, || name);
94            Ok(Outcome::with_diagnostics(
95                ReadResult {
96                    recipe: outcome.value,
97                    title,
98                    path: None,
99                },
100                outcome.diagnostics,
101            ))
102        }
103        RecipeSource::Path(lookup) => {
104            let entry =
105                cooklang_find::get_recipe(vec![ctx.base_path().to_path_buf()], lookup.clone())
106                    .map_err(|e| fetch_error(e, &lookup))?;
107
108            // `get_recipe` only ever returns path-backed entries, but the type
109            // permits otherwise; fall back to what we looked up rather than
110            // inventing a placeholder path.
111            let path = entry.path().cloned();
112            let display_path = path.clone().unwrap_or(lookup);
113
114            let content = entry.content().map_err(|source| CoreError::Io {
115                path: display_path.clone(),
116                source: entry_error(source),
117            })?;
118
119            // Diagnostics and the parse report name the file, not the title, so
120            // that the caller can open what they point at.
121            let outcome =
122                parse_recipe_at(&content, display_path.as_str(), req.scale, path.as_deref())?;
123
124            let title = title_for(&outcome.value, || entry.name().clone().unwrap_or_default());
125            Ok(Outcome::with_diagnostics(
126                ReadResult {
127                    recipe: outcome.value,
128                    title,
129                    path,
130                },
131                outcome.diagnostics,
132            ))
133        }
134    }
135}
136
137/// The recipe's own declared title, or `fallback` when it declares none.
138///
139/// The one place the rule lives. It used to be applied twice — once from the
140/// parsed recipe and once from `cooklang-find`'s `entry.name()`, which reads
141/// YAML front matter only — so the same bytes produced different titles
142/// depending on whether they arrived by path or in memory.
143fn title_for(recipe: &Recipe, fallback: impl FnOnce() -> String) -> String {
144    recipe
145        .metadata
146        .title()
147        .map_or_else(fallback, ToOwned::to_owned)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::{Context, RecipeSource};
154    use cooklang::quantity::Value;
155
156    fn fixture_dir() -> tempfile::TempDir {
157        let dir = tempfile::TempDir::new().unwrap();
158        std::fs::write(
159            dir.path().join("simple.cook"),
160            "Boil @water{2%cups} for ~{5%minutes}.\nAdd @salt{1%tsp}.\n",
161        )
162        .unwrap();
163        dir
164    }
165
166    fn ctx_for(dir: &tempfile::TempDir) -> Context {
167        Context::new(camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap())
168    }
169
170    /// The numeric value of an ingredient's quantity, ignoring its unit.
171    ///
172    /// Comparing numbers rather than formatted quantities matters: cooklang
173    /// re-fits units when scaling, so `2 cups` can render as `4 c` and a string
174    /// comparison would be testing the formatter, not the scaling.
175    fn quantity_value(recipe: &Recipe, index: usize) -> f64 {
176        match recipe.ingredients[index]
177            .quantity
178            .as_ref()
179            .expect("ingredient has a quantity")
180            .value()
181        {
182            Value::Number(n) => n.value(),
183            other => panic!("expected a numeric quantity, got {other:?}"),
184        }
185    }
186
187    fn request(source: RecipeSource, scale: f64) -> ReadRequest {
188        ReadRequest { source, scale }
189    }
190
191    fn path_request(name: &str, scale: f64) -> ReadRequest {
192        request(RecipeSource::Path(Utf8PathBuf::from(name)), scale)
193    }
194
195    #[test]
196    fn reads_a_recipe_from_a_path() {
197        let dir = fixture_dir();
198        let outcome = read(&ctx_for(&dir), path_request("simple.cook", 1.0)).expect("reads");
199
200        let ReadResult {
201            recipe,
202            title,
203            path,
204        } = outcome.value;
205        assert_eq!(title, "simple", "title falls back to the file stem");
206        assert_eq!(
207            path.as_deref(),
208            Some(
209                camino::Utf8PathBuf::from_path_buf(dir.path().join("simple.cook"))
210                    .unwrap()
211                    .as_path()
212            ),
213            "the resolved file must be reported back"
214        );
215        assert_eq!(recipe.ingredients.len(), 2);
216        assert_eq!(recipe.ingredients[0].name, "water");
217        assert_eq!(recipe.ingredients[1].name, "salt");
218        assert_eq!(quantity_value(&recipe, 0), 2.0);
219        assert_eq!(quantity_value(&recipe, 1), 1.0);
220        assert!(outcome.diagnostics.is_empty());
221    }
222
223    /// The extension is optional, exactly as in `cook recipe simple`.
224    ///
225    /// This is why [`ReadResult::path`] has to exist: `"simple"` alone does not
226    /// tell the caller which file was opened.
227    #[test]
228    fn a_bare_name_resolves_to_the_cook_file() {
229        let dir = fixture_dir();
230        let outcome = read(&ctx_for(&dir), path_request("simple", 1.0)).expect("reads");
231        assert_eq!(outcome.value.title, "simple");
232        assert_eq!(outcome.value.recipe.ingredients.len(), 2);
233        assert_eq!(
234            outcome.value.path.as_ref().and_then(|p| p.file_name()),
235            Some("simple.cook"),
236            "the extension the lookup chose must be reported back"
237        );
238    }
239
240    #[test]
241    fn reads_a_recipe_from_memory() {
242        // A base path that does not exist: reading in-memory text must not go
243        // near the filesystem, so this context is never consulted.
244        let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
245        let outcome = read(
246            &ctx,
247            request(
248                RecipeSource::Content {
249                    text: "Boil @water{2%cups}.\nAdd @salt{1%tsp}.\n".to_string(),
250                    name: "buffer".to_string(),
251                },
252                1.0,
253            ),
254        )
255        .expect("reads in-memory text");
256
257        assert_eq!(
258            outcome.value.title, "buffer",
259            "with no title of its own, the recipe falls back to the caller's name"
260        );
261        assert_eq!(outcome.value.recipe.ingredients.len(), 2);
262        assert_eq!(quantity_value(&outcome.value.recipe, 0), 2.0);
263        assert_eq!(
264            outcome.value.path, None,
265            "in-memory text has no file to report"
266        );
267    }
268
269    /// The metadata title beats the caller's name and the file stem alike.
270    ///
271    /// This is what reaches `-f markdown` headings and the `name` of
272    /// schema.org output, so a fallback leaking through here corrupts them.
273    #[test]
274    fn a_metadata_title_wins_over_the_fallback_name() {
275        let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
276        let outcome = read(
277            &ctx,
278            request(
279                RecipeSource::Content {
280                    text: "---\ntitle: Proper Title\n---\nBoil @water{1%cup}.\n".to_string(),
281                    name: "buffer".to_string(),
282                },
283                1.0,
284            ),
285        )
286        .expect("reads");
287        assert_eq!(
288            outcome.value.title, "Proper Title",
289            "the recipe's own title must beat the caller's label"
290        );
291
292        let dir = tempfile::TempDir::new().unwrap();
293        std::fs::write(
294            dir.path().join("stem.cook"),
295            "---\ntitle: Proper Title\n---\nBoil @water{1%cup}.\n",
296        )
297        .unwrap();
298        let outcome = read(&ctx_for(&dir), path_request("stem.cook", 1.0)).expect("reads");
299        assert_eq!(outcome.value.title, "Proper Title");
300    }
301
302    /// The same bytes must produce the same title whichever way they arrive.
303    ///
304    /// The two routes used to run through different metadata parsers — the
305    /// cooklang parser for `Content`, `cooklang-find`'s front-matter reader for
306    /// `Path` — so anything the two disagreed on silently forked. The `>>`
307    /// spelling is one such disagreement and stands in for the rest.
308    #[test]
309    fn a_path_and_a_buffer_of_the_same_bytes_agree_on_the_title() {
310        for text in [
311            "---\ntitle: Agreed\n---\nBoil @water{1%cup}.\n",
312            ">> title: Agreed\n\nBoil @water{1%cup}.\n",
313            "Boil @water{1%cup}.\n",
314        ] {
315            let dir = tempfile::TempDir::new().unwrap();
316            std::fs::write(dir.path().join("same.cook"), text).unwrap();
317            let from_path = read(&ctx_for(&dir), path_request("same.cook", 1.0))
318                .expect("reads")
319                .value
320                .title;
321
322            let from_memory = read(
323                &Context::new(Utf8PathBuf::from("/nonexistent")),
324                request(
325                    RecipeSource::Content {
326                        text: text.to_string(),
327                        // The same fallback the path route uses, so only a real
328                        // disagreement can make these differ.
329                        name: "same".to_string(),
330                    },
331                    1.0,
332                ),
333            )
334            .expect("reads")
335            .value
336            .title;
337
338            assert_eq!(from_path, from_memory, "titles diverged for {text:?}");
339        }
340    }
341
342    #[test]
343    fn missing_recipe_is_recipe_not_found() {
344        let dir = fixture_dir();
345        match read(&ctx_for(&dir), path_request("absent.cook", 1.0)) {
346            Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
347            other => panic!("expected RecipeNotFound, got {other:?}"),
348        }
349    }
350
351    /// A file that exists but cannot be opened is *not* "recipe not found".
352    ///
353    /// Reporting absence for a file the caller can see in its own tree sends it
354    /// looking for the wrong problem, and hides the permission error that
355    /// actually needs fixing. This also covers the only other route into
356    /// `CoreError::Io` here, since `get_recipe` fails before the entry exists.
357    #[test]
358    #[cfg(unix)]
359    fn an_unreadable_recipe_is_an_io_error_not_a_missing_one() {
360        use std::os::unix::fs::PermissionsExt;
361
362        // Root ignores the permission bits, so there would be nothing to test.
363        if unsafe { libc_geteuid() } == 0 {
364            return;
365        }
366
367        let dir = fixture_dir();
368        let path = dir.path().join("simple.cook");
369        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
370
371        let result = read(&ctx_for(&dir), path_request("simple.cook", 1.0));
372
373        // Restore before asserting, so a failure does not leave an
374        // undeletable temporary directory behind.
375        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
376
377        match result {
378            Err(CoreError::Io {
379                path: reported,
380                source,
381            }) => {
382                assert_eq!(reported.file_name(), Some("simple.cook"));
383                assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
384            }
385            other => panic!("expected CoreError::Io, got {other:?}"),
386        }
387    }
388
389    // `geteuid` without taking a dependency on `libc` for one call.
390    #[cfg(unix)]
391    extern "C" {
392        #[link_name = "geteuid"]
393        fn libc_geteuid() -> u32;
394    }
395
396    /// The other way a recipe can be present but unusable, and the one that
397    /// needs no permission bits — so it runs everywhere, including as root and
398    /// on Windows. Covers `fetch_error`'s `RecipeEntryError` arm.
399    #[test]
400    fn a_directory_named_like_a_recipe_is_an_io_error() {
401        let dir = tempfile::TempDir::new().unwrap();
402        std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
403
404        match read(&ctx_for(&dir), path_request("adir.cook", 1.0)) {
405            Err(CoreError::Io { path, .. }) => {
406                assert_eq!(path.file_name(), Some("adir.cook"));
407            }
408            other => panic!("expected CoreError::Io, got {other:?}"),
409        }
410    }
411
412    #[test]
413    fn the_request_scale_is_applied() {
414        let dir = fixture_dir();
415        // `simple.cook` declares `@water{2%cups}`.
416        let outcome = read(&ctx_for(&dir), path_request("simple.cook", 3.0)).expect("reads");
417        assert_eq!(quantity_value(&outcome.value.recipe, 0), 6.0);
418        let outcome = read(&ctx_for(&dir), path_request("simple.cook", 0.5)).expect("reads");
419        assert_eq!(quantity_value(&outcome.value.recipe, 0), 1.0);
420    }
421
422    /// `read` takes a path, not a query string: a `:factor` suffix is part of
423    /// the name it looks for, so the file simply is not there.
424    ///
425    /// Splitting inside `read` would mean a path from a file picker could pick
426    /// up a scaling factor from any segment ending in `:<number>`. Callers that
427    /// accept CookCLI's argument spelling call [`split_name_and_scale`] first.
428    #[test]
429    fn an_inline_factor_is_not_interpreted_as_scaling() {
430        let dir = fixture_dir();
431        match read(&ctx_for(&dir), path_request("simple.cook:3", 1.0)) {
432            Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "simple.cook:3"),
433            other => panic!("expected RecipeNotFound, got {other:?}"),
434        }
435
436        // And the split, applied by the caller, gets the scaling it asked for.
437        let (name, factor) = split_name_and_scale("simple.cook:3").expect("splits");
438        let outcome = read(&ctx_for(&dir), path_request(name, factor)).expect("reads");
439        assert_eq!(quantity_value(&outcome.value.recipe, 0), 6.0);
440    }
441
442    #[test]
443    fn content_is_scaled_by_the_request() {
444        let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
445        let outcome = read(
446            &ctx,
447            request(
448                RecipeSource::Content {
449                    text: "Boil @water{2%cups}.\n".to_string(),
450                    name: "buffer".to_string(),
451                },
452                2.5,
453            ),
454        )
455        .expect("reads");
456        assert_eq!(quantity_value(&outcome.value.recipe, 0), 5.0);
457    }
458
459    /// A colon in a filename is just a character, and reaches the lookup intact.
460    #[test]
461    fn a_colon_in_a_filename_is_part_of_the_name() {
462        let dir = tempfile::TempDir::new().unwrap();
463        std::fs::write(dir.path().join("odd:name.cook"), "Boil @water{2%cups}.\n").unwrap();
464        let outcome = read(&ctx_for(&dir), path_request("odd:name.cook", 2.0)).expect("reads");
465        assert_eq!(quantity_value(&outcome.value.recipe, 0), 4.0);
466    }
467
468    #[test]
469    fn parse_errors_name_the_path_not_the_title() {
470        let dir = tempfile::TempDir::new().unwrap();
471        let path = camino::Utf8PathBuf::from_path_buf(dir.path().join("broken.cook")).unwrap();
472        // A metadata title, so a report naming the title would be visibly wrong.
473        std::fs::write(&path, "---\ntitle: Fancy\n---\nAdd @{1%tsp}.\n").unwrap();
474
475        match read(&ctx_for(&dir), path_request("broken.cook", 1.0)) {
476            Err(CoreError::Parse {
477                name,
478                diagnostics,
479                rendered,
480            }) => {
481                assert_eq!(name, path.as_str());
482                assert!(
483                    rendered.contains(path.as_str()),
484                    "report should name the file: {rendered}"
485                );
486                assert_eq!(diagnostics.len(), 1);
487                assert_eq!(
488                    diagnostics[0].location.as_ref().unwrap().file.as_deref(),
489                    Some(path.as_path())
490                );
491            }
492            other => panic!("expected CoreError::Parse, got {other:?}"),
493        }
494    }
495
496    /// Warnings come back rather than being logged and dropped.
497    #[test]
498    fn warnings_reach_the_caller() {
499        let dir = tempfile::TempDir::new().unwrap();
500        std::fs::write(
501            dir.path().join("old.cook"),
502            ">> title: Old Style\n\nBoil @water{1%cup}.\n",
503        )
504        .unwrap();
505
506        let outcome = read(&ctx_for(&dir), path_request("old.cook", 1.0)).expect("parses");
507        // The declared title, even in the deprecated `>>` spelling that
508        // `cooklang-find`'s front-matter reader does not understand. Reading
509        // the same bytes from memory must give the same answer.
510        assert_eq!(outcome.value.title, "Old Style");
511        assert!(!outcome.diagnostics.is_empty(), "expected a diagnostic");
512        for d in &outcome.diagnostics {
513            assert_eq!(d.severity, crate::Severity::Warning, "got {d:?}");
514        }
515    }
516
517    #[test]
518    fn non_finite_scale_is_rejected() {
519        let dir = fixture_dir();
520        match read(&ctx_for(&dir), path_request("simple.cook", f64::NAN)) {
521            Err(CoreError::InvalidScale { scale }) => assert!(scale.is_nan()),
522            other => panic!("expected InvalidScale, got {other:?}"),
523        }
524    }
525
526    #[test]
527    fn splits_a_name_from_its_scaling_factor() {
528        assert_eq!(
529            split_name_and_scale("recipe.cook:2"),
530            Some(("recipe.cook", 2.0))
531        );
532        assert_eq!(
533            split_name_and_scale("recipe.cook:1.5"),
534            Some(("recipe.cook", 1.5))
535        );
536        assert_eq!(split_name_and_scale("recipe.cook"), None);
537        assert_eq!(split_name_and_scale("recipe.cook:abc"), None);
538        // Regression for https://github.com/cooklang/cookcli/issues/335: a
539        // Windows drive letter must not be read as a scaling factor.
540        assert_eq!(split_name_and_scale(r"C:\test\recipe.cook"), None);
541        // The last colon wins, so a directory with a colon in it still scales.
542        assert_eq!(
543            split_name_and_scale("odd:dir/recipe.cook:2"),
544            Some(("odd:dir/recipe.cook", 2.0))
545        );
546    }
547}