use anyhow::Result;
use camino::Utf8PathBuf;
use clap::{Args, CommandFactory, ValueEnum};
use cooklang::{
aisle::AisleConf,
ingredient_list::IngredientList,
quantity::{GroupedQuantity, Quantity, QuantityValue, TotalQuantity, Value},
};
use cooklang_fs::resolve_recipe;
use serde::Serialize;
use crate::{util::write_to_output, util::Input, Context};
#[derive(Debug, Args)]
pub struct CreateArgs {
recipes: Vec<String>,
#[arg(short, long)]
output: Option<Utf8PathBuf>,
#[arg(short, long)]
plain: bool,
#[arg(short, long, value_enum)]
format: Option<OutputFormat>,
#[arg(long)]
pretty: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Human,
Json,
}
pub fn run(ctx: &Context, aisle: AisleConf, args: CreateArgs) -> Result<()> {
let format = args.format.unwrap_or_else(|| match &args.output {
Some(p) => match p.extension() {
Some("json") => OutputFormat::Json,
_ => OutputFormat::Human,
},
None => OutputFormat::Human,
});
let mut list = IngredientList::new();
for entry in args.recipes {
extract_ingredients(&entry, &mut list, ctx)?;
}
write_to_output(args.output.as_deref(), |mut w| {
match format {
OutputFormat::Human => {
let table = build_human_table(list, &aisle, args.plain);
write!(w, "{table}")?;
}
OutputFormat::Json => {
let value = build_json_value(list, &aisle, args.plain);
if args.pretty {
serde_json::to_writer_pretty(w, &value)?;
} else {
serde_json::to_writer(w, &value)?;
}
}
}
Ok(())
})
}
fn extract_ingredients(entry: &str, list: &mut IngredientList, ctx: &Context) -> Result<()> {
let converter = ctx.parser()?.converter();
let (name, servings) = entry
.trim()
.rsplit_once('*')
.map(|(name, servings)| {
let target = servings.parse::<u32>().unwrap_or_else(|err| {
let mut cmd = crate::CliArgs::command();
cmd.error(
clap::error::ErrorKind::InvalidValue,
format!("Invalid scaling target for '{name}': {err}"),
)
.exit()
});
(name, Some(target))
})
.unwrap_or((entry, None));
let input = {
let entry = resolve_recipe(name, &ctx.recipe_index, None)?;
Input::File {
content: entry.read()?,
override_name: None,
}
};
let recipe = input.parse(ctx)?;
let recipe = if let Some(servings) = servings {
recipe.scale(servings, converter)
} else {
recipe.default_scale()
};
list.add_recipe(&recipe, converter);
Ok(())
}
fn total_quantity_fmt(qty: &TotalQuantity, row: &mut tabular::Row) {
match qty {
cooklang::quantity::TotalQuantity::None => {
row.add_cell("");
}
cooklang::quantity::TotalQuantity::Single(quantity) => {
row.add_ansi_cell(quantity_fmt(quantity));
}
cooklang::quantity::TotalQuantity::Many(list) => {
let list = list
.iter()
.map(quantity_fmt)
.reduce(|s, q| format!("{s}, {q}"))
.unwrap();
row.add_ansi_cell(list);
}
};
}
fn quantity_fmt(qty: &Quantity) -> String {
use owo_colors::OwoColorize;
if let Some(unit) = qty.unit() {
format!("{} {}", qty.value, unit.text().italic())
} else {
format!("{}", qty.value)
}
}
fn build_human_table(list: IngredientList, aisle: &AisleConf, plain: bool) -> tabular::Table {
use owo_colors::OwoColorize;
let mut table = tabular::Table::new("{:<} {:<}");
if plain {
for (igr, q) in list {
let mut row = tabular::Row::new().with_cell(igr);
total_quantity_fmt(&q.total(), &mut row);
table.add_row(row);
}
} else {
let categories = list.categorize(aisle);
for (cat, items) in categories {
table.add_heading(format!("[{}]", cat.green()));
for (igr, q) in items {
let mut row = tabular::Row::new().with_cell(igr);
total_quantity_fmt(&q.total(), &mut row);
table.add_row(row);
}
}
}
table
}
fn build_json_value<'a>(
list: IngredientList,
aisle: &'a AisleConf<'a>,
plain: bool,
) -> serde_json::Value {
#[derive(Serialize)]
struct Quantity {
value: Value,
unit: Option<String>,
}
impl From<cooklang::quantity::Quantity> for Quantity {
fn from(qty: cooklang::quantity::Quantity) -> Self {
let unit = qty.unit_text().map(|s| s.to_owned());
let QuantityValue::Fixed { value } = qty.value
else { panic!("Unexpected unscaled value while serializing") };
Self { value, unit }
}
}
#[derive(Serialize)]
#[serde(untagged)]
enum TotalQuantity {
None,
Single(Quantity),
Many(Vec<Quantity>),
}
impl From<cooklang::quantity::TotalQuantity> for TotalQuantity {
fn from(value: cooklang::quantity::TotalQuantity) -> Self {
match value {
cooklang::quantity::TotalQuantity::None => TotalQuantity::None,
cooklang::quantity::TotalQuantity::Single(q) => TotalQuantity::Single(q.into()),
cooklang::quantity::TotalQuantity::Many(v) => {
TotalQuantity::Many(v.into_iter().map(|q| q.into()).collect())
}
}
}
}
#[derive(Serialize)]
struct Ingredient {
name: String,
quantity: TotalQuantity,
}
impl From<(String, GroupedQuantity)> for Ingredient {
fn from((name, qty): (String, GroupedQuantity)) -> Self {
Ingredient {
name,
quantity: qty.total().into(),
}
}
}
#[derive(Serialize)]
struct Category {
category: String,
items: Vec<Ingredient>,
}
if plain {
serde_json::to_value(list.into_iter().map(Ingredient::from).collect::<Vec<_>>()).unwrap()
} else {
serde_json::to_value(
list.categorize(aisle)
.into_iter()
.map(|(category, items)| Category {
category,
items: items.into_iter().map(Ingredient::from).collect(),
})
.collect::<Vec<_>>(),
)
.unwrap()
}
}