use crate::{
find::{entry_error, fetch_error},
parse_recipe, parse_recipe_at, Context, CoreError, Outcome, RecipeSource,
};
use camino::Utf8PathBuf;
use cooklang::Recipe;
const SCALING_DELIMITER: char = ':';
#[derive(Debug, Clone)]
pub struct ReadRequest {
pub source: RecipeSource,
pub scale: f64,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ReadResult {
pub recipe: Recipe,
pub title: String,
pub path: Option<Utf8PathBuf>,
}
pub fn split_name_and_scale(query: &str) -> Option<(&str, f64)> {
let (name, factor) = query.trim().rsplit_once(SCALING_DELIMITER)?;
let factor = factor.parse::<f64>().ok()?;
Some((name, factor))
}
pub fn read(ctx: &Context, req: ReadRequest) -> Result<Outcome<ReadResult>, CoreError> {
match req.source {
RecipeSource::Content { text, name } => {
let outcome = parse_recipe(&text, &name, req.scale)?;
let title = title_for(&outcome.value, || name);
Ok(Outcome::with_diagnostics(
ReadResult {
recipe: outcome.value,
title,
path: None,
},
outcome.diagnostics,
))
}
RecipeSource::Path(lookup) => {
let entry =
cooklang_find::get_recipe(vec![ctx.base_path().to_path_buf()], lookup.clone())
.map_err(|e| fetch_error(e, &lookup))?;
let path = entry.path().cloned();
let display_path = path.clone().unwrap_or(lookup);
let content = entry.content().map_err(|source| CoreError::Io {
path: display_path.clone(),
source: entry_error(source),
})?;
let outcome =
parse_recipe_at(&content, display_path.as_str(), req.scale, path.as_deref())?;
let title = title_for(&outcome.value, || entry.name().clone().unwrap_or_default());
Ok(Outcome::with_diagnostics(
ReadResult {
recipe: outcome.value,
title,
path,
},
outcome.diagnostics,
))
}
}
}
fn title_for(recipe: &Recipe, fallback: impl FnOnce() -> String) -> String {
recipe
.metadata
.title()
.map_or_else(fallback, ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Context, RecipeSource};
use cooklang::quantity::Value;
fn fixture_dir() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("simple.cook"),
"Boil @water{2%cups} for ~{5%minutes}.\nAdd @salt{1%tsp}.\n",
)
.unwrap();
dir
}
fn ctx_for(dir: &tempfile::TempDir) -> Context {
Context::new(camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap())
}
fn quantity_value(recipe: &Recipe, index: usize) -> f64 {
match recipe.ingredients[index]
.quantity
.as_ref()
.expect("ingredient has a quantity")
.value()
{
Value::Number(n) => n.value(),
other => panic!("expected a numeric quantity, got {other:?}"),
}
}
fn request(source: RecipeSource, scale: f64) -> ReadRequest {
ReadRequest { source, scale }
}
fn path_request(name: &str, scale: f64) -> ReadRequest {
request(RecipeSource::Path(Utf8PathBuf::from(name)), scale)
}
#[test]
fn reads_a_recipe_from_a_path() {
let dir = fixture_dir();
let outcome = read(&ctx_for(&dir), path_request("simple.cook", 1.0)).expect("reads");
let ReadResult {
recipe,
title,
path,
} = outcome.value;
assert_eq!(title, "simple", "title falls back to the file stem");
assert_eq!(
path.as_deref(),
Some(
camino::Utf8PathBuf::from_path_buf(dir.path().join("simple.cook"))
.unwrap()
.as_path()
),
"the resolved file must be reported back"
);
assert_eq!(recipe.ingredients.len(), 2);
assert_eq!(recipe.ingredients[0].name, "water");
assert_eq!(recipe.ingredients[1].name, "salt");
assert_eq!(quantity_value(&recipe, 0), 2.0);
assert_eq!(quantity_value(&recipe, 1), 1.0);
assert!(outcome.diagnostics.is_empty());
}
#[test]
fn a_bare_name_resolves_to_the_cook_file() {
let dir = fixture_dir();
let outcome = read(&ctx_for(&dir), path_request("simple", 1.0)).expect("reads");
assert_eq!(outcome.value.title, "simple");
assert_eq!(outcome.value.recipe.ingredients.len(), 2);
assert_eq!(
outcome.value.path.as_ref().and_then(|p| p.file_name()),
Some("simple.cook"),
"the extension the lookup chose must be reported back"
);
}
#[test]
fn reads_a_recipe_from_memory() {
let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
let outcome = read(
&ctx,
request(
RecipeSource::Content {
text: "Boil @water{2%cups}.\nAdd @salt{1%tsp}.\n".to_string(),
name: "buffer".to_string(),
},
1.0,
),
)
.expect("reads in-memory text");
assert_eq!(
outcome.value.title, "buffer",
"with no title of its own, the recipe falls back to the caller's name"
);
assert_eq!(outcome.value.recipe.ingredients.len(), 2);
assert_eq!(quantity_value(&outcome.value.recipe, 0), 2.0);
assert_eq!(
outcome.value.path, None,
"in-memory text has no file to report"
);
}
#[test]
fn a_metadata_title_wins_over_the_fallback_name() {
let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
let outcome = read(
&ctx,
request(
RecipeSource::Content {
text: "---\ntitle: Proper Title\n---\nBoil @water{1%cup}.\n".to_string(),
name: "buffer".to_string(),
},
1.0,
),
)
.expect("reads");
assert_eq!(
outcome.value.title, "Proper Title",
"the recipe's own title must beat the caller's label"
);
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("stem.cook"),
"---\ntitle: Proper Title\n---\nBoil @water{1%cup}.\n",
)
.unwrap();
let outcome = read(&ctx_for(&dir), path_request("stem.cook", 1.0)).expect("reads");
assert_eq!(outcome.value.title, "Proper Title");
}
#[test]
fn a_path_and_a_buffer_of_the_same_bytes_agree_on_the_title() {
for text in [
"---\ntitle: Agreed\n---\nBoil @water{1%cup}.\n",
">> title: Agreed\n\nBoil @water{1%cup}.\n",
"Boil @water{1%cup}.\n",
] {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("same.cook"), text).unwrap();
let from_path = read(&ctx_for(&dir), path_request("same.cook", 1.0))
.expect("reads")
.value
.title;
let from_memory = read(
&Context::new(Utf8PathBuf::from("/nonexistent")),
request(
RecipeSource::Content {
text: text.to_string(),
name: "same".to_string(),
},
1.0,
),
)
.expect("reads")
.value
.title;
assert_eq!(from_path, from_memory, "titles diverged for {text:?}");
}
}
#[test]
fn missing_recipe_is_recipe_not_found() {
let dir = fixture_dir();
match read(&ctx_for(&dir), path_request("absent.cook", 1.0)) {
Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "absent.cook"),
other => panic!("expected RecipeNotFound, got {other:?}"),
}
}
#[test]
#[cfg(unix)]
fn an_unreadable_recipe_is_an_io_error_not_a_missing_one() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc_geteuid() } == 0 {
return;
}
let dir = fixture_dir();
let path = dir.path().join("simple.cook");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = read(&ctx_for(&dir), path_request("simple.cook", 1.0));
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
match result {
Err(CoreError::Io {
path: reported,
source,
}) => {
assert_eq!(reported.file_name(), Some("simple.cook"));
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
}
other => panic!("expected CoreError::Io, got {other:?}"),
}
}
#[cfg(unix)]
extern "C" {
#[link_name = "geteuid"]
fn libc_geteuid() -> u32;
}
#[test]
fn a_directory_named_like_a_recipe_is_an_io_error() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("adir.cook")).unwrap();
match read(&ctx_for(&dir), path_request("adir.cook", 1.0)) {
Err(CoreError::Io { path, .. }) => {
assert_eq!(path.file_name(), Some("adir.cook"));
}
other => panic!("expected CoreError::Io, got {other:?}"),
}
}
#[test]
fn the_request_scale_is_applied() {
let dir = fixture_dir();
let outcome = read(&ctx_for(&dir), path_request("simple.cook", 3.0)).expect("reads");
assert_eq!(quantity_value(&outcome.value.recipe, 0), 6.0);
let outcome = read(&ctx_for(&dir), path_request("simple.cook", 0.5)).expect("reads");
assert_eq!(quantity_value(&outcome.value.recipe, 0), 1.0);
}
#[test]
fn an_inline_factor_is_not_interpreted_as_scaling() {
let dir = fixture_dir();
match read(&ctx_for(&dir), path_request("simple.cook:3", 1.0)) {
Err(CoreError::RecipeNotFound { name }) => assert_eq!(name, "simple.cook:3"),
other => panic!("expected RecipeNotFound, got {other:?}"),
}
let (name, factor) = split_name_and_scale("simple.cook:3").expect("splits");
let outcome = read(&ctx_for(&dir), path_request(name, factor)).expect("reads");
assert_eq!(quantity_value(&outcome.value.recipe, 0), 6.0);
}
#[test]
fn content_is_scaled_by_the_request() {
let ctx = Context::new(Utf8PathBuf::from("/nonexistent"));
let outcome = read(
&ctx,
request(
RecipeSource::Content {
text: "Boil @water{2%cups}.\n".to_string(),
name: "buffer".to_string(),
},
2.5,
),
)
.expect("reads");
assert_eq!(quantity_value(&outcome.value.recipe, 0), 5.0);
}
#[test]
fn a_colon_in_a_filename_is_part_of_the_name() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("odd:name.cook"), "Boil @water{2%cups}.\n").unwrap();
let outcome = read(&ctx_for(&dir), path_request("odd:name.cook", 2.0)).expect("reads");
assert_eq!(quantity_value(&outcome.value.recipe, 0), 4.0);
}
#[test]
fn parse_errors_name_the_path_not_the_title() {
let dir = tempfile::TempDir::new().unwrap();
let path = camino::Utf8PathBuf::from_path_buf(dir.path().join("broken.cook")).unwrap();
std::fs::write(&path, "---\ntitle: Fancy\n---\nAdd @{1%tsp}.\n").unwrap();
match read(&ctx_for(&dir), path_request("broken.cook", 1.0)) {
Err(CoreError::Parse {
name,
diagnostics,
rendered,
}) => {
assert_eq!(name, path.as_str());
assert!(
rendered.contains(path.as_str()),
"report should name the file: {rendered}"
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].location.as_ref().unwrap().file.as_deref(),
Some(path.as_path())
);
}
other => panic!("expected CoreError::Parse, got {other:?}"),
}
}
#[test]
fn warnings_reach_the_caller() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("old.cook"),
">> title: Old Style\n\nBoil @water{1%cup}.\n",
)
.unwrap();
let outcome = read(&ctx_for(&dir), path_request("old.cook", 1.0)).expect("parses");
assert_eq!(outcome.value.title, "Old Style");
assert!(!outcome.diagnostics.is_empty(), "expected a diagnostic");
for d in &outcome.diagnostics {
assert_eq!(d.severity, crate::Severity::Warning, "got {d:?}");
}
}
#[test]
fn non_finite_scale_is_rejected() {
let dir = fixture_dir();
match read(&ctx_for(&dir), path_request("simple.cook", f64::NAN)) {
Err(CoreError::InvalidScale { scale }) => assert!(scale.is_nan()),
other => panic!("expected InvalidScale, got {other:?}"),
}
}
#[test]
fn splits_a_name_from_its_scaling_factor() {
assert_eq!(
split_name_and_scale("recipe.cook:2"),
Some(("recipe.cook", 2.0))
);
assert_eq!(
split_name_and_scale("recipe.cook:1.5"),
Some(("recipe.cook", 1.5))
);
assert_eq!(split_name_and_scale("recipe.cook"), None);
assert_eq!(split_name_and_scale("recipe.cook:abc"), None);
assert_eq!(split_name_and_scale(r"C:\test\recipe.cook"), None);
assert_eq!(
split_name_and_scale("odd:dir/recipe.cook:2"),
Some(("odd:dir/recipe.cook", 2.0))
);
}
}