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