#[doc = include_str!("../README.md")]
use cooklang::Recipe;
use filters::{
camelize_filter, dasherize_filter, format_price_filter, humanize_filter, numeric_filter,
titleize_filter, underscore_filter, upcase_first_filter,
};
use functions::{
aisled, excluding_pantry, from_pantry, get_from_datastore, get_ingredient_list,
number_to_currency, number_to_human, number_to_human_size, number_to_percentage,
number_with_delimiter, number_with_precision,
};
use minijinja::Environment;
use model::{Cookware, Ingredient, Metadata, Section};
use parser::{get_converter, get_parser};
use serde::Serialize;
use yaml_datastore::Datastore;
pub mod config;
pub mod error;
pub mod extension;
mod filters;
mod functions;
mod model;
pub mod parser;
pub use config::Config;
pub use error::Error;
pub use extension::ConfigExtension;
pub use minijinja;
#[derive(Debug, Serialize)]
struct TemplateContext {
scale: f64,
datastore: Option<Datastore>,
base_path: Option<String>,
aisle_content: Option<String>,
pantry_content: Option<String>,
sections: Vec<minijinja::Value>,
ingredients: Vec<minijinja::Value>,
cookware: Vec<minijinja::Value>,
metadata: minijinja::Value,
}
impl TemplateContext {
fn new(
recipe: Recipe,
scale: f64,
datastore: Option<Datastore>,
base_path: Option<String>,
aisle_content: Option<String>,
pantry_content: Option<String>,
) -> TemplateContext {
TemplateContext {
scale,
datastore,
base_path,
aisle_content,
pantry_content,
sections: Section::from_recipe_sections(&recipe)
.into_iter()
.map(minijinja::Value::from_object)
.collect(),
ingredients: recipe
.ingredients
.into_iter()
.map(Ingredient::from)
.map(minijinja::Value::from)
.collect(),
cookware: recipe
.cookware
.into_iter()
.map(Cookware::from)
.map(minijinja::Value::from)
.collect(),
metadata: Metadata::from(recipe.metadata).into(),
}
}
}
pub fn render_template(recipe: &str, template: &str) -> Result<String, Error> {
render_template_with_config(recipe, template, &Config::default())
}
pub fn render_template_with_config(
recipe: &str,
template: &str,
config: &Config,
) -> Result<String, Error> {
let (mut recipe, warnings) = get_parser().parse(recipe).into_result()?;
if warnings.has_warnings() {
for warning in warnings.warnings() {
eprintln!("Warning: {warning}");
}
}
recipe.scale(config.scale, get_converter());
let datastore = config.datastore_path.as_ref().map(Datastore::open);
let base_path = config
.base_path
.as_ref()
.and_then(|p| p.to_str())
.map(String::from);
let aisle_content = if let Some(aisle_path) = &config.aisle_path {
match std::fs::read_to_string(aisle_path) {
Ok(content) => {
let result = cooklang::aisle::parse_lenient(&content);
if result.report().has_warnings() {
for warning in result.report().warnings() {
eprintln!("Warning in aisle file: {warning}");
}
}
Some(content)
}
Err(e) => {
eprintln!("Warning: Failed to read aisle file: {e}");
None
}
}
} else {
None
};
let pantry_content = if let Some(pantry_path) = &config.pantry_path {
match std::fs::read_to_string(pantry_path) {
Ok(content) => {
let result = cooklang::pantry::parse_lenient(&content);
if result.report().has_warnings() {
for warning in result.report().warnings() {
eprintln!("Warning in pantry file: {warning}");
}
}
Some(content)
}
Err(e) => {
eprintln!("Warning: Failed to read pantry file: {e}");
None
}
}
} else {
None
};
let template_context = TemplateContext::new(
recipe,
config.scale,
datastore,
base_path,
aisle_content,
pantry_content,
);
let template_environment = template_environment(template, config)?;
let template: minijinja::Template<'_, '_> = template_environment.get_template("base")?;
Ok(template.render(template_context)?)
}
fn template_environment<'a>(template: &'a str, config: &'a Config) -> Result<Environment<'a>, Error> {
let mut env = Environment::new();
env.set_debug(true);
env.add_template("base", template)?;
env.add_function("db", get_from_datastore);
env.add_function("get_ingredient_list", get_ingredient_list);
env.add_function("aisled", aisled);
env.add_function("excluding_pantry", excluding_pantry);
env.add_function("from_pantry", from_pantry);
env.add_function("number_to_currency", number_to_currency);
env.add_function("number_to_human", number_to_human);
env.add_function("number_to_human_size", number_to_human_size);
env.add_function("number_to_percentage", number_to_percentage);
env.add_function("number_with_delimiter", number_with_delimiter);
env.add_function("number_with_precision", number_with_precision);
env.add_filter("number_to_currency", number_to_currency);
env.add_filter("number_to_human", number_to_human);
env.add_filter("number_to_human_size", number_to_human_size);
env.add_filter("number_to_percentage", number_to_percentage);
env.add_filter("number_with_delimiter", number_with_delimiter);
env.add_filter("number_with_precision", number_with_precision);
env.add_filter("numeric", numeric_filter);
env.add_filter("format_price", format_price_filter);
env.add_filter("camelize", camelize_filter);
env.add_filter("underscore", underscore_filter);
env.add_filter("dasherize", dasherize_filter);
env.add_filter("humanize", humanize_filter);
env.add_filter("titleize", titleize_filter);
env.add_filter("upcase_first", upcase_first_filter);
env.add_function("camelize", camelize_filter);
env.add_function("underscore", underscore_filter);
env.add_function("dasherize", dasherize_filter);
env.add_function("humanize", humanize_filter);
env.add_function("titleize", titleize_filter);
env.add_function("upcase_first", upcase_first_filter);
for ext in &config.extensions {
ext.register(&mut env);
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use std::path::PathBuf;
fn get_test_data_path() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("test");
path.push("data");
path
}
#[test]
fn simple_template_new() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
# Ingredients ({{ scale }}x)
{%- for ingredient in ingredients %}
- {{ ingredient.name }}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Ingredients (1.0x)
- eggs
- milk
- flour"};
assert_eq!(result, expected);
let config: Config = Config::builder().scale(2.0).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Ingredients (2.0x)
- eggs
- milk
- flour"};
assert_eq!(result, expected);
}
#[test]
fn test_datastore_missing_key() {
let datastore_path = get_test_data_path().join("db");
let recipe = "@ingredient{1}";
let template = r#"Missing key: "{{ db("nonexistent.key.path") }}" (should be empty)"#;
let config = Config::builder().datastore_path(datastore_path).build();
let result = render_template_with_config(recipe, template, &config).unwrap();
assert!(result.contains(r#"Missing key: "" (should be empty)"#));
}
#[test]
fn test_datastore_access() {
let datastore_path = get_test_data_path().join("db");
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# Eggs Info
Density: {{ db('eggs.meta.density') }}
Shelf Life: {{ db('eggs.meta.storage.shelf life') }} days
Fridge Life: {{ db('eggs.meta.storage.fridge life') }} days
"};
let config = Config::builder().datastore_path(&datastore_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Eggs Info
Density: 1.03
Shelf Life: 30 days
Fridge Life: 60 days"};
assert_eq!(result, expected);
}
#[test]
fn test_recursive_ingredients_with_base_path() {
let base_path = get_test_data_path().join("recipes");
let recipe_path = base_path.join("Recipe With Reference.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# Recursive Ingredients
{%- set all = get_ingredient_list(ingredients) %}
{%- for ingredient in all %}
- {{ ingredient.name }}: {{ ingredient.quantities }}
{%- endfor %}
"};
let config = Config::builder().base_path(&base_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Recursive Ingredients
- eggs: 6 large
- milk: 700 ml
- flour: 250 g
- sugar: 2 tbsp"};
assert_eq!(result, expected);
}
#[test]
fn test_recipe_scaling() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# Ingredients ({{ scale }}x)
{%- for ingredient in ingredients %}
- {{ ingredient.name }}: {{ ingredient.quantity }}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Ingredients (1.0x)
- eggs: 3 large
- milk: 250 ml
- flour: 125 g"};
assert_eq!(result, expected);
let config = Config::builder().scale(2.0).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Ingredients (2.0x)
- eggs: 6 large
- milk: 500 ml
- flour: 250 g"};
assert_eq!(result, expected);
let config = Config::builder().scale(3.0).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Ingredients (3.0x)
- eggs: 9 large
- milk: 750 ml
- flour: 375 g"};
assert_eq!(result, expected);
let config = Config::builder().scale(0.5).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Ingredients (0.5x)
- eggs: 1.5 large
- milk: 125 ml
- flour: 62.5 g"};
assert_eq!(result, expected);
}
#[test]
fn test_with_template_from_files() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template_path = get_test_data_path()
.join("reports")
.join("ingredients.md.jinja");
let template = std::fs::read_to_string(template_path).unwrap();
let result = render_template(&recipe, &template).unwrap();
let expected = indoc! {"
# Ingredients Report
* eggs: 3 large
* flour: 125 g
* milk: 250 ml"};
assert_eq!(result, expected);
}
#[test]
fn test_with_template_from_files_with_db() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let datastore_path = get_test_data_path().join("db");
let template_path = get_test_data_path().join("reports").join("cost.md.jinja");
let template = std::fs::read_to_string(template_path).unwrap();
let config = Config::builder().datastore_path(datastore_path).build();
let result = render_template_with_config(&recipe, &template, &config).unwrap();
let expected = indoc! {"
# Cost Report
* eggs: $0.75
* milk: $0.25
* flour: $0.19
Total: $1.19"};
assert_eq!(result, expected);
}
#[test]
fn cookware() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
# Cookware
{%- for item in cookware %}
- {{ item.name }}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Cookware
- whisk
- large bowl"};
assert_eq!(result, expected);
}
#[test]
fn metadata_render() {
let recipe_path = get_test_data_path()
.join("recipes")
.join("Chinese Udon Noodles.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
# Metadata
{{ metadata }}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Metadata
---
title: Chinese-Style Udon Noodles
description: A quick, simple, yet satisfying take on a Chinese-style noodle dish.
author: Dan Fego
servings: 2
tags:
- vegan
---
"};
assert_eq!(result, expected);
}
#[test]
fn metadata_enumerate() {
let recipe_path = get_test_data_path()
.join("recipes")
.join("Chinese Udon Noodles.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
# Metadata
{%- for key, value in metadata | items %}
- {{ key }}: {{ value }}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Metadata
- title: Chinese-Style Udon Noodles
- description: A quick, simple, yet satisfying take on a Chinese-style noodle dish.
- author: Dan Fego
- servings: 2
- tags: [\"vegan\"]"};
assert_eq!(result, expected);
}
#[test]
fn sections() {
let recipe_path = get_test_data_path()
.join("recipes")
.join("Contrived Eggs.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
# Recipe
{%- for section in sections %}
## {{ section.name }}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Recipe
## Preparation
## Cooking
## Consumption"};
assert_eq!(result, expected);
}
#[test]
fn sections_default() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
# Recipe
{%- for section in sections %}
{% if section.name %}
## {{ section.name }}
{% endif %}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
# Recipe
"};
assert_eq!(result, expected);
}
#[test]
fn test_template_syntax_error() {
let recipe = "@eggs{2}";
let template = "{% for item in ingredients %}{{ item.name }}{% endfor";
let result = render_template(recipe, template);
assert!(result.is_err());
if let Err(e) = result {
let formatted = e.format_with_source();
assert!(formatted.contains("syntax error"));
assert!(formatted.contains("endfor")); assert!(formatted.contains("Hint:")); assert!(formatted.contains("Missing closing tags"));
}
}
#[test]
fn test_template_undefined_error() {
let recipe = "@eggs{2}";
let template = "{{ nonexistent_variable }}";
let result = render_template(recipe, template);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_template_attribute_error() {
let recipe = "@eggs{2}";
let template = "{% for item in ingredients %}{{ item.nonexistent }}{% endfor %}";
let result = render_template(recipe, template);
assert!(result.is_ok());
}
#[test]
fn test_template_invalid_function_call() {
let recipe = "@eggs{2}";
let template = "{{ unknown_function() }}";
let result = render_template(recipe, template);
assert!(result.is_err());
if let Err(e) = result {
let formatted = e.format_with_source();
assert!(formatted.contains("unknown function"));
assert!(formatted.contains("unknown_function()")); }
}
#[test]
fn test_recipe_references_with_servings_scaling() {
let base_path = get_test_data_path().join("recipes");
let recipe_path = base_path.join("Recipe With Scaled References.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# All Ingredients
{%- set all = get_ingredient_list(ingredients) %}
{%- for ingredient in all %}
- {{ ingredient.name }}: {{ ingredient.quantities }}
{%- endfor %}
"};
let config = Config::builder().base_path(&base_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# All Ingredients
- flour: 775 g
- milk: 1100 ml
- eggs: 6 large, 4
- butter: 50 g
- sugar: 75 g
- salt: 1 tsp"};
assert_eq!(result, expected);
}
#[test]
fn test_recipe_references_yield_unit_mismatch() {
let base_path = get_test_data_path().join("recipes");
let recipe = indoc! {"
---
title: Bad Yield Reference
---
Make @./Recipe With Yield.cook{100%ml} incorrectly.
"};
let template = indoc! {"
{%- set all = get_ingredient_list(ingredients) %}
Error should happen before this
"};
let config = Config::builder().base_path(&base_path).build();
let result = render_template_with_config(recipe, template, &config);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = err.format_with_source();
assert!(
err_msg.contains("Failed to scale recipe"),
"Expected error about scaling recipe, got: {err_msg}"
);
}
#[test]
fn test_recipe_references_missing_servings() {
let base_path = get_test_data_path().join("recipes");
let no_servings_path = base_path.join("No Servings.cook");
std::fs::write(&no_servings_path, "Mix @flour{100%g} with @water{200%ml}.").unwrap();
let recipe = indoc! {"
---
title: Bad Servings Reference
---
Make @./No Servings.cook{4%servings} incorrectly.
"};
let template = indoc! {"
{%- set all = get_ingredient_list(ingredients) %}
Error should happen before this
"};
let config = Config::builder().base_path(&base_path).build();
let result = render_template_with_config(recipe, template, &config);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = err.format_with_source();
assert!(
err_msg.contains("Failed to scale recipe") && err_msg.contains("servings"),
"Expected error about missing servings metadata, got: {err_msg}"
);
std::fs::remove_file(no_servings_path).ok();
}
#[test]
fn test_recipe_references_missing_yield() {
let base_path = get_test_data_path().join("recipes");
let recipe = indoc! {"
---
title: Bad Yield Reference
---
Make @./Pancakes.cook{500%g} incorrectly.
"};
let template = indoc! {"
{%- set all = get_ingredient_list(ingredients) %}
Error should happen before this
"};
let config = Config::builder().base_path(&base_path).build();
let result = render_template_with_config(recipe, template, &config);
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = err.format_with_source();
assert!(
err_msg.contains("Failed to scale recipe"),
"Expected error about scaling recipe, got: {err_msg}"
);
}
#[test]
fn test_recursive_ingredients_without_expansion() {
let base_path = get_test_data_path().join("recipes");
let recipe_path = base_path.join("Recipe With Reference.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# Non-Recursive Ingredients
{%- set all = get_ingredient_list(ingredients, false) %}
{%- for ingredient in all %}
- {{ ingredient.name }}: {{ ingredient.quantities }}
{%- endfor %}
"};
let config = Config::builder().base_path(&base_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
let expected = indoc! {"
# Non-Recursive Ingredients
- Pancakes: 2
- sugar: 2 tbsp
- milk: 200 ml"};
assert_eq!(result, expected);
}
#[test]
fn test_base_path_defaults_to_cwd() {
let config_default = Config::default();
assert!(config_default.base_path.is_some());
let cwd = std::env::current_dir().unwrap();
assert_eq!(config_default.base_path.unwrap(), cwd);
let config_built = Config::builder().scale(2.0).build();
assert!(config_built.base_path.is_some());
assert_eq!(config_built.base_path.unwrap(), cwd);
}
#[test]
fn sections_with_text() {
let recipe_path = get_test_data_path().join("recipes").join("Blog Post.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template: &str = indoc! {"
{%- for section in sections -%}
{{ section }}
{%- endfor -%}\n
"};
let result = render_template(&recipe, template).unwrap();
let expected = indoc! {"
= My Life Story
This is a blog post about something.
It has many paragraphs.
= Recipe
Nope, just kidding.
"};
assert_eq!(result, expected);
}
#[test]
fn test_aisled_function() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let aisle_path = get_test_data_path().join("aisle.conf");
let template = indoc! {"
# Aisled Ingredients
{%- for aisle, items in aisled(ingredients) | items %}
## {{ aisle }}
{%- for ingredient in items %}
- {{ ingredient.name }}: {{ ingredient.quantity }}
{%- endfor %}
{%- endfor %}
"};
let config = Config::builder().aisle_path(&aisle_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
assert!(result.contains("## dairy"));
assert!(result.contains("## grains"));
assert!(result.contains("- eggs:"));
assert!(result.contains("- milk:"));
assert!(result.contains("- flour:"));
}
#[test]
fn test_aisled_with_template_file() {
let recipe_path = get_test_data_path()
.join("recipes")
.join("Chinese Udon Noodles.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let aisle_path = get_test_data_path().join("aisle.conf");
let template_path = get_test_data_path()
.join("reports")
.join("aisled_shopping.md.jinja");
let template = std::fs::read_to_string(template_path).unwrap();
let config = Config::builder().aisle_path(&aisle_path).build();
let result = render_template_with_config(&recipe, &template, &config).unwrap();
assert!(result.contains("# Shopping List by Aisle"));
assert!(result.contains("## Organized by Store Aisle"));
assert!(result.contains("## All Ingredients (Flat List)"));
println!("Generated Shopping List:\n{result}");
}
#[test]
fn test_aisled_function_without_config() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# Aisled Ingredients
{%- for aisle, items in aisled(ingredients) | items %}
## {{ aisle }}
{%- for ingredient in items %}
- {{ ingredient.name }}: {{ ingredient.quantity }}
{%- endfor %}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
assert!(result.contains("## other"));
assert!(result.contains("- eggs:"));
assert!(result.contains("- milk:"));
assert!(result.contains("- flour:"));
}
#[test]
fn test_excluding_pantry() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let pantry_path = get_test_data_path().join("pantry.conf");
let template = indoc! {"
# Need to buy
{%- for ingredient in excluding_pantry(ingredients) %}
- {{ ingredient.name }}: {{ ingredient.quantity }}
{%- endfor %}
"};
let config = Config::builder().pantry_path(&pantry_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
assert!(!result.contains("- flour:"));
assert!(!result.contains("- butter:"));
assert!(result.contains("- eggs:"));
assert!(result.contains("- milk:"));
}
#[test]
fn test_from_pantry() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let pantry_path = get_test_data_path().join("pantry.conf");
let template = indoc! {"
# Already in pantry
{%- for ingredient in from_pantry(ingredients) %}
- {{ ingredient.name }}: {{ ingredient.quantity }}
{%- endfor %}
"};
let config = Config::builder().pantry_path(&pantry_path).build();
let result = render_template_with_config(&recipe, template, &config).unwrap();
assert!(result.contains("- flour:"));
assert!(!result.contains("- eggs:"));
assert!(!result.contains("- milk:"));
}
#[test]
fn test_pantry_without_config() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let template = indoc! {"
# Need to buy
{%- for ingredient in excluding_pantry(ingredients) %}
- {{ ingredient.name }}: {{ ingredient.quantity }}
{%- endfor %}
"};
let result = render_template(&recipe, template).unwrap();
assert!(result.contains("- eggs:"));
assert!(result.contains("- milk:"));
assert!(result.contains("- flour:"));
}
#[test]
fn test_smart_shopping_template() {
let recipe_path = get_test_data_path().join("recipes").join("Pancakes.cook");
let recipe = std::fs::read_to_string(recipe_path).unwrap();
let aisle_path = get_test_data_path().join("aisle.conf");
let pantry_path = get_test_data_path().join("pantry.conf");
let template_path = get_test_data_path()
.join("reports")
.join("smart_shopping.md.jinja");
let template = std::fs::read_to_string(template_path).unwrap();
let config = Config::builder()
.aisle_path(&aisle_path)
.pantry_path(&pantry_path)
.build();
let result = render_template_with_config(&recipe, &template, &config).unwrap();
println!("Smart Shopping List:\n{result}");
assert!(result.contains("# Smart Shopping List"));
assert!(result.contains("## Items to Buy"));
assert!(result.contains("## Already Have in Pantry"));
assert!(result.contains("✓ Flour:"));
assert!(result.contains("[ ] Eggs:"));
assert!(result.contains("[ ] Milk:"));
}
#[test]
fn test_number_formatting_functions() {
let recipe = "@eggs{2}";
let template = "{{ number_to_currency(1234.567) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "$1,234.57");
let template = "{{ number_to_currency(1234.567, precision=1) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "$1,234.6");
let template = "{{ number_to_currency(1234.567, unit='£') }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "£1,234.57");
let template = "{{ number_to_human(1234567) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.235 Million");
let template = "{{ number_to_human(1234567890) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.235 Billion");
let template = "{{ number_to_human_size(1234567) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.177 MB");
let template = "{{ number_to_human_size(1234567890) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.150 GB");
let template = "{{ number_to_percentage(100) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "100.000%");
let template = "{{ number_to_percentage(100, precision=0) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "100%");
let template = "{{ number_with_delimiter(12345678) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "12,345,678");
let template = "{{ number_with_delimiter(12345678, delimiter='_') }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "12_345_678");
let template = "{{ number_with_precision(111.2345) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "111.234");
let template = "{{ number_with_precision(111.2345, precision=2) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "111.23");
let template =
"{{ number_with_precision(13, precision=5, strip_insignificant_zeros=true) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "13");
}
#[test]
fn test_number_formatting_with_strings() {
let recipe = "@eggs{2}";
let template = "{{ number_to_currency('1234.567') }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "$1,234.57");
let template = "{{ number_to_human('1234567') }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.235 Million");
let template = "{{ number_with_delimiter('12345678.05') }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "12,345,678.05");
}
#[test]
fn test_number_formatting_as_filters() {
let recipe = "@eggs{2}";
let template = "{{ 1234.567 | number_to_currency }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "$1,234.57");
let template = "{{ 1234.567 | number_to_currency(precision=1) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "$1,234.6");
let template = "{{ 1234567 | number_to_human }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.235 Million");
let template = "{{ 1234567 | number_to_human_size }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "1.177 MB");
let template = "{{ 100 | number_to_percentage(precision=0) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "100%");
let template = "{{ 12345678 | number_with_delimiter }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "12,345,678");
let template = "{{ 111.2345 | number_with_precision(precision=2) }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "111.23");
let template = "{{ '123.45kg' | numeric | number_to_currency }}";
let result = render_template(recipe, template).unwrap();
assert_eq!(result, "$123.45");
}
#[test]
fn one_section_with_steps() {
let recipe = indoc! {"
Put @butter{1%pat} into #frying pan{} on low heat.
Crack @egg into pan.
Fry egg on low heat until cooked.
Enjoy.
"};
let template: &str = indoc! {"
# Steps
{% for content in sections[0] %}
{{ content }}
{%- endfor %}
"};
let result = render_template(recipe, template).unwrap();
println!("{result}");
let expected = indoc! {"
# Steps
1. Put 1 pat butter into frying pan on low heat.
2. Crack egg into pan.
3. Fry egg on low heat until cooked.
4. Enjoy."};
assert_eq!(result, expected);
}
}