use crate::model::{IngredientList as ModelIngredientList, quantity_from_value};
use crate::parser::{get_converter, get_parser};
use anyhow::{Context, Result, anyhow};
use cooklang::{
ingredient_list::IngredientList,
quantity::{GroupedQuantity, Quantity, Value as QuantityValue},
};
use minijinja::{Error, ErrorKind, State, Value};
use std::collections::BTreeMap;
#[allow(clippy::needless_pass_by_value)]
pub fn get_ingredient_list(
state: &State,
ingredients: &Value,
expand_references: Option<Value>,
) -> Result<Value, Error> {
let base_path = extract_base_path(state);
let should_expand = expand_references
.as_ref()
.is_none_or(minijinja::Value::is_true);
let mut list = IngredientList::new();
let mut seen = BTreeMap::new();
process_ingredients(
ingredients,
&mut list,
&mut seen,
&base_path,
1.0,
should_expand,
)
.map_err(|e| {
Error::new(ErrorKind::InvalidOperation, format!("{e:#}"))
})?;
let model_list = ModelIngredientList::from_cooklang(list);
Ok(Value::from(model_list))
}
fn extract_base_path(state: &State) -> String {
state
.lookup("base_path")
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| {
std::env::current_dir()
.map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
})
}
fn process_ingredients(
ingredients: &Value,
list: &mut IngredientList,
seen: &mut BTreeMap<String, usize>,
base_path: &str,
parent_scaling: f64,
expand_references: bool,
) -> Result<()> {
let iter = ingredients
.try_iter()
.map_err(|e| anyhow!("ingredients must be an array: {}", e))?;
for item in iter {
let is_reference = item
.get_attr("reference")
.map(|v| v.is_true())
.unwrap_or(false);
if is_reference && expand_references {
process_recipe_reference(
&item,
list,
seen,
base_path,
parent_scaling,
expand_references,
)?;
} else {
process_regular_ingredient(&item, list, parent_scaling)?;
}
}
Ok(())
}
fn process_regular_ingredient(
item: &Value,
list: &mut IngredientList,
parent_scaling: f64,
) -> Result<()> {
let name = item
.get_attr("name")
.map_err(|e| anyhow!("Failed to get ingredient name: {}", e))?
.as_str()
.ok_or_else(|| anyhow!("Ingredient name must be a string"))?
.to_string();
let display_name = item
.get_attr("alias")
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or(name);
let mut grouped = GroupedQuantity::empty();
if let Ok(qty_val) = item.get_attr("quantity")
&& let Ok(qty) = quantity_from_value(&qty_val) {
let final_qty = if (parent_scaling - 1.0).abs() > f64::EPSILON {
match qty.value() {
QuantityValue::Number(n) => Quantity::new(
QuantityValue::Number((n.value() * parent_scaling).into()),
qty.unit().map(String::from),
),
_ => qty,
}
} else {
qty
};
grouped.add(&final_qty, get_converter());
}
list.add_ingredient(display_name, &grouped, get_converter());
Ok(())
}
fn process_recipe_reference(
item: &Value,
list: &mut IngredientList,
seen: &mut BTreeMap<String, usize>,
base_path: &str,
parent_scaling: f64,
expand_references: bool,
) -> Result<()> {
let name = item
.get_attr("name")
.map_err(|e| anyhow!("Failed to get ingredient name: {}", e))?
.as_str()
.ok_or_else(|| anyhow!("Ingredient name must be a string"))?
.to_string();
let reference_path = item
.get_attr("reference_path")
.ok()
.and_then(|v| v.as_str().map(String::from))
.map_or_else(|| name, |s| normalize_path(&s));
if seen.contains_key(&reference_path) {
return Err(anyhow!(
"Circular dependency found: {} -> {}",
seen.keys().cloned().collect::<Vec<_>>().join(" -> "),
reference_path
));
}
seen.insert(reference_path.clone(), seen.len());
let recipe_entry = get_recipe(base_path, &reference_path)?;
let content = recipe_entry
.content()
.context("Failed to read recipe content")?;
let parse_result = get_parser().parse(&content);
if parse_result.report().has_errors() {
let mut error_msg = format!("Failed to parse recipe '{reference_path}':");
for error in parse_result.report().errors() {
use std::fmt::Write;
let _ = write!(error_msg, "\n - {error}");
}
return Err(anyhow!(error_msg));
}
if parse_result.report().has_warnings() {
for warning in parse_result.report().warnings() {
eprintln!("Warning in '{reference_path}': {warning}");
}
}
let mut recipe = parse_result
.output()
.ok_or_else(|| anyhow!("Failed to get recipe output for '{}'", reference_path))?
.clone();
if let Ok(qty_val) = item.get_attr("quantity")
&& let Ok(qty) = quantity_from_value(&qty_val) {
if let Some(unit) = qty.unit() {
let target_value = match qty.value() {
QuantityValue::Number(n) => n.value(),
_ => 1.0,
};
recipe
.scale_to_target(target_value, Some(unit), get_converter())
.with_context(|| {
format!(
"Failed to scale recipe '{reference_path}' with target {target_value} {unit}"
)
})?;
} else if let QuantityValue::Number(n) = qty.value() {
recipe.scale(n.value(), get_converter());
}
}
if (parent_scaling - 1.0).abs() > f64::EPSILON {
recipe.scale(parent_scaling, get_converter());
}
let ref_indices = list.add_recipe(&recipe, get_converter(), false);
for ref_index in ref_indices {
let nested_ingredient = &recipe.ingredients[ref_index];
let mut map = std::collections::HashMap::new();
map.insert("name", Value::from(nested_ingredient.name.clone()));
map.insert("reference", Value::from(true));
if let Some(ref_) = &nested_ingredient.reference {
map.insert(
"reference_path",
Value::from(ref_.path(std::path::MAIN_SEPARATOR_STR)),
);
}
if let Some(qty) = &nested_ingredient.quantity {
let mut qty_map = std::collections::HashMap::new();
qty_map.insert("value", Value::from(qty.value().to_string()));
if let Some(unit) = qty.unit() {
qty_map.insert("unit", Value::from(unit));
}
map.insert("quantity", Value::from_iter(qty_map));
}
let nested_value = Value::from_iter(map);
let nested_ingredients = Value::from(vec![nested_value]);
process_ingredients(
&nested_ingredients,
list,
seen,
base_path,
parent_scaling,
expand_references,
)?;
}
seen.remove(&reference_path);
Ok(())
}
fn normalize_path(path: &str) -> String {
path.strip_prefix('/').unwrap_or(path).to_string()
}
fn get_recipe(base_path: &str, name: &str) -> Result<cooklang_find::RecipeEntry> {
Ok(cooklang_find::get_recipe_str(vec![base_path], name)?)
}