pub mod cooklang_to_cooklang;
pub mod cooklang_to_human;
pub mod cooklang_to_latex;
pub mod cooklang_to_md;
pub mod cooklang_to_schema;
pub mod cooklang_to_typst;
pub mod format;
use anyhow::{Context as _, Result};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang::{
ingredient_list::IngredientList, quantity::Value, Converter, CooklangParser, Extensions, Recipe,
};
use cooklang_find::RecipeEntry;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::LazyLock;
use tracing::warn;
pub const RECIPE_SCALING_DELIMITER: char = ':';
pub static PARSER: LazyLock<CooklangParser> = LazyLock::new(|| {
CooklangParser::new(Extensions::empty(), Converter::default())
});
pub fn parse_recipe_from_entry(entry: &RecipeEntry, scaling_factor: f64) -> Result<Arc<Recipe>> {
let content = entry.content().context("Failed to read recipe content")?;
let parsed = PARSER.parse(&content);
if parsed.report().has_warnings() {
let recipe_name = entry.name().as_deref().unwrap_or("unknown");
for warning in parsed.report().warnings() {
warn!("Recipe '{}': {}", recipe_name, warning);
}
}
let recipe_path = entry
.path()
.map(|p| p.to_string())
.unwrap_or_else(|| "unknown".to_string());
if parsed.report().has_errors() {
let mut error_output = Vec::new();
parsed
.report()
.write(&recipe_path, &content, false, &mut error_output)
.ok();
let error_details = String::from_utf8_lossy(&error_output);
return Err(anyhow::anyhow!(
"Failed to parse recipe '{}'\n{}",
recipe_path,
error_details
));
}
let (mut recipe, _warnings) = parsed.into_result().expect("already checked for errors");
recipe.scale(scaling_factor, PARSER.converter());
Ok(Arc::new(recipe))
}
pub fn write_to_output<F>(output: Option<&Utf8Path>, f: F) -> Result<()>
where
F: FnOnce(&mut dyn std::io::Write) -> Result<()>,
{
let mut stream: Box<dyn std::io::Write> = if let Some(path) = output {
let file = std::fs::File::create(path).context("Failed to create output file")?;
let stream = anstream::StripStream::new(file);
Box::new(stream)
} else {
Box::new(anstream::stdout().lock())
};
f(stream.as_mut())?;
use std::io::Write;
stream.flush()?;
Ok(())
}
pub fn split_recipe_name_and_scaling_factor(query: &str) -> Option<(&str, f64)> {
let (name, factor) = query.trim().rsplit_once(RECIPE_SCALING_DELIMITER)?;
let factor = factor.parse::<f64>().ok()?;
Some((name, factor))
}
pub fn resolve_to_absolute_path(path: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| {
tracing::error!("Failed to get current directory: {:?}", e);
anyhow::anyhow!("Failed to get current directory")
})?
.join(path)
.try_into()
.map_err(|e| {
tracing::error!("Failed to convert path to UTF-8: {:?}", e);
anyhow::anyhow!("Failed to convert path to UTF-8")
})?
};
std::fs::canonicalize(&absolute)
.map_err(|e| {
tracing::error!("Failed to canonicalize path: {:?}", e);
anyhow::anyhow!("Failed to canonicalize path")
})?
.try_into()
.map_err(|e| {
tracing::error!("Failed to convert canonicalized path to UTF-8: {:?}", e);
anyhow::anyhow!("Failed to convert canonicalized path to UTF-8")
})
}
pub fn extract_ingredients(
entry: &str,
list: &mut IngredientList,
seen: &mut BTreeMap<String, usize>,
base_path: &Utf8PathBuf,
converter: &Converter,
ignore_references: bool,
included_references: Option<&[String]>,
) -> Result<()> {
if seen.contains_key(entry) {
return Err(anyhow::anyhow!(
"Circular dependency found: {} -> {}",
seen.keys().cloned().collect::<Vec<_>>().join(" -> "),
entry
));
}
seen.insert(entry.to_string(), seen.len());
let (name, scaling_factor) =
split_recipe_name_and_scaling_factor(entry).unwrap_or((entry, 1.0));
let recipe_entry =
get_recipe(base_path, name).with_context(|| format!("Failed to find recipe '{name}'"))?;
let recipe = parse_recipe_from_entry(&recipe_entry, scaling_factor)?;
let ref_indices = list.add_recipe(&recipe, converter, ignore_references);
tracing::debug!(
"ignore_references = {}, ref_indices.len() = {}",
ignore_references,
ref_indices.len()
);
if !ignore_references {
for ref_index in ref_indices {
let ingredient = &recipe.ingredients[ref_index];
let reference = ingredient.reference.as_ref().unwrap();
let ref_display_path = if reference.components.is_empty() {
reference.name.clone()
} else {
format!("{}/{}", reference.components.join("/"), reference.name)
};
if let Some(included) = included_references {
fn strip_dot_slash(s: &str) -> &str {
s.strip_prefix("./").unwrap_or(s)
}
if !included
.iter()
.any(|r| strip_dot_slash(r) == strip_dot_slash(&ref_display_path))
{
tracing::debug!(
"Skipping reference '{}' — not in included_references",
ref_display_path
);
continue;
}
}
let ref_path = reference.path(std::path::MAIN_SEPARATOR_STR);
let ref_entry = get_recipe(base_path, &ref_path).with_context(|| {
format!(
"Failed to find referenced recipe '{}' from '{}'",
ref_path,
recipe_entry
.path()
.map(|p| p.to_string())
.unwrap_or_else(|| name.to_string())
)
})?;
let ref_recipe = match ingredient.quantity.as_ref() {
Some(quantity) => {
let target_value = match quantity.value() {
Value::Number(num) => num
.to_string()
.parse::<f64>()
.map_err(|_| anyhow::anyhow!("Invalid numeric value: {}", num))?,
_ => {
return Err(anyhow::anyhow!(
"Invalid quantity value for referenced recipe: {}",
ingredient.name
));
}
};
let content = ref_entry
.content()
.context("Failed to read recipe content")?;
let parsed = PARSER.parse(&content);
if parsed.report().has_errors() {
let mut error_output = Vec::new();
parsed
.report()
.write(&ref_path, &content, false, &mut error_output)
.ok();
let error_details = String::from_utf8_lossy(&error_output);
return Err(anyhow::anyhow!(
"Failed to parse referenced recipe '{}'\n{}",
ref_path,
error_details
));
}
let (mut recipe, _warnings) =
parsed.into_result().expect("already checked for errors");
tracing::debug!(
"Scaling recipe '{}' to target {} {}",
ref_path,
target_value,
quantity.unit().unwrap_or("(no unit)")
);
recipe
.scale_to_target(target_value, quantity.unit(), PARSER.converter())
.context(format!(
"Failed to scale recipe '{}' with target {} {}",
ref_path,
target_value,
quantity.unit().unwrap_or("(no unit)")
))?;
Arc::new(recipe)
}
None => {
parse_recipe_from_entry(&ref_entry, scaling_factor)?
}
};
let mut nested_refs = Vec::new();
for (index, ingredient) in ref_recipe.ingredients.iter().enumerate() {
if ingredient.reference.is_some() {
nested_refs.push(index);
}
}
tracing::debug!("Found {} nested references to process", nested_refs.len());
for nested_index in nested_refs {
let nested_ingredient = &ref_recipe.ingredients[nested_index];
tracing::debug!("Processing nested ingredient: {:?}", nested_ingredient.name);
if let Some(nested_ref) = &nested_ingredient.reference {
let nested_path = if nested_ref.components.is_empty() {
nested_ref.name.clone()
} else {
let sep = std::path::MAIN_SEPARATOR.to_string();
format!(
".{}{}{}{}",
sep,
nested_ref.components.join(&sep),
sep,
nested_ref.name
)
};
let nested_entry_path = get_recipe(base_path, &nested_path)?;
let nested_content = nested_entry_path
.content()
.context("Failed to read nested recipe")?;
let (nested_recipe, _) = PARSER
.parse(&nested_content)
.into_result()
.context("Failed to parse nested recipe")?;
if let Some(quantity) = &nested_ingredient.quantity {
if quantity.unit() == Some("servings") {
if let Value::Number(target_servings) = quantity.value() {
let mut scaled_nested = nested_recipe;
let target = target_servings.to_string().parse().unwrap_or(1.0);
tracing::debug!("Scaling nested recipe to {} servings", target);
scaled_nested
.scale_to_target(target, Some("servings"), PARSER.converter())
.context("Failed to scale nested recipe")?;
list.add_recipe(&Arc::new(scaled_nested), converter, false);
}
} else {
if let Value::Number(num) = quantity.value() {
let scaling = num.to_string().parse().unwrap_or(1.0);
let mut scaled_nested = nested_recipe;
scaled_nested.scale(scaling, PARSER.converter());
list.add_recipe(&Arc::new(scaled_nested), converter, false);
}
}
} else {
list.add_recipe(&Arc::new(nested_recipe), converter, false);
}
}
}
list.add_recipe(&ref_recipe, converter, false);
}
}
seen.remove(entry);
Ok(())
}
pub fn get_recipe(base_path: &Utf8PathBuf, name: &str) -> Result<RecipeEntry> {
let clean_name = name.strip_prefix("./").unwrap_or(name);
Ok(cooklang_find::get_recipe(
vec![base_path.clone()],
clean_name.into(),
)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_recipe_with_numeric_scaling_factor() {
assert_eq!(
split_recipe_name_and_scaling_factor("recipe.cook:2"),
Some(("recipe.cook", 2.0))
);
}
#[test]
fn splits_recipe_with_decimal_scaling_factor() {
assert_eq!(
split_recipe_name_and_scaling_factor("recipe.cook:1.5"),
Some(("recipe.cook", 1.5))
);
}
#[test]
fn returns_none_when_no_colon() {
assert_eq!(split_recipe_name_and_scaling_factor("recipe.cook"), None);
}
#[test]
fn returns_none_for_windows_absolute_path() {
assert_eq!(
split_recipe_name_and_scaling_factor(r"C:\test\recipe.cook"),
None
);
}
#[test]
fn returns_none_when_right_side_is_not_numeric() {
assert_eq!(
split_recipe_name_and_scaling_factor("recipe.cook:abc"),
None
);
}
}