use crate::{
find::{entry_error, get_recipe},
ConfigSource, Context, CoreError, Outcome, RecipeSource,
};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang_reports::{config::Config, render_template_with_config};
#[derive(Debug, Clone)]
pub struct RenderRequest {
pub source: RecipeSource,
pub template: String,
pub scale: f64,
pub datastore: Option<Utf8PathBuf>,
pub base_path: Option<Utf8PathBuf>,
}
pub fn render(ctx: &Context, req: RenderRequest) -> Result<Outcome<String>, CoreError> {
if !req.scale.is_finite() {
return Err(CoreError::InvalidScale { scale: req.scale });
}
let base_path = req
.base_path
.unwrap_or_else(|| ctx.base_path().to_path_buf());
let recipe = recipe_text(&base_path, req.source)?;
let mut builder = Config::builder();
builder.scale(req.scale);
builder.base_path(base_path.as_std_path());
if let Some(datastore) = &req.datastore {
builder.datastore_path(datastore.as_std_path());
}
if let Some(aisle) = ctx.aisle().path() {
builder.aisle_path(aisle.as_std_path());
}
if let Some(pantry) = ctx.pantry().path() {
builder.pantry_path(pantry.as_std_path());
}
let mut config = builder.build();
if let ConfigSource::Inline(text) = ctx.aisle() {
config = config.with_context("aisle_content", text.clone());
}
if let ConfigSource::Inline(text) = ctx.pantry() {
config = config.with_context("pantry_content", text.clone());
}
tracing::trace!(
"rendering a report against {base_path} at scale {}",
req.scale
);
let report =
render_template_with_config(&recipe, &req.template, &config).map_err(render_error)?;
Ok(Outcome::new(report))
}
fn recipe_text(base_path: &Utf8Path, source: RecipeSource) -> Result<String, CoreError> {
match source {
RecipeSource::Content { text, .. } => Ok(text),
RecipeSource::Path(lookup) => {
let entry = get_recipe(base_path, lookup.as_str())?;
let path = entry.path().cloned().unwrap_or(lookup);
entry.content().map_err(|source| CoreError::Io {
path,
source: entry_error(source),
})
}
}
}
fn render_error(error: cooklang_reports::Error) -> CoreError {
let rendered = error.format_with_source();
let message = match &error {
cooklang_reports::Error::TemplateError(e) => first_line(&e.to_string()),
cooklang_reports::Error::RecipeParseError(_) => {
"the recipe could not be parsed".to_string()
}
};
CoreError::Render { message, rendered }
}
fn first_line(text: &str) -> String {
text.lines()
.next()
.unwrap_or_default()
.trim_end()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ConfigSource, Context, RecipeSource};
const PANCAKES: &str = "---\ntitle: Pancakes\n---\n\n\
Mix @eggs{3%large} with @milk{250%ml} and @flour{125%g}.\n";
fn ctx() -> Context {
Context::new(Utf8PathBuf::from("."))
}
fn request(template: &str) -> RenderRequest {
RenderRequest {
source: RecipeSource::Content {
text: PANCAKES.to_string(),
name: "pancakes".to_string(),
},
template: template.to_string(),
scale: 1.0,
datastore: None,
base_path: None,
}
}
fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
}
fn write(path: &Utf8Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
#[test]
fn renders_a_template_against_the_recipe() {
let outcome = render(
&ctx(),
request("{% for i in ingredients %}{{ i.name }};{% endfor %}"),
)
.expect("renders");
assert_eq!(outcome.value, "eggs;milk;flour;");
assert!(outcome.diagnostics.is_empty());
}
#[test]
fn metadata_is_available_to_the_template() {
let outcome = render(&ctx(), request("{{ metadata.title }}")).expect("renders");
assert_eq!(outcome.value, "Pancakes");
}
#[test]
fn scale_reaches_the_template_and_the_quantities() {
let template = "{{ scale }}|{{ ingredients[1].name }}={{ ingredients[1].quantity }}";
let one = render(&ctx(), request(template)).expect("renders");
assert_eq!(one.value, "1.0|milk=250 ml");
let mut req = request(template);
req.scale = 3.0;
let three = render(&ctx(), req).expect("renders");
assert_eq!(three.value, "3.0|milk=750 ml");
}
#[test]
fn a_non_finite_scale_is_rejected_before_anything_is_read() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut req = request("{{ scale }}");
req.scale = bad;
req.source = RecipeSource::Path(Utf8PathBuf::from("no/such/recipe.cook"));
match render(&ctx(), req) {
Err(CoreError::InvalidScale { scale }) => {
assert_eq!(scale.is_nan(), bad.is_nan());
}
other => panic!("expected InvalidScale for {bad}, got {other:?}"),
}
}
}
#[test]
fn a_broken_template_is_a_render_error() {
let req = request("{% for i in ingredients %}{{ i.name }}{% endfor");
match render(&ctx(), req) {
Err(CoreError::Render { message, rendered }) => {
assert!(
!message.contains('\n'),
"the summary must stay one line: {message:?}"
);
assert!(
message.to_lowercase().contains("syntax"),
"expected the engine's own summary, got {message:?}"
);
assert!(
rendered.len() > message.len(),
"rendered should carry the long report, got {rendered:?}"
);
assert!(
rendered.contains("endfor"),
"rendered should quote the template, got {rendered:?}"
);
}
other => panic!("expected CoreError::Render, got {other:?}"),
}
}
#[test]
fn a_failing_expression_is_a_render_error_too() {
match render(&ctx(), request("{{ db('eggs.price') }}")) {
Err(CoreError::Render { message, .. }) => {
assert!(!message.is_empty(), "expected a summary");
assert!(!message.contains('\n'), "one line: {message:?}");
}
other => panic!("expected CoreError::Render, got {other:?}"),
}
}
#[test]
fn an_unparseable_recipe_is_also_a_render_error() {
let mut req = request("{{ metadata.title }}");
req.source = RecipeSource::Content {
text: "Add @{1%tsp} to the pot.\n".to_string(),
name: "broken".to_string(),
};
match render(&ctx(), req) {
Err(CoreError::Render { message, rendered }) => {
assert_eq!(message, "the recipe could not be parsed");
assert!(
!rendered.is_empty(),
"the parse report must survive into `rendered`"
);
}
other => panic!("expected CoreError::Render, got {other:?}"),
}
}
#[test]
fn a_path_source_is_resolved_by_name_under_the_base_path() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
write(&base.join("pancakes.cook"), PANCAKES);
let mut req = request("{{ metadata.title }}");
req.source = RecipeSource::Path(Utf8PathBuf::from("pancakes"));
let outcome = render(&Context::new(base), req).expect("renders");
assert_eq!(outcome.value, "Pancakes");
}
#[test]
fn a_missing_path_source_is_not_found() {
let dir = tempfile::TempDir::new().unwrap();
let mut req = request("{{ metadata.title }}");
req.source = RecipeSource::Path(Utf8PathBuf::from("absent.cook"));
match render(&Context::new(utf8(&dir)), req) {
Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
other => panic!("expected RecipeNotFound, got {other:?}"),
}
}
#[test]
fn content_is_rendered_as_given_and_never_read_from_disk() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
write(
&base.join("pancakes.cook"),
"---\ntitle: On Disk\n---\n\nBoil @water{1%l}.\n",
);
let outcome =
render(&Context::new(base), request("{{ metadata.title }}")).expect("renders");
assert_eq!(
outcome.value, "Pancakes",
"the buffer must win over the file of the same name"
);
}
#[test]
fn the_base_path_defaults_to_the_context_and_is_overridable() {
let ctx = Context::new(Utf8PathBuf::from("/from/context"));
let outcome = render(&ctx, request("{{ base_path }}")).expect("renders");
assert_eq!(outcome.value, "/from/context");
let mut req = request("{{ base_path }}");
req.base_path = Some(Utf8PathBuf::from("/from/request"));
let outcome = render(&ctx, req).expect("renders");
assert_eq!(outcome.value, "/from/request");
}
#[test]
fn the_request_base_path_steers_the_recipe_lookup() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
write(&base.join("elsewhere").join("pancakes.cook"), PANCAKES);
let mut req = request("{{ metadata.title }}");
req.source = RecipeSource::Path(Utf8PathBuf::from("pancakes"));
req.base_path = Some(base.join("elsewhere"));
let ctx = Context::new(base.join("nothing-here"));
let outcome = render(&ctx, req).expect("renders");
assert_eq!(outcome.value, "Pancakes");
}
const AISLE: &str = "[dairy]\nmilk\neggs\n\n[grains]\nflour\n";
const PANTRY: &str = "[baking]\nflour = \"2%kg\"\n";
#[test]
fn an_aisle_reaches_the_template_from_a_path_and_from_inline_text() {
let dir = tempfile::TempDir::new().unwrap();
let path = utf8(&dir).join("aisle.conf");
write(&path, AISLE);
let template = "{{ aisle_content | length }}|\
{% for aisle, items in aisled(ingredients) | items %}{{ aisle }},{% endfor %}";
for source in [
ConfigSource::Path(path.clone()),
ConfigSource::Inline(AISLE.to_string()),
] {
let ctx = Context::new(Utf8PathBuf::from(".")).with_aisle(source.clone());
let outcome = render(&ctx, request(template)).expect("renders");
assert_eq!(
outcome.value,
format!("{}|dairy,grains,", AISLE.len()),
"aisle not honoured for {source:?}"
);
}
}
#[test]
fn a_pantry_reaches_the_template_from_a_path_and_from_inline_text() {
let dir = tempfile::TempDir::new().unwrap();
let path = utf8(&dir).join("pantry.conf");
write(&path, PANTRY);
let template = "{{ pantry_content | length }}|\
{% for i in excluding_pantry(ingredients) %}{{ i.name }},{% endfor %}";
for source in [
ConfigSource::Path(path.clone()),
ConfigSource::Inline(PANTRY.to_string()),
] {
let ctx = Context::new(Utf8PathBuf::from(".")).with_pantry(source.clone());
let outcome = render(&ctx, request(template)).expect("renders");
assert_eq!(
outcome.value,
format!("{}|eggs,milk,", PANTRY.len()),
"pantry not honoured for {source:?}"
);
}
}
#[test]
fn no_aisle_or_pantry_leaves_the_template_functions_usable() {
let outcome = render(
&ctx(),
request("{% for i in excluding_pantry(ingredients) %}{{ i.name }},{% endfor %}"),
)
.expect("renders");
assert_eq!(outcome.value, "eggs,milk,flour,");
}
#[test]
fn the_datastore_path_is_what_the_db_function_reads() {
let dir = tempfile::TempDir::new().unwrap();
let store = utf8(&dir).join("db");
write(
&store.join("eggs").join("shopping.yml"),
"price_per_unit: 0.25\n",
);
let mut req = request("{{ db('eggs.shopping.price_per_unit') }}");
req.datastore = Some(store);
let outcome = render(&ctx(), req).expect("renders");
assert_eq!(outcome.value, "0.25");
}
}