pub mod menu;
pub mod recipe_info;
pub use cookcli_core::format;
pub use cookcli_core::parser::PARSER;
use anyhow::{Context as _, Result};
use camino::{Utf8Path, Utf8PathBuf};
use cooklang::Recipe;
use cooklang_find::RecipeEntry;
use std::sync::Arc;
use tracing::warn;
pub fn parse_recipe_from_entry(entry: &RecipeEntry, scaling_factor: f64) -> Result<Arc<Recipe>> {
let mut recipe = parse_unscaled_recipe_from_entry(entry)?;
recipe.scale(scaling_factor, PARSER.converter());
Ok(Arc::new(recipe))
}
pub fn parse_unscaled_recipe_from_entry(entry: &RecipeEntry) -> Result<Recipe> {
let content = entry.content().context("Failed to read recipe content")?;
let parsed = PARSER.parse(&content);
if parsed.report().has_warnings() {
let recipe_name = entry.name().as_deref().unwrap_or("unknown");
for warning in parsed.report().warnings() {
warn!("Recipe '{}': {}", recipe_name, warning);
}
}
let recipe_path = entry
.path()
.map(|p| p.to_string())
.unwrap_or_else(|| "unknown".to_string());
if parsed.report().has_errors() {
let mut error_output = Vec::new();
parsed
.report()
.write(&recipe_path, &content, false, &mut error_output)
.ok();
let error_details = String::from_utf8_lossy(&error_output);
return Err(anyhow::anyhow!(
"Failed to parse recipe '{}'\n{}",
recipe_path,
error_details
));
}
let (recipe, _warnings) = parsed.into_result().expect("already checked for errors");
Ok(recipe)
}
pub fn write_to_output<F>(output: Option<&Utf8Path>, f: F) -> Result<()>
where
F: FnOnce(&mut dyn std::io::Write) -> Result<()>,
{
let mut stream: Box<dyn std::io::Write> = if let Some(path) = output {
let file = std::fs::File::create(path).context("Failed to create output file")?;
let stream = anstream::StripStream::new(file);
Box::new(stream)
} else {
Box::new(anstream::stdout().lock())
};
f(stream.as_mut())?;
use std::io::Write;
stream.flush()?;
Ok(())
}
pub fn cli_error(error: cookcli_core::CoreError) -> anyhow::Error {
use cookcli_core::CoreError;
match error {
CoreError::Parse { name, rendered, .. } => {
anyhow::anyhow!("Failed to parse recipe '{name}'\n{rendered}")
}
CoreError::RecipeNotFound { name } => anyhow::anyhow!("Recipe not found: {name}"),
CoreError::MissingConfig { kind } => anyhow::anyhow!("No {kind} configuration found"),
CoreError::PantryEdit { message } => anyhow::anyhow!("{}", sentence_case(&message)),
CoreError::Search { base_dir, message } => {
anyhow::anyhow!("Cannot search '{base_dir}': {message}")
}
CoreError::Io { path, source } => {
anyhow::Error::new(source).context(format!("Failed to read '{path}'"))
}
other => other.into(),
}
}
fn sentence_case(message: &str) -> String {
let mut chars = message.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}
pub fn split_recipe_name_and_scaling_factor(query: &str) -> Option<(&str, f64)> {
cookcli_core::recipe::split_name_and_scale(query)
}
pub fn resolve_to_absolute_path(path: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| {
tracing::error!("Failed to get current directory: {:?}", e);
anyhow::anyhow!("Failed to get current directory")
})?
.join(path)
.try_into()
.map_err(|e| {
tracing::error!("Failed to convert path to UTF-8: {:?}", e);
anyhow::anyhow!("Failed to convert path to UTF-8")
})?
};
std::fs::canonicalize(&absolute)
.map_err(|e| {
tracing::error!("Failed to canonicalize path: {:?}", e);
anyhow::anyhow!("Failed to canonicalize path")
})?
.try_into()
.map_err(|e| {
tracing::error!("Failed to convert canonicalized path to UTF-8: {:?}", e);
anyhow::anyhow!("Failed to convert canonicalized path to UTF-8")
})
}
pub use cookcli_core::find::is_safe_relative_path;
pub fn encode_url_path(relpath: &str) -> String {
relpath
.split('/')
.map(|seg| urlencoding::encode(seg).into_owned())
.collect::<Vec<_>>()
.join("/")
}
pub fn get_recipe(base_path: &Utf8Path, name: &str) -> Result<RecipeEntry> {
cookcli_core::find::get_recipe(base_path, name).map_err(cli_error)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_spaces_per_segment_preserving_slashes() {
assert_eq!(
encode_url_path("recipe/Root Vegetables/Mash Up.html"),
"recipe/Root%20Vegetables/Mash%20Up.html"
);
}
#[test]
fn sentence_case_capitalises_only_the_first_character() {
assert_eq!(
sentence_case("item 'flour' not found in section 'pantry'"),
"Item 'flour' not found in section 'pantry'"
);
assert_eq!(sentence_case("äpple is gone"), "Äpple is gone");
assert_eq!(sentence_case("'quoted' first"), "'quoted' first");
assert_eq!(sentence_case(""), "");
}
#[test]
fn splits_recipe_with_numeric_scaling_factor() {
assert_eq!(
split_recipe_name_and_scaling_factor("recipe.cook:2"),
Some(("recipe.cook", 2.0))
);
}
#[test]
fn splits_recipe_with_decimal_scaling_factor() {
assert_eq!(
split_recipe_name_and_scaling_factor("recipe.cook:1.5"),
Some(("recipe.cook", 1.5))
);
}
#[test]
fn returns_none_when_no_colon() {
assert_eq!(split_recipe_name_and_scaling_factor("recipe.cook"), None);
}
#[test]
fn returns_none_for_windows_absolute_path() {
assert_eq!(
split_recipe_name_and_scaling_factor(r"C:\test\recipe.cook"),
None
);
}
#[test]
fn returns_none_when_right_side_is_not_numeric() {
assert_eq!(
split_recipe_name_and_scaling_factor("recipe.cook:abc"),
None
);
}
}