1use crate::{
4 find::{entry_error, fetch_error},
5 parse_recipe, parse_recipe_at, Context, CoreError, Outcome, RecipeSource,
6};
7use camino::Utf8PathBuf;
8use cooklang::Recipe;
9
10const SCALING_DELIMITER: char = ':';
12
13#[derive(Debug, Clone)]
15pub struct ReadRequest {
16 pub source: RecipeSource,
18 pub scale: f64,
27}
28
29#[derive(Debug, Clone)]
31#[non_exhaustive]
32pub struct ReadResult {
33 pub recipe: Recipe,
35 pub title: String,
43 pub path: Option<Utf8PathBuf>,
52}
53
54pub fn split_name_and_scale(query: &str) -> Option<(&str, f64)> {
66 let (name, factor) = query.trim().rsplit_once(SCALING_DELIMITER)?;
67 let factor = factor.trim().parse::<f64>().ok()?;
68 Some((name.trim_end(), factor))
69}
70
71pub fn read(ctx: &Context, req: ReadRequest) -> Result<Outcome<ReadResult>, CoreError> {
91 match req.source {
92 RecipeSource::Content { text, name } => {
93 let outcome = parse_recipe(&text, &name, req.scale)?;
94 let title = title_for(&outcome.value, || name);
95 Ok(Outcome::with_diagnostics(
96 ReadResult {
97 recipe: outcome.value,
98 title,
99 path: None,
100 },
101 outcome.diagnostics,
102 ))
103 }
104 RecipeSource::Path(lookup) => {
105 let entry =
106 cooklang_find::get_recipe(vec![ctx.base_path().to_path_buf()], lookup.clone())
107 .map_err(|e| fetch_error(e, &lookup))?;
108
109 let path = entry.path().cloned();
113 let display_path = path.clone().unwrap_or(lookup);
114
115 let content = entry.content().map_err(|source| CoreError::Io {
116 path: display_path.clone(),
117 source: entry_error(source),
118 })?;
119
120 let outcome =
123 parse_recipe_at(&content, display_path.as_str(), req.scale, path.as_deref())?;
124
125 let title = title_for(&outcome.value, || entry.name().clone().unwrap_or_default());
126 Ok(Outcome::with_diagnostics(
127 ReadResult {
128 recipe: outcome.value,
129 title,
130 path,
131 },
132 outcome.diagnostics,
133 ))
134 }
135 }
136}
137
138fn title_for(recipe: &Recipe, fallback: impl FnOnce() -> String) -> String {
145 recipe
146 .metadata
147 .title()
148 .map_or_else(fallback, ToOwned::to_owned)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::{Context, RecipeSource};
155 use cooklang::quantity::Value;
156
157 fn fixture_dir() -> tempfile::TempDir {
158 let dir = tempfile::TempDir::new().unwrap();
159 std::fs::write(
160 dir.path().join("simple.cook"),
161 "Boil @water{2%cups} for ~{5%minutes}.\nAdd @salt{1%tsp}.\n",
162 )
163 .unwrap();
164 dir
165 }
166
167 fn ctx_for(dir: &tempfile::TempDir) -> Context {
168 Context::new(camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap())
169 }
170
171 fn quantity_value(recipe: &Recipe, index: usize) -> f64 {
177 match recipe.ingredients[index]
178 .quantity
179 .as_ref()
180 .expect("ingredient has a quantity")
181 .value()
182 {
183 Value::Number(n) => n.value(),
184 other => panic!("expected a numeric quantity, got {other:?}"),
185 }
186 }
187
188 fn request(source: RecipeSource, scale: f64) -> ReadRequest {
189 ReadRequest { source, scale }
190 }
191
192 fn path_request(name: &str, scale: f64) -> ReadRequest {
193 request(RecipeSource::Path(Utf8PathBuf::from(name)), scale)
194 }
195
196 #[test]
197 fn reads_a_recipe_from_a_path() {
198 let dir = fixture_dir();
199 let outcome = read(&ctx_for(&dir), path_request("simple.cook", 1.0)).expect("reads");
200
201 let ReadResult {
202 recipe,
203 title,
204 path,
205 } = outcome.value;
206 assert_eq!(title, "simple", "title falls back to the file stem");
207 assert_eq!(
208 path.as_deref(),
209 Some(
210 camino::Utf8PathBuf::from_path_buf(dir.path().join("simple.cook"))
211 .unwrap()
212 .as_path()
213 ),
214 "the resolved file must be reported back"
215 );
216 assert_eq!(recipe.ingredients.len(), 2);
217 assert_eq!(recipe.ingredients[0].name, "water");
218 assert_eq!(recipe.ingredients[1].name, "salt");
219 assert_eq!(quantity_value(&recipe, 0), 2.0);
220 assert_eq!(quantity_value(&recipe, 1), 1.0);
221 assert!(outcome.diagnostics.is_empty());
222 }
223
224 #[test]
229 fn a_bare_name_resolves_to_the_cook_file() {
230 let dir = fixture_dir();
231 let outcome = read(&ctx_for(&dir), path_request("simple", 1.0)).expect("reads");
232 assert_eq!(outcome.value.title, "simple");
233 assert_eq!(outcome.value.recipe.ingredients.len(), 2);
234 assert_eq!(
235 outcome.value.path.as_ref().and_then(|p| p.file_name()),
236 Some("simple.cook"),
237 "the extension the lookup chose must be reported back"
238 );
239 }
240
241 #[test]
242 fn reads_a_recipe_from_memory() {
243 let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
246 let outcome = read(
247 &ctx,
248 request(
249 RecipeSource::Content {
250 text: "Boil @water{2%cups}.\nAdd @salt{1%tsp}.\n".to_string(),
251 name: "buffer".to_string(),
252 },
253 1.0,
254 ),
255 )
256 .expect("reads in-memory text");
257
258 assert_eq!(
259 outcome.value.title, "buffer",
260 "with no title of its own, the recipe falls back to the caller's name"
261 );
262 assert_eq!(outcome.value.recipe.ingredients.len(), 2);
263 assert_eq!(quantity_value(&outcome.value.recipe, 0), 2.0);
264 assert_eq!(
265 outcome.value.path, None,
266 "in-memory text has no file to report"
267 );
268 }
269
270 #[test]
275 fn a_metadata_title_wins_over_the_fallback_name() {
276 let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
277 let outcome = read(
278 &ctx,
279 request(
280 RecipeSource::Content {
281 text: "---\ntitle: Proper Title\n---\nBoil @water{1%cup}.\n".to_string(),
282 name: "buffer".to_string(),
283 },
284 1.0,
285 ),
286 )
287 .expect("reads");
288 assert_eq!(
289 outcome.value.title, "Proper Title",
290 "the recipe's own title must beat the caller's label"
291 );
292
293 let dir = tempfile::TempDir::new().unwrap();
294 std::fs::write(
295 dir.path().join("stem.cook"),
296 "---\ntitle: Proper Title\n---\nBoil @water{1%cup}.\n",
297 )
298 .unwrap();
299 let outcome = read(&ctx_for(&dir), path_request("stem.cook", 1.0)).expect("reads");
300 assert_eq!(outcome.value.title, "Proper Title");
301 }
302
303 #[test]
310 fn a_path_and_a_buffer_of_the_same_bytes_agree_on_the_title() {
311 for text in [
312 "---\ntitle: Agreed\n---\nBoil @water{1%cup}.\n",
313 ">> title: Agreed\n\nBoil @water{1%cup}.\n",
314 "Boil @water{1%cup}.\n",
315 ] {
316 let dir = tempfile::TempDir::new().unwrap();
317 std::fs::write(dir.path().join("same.cook"), text).unwrap();
318 let from_path = read(&ctx_for(&dir), path_request("same.cook", 1.0))
319 .expect("reads")
320 .value
321 .title;
322
323 let from_memory = read(
324 &Context::new(Utf8PathBuf::from("/nonexistent")),
325 request(
326 RecipeSource::Content {
327 text: text.to_string(),
328 name: "same".to_string(),
331 },
332 1.0,
333 ),
334 )
335 .expect("reads")
336 .value
337 .title;
338
339 assert_eq!(from_path, from_memory, "titles diverged for {text:?}");
340 }
341 }
342
343 #[test]
344 fn missing_recipe_is_recipe_not_found() {
345 let dir = fixture_dir();
346 match read(&ctx_for(&dir), path_request("absent.cook", 1.0)) {
347 Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
348 other => panic!("expected RecipeNotFound, got {other:?}"),
349 }
350 }
351
352 #[test]
359 #[cfg(unix)]
360 fn an_unreadable_recipe_is_an_io_error_not_a_missing_one() {
361 use std::os::unix::fs::PermissionsExt;
362
363 if unsafe { libc_geteuid() } == 0 {
365 return;
366 }
367
368 let dir = fixture_dir();
369 let path = dir.path().join("simple.cook");
370 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
371
372 let result = read(&ctx_for(&dir), path_request("simple.cook", 1.0));
373
374 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
377
378 match result {
379 Err(CoreError::Io {
380 path: reported,
381 source,
382 }) => {
383 assert_eq!(reported.file_name(), Some("simple.cook"));
384 assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
385 }
386 other => panic!("expected CoreError::Io, got {other:?}"),
387 }
388 }
389
390 #[cfg(unix)]
392 extern "C" {
393 #[link_name = "geteuid"]
394 fn libc_geteuid() -> u32;
395 }
396
397 #[test]
401 fn a_directory_named_like_a_recipe_is_an_io_error() {
402 let dir = tempfile::TempDir::new().unwrap();
403 std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
404
405 match read(&ctx_for(&dir), path_request("adir.cook", 1.0)) {
406 Err(CoreError::Io { path, .. }) => {
407 assert_eq!(path.file_name(), Some("adir.cook"));
408 }
409 other => panic!("expected CoreError::Io, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn the_request_scale_is_applied() {
415 let dir = fixture_dir();
416 let outcome = read(&ctx_for(&dir), path_request("simple.cook", 3.0)).expect("reads");
418 assert_eq!(quantity_value(&outcome.value.recipe, 0), 6.0);
419 let outcome = read(&ctx_for(&dir), path_request("simple.cook", 0.5)).expect("reads");
420 assert_eq!(quantity_value(&outcome.value.recipe, 0), 1.0);
421 }
422
423 #[test]
430 fn an_inline_factor_is_not_interpreted_as_scaling() {
431 let dir = fixture_dir();
432 match read(&ctx_for(&dir), path_request("simple.cook:3", 1.0)) {
433 Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "simple.cook:3"),
434 other => panic!("expected RecipeNotFound, got {other:?}"),
435 }
436
437 let (name, factor) = split_name_and_scale("simple.cook:3").expect("splits");
439 let outcome = read(&ctx_for(&dir), path_request(name, factor)).expect("reads");
440 assert_eq!(quantity_value(&outcome.value.recipe, 0), 6.0);
441 }
442
443 #[test]
444 fn content_is_scaled_by_the_request() {
445 let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
446 let outcome = read(
447 &ctx,
448 request(
449 RecipeSource::Content {
450 text: "Boil @water{2%cups}.\n".to_string(),
451 name: "buffer".to_string(),
452 },
453 2.5,
454 ),
455 )
456 .expect("reads");
457 assert_eq!(quantity_value(&outcome.value.recipe, 0), 5.0);
458 }
459
460 #[test]
462 fn a_colon_in_a_filename_is_part_of_the_name() {
463 let dir = tempfile::TempDir::new().unwrap();
464 std::fs::write(dir.path().join("odd:name.cook"), "Boil @water{2%cups}.\n").unwrap();
465 let outcome = read(&ctx_for(&dir), path_request("odd:name.cook", 2.0)).expect("reads");
466 assert_eq!(quantity_value(&outcome.value.recipe, 0), 4.0);
467 }
468
469 #[test]
470 fn parse_errors_name_the_path_not_the_title() {
471 let dir = tempfile::TempDir::new().unwrap();
472 let path = camino::Utf8PathBuf::from_path_buf(dir.path().join("broken.cook")).unwrap();
473 std::fs::write(&path, "---\ntitle: Fancy\n---\nAdd @{1%tsp}.\n").unwrap();
475
476 match read(&ctx_for(&dir), path_request("broken.cook", 1.0)) {
477 Err(CoreError::Parse {
478 name,
479 diagnostics,
480 rendered,
481 }) => {
482 assert_eq!(name, path.as_str());
483 assert!(
484 rendered.contains(path.as_str()),
485 "report should name the file: {rendered}"
486 );
487 assert_eq!(diagnostics.len(), 1);
488 assert_eq!(
489 diagnostics[0].location.as_ref().unwrap().file.as_deref(),
490 Some(path.as_path())
491 );
492 }
493 other => panic!("expected CoreError::Parse, got {other:?}"),
494 }
495 }
496
497 #[test]
499 fn warnings_reach_the_caller() {
500 let dir = tempfile::TempDir::new().unwrap();
501 std::fs::write(
502 dir.path().join("old.cook"),
503 ">> title: Old Style\n\nBoil @water{1%cup}.\n",
504 )
505 .unwrap();
506
507 let outcome = read(&ctx_for(&dir), path_request("old.cook", 1.0)).expect("parses");
508 assert_eq!(outcome.value.title, "Old Style");
512 assert!(!outcome.diagnostics.is_empty(), "expected a diagnostic");
513 for d in &outcome.diagnostics {
514 assert_eq!(d.severity, crate::Severity::Warning, "got {d:?}");
515 }
516 }
517
518 #[test]
519 fn non_finite_scale_is_rejected() {
520 let dir = fixture_dir();
521 match read(&ctx_for(&dir), path_request("simple.cook", f64::NAN)) {
522 Err(CoreError::InvalidScale { scale }) => assert!(scale.is_nan()),
523 other => panic!("expected InvalidScale, got {other:?}"),
524 }
525 }
526
527 #[test]
528 fn splits_a_name_from_its_scaling_factor() {
529 assert_eq!(
530 split_name_and_scale("recipe.cook:2"),
531 Some(("recipe.cook", 2.0))
532 );
533 assert_eq!(
534 split_name_and_scale("recipe.cook:1.5"),
535 Some(("recipe.cook", 1.5))
536 );
537 assert_eq!(split_name_and_scale("recipe.cook"), None);
538 assert_eq!(split_name_and_scale("recipe.cook:abc"), None);
539 assert_eq!(split_name_and_scale(r"C:\test\recipe.cook"), None);
542 assert_eq!(
544 split_name_and_scale("odd:dir/recipe.cook:2"),
545 Some(("odd:dir/recipe.cook", 2.0))
546 );
547 }
548
549 #[test]
550 fn spaces_around_the_colon_are_not_part_of_the_name_or_factor() {
551 assert_eq!(split_name_and_scale("Soup :2"), Some(("Soup", 2.0)));
552 assert_eq!(split_name_and_scale(" Soup : 2 "), Some(("Soup", 2.0)));
553 }
554}