Skip to main content

cookcli_core/
report.rs

1//! Rendering a recipe through a Jinja2 template.
2//!
3//! [`render`] is a thin, non-fatal layer over the `cooklang-reports` crate: it
4//! resolves the recipe and the aisle/pantry configuration the way the rest of
5//! this crate does, hands the result to `cooklang-reports`, and turns any
6//! failure into a [`CoreError`] instead of ending the process.
7//!
8//! # What a template can see
9//!
10//! The variables come from `cooklang-reports`, not from here. As of 0.5.1 they
11//! are `scale`, `sections`, `ingredients`, `cookware`, `metadata`, `datastore`,
12//! `base_path`, `aisle_content` and `pantry_content`, plus the functions and
13//! filters that crate registers — `db`, `get_ingredient_list`, `aisled`,
14//! `excluding_pantry`, `from_pantry`, the `number_*` family and the string
15//! filters. There is **no** `recipe` variable: `{{ recipe.title }}` is
16//! `{{ metadata.title }}`, and `recipe.ingredients` is `ingredients`.
17//!
18//! # Two things `cooklang-reports` does that this crate cannot stop
19//!
20//! - **It parses the recipe itself**, with `CooklangParser::canonical` (every
21//!   extension on, no unit converter) rather than the configuration [`PARSER`]
22//!   uses. So a recipe may render here and fail [`parse_recipe`], or the
23//!   reverse, and the quantities a template sees are not necessarily the ones
24//!   [`recipe::read`] would produce.
25//! - **It writes warnings straight to stderr.** Recipe warnings, aisle and
26//!   pantry warnings, and a datastore key it could not find are all
27//!   `eprintln!`ed from inside the render call. Nothing reaches this crate, so
28//!   [`Outcome::diagnostics`] on the way back is always empty — do not read it
29//!   as "the recipe was clean".
30//!
31//! [`PARSER`]: crate::PARSER
32//! [`parse_recipe`]: crate::parse_recipe
33//! [`recipe::read`]: crate::recipe::read
34//! [`Outcome::diagnostics`]: crate::Outcome::diagnostics
35
36use crate::{
37    find::{entry_error, get_recipe},
38    ConfigSource, Context, CoreError, Outcome, RecipeSource,
39};
40use camino::{Utf8Path, Utf8PathBuf};
41use cooklang_reports::{config::Config, render_template_with_config};
42
43/// A report to render.
44#[derive(Debug, Clone)]
45pub struct RenderRequest {
46    /// The recipe to render.
47    pub source: RecipeSource,
48    /// The template text itself, not a path to it.
49    ///
50    /// Reading a `.jinja` off disk is the caller's job, which is what lets a
51    /// caller holding one in a buffer pass it straight in. Nothing is lost by
52    /// taking the text: `cooklang-reports` registers this as the environment's
53    /// only template and configures no loader, so `{% include %}` and
54    /// `{% extends %}` have nothing to resolve against either way.
55    pub template: String,
56    /// Scaling factor. Pass `1.0` to leave quantities alone.
57    ///
58    /// Reaches the template as the `scale` variable *and* scales the recipe, so
59    /// a template printing `{{ scale }}` alongside its quantities stays
60    /// consistent. As in [`ReadRequest::scale`](crate::recipe::ReadRequest),
61    /// CookCLI's `name:factor` spelling is a command-line convention: callers
62    /// split it themselves with
63    /// [`split_name_and_scale`](crate::recipe::split_name_and_scale).
64    pub scale: f64,
65    /// Directory of the YAML datastore the `db()` template function reads.
66    ///
67    /// `None` leaves `db()` unusable; a template that calls it then fails with
68    /// [`CoreError::Render`]. A path that does not exist is *not* an error
69    /// here — `cooklang-reports` reports a key it cannot find by warning on
70    /// stderr and substituting an empty string.
71    pub datastore: Option<Utf8PathBuf>,
72    /// Directory that recipe references inside the template resolve against,
73    /// and that a [`RecipeSource::Path`] is looked up under.
74    ///
75    /// Defaults to [`Context::base_path`]. It also reaches the template as the
76    /// `base_path` variable. Unlike the CLI, nothing here makes it absolute: a
77    /// relative path is interpreted against the *process* working directory.
78    pub base_path: Option<Utf8PathBuf>,
79}
80
81/// Render `req`'s recipe through `req`'s template.
82///
83/// Aisle and pantry configuration come from `ctx`, and both
84/// [`ConfigSource`] kinds work: a [`ConfigSource::Path`] is handed to
85/// `cooklang-reports` to read, and a [`ConfigSource::Inline`] is injected
86/// directly as the `aisle_content` / `pantry_content` template variables that
87/// `aisled()`, `excluding_pantry()` and `from_pantry()` look up. The two are
88/// equivalent to a template, with one difference worth knowing: a
89/// [`ConfigSource::Path`] naming a file that cannot be read is *not* an error —
90/// `cooklang-reports` warns on stderr and carries on as though no aisle or
91/// pantry had been given.
92///
93/// # Errors
94///
95/// - [`CoreError::InvalidScale`] if the scale is not finite. Checked before
96///   anything is read.
97/// - [`CoreError::RecipeNotFound`] if a [`RecipeSource::Path`] matches nothing.
98/// - [`CoreError::Io`] if such a path matches a file that cannot be read.
99/// - [`CoreError::Render`] if the template is broken, if rendering it fails, or
100///   if `cooklang-reports` could not parse the recipe. Its `rendered` field
101///   carries that crate's own formatted report, which the CLI prints verbatim.
102pub fn render(ctx: &Context, req: RenderRequest) -> Result<Outcome<String>, CoreError> {
103    if !req.scale.is_finite() {
104        return Err(CoreError::InvalidScale { scale: req.scale });
105    }
106
107    let base_path = req
108        .base_path
109        .unwrap_or_else(|| ctx.base_path().to_path_buf());
110
111    let recipe = recipe_text(&base_path, req.source)?;
112
113    let mut builder = Config::builder();
114    builder.scale(req.scale);
115    builder.base_path(base_path.as_std_path());
116    if let Some(datastore) = &req.datastore {
117        builder.datastore_path(datastore.as_std_path());
118    }
119    if let Some(aisle) = ctx.aisle().path() {
120        builder.aisle_path(aisle.as_std_path());
121    }
122    if let Some(pantry) = ctx.pantry().path() {
123        builder.pantry_path(pantry.as_std_path());
124    }
125
126    let mut config = builder.build();
127    // Inline configuration cannot go through the builder, which only takes
128    // paths. `with_context` overlays the template context and wins on conflict,
129    // and these are the very names `cooklang-reports` fills in from a path and
130    // that its aisle and pantry functions look up — so an inline source is not
131    // a second-class one, and nothing is written to a temporary file to fake it.
132    if let ConfigSource::Inline(text) = ctx.aisle() {
133        config = config.with_context("aisle_content", text.clone());
134    }
135    if let ConfigSource::Inline(text) = ctx.pantry() {
136        config = config.with_context("pantry_content", text.clone());
137    }
138
139    tracing::trace!(
140        "rendering a report against {base_path} at scale {}",
141        req.scale
142    );
143
144    let report =
145        render_template_with_config(&recipe, &req.template, &config).map_err(render_error)?;
146
147    // Always empty: see the module docs on where `cooklang-reports` puts its
148    // warnings. `Outcome` is returned anyway so that this command reads like
149    // every other one, and so that diagnostics can start arriving without a
150    // breaking change.
151    Ok(Outcome::new(report))
152}
153
154/// The recipe text to render, read from disk only when asked for a path.
155fn recipe_text(base_path: &Utf8Path, source: RecipeSource) -> Result<String, CoreError> {
156    match source {
157        RecipeSource::Content { text, .. } => Ok(text),
158        RecipeSource::Path(lookup) => {
159            let entry = get_recipe(base_path, lookup.as_str())?;
160            let path = entry.path().cloned().unwrap_or(lookup);
161            entry.content().map_err(|source| CoreError::Io {
162                path,
163                source: entry_error(source),
164            })
165        }
166    }
167}
168
169/// Turn a `cooklang-reports` failure into a [`CoreError::Render`].
170///
171/// `format_with_source` is multi-line — source location, error chain and hints
172/// — so it goes in `rendered` and a one-line summary goes in `message`, keeping
173/// `Display` to a single line like every other variant.
174fn render_error(error: cooklang_reports::Error) -> CoreError {
175    let rendered = error.format_with_source();
176    let message = match &error {
177        // minijinja's own `Display`, e.g. "syntax error: unexpected end of
178        // input (in base:1)". `first_line` guards the day it stops being one.
179        cooklang_reports::Error::TemplateError(e) => first_line(&e.to_string()),
180        // The `SourceReport` this carries renders as a multi-line report, which
181        // is already in `rendered`; summarising it here would only repeat the
182        // first diagnostic out of context.
183        cooklang_reports::Error::RecipeParseError(_) => {
184            "the recipe could not be parsed".to_string()
185        }
186    };
187    CoreError::Render { message, rendered }
188}
189
190/// The first line of `text`, with trailing whitespace removed.
191fn first_line(text: &str) -> String {
192    text.lines()
193        .next()
194        .unwrap_or_default()
195        .trim_end()
196        .to_string()
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::{ConfigSource, Context, RecipeSource};
203
204    const PANCAKES: &str = "---\ntitle: Pancakes\n---\n\n\
205        Mix @eggs{3%large} with @milk{250%ml} and @flour{125%g}.\n";
206
207    fn ctx() -> Context {
208        Context::new(Utf8PathBuf::from("."))
209    }
210
211    fn request(template: &str) -> RenderRequest {
212        RenderRequest {
213            source: RecipeSource::Content {
214                text: PANCAKES.to_string(),
215                name: "pancakes".to_string(),
216            },
217            template: template.to_string(),
218            scale: 1.0,
219            datastore: None,
220            base_path: None,
221        }
222    }
223
224    fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
225        Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
226    }
227
228    fn write(path: &Utf8Path, text: &str) {
229        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
230        std::fs::write(path, text).unwrap();
231    }
232
233    #[test]
234    fn renders_a_template_against_the_recipe() {
235        let outcome = render(
236            &ctx(),
237            request("{% for i in ingredients %}{{ i.name }};{% endfor %}"),
238        )
239        .expect("renders");
240        assert_eq!(outcome.value, "eggs;milk;flour;");
241        assert!(outcome.diagnostics.is_empty());
242    }
243
244    #[test]
245    fn metadata_is_available_to_the_template() {
246        let outcome = render(&ctx(), request("{{ metadata.title }}")).expect("renders");
247        assert_eq!(outcome.value, "Pancakes");
248    }
249
250    /// Both halves of the scale contract: the number reaches the template, and
251    /// the quantities it prints have actually been scaled by it. Asserting only
252    /// the former would pass with the scaling dropped.
253    #[test]
254    fn scale_reaches_the_template_and_the_quantities() {
255        let template = "{{ scale }}|{{ ingredients[1].name }}={{ ingredients[1].quantity }}";
256
257        let one = render(&ctx(), request(template)).expect("renders");
258        assert_eq!(one.value, "1.0|milk=250 ml");
259
260        let mut req = request(template);
261        req.scale = 3.0;
262        let three = render(&ctx(), req).expect("renders");
263        assert_eq!(three.value, "3.0|milk=750 ml");
264    }
265
266    #[test]
267    fn a_non_finite_scale_is_rejected_before_anything_is_read() {
268        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
269            let mut req = request("{{ scale }}");
270            req.scale = bad;
271            // A source that would fail loudly if it were ever looked at, so
272            // this cannot pass by rendering something.
273            req.source = RecipeSource::Path(Utf8PathBuf::from("no/such/recipe.cook"));
274            match render(&ctx(), req) {
275                Err(CoreError::InvalidScale { scale }) => {
276                    assert_eq!(scale.is_nan(), bad.is_nan());
277                }
278                other => panic!("expected InvalidScale for {bad}, got {other:?}"),
279            }
280        }
281    }
282
283    /// The whole point of the extraction: a broken template must come back as a
284    /// value, not end the process. The CLI is what exits.
285    #[test]
286    fn a_broken_template_is_a_render_error() {
287        // Missing `%}` on the endfor.
288        let req = request("{% for i in ingredients %}{{ i.name }}{% endfor");
289        match render(&ctx(), req) {
290            Err(CoreError::Render { message, rendered }) => {
291                assert!(
292                    !message.contains('\n'),
293                    "the summary must stay one line: {message:?}"
294                );
295                assert!(
296                    message.to_lowercase().contains("syntax"),
297                    "expected the engine's own summary, got {message:?}"
298                );
299                // What the CLI prints instead of core's one-liner: it must say
300                // more than the summary does.
301                assert!(
302                    rendered.len() > message.len(),
303                    "rendered should carry the long report, got {rendered:?}"
304                );
305                assert!(
306                    rendered.contains("endfor"),
307                    "rendered should quote the template, got {rendered:?}"
308                );
309            }
310            other => panic!("expected CoreError::Render, got {other:?}"),
311        }
312    }
313
314    /// A template that parses but fails while rendering — a different minijinja
315    /// error kind, down the same channel.
316    #[test]
317    fn a_failing_expression_is_a_render_error_too() {
318        // `db()` with no datastore configured.
319        match render(&ctx(), request("{{ db('eggs.price') }}")) {
320            Err(CoreError::Render { message, .. }) => {
321                assert!(!message.is_empty(), "expected a summary");
322                assert!(!message.contains('\n'), "one line: {message:?}");
323            }
324            other => panic!("expected CoreError::Render, got {other:?}"),
325        }
326    }
327
328    /// `cooklang-reports` parses the recipe itself, so this arrives as a render
329    /// failure rather than `CoreError::Parse`. Pinned because it is surprising.
330    #[test]
331    fn an_unparseable_recipe_is_also_a_render_error() {
332        let mut req = request("{{ metadata.title }}");
333        req.source = RecipeSource::Content {
334            // An ingredient with a quantity and no name.
335            text: "Add @{1%tsp} to the pot.\n".to_string(),
336            name: "broken".to_string(),
337        };
338        match render(&ctx(), req) {
339            Err(CoreError::Render { message, rendered }) => {
340                assert_eq!(message, "the recipe could not be parsed");
341                assert!(
342                    !rendered.is_empty(),
343                    "the parse report must survive into `rendered`"
344                );
345            }
346            other => panic!("expected CoreError::Render, got {other:?}"),
347        }
348    }
349
350    #[test]
351    fn a_path_source_is_resolved_by_name_under_the_base_path() {
352        let dir = tempfile::TempDir::new().unwrap();
353        let base = utf8(&dir);
354        write(&base.join("pancakes.cook"), PANCAKES);
355
356        // A bare name with no extension: only a `cooklang-find` lookup resolves
357        // this, which is the difference from opening the path as given.
358        let mut req = request("{{ metadata.title }}");
359        req.source = RecipeSource::Path(Utf8PathBuf::from("pancakes"));
360        let outcome = render(&Context::new(base), req).expect("renders");
361        assert_eq!(outcome.value, "Pancakes");
362    }
363
364    #[test]
365    fn a_missing_path_source_is_not_found() {
366        let dir = tempfile::TempDir::new().unwrap();
367        let mut req = request("{{ metadata.title }}");
368        req.source = RecipeSource::Path(Utf8PathBuf::from("absent.cook"));
369        match render(&Context::new(utf8(&dir)), req) {
370            Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
371            other => panic!("expected RecipeNotFound, got {other:?}"),
372        }
373    }
374
375    /// `Content` must never touch the filesystem, even when a file of the same
376    /// name is sitting there with different text.
377    #[test]
378    fn content_is_rendered_as_given_and_never_read_from_disk() {
379        let dir = tempfile::TempDir::new().unwrap();
380        let base = utf8(&dir);
381        write(
382            &base.join("pancakes.cook"),
383            "---\ntitle: On Disk\n---\n\nBoil @water{1%l}.\n",
384        );
385
386        let outcome =
387            render(&Context::new(base), request("{{ metadata.title }}")).expect("renders");
388        assert_eq!(
389            outcome.value, "Pancakes",
390            "the buffer must win over the file of the same name"
391        );
392    }
393
394    #[test]
395    fn the_base_path_defaults_to_the_context_and_is_overridable() {
396        let ctx = Context::new(Utf8PathBuf::from("/from/context"));
397        let outcome = render(&ctx, request("{{ base_path }}")).expect("renders");
398        assert_eq!(outcome.value, "/from/context");
399
400        let mut req = request("{{ base_path }}");
401        req.base_path = Some(Utf8PathBuf::from("/from/request"));
402        let outcome = render(&ctx, req).expect("renders");
403        assert_eq!(outcome.value, "/from/request");
404    }
405
406    /// The request's base path must steer the recipe lookup too, not only the
407    /// variable the template sees.
408    #[test]
409    fn the_request_base_path_steers_the_recipe_lookup() {
410        let dir = tempfile::TempDir::new().unwrap();
411        let base = utf8(&dir);
412        write(&base.join("elsewhere").join("pancakes.cook"), PANCAKES);
413
414        let mut req = request("{{ metadata.title }}");
415        req.source = RecipeSource::Path(Utf8PathBuf::from("pancakes"));
416        req.base_path = Some(base.join("elsewhere"));
417
418        // The context points somewhere with no recipes in it at all.
419        let ctx = Context::new(base.join("nothing-here"));
420        let outcome = render(&ctx, req).expect("renders");
421        assert_eq!(outcome.value, "Pancakes");
422    }
423
424    const AISLE: &str = "[dairy]\nmilk\neggs\n\n[grains]\nflour\n";
425    const PANTRY: &str = "[baking]\nflour = \"2%kg\"\n";
426
427    /// Both the raw variable and the function that consumes it, for each
428    /// source kind — a `ConfigSource::Inline` reaching only the variable would
429    /// leave `aisled()` quietly returning nothing.
430    #[test]
431    fn an_aisle_reaches_the_template_from_a_path_and_from_inline_text() {
432        let dir = tempfile::TempDir::new().unwrap();
433        let path = utf8(&dir).join("aisle.conf");
434        write(&path, AISLE);
435
436        let template = "{{ aisle_content | length }}|\
437            {% for aisle, items in aisled(ingredients) | items %}{{ aisle }},{% endfor %}";
438
439        for source in [
440            ConfigSource::Path(path.clone()),
441            ConfigSource::Inline(AISLE.to_string()),
442        ] {
443            let ctx = Context::new(Utf8PathBuf::from(".")).with_aisle(source.clone());
444            let outcome = render(&ctx, request(template)).expect("renders");
445            assert_eq!(
446                outcome.value,
447                format!("{}|dairy,grains,", AISLE.len()),
448                "aisle not honoured for {source:?}"
449            );
450        }
451    }
452
453    #[test]
454    fn a_pantry_reaches_the_template_from_a_path_and_from_inline_text() {
455        let dir = tempfile::TempDir::new().unwrap();
456        let path = utf8(&dir).join("pantry.conf");
457        write(&path, PANTRY);
458
459        let template = "{{ pantry_content | length }}|\
460            {% for i in excluding_pantry(ingredients) %}{{ i.name }},{% endfor %}";
461
462        for source in [
463            ConfigSource::Path(path.clone()),
464            ConfigSource::Inline(PANTRY.to_string()),
465        ] {
466            let ctx = Context::new(Utf8PathBuf::from(".")).with_pantry(source.clone());
467            let outcome = render(&ctx, request(template)).expect("renders");
468            assert_eq!(
469                outcome.value,
470                format!("{}|eggs,milk,", PANTRY.len()),
471                "pantry not honoured for {source:?}"
472            );
473        }
474    }
475
476    /// Without a configuration the functions must still work, returning
477    /// everything unfiltered rather than failing.
478    #[test]
479    fn no_aisle_or_pantry_leaves_the_template_functions_usable() {
480        let outcome = render(
481            &ctx(),
482            request("{% for i in excluding_pantry(ingredients) %}{{ i.name }},{% endfor %}"),
483        )
484        .expect("renders");
485        assert_eq!(outcome.value, "eggs,milk,flour,");
486    }
487
488    #[test]
489    fn the_datastore_path_is_what_the_db_function_reads() {
490        let dir = tempfile::TempDir::new().unwrap();
491        let store = utf8(&dir).join("db");
492        write(
493            &store.join("eggs").join("shopping.yml"),
494            "price_per_unit: 0.25\n",
495        );
496
497        let mut req = request("{{ db('eggs.shopping.price_per_unit') }}");
498        req.datastore = Some(store);
499        let outcome = render(&ctx(), req).expect("renders");
500        assert_eq!(outcome.value, "0.25");
501    }
502}