1#[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#[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
92pub fn render_template(recipe: &str, template: &str) -> Result<String, Error> {
103 render_template_with_config(recipe, template, &Config::default())
104}
105
106pub fn render_template_with_config(
123 recipe: &str,
124 template: &str,
125 config: &Config,
126) -> Result<String, Error> {
127 let (mut recipe, warnings) = get_parser().parse(recipe).into_result()?;
129
130 if warnings.has_warnings() {
132 for warning in warnings.warnings() {
133 eprintln!("Warning: {warning}");
134 }
135 }
136
137 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 let aisle_content = if let Some(aisle_path) = &config.aisle_path {
148 match std::fs::read_to_string(aisle_path) {
149 Ok(content) => {
150 let result = cooklang::aisle::parse_lenient(&content);
152
153 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 let pantry_content = if let Some(pantry_path) = &config.pantry_path {
173 match std::fs::read_to_string(pantry_path) {
174 Ok(content) => {
175 let result = cooklang::pantry::parse_lenient(&content);
177
178 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
210fn template_environment(template: &str) -> Result<Environment<'_>, Error> {
212 let mut env = Environment::new();
213
214 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 }
513
514 #[test]
515 fn metadata_render() {
516 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 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"; 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 assert!(formatted.contains("syntax error"));
624 assert!(formatted.contains("endfor")); assert!(formatted.contains("Hint:")); 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 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 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 assert!(formatted.contains("unknown function"));
663 assert!(formatted.contains("unknown_function()")); }
665 }
666
667 #[test]
668 fn test_recipe_references_with_servings_scaling() {
669 let base_path = get_test_data_path().join("recipes");
670
671 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 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 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 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 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 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 let recipe_path = base_path.join("Recipe With Reference.cook");
821 let recipe = std::fs::read_to_string(recipe_path).unwrap();
822
823 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 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 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 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 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 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 let config = Config::builder().aisle_path(&aisle_path).build();
912 let result = render_template_with_config(&recipe, template, &config).unwrap();
913
914 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 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 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 println!("Generated Shopping List:\n{result}");
946 }
947
948 #[test]
949 fn test_aisled_function_without_config() {
950 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 let result = render_template(&recipe, template).unwrap();
966
967 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 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 let config = Config::builder().pantry_path(&pantry_path).build();
991 let result = render_template_with_config(&recipe, template, &config).unwrap();
992
993 assert!(!result.contains("- flour:"));
995 assert!(!result.contains("- butter:"));
996 assert!(result.contains("- eggs:"));
998 assert!(result.contains("- milk:"));
999 }
1000
1001 #[test]
1002 fn test_from_pantry() {
1003 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 let config = Config::builder().pantry_path(&pantry_path).build();
1018 let result = render_template_with_config(&recipe, template, &config).unwrap();
1019
1020 assert!(result.contains("- flour:"));
1022 assert!(!result.contains("- eggs:"));
1024 assert!(!result.contains("- milk:"));
1025 }
1027
1028 #[test]
1029 fn test_pantry_without_config() {
1030 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 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 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 assert!(result.contains("# Smart Shopping List"));
1073 assert!(result.contains("## Items to Buy"));
1074 assert!(result.contains("## Already Have in Pantry"));
1075
1076 assert!(result.contains("✓ Flour:"));
1078
1079 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 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 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 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 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 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 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 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 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 let template = "{{ 1234567 | number_to_human }}";
1185 let result = render_template(recipe, template).unwrap();
1186 assert_eq!(result, "1.235 Million");
1187
1188 let template = "{{ 1234567 | number_to_human_size }}";
1190 let result = render_template(recipe, template).unwrap();
1191 assert_eq!(result, "1.177 MB");
1192
1193 let template = "{{ 100 | number_to_percentage(precision=0) }}";
1195 let result = render_template(recipe, template).unwrap();
1196 assert_eq!(result, "100%");
1197
1198 let template = "{{ 12345678 | number_with_delimiter }}";
1200 let result = render_template(recipe, template).unwrap();
1201 assert_eq!(result, "12,345,678");
1202
1203 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 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}