1use crate::{
37 find::{entry_error, get_recipe},
38 ConfigSource, Context, CoreError, Outcome, RecipeSource,
39};
40use camino::{Utf8Path, Utf8PathBuf};
41use cooklang_reports::{config::Config, render_template_with_config};
42
43#[derive(Debug, Clone)]
45pub struct RenderRequest {
46 pub source: RecipeSource,
48 pub template: String,
56 pub scale: f64,
65 pub datastore: Option<Utf8PathBuf>,
72 pub base_path: Option<Utf8PathBuf>,
79}
80
81pub fn render(ctx: &Context, req: RenderRequest) -> Result<Outcome<String>, CoreError> {
103 if !req.scale.is_finite() {
104 return Err(CoreError::InvalidScale { scale: req.scale });
105 }
106
107 let base_path = req
108 .base_path
109 .unwrap_or_else(|| ctx.base_path().to_path_buf());
110
111 let recipe = recipe_text(&base_path, req.source)?;
112
113 let mut builder = Config::builder();
114 builder.scale(req.scale);
115 builder.base_path(base_path.as_std_path());
116 if let Some(datastore) = &req.datastore {
117 builder.datastore_path(datastore.as_std_path());
118 }
119 if let Some(aisle) = ctx.aisle().path() {
120 builder.aisle_path(aisle.as_std_path());
121 }
122 if let Some(pantry) = ctx.pantry().path() {
123 builder.pantry_path(pantry.as_std_path());
124 }
125
126 let mut config = builder.build();
127 if let ConfigSource::Inline(text) = ctx.aisle() {
133 config = config.with_context("aisle_content", text.clone());
134 }
135 if let ConfigSource::Inline(text) = ctx.pantry() {
136 config = config.with_context("pantry_content", text.clone());
137 }
138
139 tracing::trace!(
140 "rendering a report against {base_path} at scale {}",
141 req.scale
142 );
143
144 let report =
145 render_template_with_config(&recipe, &req.template, &config).map_err(render_error)?;
146
147 Ok(Outcome::new(report))
152}
153
154fn recipe_text(base_path: &Utf8Path, source: RecipeSource) -> Result<String, CoreError> {
156 match source {
157 RecipeSource::Content { text, .. } => Ok(text),
158 RecipeSource::Path(lookup) => {
159 let entry = get_recipe(base_path, lookup.as_str())?;
160 let path = entry.path().cloned().unwrap_or(lookup);
161 entry.content().map_err(|source| CoreError::Io {
162 path,
163 source: entry_error(source),
164 })
165 }
166 }
167}
168
169fn render_error(error: cooklang_reports::Error) -> CoreError {
175 let rendered = error.format_with_source();
176 let message = match &error {
177 cooklang_reports::Error::TemplateError(e) => first_line(&e.to_string()),
180 cooklang_reports::Error::RecipeParseError(_) => {
184 "the recipe could not be parsed".to_string()
185 }
186 };
187 CoreError::Render { message, rendered }
188}
189
190fn first_line(text: &str) -> String {
192 text.lines()
193 .next()
194 .unwrap_or_default()
195 .trim_end()
196 .to_string()
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::{ConfigSource, Context, RecipeSource};
203
204 const PANCAKES: &str = "---\ntitle: Pancakes\n---\n\n\
205 Mix @eggs{3%large} with @milk{250%ml} and @flour{125%g}.\n";
206
207 fn ctx() -> Context {
208 Context::new(Utf8PathBuf::from("."))
209 }
210
211 fn request(template: &str) -> RenderRequest {
212 RenderRequest {
213 source: RecipeSource::Content {
214 text: PANCAKES.to_string(),
215 name: "pancakes".to_string(),
216 },
217 template: template.to_string(),
218 scale: 1.0,
219 datastore: None,
220 base_path: None,
221 }
222 }
223
224 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
225 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
226 }
227
228 fn write(path: &Utf8Path, text: &str) {
229 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
230 std::fs::write(path, text).unwrap();
231 }
232
233 #[test]
234 fn renders_a_template_against_the_recipe() {
235 let outcome = render(
236 &ctx(),
237 request("{% for i in ingredients %}{{ i.name }};{% endfor %}"),
238 )
239 .expect("renders");
240 assert_eq!(outcome.value, "eggs;milk;flour;");
241 assert!(outcome.diagnostics.is_empty());
242 }
243
244 #[test]
245 fn metadata_is_available_to_the_template() {
246 let outcome = render(&ctx(), request("{{ metadata.title }}")).expect("renders");
247 assert_eq!(outcome.value, "Pancakes");
248 }
249
250 #[test]
254 fn scale_reaches_the_template_and_the_quantities() {
255 let template = "{{ scale }}|{{ ingredients[1].name }}={{ ingredients[1].quantity }}";
256
257 let one = render(&ctx(), request(template)).expect("renders");
258 assert_eq!(one.value, "1.0|milk=250 ml");
259
260 let mut req = request(template);
261 req.scale = 3.0;
262 let three = render(&ctx(), req).expect("renders");
263 assert_eq!(three.value, "3.0|milk=750 ml");
264 }
265
266 #[test]
267 fn a_non_finite_scale_is_rejected_before_anything_is_read() {
268 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
269 let mut req = request("{{ scale }}");
270 req.scale = bad;
271 req.source = RecipeSource::Path(Utf8PathBuf::from("no/such/recipe.cook"));
274 match render(&ctx(), req) {
275 Err(CoreError::InvalidScale { scale }) => {
276 assert_eq!(scale.is_nan(), bad.is_nan());
277 }
278 other => panic!("expected InvalidScale for {bad}, got {other:?}"),
279 }
280 }
281 }
282
283 #[test]
286 fn a_broken_template_is_a_render_error() {
287 let req = request("{% for i in ingredients %}{{ i.name }}{% endfor");
289 match render(&ctx(), req) {
290 Err(CoreError::Render { message, rendered }) => {
291 assert!(
292 !message.contains('\n'),
293 "the summary must stay one line: {message:?}"
294 );
295 assert!(
296 message.to_lowercase().contains("syntax"),
297 "expected the engine's own summary, got {message:?}"
298 );
299 assert!(
302 rendered.len() > message.len(),
303 "rendered should carry the long report, got {rendered:?}"
304 );
305 assert!(
306 rendered.contains("endfor"),
307 "rendered should quote the template, got {rendered:?}"
308 );
309 }
310 other => panic!("expected CoreError::Render, got {other:?}"),
311 }
312 }
313
314 #[test]
317 fn a_failing_expression_is_a_render_error_too() {
318 match render(&ctx(), request("{{ db('eggs.price') }}")) {
320 Err(CoreError::Render { message, .. }) => {
321 assert!(!message.is_empty(), "expected a summary");
322 assert!(!message.contains('\n'), "one line: {message:?}");
323 }
324 other => panic!("expected CoreError::Render, got {other:?}"),
325 }
326 }
327
328 #[test]
331 fn an_unparseable_recipe_is_also_a_render_error() {
332 let mut req = request("{{ metadata.title }}");
333 req.source = RecipeSource::Content {
334 text: "Add @{1%tsp} to the pot.\n".to_string(),
336 name: "broken".to_string(),
337 };
338 match render(&ctx(), req) {
339 Err(CoreError::Render { message, rendered }) => {
340 assert_eq!(message, "the recipe could not be parsed");
341 assert!(
342 !rendered.is_empty(),
343 "the parse report must survive into `rendered`"
344 );
345 }
346 other => panic!("expected CoreError::Render, got {other:?}"),
347 }
348 }
349
350 #[test]
351 fn a_path_source_is_resolved_by_name_under_the_base_path() {
352 let dir = tempfile::TempDir::new().unwrap();
353 let base = utf8(&dir);
354 write(&base.join("pancakes.cook"), PANCAKES);
355
356 let mut req = request("{{ metadata.title }}");
359 req.source = RecipeSource::Path(Utf8PathBuf::from("pancakes"));
360 let outcome = render(&Context::new(base), req).expect("renders");
361 assert_eq!(outcome.value, "Pancakes");
362 }
363
364 #[test]
365 fn a_missing_path_source_is_not_found() {
366 let dir = tempfile::TempDir::new().unwrap();
367 let mut req = request("{{ metadata.title }}");
368 req.source = RecipeSource::Path(Utf8PathBuf::from("absent.cook"));
369 match render(&Context::new(utf8(&dir)), req) {
370 Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
371 other => panic!("expected RecipeNotFound, got {other:?}"),
372 }
373 }
374
375 #[test]
378 fn content_is_rendered_as_given_and_never_read_from_disk() {
379 let dir = tempfile::TempDir::new().unwrap();
380 let base = utf8(&dir);
381 write(
382 &base.join("pancakes.cook"),
383 "---\ntitle: On Disk\n---\n\nBoil @water{1%l}.\n",
384 );
385
386 let outcome =
387 render(&Context::new(base), request("{{ metadata.title }}")).expect("renders");
388 assert_eq!(
389 outcome.value, "Pancakes",
390 "the buffer must win over the file of the same name"
391 );
392 }
393
394 #[test]
395 fn the_base_path_defaults_to_the_context_and_is_overridable() {
396 let ctx = Context::new(Utf8PathBuf::from("/from/context"));
397 let outcome = render(&ctx, request("{{ base_path }}")).expect("renders");
398 assert_eq!(outcome.value, "/from/context");
399
400 let mut req = request("{{ base_path }}");
401 req.base_path = Some(Utf8PathBuf::from("/from/request"));
402 let outcome = render(&ctx, req).expect("renders");
403 assert_eq!(outcome.value, "/from/request");
404 }
405
406 #[test]
409 fn the_request_base_path_steers_the_recipe_lookup() {
410 let dir = tempfile::TempDir::new().unwrap();
411 let base = utf8(&dir);
412 write(&base.join("elsewhere").join("pancakes.cook"), PANCAKES);
413
414 let mut req = request("{{ metadata.title }}");
415 req.source = RecipeSource::Path(Utf8PathBuf::from("pancakes"));
416 req.base_path = Some(base.join("elsewhere"));
417
418 let ctx = Context::new(base.join("nothing-here"));
420 let outcome = render(&ctx, req).expect("renders");
421 assert_eq!(outcome.value, "Pancakes");
422 }
423
424 const AISLE: &str = "[dairy]\nmilk\neggs\n\n[grains]\nflour\n";
425 const PANTRY: &str = "[baking]\nflour = \"2%kg\"\n";
426
427 #[test]
431 fn an_aisle_reaches_the_template_from_a_path_and_from_inline_text() {
432 let dir = tempfile::TempDir::new().unwrap();
433 let path = utf8(&dir).join("aisle.conf");
434 write(&path, AISLE);
435
436 let template = "{{ aisle_content | length }}|\
437 {% for aisle, items in aisled(ingredients) | items %}{{ aisle }},{% endfor %}";
438
439 for source in [
440 ConfigSource::Path(path.clone()),
441 ConfigSource::Inline(AISLE.to_string()),
442 ] {
443 let ctx = Context::new(Utf8PathBuf::from(".")).with_aisle(source.clone());
444 let outcome = render(&ctx, request(template)).expect("renders");
445 assert_eq!(
446 outcome.value,
447 format!("{}|dairy,grains,", AISLE.len()),
448 "aisle not honoured for {source:?}"
449 );
450 }
451 }
452
453 #[test]
454 fn a_pantry_reaches_the_template_from_a_path_and_from_inline_text() {
455 let dir = tempfile::TempDir::new().unwrap();
456 let path = utf8(&dir).join("pantry.conf");
457 write(&path, PANTRY);
458
459 let template = "{{ pantry_content | length }}|\
460 {% for i in excluding_pantry(ingredients) %}{{ i.name }},{% endfor %}";
461
462 for source in [
463 ConfigSource::Path(path.clone()),
464 ConfigSource::Inline(PANTRY.to_string()),
465 ] {
466 let ctx = Context::new(Utf8PathBuf::from(".")).with_pantry(source.clone());
467 let outcome = render(&ctx, request(template)).expect("renders");
468 assert_eq!(
469 outcome.value,
470 format!("{}|eggs,milk,", PANTRY.len()),
471 "pantry not honoured for {source:?}"
472 );
473 }
474 }
475
476 #[test]
479 fn no_aisle_or_pantry_leaves_the_template_functions_usable() {
480 let outcome = render(
481 &ctx(),
482 request("{% for i in excluding_pantry(ingredients) %}{{ i.name }},{% endfor %}"),
483 )
484 .expect("renders");
485 assert_eq!(outcome.value, "eggs,milk,flour,");
486 }
487
488 #[test]
489 fn the_datastore_path_is_what_the_db_function_reads() {
490 let dir = tempfile::TempDir::new().unwrap();
491 let store = utf8(&dir).join("db");
492 write(
493 &store.join("eggs").join("shopping.yml"),
494 "price_per_unit: 0.25\n",
495 );
496
497 let mut req = request("{{ db('eggs.shopping.price_per_unit') }}");
498 req.datastore = Some(store);
499 let outcome = render(&ctx(), req).expect("renders");
500 assert_eq!(outcome.value, "0.25");
501 }
502}