cooklang_reports/
lib.rs

1//! A Rust library for generating reports from [Cooklang][00] recipes using [Jinja2][01]-style templates.
2//!
3//! Templates are provided with multiple context variables:
4//!
5//! - `scale`: a float representing the recipe scaling factor (i.e. 1 by default)
6//! - `sections`: the sections, containing steps and text, within the recipe
7//! - `ingredients`: the list of ingredients in the recipe
8//! - `cookware`: the list of cookware pieces in the recipe
9//! - `metadata`: the dictionary of metadata from the recipe
10//!
11//! For more details about each of these, look through the source for the `models` module`.`
12//!
13//! [00]: https://cooklang.org/
14//! [01]: https://jinja.palletsprojects.com/en/stable/
15#[doc = include_str!("../README.md")]
16use config::Config;
17use cooklang::Recipe;
18use filters::{
19    camelize_filter, dasherize_filter, format_price_filter, humanize_filter, numeric_filter,
20    titleize_filter, underscore_filter, upcase_first_filter,
21};
22use functions::{
23    aisled, excluding_pantry, from_pantry, get_from_datastore, get_ingredient_list,
24    number_to_currency, number_to_human, number_to_human_size, number_to_percentage,
25    number_with_delimiter, number_with_precision,
26};
27use minijinja::Environment;
28use model::{Cookware, Ingredient, Metadata, Section};
29use parser::{get_converter, get_parser};
30use serde::Serialize;
31use yaml_datastore::Datastore;
32
33pub mod config;
34pub mod error;
35mod filters;
36mod functions;
37mod model;
38pub mod parser;
39
40pub use error::Error;
41
42/// Context passed to the template
43#[derive(Debug, Serialize)]
44struct TemplateContext {
45    scale: f64,
46    datastore: Option<Datastore>,
47    base_path: Option<String>,
48    aisle_content: Option<String>,
49    pantry_content: Option<String>,
50    sections: Vec<minijinja::Value>,
51    ingredients: Vec<minijinja::Value>,
52    cookware: Vec<minijinja::Value>,
53    metadata: minijinja::Value,
54}
55
56impl TemplateContext {
57    fn new(
58        recipe: Recipe,
59        scale: f64,
60        datastore: Option<Datastore>,
61        base_path: Option<String>,
62        aisle_content: Option<String>,
63        pantry_content: Option<String>,
64    ) -> TemplateContext {
65        TemplateContext {
66            scale,
67            datastore,
68            base_path,
69            aisle_content,
70            pantry_content,
71            sections: Section::from_recipe_sections(&recipe)
72                .into_iter()
73                .map(minijinja::Value::from_object)
74                .collect(),
75            ingredients: recipe
76                .ingredients
77                .into_iter()
78                .map(Ingredient::from)
79                .map(minijinja::Value::from)
80                .collect(),
81            cookware: recipe
82                .cookware
83                .into_iter()
84                .map(Cookware::from)
85                .map(minijinja::Value::from)
86                .collect(),
87            metadata: Metadata::from(recipe.metadata).into(),
88        }
89    }
90}
91
92/// Render a recipe with the deault configuration.
93///
94/// This is equivalent to calling [`render_template_with_config`] with a default [`Config`].
95///
96/// # Errors
97///
98/// Returns [`RecipeParseError`][`Error::RecipeParseError`] if the recipe cannot be parsed by the
99/// [`CooklangParser`][`cooklang::CooklangParser`].
100///
101/// Returns [`TemplateError`][`Error::TemplateError`] if the template has a syntax error or rendering fails.
102pub fn render_template(recipe: &str, template: &str) -> Result<String, Error> {
103    render_template_with_config(recipe, template, &Config::default())
104}
105
106/// Render a recipe to a String with the provided [`Config`].
107///
108/// On success, returns a String with the recipe as rendered by the template.
109///
110/// # Parameters
111///
112/// * `recipe` is a (hopefully valid) cooklang recipe as a string, ready to be parsed.
113/// * `template` is a (hopefully valid) template. Format will be documented in the future.
114/// * `config` is a [`Config`][`config::Config`] with options for rendering the recipe.
115///
116/// # Errors
117///
118/// Returns [`RecipeParseError`][`Error::RecipeParseError`] if the recipe cannot be parsed by the
119/// [`CooklangParser`][`cooklang::CooklangParser`].
120///
121/// Returns [`TemplateError`][`Error::TemplateError`] if the template has a syntax error or rendering fails.
122pub fn render_template_with_config(
123    recipe: &str,
124    template: &str,
125    config: &Config,
126) -> Result<String, Error> {
127    // Parse and validate recipe string using global parser
128    let (mut recipe, warnings) = get_parser().parse(recipe).into_result()?;
129
130    // Log warnings if present
131    if warnings.has_warnings() {
132        for warning in warnings.warnings() {
133            eprintln!("Warning: {warning}");
134        }
135    }
136
137    // Scale the recipe using global converter
138    recipe.scale(config.scale, get_converter());
139    let datastore = config.datastore_path.as_ref().map(Datastore::open);
140    let base_path = config
141        .base_path
142        .as_ref()
143        .and_then(|p| p.to_str())
144        .map(String::from);
145
146    // Load aisle configuration content if provided
147    let aisle_content = if let Some(aisle_path) = &config.aisle_path {
148        match std::fs::read_to_string(aisle_path) {
149            Ok(content) => {
150                // Validate the aisle file
151                let result = cooklang::aisle::parse_lenient(&content);
152
153                // Log warnings if present
154                if result.report().has_warnings() {
155                    for warning in result.report().warnings() {
156                        eprintln!("Warning in aisle file: {warning}");
157                    }
158                }
159
160                Some(content)
161            }
162            Err(e) => {
163                eprintln!("Warning: Failed to read aisle file: {e}");
164                None
165            }
166        }
167    } else {
168        None
169    };
170
171    // Load pantry configuration content if provided
172    let pantry_content = if let Some(pantry_path) = &config.pantry_path {
173        match std::fs::read_to_string(pantry_path) {
174            Ok(content) => {
175                // Validate the pantry file
176                let result = cooklang::pantry::parse_lenient(&content);
177
178                // Log warnings if present
179                if result.report().has_warnings() {
180                    for warning in result.report().warnings() {
181                        eprintln!("Warning in pantry file: {warning}");
182                    }
183                }
184
185                Some(content)
186            }
187            Err(e) => {
188                eprintln!("Warning: Failed to read pantry file: {e}");
189                None
190            }
191        }
192    } else {
193        None
194    };
195
196    let template_context = TemplateContext::new(
197        recipe,
198        config.scale,
199        datastore,
200        base_path,
201        aisle_content,
202        pantry_content,
203    );
204    let template_environment = template_environment(template)?;
205
206    let template: minijinja::Template<'_, '_> = template_environment.get_template("base")?;
207    Ok(template.render(template_context)?)
208}
209
210/// Build an environment for the given template.
211fn template_environment(template: &str) -> Result<Environment<'_>, Error> {
212    let mut env = Environment::new();
213
214    // Enable debug mode for better error messages
215    env.set_debug(true);
216
217    env.add_template("base", template)?;
218    env.add_function("db", get_from_datastore);
219    env.add_function("get_ingredient_list", get_ingredient_list);
220    env.add_function("aisled", aisled);
221    env.add_function("excluding_pantry", excluding_pantry);
222    env.add_function("from_pantry", from_pantry);
223
224    // Number formatting functions (also available as filters)
225    env.add_function("number_to_currency", number_to_currency);
226    env.add_function("number_to_human", number_to_human);
227    env.add_function("number_to_human_size", number_to_human_size);
228    env.add_function("number_to_percentage", number_to_percentage);
229    env.add_function("number_with_delimiter", number_with_delimiter);
230    env.add_function("number_with_precision", number_with_precision);
231
232    // Also register as filters
233    env.add_filter("number_to_currency", number_to_currency);
234    env.add_filter("number_to_human", number_to_human);
235    env.add_filter("number_to_human_size", number_to_human_size);
236    env.add_filter("number_to_percentage", number_to_percentage);
237    env.add_filter("number_with_delimiter", number_with_delimiter);
238    env.add_filter("number_with_precision", number_with_precision);
239
240    env.add_filter("numeric", numeric_filter);
241    env.add_filter("format_price", format_price_filter);
242
243    // String transformation filters (also available as functions)
244    env.add_filter("camelize", camelize_filter);
245    env.add_filter("underscore", underscore_filter);
246    env.add_filter("dasherize", dasherize_filter);
247    env.add_filter("humanize", humanize_filter);
248    env.add_filter("titleize", titleize_filter);
249    env.add_filter("upcase_first", upcase_first_filter);
250
251    // Also register as functions for direct calls
252    env.add_function("camelize", camelize_filter);
253    env.add_function("underscore", underscore_filter);
254    env.add_function("dasherize", dasherize_filter);
255    env.add_function("humanize", humanize_filter);
256    env.add_function("titleize", titleize_filter);
257    env.add_function("upcase_first", upcase_first_filter);
258
259    Ok(env)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use indoc::indoc;
266    use std::path::PathBuf;
267
268    fn get_test_data_path() -> PathBuf {
269        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
270        path.push("test");
271        path.push("data");
272        path
273    }
274
275    #[test]
276    fn simple_template_new() {
277        // Use Pancakes.cook from test data
278        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
279        let recipe = std::fs::read_to_string(recipe_path).unwrap();
280
281        let template: &str = indoc! {"
282            # Ingredients ({{ scale }}x)
283            {%- for ingredient in ingredients %}
284            - {{ ingredient.name }}
285            {%- endfor %}
286        "};
287
288        // Test default scaling (1x)
289        let result = render_template(&recipe, template).unwrap();
290        let expected = indoc! {"
291            # Ingredients (1.0x)
292            - eggs
293            - milk
294            - flour"};
295        assert_eq!(result, expected);
296
297        // Test with 2x scaling, but only for the actual scale number
298        let config: Config = Config::builder().scale(2.0).build();
299        let result = render_template_with_config(&recipe, template, &config).unwrap();
300        let expected = indoc! {"
301            # Ingredients (2.0x)
302            - eggs
303            - milk
304            - flour"};
305        assert_eq!(result, expected);
306    }
307
308    #[test]
309    fn test_datastore_missing_key() {
310        // Test that missing keys produce warning and empty value instead of error
311        let datastore_path = get_test_data_path().join("db");
312
313        let recipe = "@ingredient{1}";
314        let template = r#"Missing key: "{{ db("nonexistent.key.path") }}" (should be empty)"#;
315
316        let config = Config::builder().datastore_path(datastore_path).build();
317
318        let result = render_template_with_config(recipe, template, &config).unwrap();
319        assert!(result.contains(r#"Missing key: "" (should be empty)"#));
320    }
321
322    #[test]
323    fn test_datastore_access() {
324        let datastore_path = get_test_data_path().join("db");
325
326        // Use Pancakes.cook from test data
327        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
328        let recipe = std::fs::read_to_string(recipe_path).unwrap();
329
330        let template = indoc! {"
331            # Eggs Info
332
333            Density: {{ db('eggs.meta.density') }}
334            Shelf Life: {{ db('eggs.meta.storage.shelf life') }} days
335            Fridge Life: {{ db('eggs.meta.storage.fridge life') }} days
336        "};
337
338        let config = Config::builder().datastore_path(&datastore_path).build();
339        let result = render_template_with_config(&recipe, template, &config).unwrap();
340        let expected = indoc! {"
341            # Eggs Info
342
343            Density: 1.03
344            Shelf Life: 30 days
345            Fridge Life: 60 days"};
346
347        assert_eq!(result, expected);
348    }
349
350    #[test]
351    fn test_recursive_ingredients_with_base_path() {
352        let base_path = get_test_data_path().join("recipes");
353
354        // Use the actual Recipe With Reference.cook file
355        let recipe_path = base_path.join("Recipe With Reference.cook");
356        let recipe = std::fs::read_to_string(recipe_path).unwrap();
357
358        let template = indoc! {"
359            # Recursive Ingredients
360            {%- set all = get_ingredient_list(ingredients) %}
361            {%- for ingredient in all %}
362            - {{ ingredient.name }}: {{ ingredient.quantities }}
363            {%- endfor %}
364        "};
365
366        let config = Config::builder().base_path(&base_path).build();
367
368        let result = render_template_with_config(&recipe, template, &config).unwrap();
369
370        // Recipe With Reference.cook contains:
371        // - @Pancakes.cook{2} - should be expanded to Pancakes ingredients scaled by 2
372        // - @sugar{2%tbsp}
373        // - @milk{200%ml}
374        // Pancakes.cook contains: @eggs{3%large}, @milk{250%ml}, @flour{125%g}
375        // With scaling of 2: eggs: 6 large, milk: 500 ml (plus 200 ml from direct), flour: 250 g
376        // Combined ingredients should merge milk quantities
377        let expected = indoc! {"
378            # Recursive Ingredients
379            - eggs: 6 large
380            - flour: 250 g
381            - milk: 700 ml
382            - sugar: 2 tbsp"};
383
384        assert_eq!(result, expected);
385    }
386
387    #[test]
388    fn test_recipe_scaling() {
389        // Use Pancakes.cook from test data
390        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
391        let recipe = std::fs::read_to_string(recipe_path).unwrap();
392
393        let template = indoc! {"
394            # Ingredients ({{ scale }}x)
395            {%- for ingredient in ingredients %}
396            - {{ ingredient.name }}: {{ ingredient.quantity }}
397            {%- endfor %}
398        "};
399
400        // Test default scaling (1x)
401        let result = render_template(&recipe, template).unwrap();
402        let expected = indoc! {"
403            # Ingredients (1.0x)
404            - eggs: 3 large
405            - milk: 250 ml
406            - flour: 125 g"};
407        assert_eq!(result, expected);
408
409        // Test with 2x scaling
410        let config = Config::builder().scale(2.0).build();
411        let result = render_template_with_config(&recipe, template, &config).unwrap();
412        let expected = indoc! {"
413            # Ingredients (2.0x)
414            - eggs: 6 large
415            - milk: 500 ml
416            - flour: 250 g"};
417        assert_eq!(result, expected);
418
419        // Test with 3x scaling
420        let config = Config::builder().scale(3.0).build();
421        let result = render_template_with_config(&recipe, template, &config).unwrap();
422        let expected = indoc! {"
423            # Ingredients (3.0x)
424            - eggs: 9 large
425            - milk: 750 ml
426            - flour: 375 g"};
427        assert_eq!(result, expected);
428
429        // Test with 0.5x scaling
430        let config = Config::builder().scale(0.5).build();
431        let result = render_template_with_config(&recipe, template, &config).unwrap();
432        let expected = indoc! {"
433            # Ingredients (0.5x)
434            - eggs: 1.5 large
435            - milk: 125 ml
436            - flour: 62.5 g"};
437        assert_eq!(result, expected);
438    }
439
440    #[test]
441    fn test_with_template_from_files() {
442        // Use Pancakes.cook from test data
443        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
444        let recipe = std::fs::read_to_string(recipe_path).unwrap();
445
446        let template_path = get_test_data_path()
447            .join("reports")
448            .join("ingredients.md.jinja");
449        let template = std::fs::read_to_string(template_path).unwrap();
450
451        let result = render_template(&recipe, &template).unwrap();
452        let expected = indoc! {"
453            # Ingredients Report
454
455            * eggs: 3 large
456            * flour: 125 g
457            * milk: 250 ml"};
458
459        assert_eq!(result, expected);
460    }
461
462    #[test]
463    fn test_with_template_from_files_with_db() {
464        // Use Pancakes.cook from test data
465        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
466        let recipe = std::fs::read_to_string(recipe_path).unwrap();
467
468        // Use database path from test data
469        let datastore_path = get_test_data_path().join("db");
470
471        let template_path = get_test_data_path().join("reports").join("cost.md.jinja");
472        let template = std::fs::read_to_string(template_path).unwrap();
473
474        let config = Config::builder().datastore_path(datastore_path).build();
475        let result = render_template_with_config(&recipe, &template, &config).unwrap();
476
477        // Verify the report structure and content
478        let expected = indoc! {"
479            # Cost Report
480
481            * eggs: $0.75
482            * milk: $0.25
483            * flour: $0.19
484
485            Total: $1.19"};
486
487        assert_eq!(result, expected);
488    }
489
490    #[test]
491    fn cookware() {
492        // Use Pancakes.cook from test data
493        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
494        let recipe = std::fs::read_to_string(recipe_path).unwrap();
495
496        let template: &str = indoc! {"
497            # Cookware
498            {%- for item in cookware %}
499            - {{ item.name }}
500            {%- endfor %}
501        "};
502
503        // Test default scaling (1x)
504        let result = render_template(&recipe, template).unwrap();
505        let expected = indoc! {"
506            # Cookware
507            - whisk
508            - large bowl"};
509        assert_eq!(result, expected);
510
511        // TODO scaling? should it? No, right?
512    }
513
514    #[test]
515    fn metadata_render() {
516        // Use Pancakes.cook from test data
517        let recipe_path = get_test_data_path()
518            .join("recipes")
519            .join("Chinese Udon Noodles.cook");
520        let recipe = std::fs::read_to_string(recipe_path).unwrap();
521
522        let template: &str = indoc! {"
523            # Metadata
524            {{ metadata }}
525        "};
526
527        let result = render_template(&recipe, template).unwrap();
528        let expected = indoc! {"
529            # Metadata
530            ---
531            title: Chinese-Style Udon Noodles
532            description: A quick, simple, yet satisfying take on a Chinese-style noodle dish.
533            author: Dan Fego
534            servings: 2
535            tags:
536            - vegan
537            ---
538            "};
539        assert_eq!(result, expected);
540    }
541
542    #[test]
543    fn metadata_enumerate() {
544        // Use Pancakes.cook from test data
545        let recipe_path = get_test_data_path()
546            .join("recipes")
547            .join("Chinese Udon Noodles.cook");
548        let recipe = std::fs::read_to_string(recipe_path).unwrap();
549
550        let template: &str = indoc! {"
551            # Metadata
552            {%- for key, value in metadata | items %}
553            - {{ key }}: {{ value }}
554            {%- endfor %}
555        "};
556
557        let result = render_template(&recipe, template).unwrap();
558        let expected = indoc! {"
559            # Metadata
560            - title: Chinese-Style Udon Noodles
561            - description: A quick, simple, yet satisfying take on a Chinese-style noodle dish.
562            - author: Dan Fego
563            - servings: 2
564            - tags: [\"vegan\"]"};
565        assert_eq!(result, expected);
566    }
567
568    #[test]
569    fn sections() {
570        let recipe_path = get_test_data_path()
571            .join("recipes")
572            .join("Contrived Eggs.cook");
573        let recipe = std::fs::read_to_string(recipe_path).unwrap();
574
575        let template: &str = indoc! {"
576            # Recipe
577            {%- for section in sections %}
578            ## {{ section.name }}
579            {%- endfor %}
580        "};
581
582        let result = render_template(&recipe, template).unwrap();
583        let expected = indoc! {"
584            # Recipe
585            ## Preparation
586            ## Cooking
587            ## Consumption"};
588        assert_eq!(result, expected);
589    }
590
591    #[test]
592    fn sections_default() {
593        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
594        let recipe = std::fs::read_to_string(recipe_path).unwrap();
595
596        let template: &str = indoc! {"
597            # Recipe
598            {%- for section in sections %}
599            {% if section.name %}
600            ## {{ section.name }}
601            {% endif %}
602            {%- endfor %}
603        "};
604
605        let result = render_template(&recipe, template).unwrap();
606        let expected = indoc! {"
607        # Recipe
608        "};
609        assert_eq!(result, expected);
610    }
611
612    #[test]
613    fn test_template_syntax_error() {
614        let recipe = "@eggs{2}";
615        let template = "{% for item in ingredients %}{{ item.name }}{% endfor"; // Missing %}
616
617        let result = render_template(recipe, template);
618        assert!(result.is_err());
619
620        if let Err(e) = result {
621            let formatted = e.format_with_source();
622            // Check for enhanced error display features
623            assert!(formatted.contains("syntax error"));
624            assert!(formatted.contains("endfor")); // The problematic token
625            assert!(formatted.contains("Hint:")); // Our helpful hints
626            assert!(formatted.contains("Missing closing tags"));
627        }
628    }
629
630    #[test]
631    fn test_template_undefined_error() {
632        let recipe = "@eggs{2}";
633        let template = "{{ nonexistent_variable }}";
634
635        let result = render_template(recipe, template);
636        // Undefined variables render as empty strings by default in minijinja
637        assert!(result.is_ok());
638        assert_eq!(result.unwrap(), "");
639    }
640
641    #[test]
642    fn test_template_attribute_error() {
643        let recipe = "@eggs{2}";
644        let template = "{% for item in ingredients %}{{ item.nonexistent }}{% endfor %}";
645
646        let result = render_template(recipe, template);
647        // Undefined attributes also render as empty by default
648        assert!(result.is_ok());
649    }
650
651    #[test]
652    fn test_template_invalid_function_call() {
653        let recipe = "@eggs{2}";
654        let template = "{{ unknown_function() }}";
655
656        let result = render_template(recipe, template);
657        assert!(result.is_err());
658
659        if let Err(e) = result {
660            let formatted = e.format_with_source();
661            // Check for enhanced error display
662            assert!(formatted.contains("unknown function"));
663            assert!(formatted.contains("unknown_function()")); // The problematic expression
664        }
665    }
666
667    #[test]
668    fn test_recipe_references_with_servings_scaling() {
669        let base_path = get_test_data_path().join("recipes");
670
671        // Load the recipe with scaled references
672        let recipe_path = base_path.join("Recipe With Scaled References.cook");
673        let recipe = std::fs::read_to_string(recipe_path).unwrap();
674
675        let template = indoc! {"
676            # All Ingredients
677            {%- set all = get_ingredient_list(ingredients) %}
678            {%- for ingredient in all %}
679            - {{ ingredient.name }}: {{ ingredient.quantities }}
680            {%- endfor %}
681        "};
682
683        let config = Config::builder().base_path(&base_path).build();
684        let result = render_template_with_config(&recipe, template, &config).unwrap();
685
686        // Recipe With Servings has 4 servings, requesting 8 servings = 2x scale
687        // Original: flour 200g, milk 300ml, eggs 2
688        // Scaled 2x: flour 400g, milk 600ml, eggs 4
689
690        // Recipe With Yield yields 500g, requesting 250g = 0.5x scale
691        // Original: butter 100g, sugar 150g, flour 250g
692        // Scaled 0.5x: butter 50g, sugar 75g, flour 125g
693
694        // Pancakes scaled by 2x directly
695        // Original: eggs 3, milk 250ml, flour 125g
696        // Scaled 2x: eggs 6, milk 500ml, flour 250g
697
698        // Combined:
699        // - butter: 50g
700        // - eggs: 6 large (from Pancakes), 4 (from Recipe With Servings)
701        //   Note: these don't merge because units differ
702        // - flour: 400g + 125g + 250g = 775g
703        // - milk: 600ml + 500ml = 1100ml
704        // - salt: 1 tsp
705        // - sugar: 75g
706
707        let expected = indoc! {"
708            # All Ingredients
709            - butter: 50 g
710            - eggs: 6 large, 4
711            - flour: 775 g
712            - milk: 1100 ml
713            - salt: 1 tsp
714            - sugar: 75 g"};
715
716        assert_eq!(result, expected);
717    }
718
719    #[test]
720    fn test_recipe_references_yield_unit_mismatch() {
721        let base_path = get_test_data_path().join("recipes");
722
723        // Create a recipe that requests wrong units
724        let recipe = indoc! {"
725            ---
726            title: Bad Yield Reference
727            ---
728
729            Make @./Recipe With Yield.cook{100%ml} incorrectly.
730        "};
731
732        let template = indoc! {"
733            {%- set all = get_ingredient_list(ingredients) %}
734            Error should happen before this
735        "};
736
737        let config = Config::builder().base_path(&base_path).build();
738        let result = render_template_with_config(recipe, template, &config);
739
740        assert!(result.is_err());
741        let err = result.unwrap_err();
742        let err_msg = err.format_with_source();
743        assert!(
744            err_msg.contains("Failed to scale recipe"),
745            "Expected error about scaling recipe, got: {err_msg}"
746        );
747    }
748
749    #[test]
750    fn test_recipe_references_missing_servings() {
751        let base_path = get_test_data_path().join("recipes");
752
753        // Create a recipe without servings metadata
754        let no_servings_path = base_path.join("No Servings.cook");
755        std::fs::write(&no_servings_path, "Mix @flour{100%g} with @water{200%ml}.").unwrap();
756
757        let recipe = indoc! {"
758            ---
759            title: Bad Servings Reference
760            ---
761
762            Make @./No Servings.cook{4%servings} incorrectly.
763        "};
764
765        let template = indoc! {"
766            {%- set all = get_ingredient_list(ingredients) %}
767            Error should happen before this
768        "};
769
770        let config = Config::builder().base_path(&base_path).build();
771        let result = render_template_with_config(recipe, template, &config);
772
773        assert!(result.is_err());
774        let err = result.unwrap_err();
775        let err_msg = err.format_with_source();
776        assert!(
777            err_msg.contains("Failed to scale recipe") && err_msg.contains("servings"),
778            "Expected error about missing servings metadata, got: {err_msg}"
779        );
780
781        // Clean up
782        std::fs::remove_file(no_servings_path).ok();
783    }
784
785    #[test]
786    fn test_recipe_references_missing_yield() {
787        let base_path = get_test_data_path().join("recipes");
788
789        // Pancakes doesn't have yield metadata
790        let recipe = indoc! {"
791            ---
792            title: Bad Yield Reference
793            ---
794
795            Make @./Pancakes.cook{500%g} incorrectly.
796        "};
797
798        let template = indoc! {"
799            {%- set all = get_ingredient_list(ingredients) %}
800            Error should happen before this
801        "};
802
803        let config = Config::builder().base_path(&base_path).build();
804        let result = render_template_with_config(recipe, template, &config);
805
806        assert!(result.is_err());
807        let err = result.unwrap_err();
808        let err_msg = err.format_with_source();
809        assert!(
810            err_msg.contains("Failed to scale recipe"),
811            "Expected error about scaling recipe, got: {err_msg}"
812        );
813    }
814
815    #[test]
816    fn test_recursive_ingredients_without_expansion() {
817        let base_path = get_test_data_path().join("recipes");
818
819        // Use the actual Recipe With Reference.cook file
820        let recipe_path = base_path.join("Recipe With Reference.cook");
821        let recipe = std::fs::read_to_string(recipe_path).unwrap();
822
823        // Test with expand_references = false
824        let template = indoc! {"
825            # Non-Recursive Ingredients
826            {%- set all = get_ingredient_list(ingredients, false) %}
827            {%- for ingredient in all %}
828            - {{ ingredient.name }}: {{ ingredient.quantities }}
829            {%- endfor %}
830        "};
831
832        let config = Config::builder().base_path(&base_path).build();
833
834        let result = render_template_with_config(&recipe, template, &config).unwrap();
835
836        // When not expanding references, Recipe With Reference.cook contains:
837        // - @./Pancakes{2} - should remain as "Pancakes" with quantity 2
838        // - @sugar{2%tbsp}
839        // - @milk{200%ml}
840        let expected = indoc! {"
841            # Non-Recursive Ingredients
842            - Pancakes: 2
843            - milk: 200 ml
844            - sugar: 2 tbsp"};
845
846        assert_eq!(result, expected);
847    }
848
849    #[test]
850    fn test_base_path_defaults_to_cwd() {
851        // Test that base_path always defaults to current working directory
852        let config_default = Config::default();
853        assert!(config_default.base_path.is_some());
854        let cwd = std::env::current_dir().unwrap();
855        assert_eq!(config_default.base_path.unwrap(), cwd);
856
857        let config_built = Config::builder().scale(2.0).build();
858        // After building, base_path should still be set to current working directory
859        assert!(config_built.base_path.is_some());
860        assert_eq!(config_built.base_path.unwrap(), cwd);
861    }
862
863    #[test]
864    fn sections_with_text() {
865        let recipe_path = get_test_data_path().join("recipes").join("Blog Post.cook");
866        let recipe = std::fs::read_to_string(recipe_path).unwrap();
867
868        // I hate the nesting in this template but I couldn't get the whitespace
869        // modifiers to work the way I want. I hate jinja whitespace.
870        let template: &str = indoc! {"
871        {%- for section in sections -%}
872        {{ section }}
873        {%- endfor -%}\n
874        "};
875
876        let result = render_template(&recipe, template).unwrap();
877        let expected = indoc! {"
878        = My Life Story
879
880        This is a blog post about something.
881
882        It has many paragraphs.
883
884        = Recipe
885
886        Nope, just kidding.
887
888        "};
889        assert_eq!(result, expected);
890    }
891
892    #[test]
893    fn test_aisled_function() {
894        // Use Pancakes.cook from test data
895        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
896        let recipe = std::fs::read_to_string(recipe_path).unwrap();
897
898        let aisle_path = get_test_data_path().join("aisle.conf");
899
900        let template = indoc! {"
901            # Aisled Ingredients
902            {%- for aisle, items in aisled(ingredients) | items %}
903            ## {{ aisle }}
904            {%- for ingredient in items %}
905            - {{ ingredient.name }}: {{ ingredient.quantity }}
906            {%- endfor %}
907            {%- endfor %}
908        "};
909
910        // Test with aisle configuration
911        let config = Config::builder().aisle_path(&aisle_path).build();
912        let result = render_template_with_config(&recipe, template, &config).unwrap();
913
914        // Should have dairy and grains categories
915        assert!(result.contains("## dairy"));
916        assert!(result.contains("## grains"));
917        assert!(result.contains("- eggs:"));
918        assert!(result.contains("- milk:"));
919        assert!(result.contains("- flour:"));
920    }
921
922    #[test]
923    fn test_aisled_with_template_file() {
924        // Use Chinese Udon Noodles which has more ingredients
925        let recipe_path = get_test_data_path()
926            .join("recipes")
927            .join("Chinese Udon Noodles.cook");
928        let recipe = std::fs::read_to_string(recipe_path).unwrap();
929
930        let aisle_path = get_test_data_path().join("aisle.conf");
931        let template_path = get_test_data_path()
932            .join("reports")
933            .join("aisled_shopping.md.jinja");
934        let template = std::fs::read_to_string(template_path).unwrap();
935
936        let config = Config::builder().aisle_path(&aisle_path).build();
937        let result = render_template_with_config(&recipe, &template, &config).unwrap();
938
939        // Verify the structure
940        assert!(result.contains("# Shopping List by Aisle"));
941        assert!(result.contains("## Organized by Store Aisle"));
942        assert!(result.contains("## All Ingredients (Flat List)"));
943
944        // Print the result for manual inspection
945        println!("Generated Shopping List:\n{result}");
946    }
947
948    #[test]
949    fn test_aisled_function_without_config() {
950        // Use Pancakes.cook from test data
951        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
952        let recipe = std::fs::read_to_string(recipe_path).unwrap();
953
954        let template = indoc! {"
955            # Aisled Ingredients
956            {%- for aisle, items in aisled(ingredients) | items %}
957            ## {{ aisle }}
958            {%- for ingredient in items %}
959            - {{ ingredient.name }}: {{ ingredient.quantity }}
960            {%- endfor %}
961            {%- endfor %}
962        "};
963
964        // Test without aisle configuration
965        let result = render_template(&recipe, template).unwrap();
966
967        // Should only have "other" category
968        assert!(result.contains("## other"));
969        assert!(result.contains("- eggs:"));
970        assert!(result.contains("- milk:"));
971        assert!(result.contains("- flour:"));
972    }
973
974    #[test]
975    fn test_excluding_pantry() {
976        // Use Pancakes.cook from test data
977        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
978        let recipe = std::fs::read_to_string(recipe_path).unwrap();
979
980        let pantry_path = get_test_data_path().join("pantry.conf");
981
982        let template = indoc! {"
983            # Need to buy
984            {%- for ingredient in excluding_pantry(ingredients) %}
985            - {{ ingredient.name }}: {{ ingredient.quantity }}
986            {%- endfor %}
987        "};
988
989        // Test with pantry configuration
990        let config = Config::builder().pantry_path(&pantry_path).build();
991        let result = render_template_with_config(&recipe, template, &config).unwrap();
992
993        // flour and butter are in pantry, so they should be excluded
994        assert!(!result.contains("- flour:"));
995        assert!(!result.contains("- butter:"));
996        // eggs and milk are NOT in pantry, so they should be included
997        assert!(result.contains("- eggs:"));
998        assert!(result.contains("- milk:"));
999    }
1000
1001    #[test]
1002    fn test_from_pantry() {
1003        // Use Pancakes.cook from test data
1004        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
1005        let recipe = std::fs::read_to_string(recipe_path).unwrap();
1006
1007        let pantry_path = get_test_data_path().join("pantry.conf");
1008
1009        let template = indoc! {"
1010            # Already in pantry
1011            {%- for ingredient in from_pantry(ingredients) %}
1012            - {{ ingredient.name }}: {{ ingredient.quantity }}
1013            {%- endfor %}
1014        "};
1015
1016        // Test with pantry configuration
1017        let config = Config::builder().pantry_path(&pantry_path).build();
1018        let result = render_template_with_config(&recipe, template, &config).unwrap();
1019
1020        // flour is in pantry, so it should be included
1021        assert!(result.contains("- flour:"));
1022        // eggs and milk are NOT in pantry, so they should NOT be included
1023        assert!(!result.contains("- eggs:"));
1024        assert!(!result.contains("- milk:"));
1025        // Note: Pancakes.cook doesn't have butter, so we can't test for it here
1026    }
1027
1028    #[test]
1029    fn test_pantry_without_config() {
1030        // Use Pancakes.cook from test data
1031        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
1032        let recipe = std::fs::read_to_string(recipe_path).unwrap();
1033
1034        let template = indoc! {"
1035            # Need to buy
1036            {%- for ingredient in excluding_pantry(ingredients) %}
1037            - {{ ingredient.name }}: {{ ingredient.quantity }}
1038            {%- endfor %}
1039        "};
1040
1041        // Test without pantry configuration - should return all ingredients
1042        let result = render_template(&recipe, template).unwrap();
1043
1044        assert!(result.contains("- eggs:"));
1045        assert!(result.contains("- milk:"));
1046        assert!(result.contains("- flour:"));
1047    }
1048
1049    #[test]
1050    fn test_smart_shopping_template() {
1051        // Use Pancakes.cook from test data
1052        let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
1053        let recipe = std::fs::read_to_string(recipe_path).unwrap();
1054
1055        let aisle_path = get_test_data_path().join("aisle.conf");
1056        let pantry_path = get_test_data_path().join("pantry.conf");
1057        let template_path = get_test_data_path()
1058            .join("reports")
1059            .join("smart_shopping.md.jinja");
1060        let template = std::fs::read_to_string(template_path).unwrap();
1061
1062        let config = Config::builder()
1063            .aisle_path(&aisle_path)
1064            .pantry_path(&pantry_path)
1065            .build();
1066
1067        let result = render_template_with_config(&recipe, &template, &config).unwrap();
1068
1069        println!("Smart Shopping List:\n{result}");
1070
1071        // Verify structure
1072        assert!(result.contains("# Smart Shopping List"));
1073        assert!(result.contains("## Items to Buy"));
1074        assert!(result.contains("## Already Have in Pantry"));
1075
1076        // flour is in pantry, should be in "Already Have" section
1077        assert!(result.contains("✓ Flour:"));
1078
1079        // eggs and milk are not in pantry, should be in "Items to Buy" section
1080        assert!(result.contains("[ ] Eggs:"));
1081        assert!(result.contains("[ ] Milk:"));
1082    }
1083
1084    #[test]
1085    fn test_number_formatting_functions() {
1086        let recipe = "@eggs{2}";
1087
1088        // Test number_to_currency
1089        let template = "{{ number_to_currency(1234.567) }}";
1090        let result = render_template(recipe, template).unwrap();
1091        assert_eq!(result, "$1,234.57");
1092
1093        let template = "{{ number_to_currency(1234.567, precision=1) }}";
1094        let result = render_template(recipe, template).unwrap();
1095        assert_eq!(result, "$1,234.6");
1096
1097        let template = "{{ number_to_currency(1234.567, unit='£') }}";
1098        let result = render_template(recipe, template).unwrap();
1099        assert_eq!(result, "£1,234.57");
1100
1101        // Test number_to_human
1102        let template = "{{ number_to_human(1234567) }}";
1103        let result = render_template(recipe, template).unwrap();
1104        assert_eq!(result, "1.235 Million");
1105
1106        let template = "{{ number_to_human(1234567890) }}";
1107        let result = render_template(recipe, template).unwrap();
1108        assert_eq!(result, "1.235 Billion");
1109
1110        // Test number_to_human_size
1111        let template = "{{ number_to_human_size(1234567) }}";
1112        let result = render_template(recipe, template).unwrap();
1113        assert_eq!(result, "1.177 MB");
1114
1115        let template = "{{ number_to_human_size(1234567890) }}";
1116        let result = render_template(recipe, template).unwrap();
1117        assert_eq!(result, "1.150 GB");
1118
1119        // Test number_to_percentage
1120        let template = "{{ number_to_percentage(100) }}";
1121        let result = render_template(recipe, template).unwrap();
1122        assert_eq!(result, "100.000%");
1123
1124        let template = "{{ number_to_percentage(100, precision=0) }}";
1125        let result = render_template(recipe, template).unwrap();
1126        assert_eq!(result, "100%");
1127
1128        // Test number_with_delimiter
1129        let template = "{{ number_with_delimiter(12345678) }}";
1130        let result = render_template(recipe, template).unwrap();
1131        assert_eq!(result, "12,345,678");
1132
1133        let template = "{{ number_with_delimiter(12345678, delimiter='_') }}";
1134        let result = render_template(recipe, template).unwrap();
1135        assert_eq!(result, "12_345_678");
1136
1137        // Test number_with_precision
1138        let template = "{{ number_with_precision(111.2345) }}";
1139        let result = render_template(recipe, template).unwrap();
1140        assert_eq!(result, "111.234");
1141
1142        let template = "{{ number_with_precision(111.2345, precision=2) }}";
1143        let result = render_template(recipe, template).unwrap();
1144        assert_eq!(result, "111.23");
1145
1146        let template =
1147            "{{ number_with_precision(13, precision=5, strip_insignificant_zeros=true) }}";
1148        let result = render_template(recipe, template).unwrap();
1149        assert_eq!(result, "13");
1150    }
1151
1152    #[test]
1153    fn test_number_formatting_with_strings() {
1154        let recipe = "@eggs{2}";
1155
1156        // Test that functions work with string inputs
1157        let template = "{{ number_to_currency('1234.567') }}";
1158        let result = render_template(recipe, template).unwrap();
1159        assert_eq!(result, "$1,234.57");
1160
1161        let template = "{{ number_to_human('1234567') }}";
1162        let result = render_template(recipe, template).unwrap();
1163        assert_eq!(result, "1.235 Million");
1164
1165        let template = "{{ number_with_delimiter('12345678.05') }}";
1166        let result = render_template(recipe, template).unwrap();
1167        assert_eq!(result, "12,345,678.05");
1168    }
1169
1170    #[test]
1171    fn test_number_formatting_as_filters() {
1172        let recipe = "@eggs{2}";
1173
1174        // Test number_to_currency filter
1175        let template = "{{ 1234.567 | number_to_currency }}";
1176        let result = render_template(recipe, template).unwrap();
1177        assert_eq!(result, "$1,234.57");
1178
1179        let template = "{{ 1234.567 | number_to_currency(precision=1) }}";
1180        let result = render_template(recipe, template).unwrap();
1181        assert_eq!(result, "$1,234.6");
1182
1183        // Test number_to_human filter
1184        let template = "{{ 1234567 | number_to_human }}";
1185        let result = render_template(recipe, template).unwrap();
1186        assert_eq!(result, "1.235 Million");
1187
1188        // Test number_to_human_size filter
1189        let template = "{{ 1234567 | number_to_human_size }}";
1190        let result = render_template(recipe, template).unwrap();
1191        assert_eq!(result, "1.177 MB");
1192
1193        // Test number_to_percentage filter
1194        let template = "{{ 100 | number_to_percentage(precision=0) }}";
1195        let result = render_template(recipe, template).unwrap();
1196        assert_eq!(result, "100%");
1197
1198        // Test number_with_delimiter filter
1199        let template = "{{ 12345678 | number_with_delimiter }}";
1200        let result = render_template(recipe, template).unwrap();
1201        assert_eq!(result, "12,345,678");
1202
1203        // Test number_with_precision filter
1204        let template = "{{ 111.2345 | number_with_precision(precision=2) }}";
1205        let result = render_template(recipe, template).unwrap();
1206        assert_eq!(result, "111.23");
1207
1208        // Test chaining with numeric filter
1209        let template = "{{ '123.45kg' | numeric | number_to_currency }}";
1210        let result = render_template(recipe, template).unwrap();
1211        assert_eq!(result, "$123.45");
1212    }
1213
1214    #[test]
1215    fn one_section_with_steps() {
1216        let recipe = indoc! {"
1217        Put @butter{1%pat} into #frying pan{} on low heat.
1218
1219        Crack @egg into pan.
1220
1221        Fry egg on low heat until cooked.
1222
1223        Enjoy.
1224        "};
1225
1226        let template: &str = indoc! {"
1227            # Steps
1228            {% for content in sections[0] %}
1229            {{ content }}
1230            {%- endfor %}
1231        "};
1232
1233        let result = render_template(recipe, template).unwrap();
1234        println!("{result}");
1235        let expected = indoc! {"
1236            # Steps
1237
1238            1. Put 1 pat butter into frying pan on low heat.
1239            2. Crack egg into pan.
1240            3. Fry egg on low heat until cooked.
1241            4. Enjoy."};
1242        assert_eq!(result, expected);
1243    }
1244}