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