use crate::quantity::grouped_quantity_fmt;
use std::{fmt::Write, io};
use cooklang::{
convert::Converter,
metadata::Metadata,
model::{Item, Section, Step},
Recipe,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(default)]
#[non_exhaustive]
pub(crate) struct Options {
pub(crate) tags: bool,
#[serde(deserialize_with = "des_or_bool")]
pub(crate) description: DescriptionStyle,
pub(crate) escape_step_numbers: bool,
pub(crate) italic_amounts: bool,
#[serde(deserialize_with = "des_or_bool")]
pub(crate) front_matter_name: FrontMatterName,
pub(crate) heading: Headings,
pub(crate) optional_marker: String,
}
impl Default for Options {
fn default() -> Self {
Self {
tags: true,
description: DescriptionStyle::Blockquote,
escape_step_numbers: false,
italic_amounts: true,
front_matter_name: FrontMatterName::default(),
heading: Headings::default(),
optional_marker: "(optional)".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub(crate) enum DescriptionStyle {
Hidden,
#[default]
#[serde(alias = "default")]
Blockquote,
Heading,
}
impl From<bool> for DescriptionStyle {
fn from(value: bool) -> Self {
match value {
true => Self::default(),
false => Self::Hidden,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(transparent)]
pub(crate) struct FrontMatterName(
pub(crate) Option<String>,
);
impl Default for FrontMatterName {
fn default() -> Self {
Self(Some("name".to_string()))
}
}
impl From<bool> for FrontMatterName {
fn from(value: bool) -> Self {
match value {
true => Self::default(),
false => Self(None),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)]
pub(crate) struct Headings {
pub(crate) section: String,
pub(crate) ingredients: String,
pub(crate) cookware: String,
pub(crate) steps: String,
pub(crate) description: String,
}
impl Default for Headings {
fn default() -> Self {
Self {
section: "Section %n".into(),
ingredients: "Ingredients".into(),
cookware: "Cookware".into(),
steps: "Steps".into(),
description: "Description".into(),
}
}
}
fn des_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de> + From<bool>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Wrapper<T> {
Bool(bool),
Thing(T),
}
let v = match Wrapper::deserialize(deserializer)? {
Wrapper::Bool(v) => T::from(v),
Wrapper::Thing(val) => val,
};
Ok(v)
}
pub fn print_md(
recipe: &Recipe,
name: &str,
scale: f64,
converter: &Converter,
writer: impl io::Write,
) -> io::Result<()> {
print_md_with_options(recipe, name, scale, &Options::default(), converter, writer)
}
pub(crate) fn print_md_with_options(
recipe: &Recipe,
name: &str,
scale: f64,
opts: &Options,
converter: &Converter,
mut writer: impl io::Write,
) -> io::Result<()> {
frontmatter(&mut writer, &recipe.metadata, name, opts)?;
writeln!(
writer,
"# {}{}\n",
name,
if scale != 1.0 {
format!(" @ {scale}")
} else {
"".to_string()
}
)?;
if opts.tags {
if let Some(tags) = recipe.metadata.tags() {
for (i, tag) in tags.iter().enumerate() {
write!(writer, "#{tag}")?;
if i < tags.len() - 1 {
write!(writer, " ")?;
}
}
writeln!(writer, "\n")?;
}
}
if let Some(desc) = recipe.metadata.description() {
match opts.description {
DescriptionStyle::Hidden => {}
DescriptionStyle::Blockquote => {
write_block(&mut writer, desc, "> ", "> ")?;
writeln!(writer)?;
}
DescriptionStyle::Heading => {
writeln!(writer, "## {}\n", opts.heading.description)?;
write_block(&mut writer, desc, "", "")?;
writeln!(writer)?;
}
}
}
ingredients(&mut writer, recipe, converter, opts)?;
cookware(&mut writer, recipe, opts, converter)?;
sections(&mut writer, recipe, opts)?;
Ok(())
}
fn frontmatter(
mut w: impl io::Write,
metadata: &Metadata,
name: &str,
opts: &Options,
) -> io::Result<()> {
if metadata.map.is_empty() {
return Ok(());
}
let mut map = metadata.map.clone();
if let Some(name_key) = &opts.front_matter_name.0 {
map.insert(name_key.as_str().into(), name.into());
}
const FRONTMATTER_FENCE: &str = "---";
writeln!(w, "{FRONTMATTER_FENCE}")?;
serde_yaml::to_writer(&mut w, &map)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writeln!(w, "{FRONTMATTER_FENCE}\n")?;
Ok(())
}
fn ingredients(
w: &mut impl io::Write,
recipe: &Recipe,
converter: &Converter,
opts: &Options,
) -> io::Result<()> {
if recipe.ingredients.is_empty() {
return Ok(());
}
writeln!(w, "## {}\n", opts.heading.ingredients)?;
for entry in recipe.group_ingredients(converter) {
let ingredient = entry.ingredient;
if !ingredient.modifiers().should_be_listed() {
continue;
}
write!(w, "- ")?;
if !entry.quantity.is_empty() {
let quantity = grouped_quantity_fmt(&entry.quantity);
if opts.italic_amounts {
write!(w, "*{quantity}* ")?;
} else {
write!(w, "{quantity} ")?;
}
}
if let Some(reference) = &ingredient.reference {
let sep = crate::REFERENCE_SEPARATOR;
let path = reference.components.join(sep);
write!(
w,
"[{}]({}{}{})",
ingredient.display_name(),
path,
sep,
ingredient.name
)?;
} else {
write!(w, "{}", ingredient.display_name())?;
}
if ingredient.modifiers().is_optional() {
write!(w, " {}", opts.optional_marker)?;
}
if let Some(note) = &ingredient.note {
write!(w, " ({note})")?;
}
writeln!(w)?;
}
writeln!(w)?;
Ok(())
}
fn cookware(
w: &mut impl io::Write,
recipe: &Recipe,
opts: &Options,
converter: &Converter,
) -> io::Result<()> {
if recipe.cookware.is_empty() {
return Ok(());
}
writeln!(w, "## {}\n", opts.heading.cookware)?;
for item in recipe.group_cookware(converter) {
let cw = item.cookware;
write!(w, "- ")?;
if !item.quantity.is_empty() {
let quantity = grouped_quantity_fmt(&item.quantity);
if opts.italic_amounts {
write!(w, "*{quantity}* ")?;
} else {
write!(w, "{quantity} ")?;
}
}
write!(w, "{}", cw.display_name())?;
if cw.modifiers().is_optional() {
write!(w, " {}", opts.optional_marker)?;
}
if let Some(note) = &cw.note {
write!(w, " ({note})")?;
}
writeln!(w)?;
}
writeln!(w)?;
Ok(())
}
fn sections(w: &mut impl io::Write, recipe: &Recipe, opts: &Options) -> io::Result<()> {
writeln!(w, "## {}\n", opts.heading.steps)?;
for (idx, section) in recipe.sections.iter().enumerate() {
w_section(w, section, recipe, idx + 1, opts)?;
}
Ok(())
}
fn w_section(
w: &mut impl io::Write,
section: &Section,
recipe: &Recipe,
num: usize,
opts: &Options,
) -> io::Result<()> {
if section.name.is_some() || recipe.sections.len() > 1 {
if let Some(name) = §ion.name {
writeln!(w, "### {name}\n")?;
} else {
let s = opts.heading.section.replace("%n", &num.to_string());
writeln!(w, "### {s}\n")?;
}
}
for content in §ion.content {
match content {
cooklang::Content::Step(step) => w_step(w, step, recipe, opts)?,
cooklang::Content::Text(text) => {
if text.trim() == "-" {
writeln!(w)?
} else {
writeln!(w, "> **Note:** {}", text.trim())?
}
}
};
writeln!(w)?;
}
Ok(())
}
fn w_step(w: &mut impl io::Write, step: &Step, recipe: &Recipe, opts: &Options) -> io::Result<()> {
let mut marker = step.number.to_string();
if opts.escape_step_numbers {
marker.push_str("\\. ")
} else {
marker.push_str(". ")
}
let mut step_str = String::new();
for item in &step.items {
match item {
Item::Text { value } => {
if value.trim() == "-" {
step_str.push_str("\n- ");
} else {
step_str.push_str(value);
}
}
&Item::Ingredient { index } => {
let igr = &recipe.ingredients[index];
step_str.push_str(igr.display_name().as_ref());
}
&Item::Cookware { index } => {
let cw = &recipe.cookware[index];
step_str.push_str(&cw.name);
}
&Item::Timer { index } => {
let t = &recipe.timers[index];
if let Some(name) = &t.name {
write!(&mut step_str, "({name})").expect("writing to a String is infallible");
}
if let Some(quantity) = &t.quantity {
write!(&mut step_str, "{quantity}").expect("writing to a String is infallible");
}
}
&Item::InlineQuantity { index } => {
let q = &recipe.inline_quantities[index];
if opts.italic_amounts {
write!(&mut step_str, "*{q}*").expect("writing to a String is infallible");
} else {
write!(&mut step_str, "{q}").expect("writing to a String is infallible");
}
}
}
}
let indent = " ".repeat(marker.chars().count());
write_block(w, &step_str, &marker, &indent)?;
Ok(())
}
fn write_block(w: &mut impl io::Write, text: &str, first: &str, rest: &str) -> io::Result<()> {
for (i, line) in text.trim_end_matches('\n').split('\n').enumerate() {
let prefix = if i == 0 { first } else { rest };
writeln!(w, "{}", format!("{prefix}{line}").trim_end())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{parse_recipe, PARSER};
const FIXTURE: &str = "\
---
title: Long Lines
description: A description that is comfortably longer than eighty columns so that any hard wrapping shows up as an extra line.
---
Place the base on a lightly floured surface and spread @San Marzano tomato \
sauce{5%tbsp} on it. Add some fresh @basil leaves{} and fresh \
@mozzarella cheese{100%grams}.
";
fn render(text: &str) -> String {
let recipe = parse_recipe(text, "Long Lines", 1.0)
.expect("the fixture parses")
.value;
let mut buf = Vec::new();
print_md(&recipe, "Long Lines", 1.0, PARSER.converter(), &mut buf).expect("formats");
String::from_utf8(buf).expect("utf8")
}
#[test]
fn step_text_is_not_hard_wrapped() {
let md = render(FIXTURE);
let step = md
.lines()
.find(|l| l.starts_with("1. "))
.expect("the step is numbered");
assert_eq!(
step,
"1. Place the base on a lightly floured surface and spread San Marzano tomato \
sauce on it. Add some fresh basil leaves and fresh mozzarella cheese.",
"the step should be one line, in:\n{md}"
);
}
#[test]
fn description_is_not_hard_wrapped() {
let md = render(FIXTURE);
let quoted: Vec<_> = md.lines().filter(|l| l.starts_with("> ")).collect();
assert_eq!(
quoted,
[
"> A description that is comfortably longer than eighty columns so that any hard \
wrapping shows up as an extra line."
],
"the description should be one blockquote line, in:\n{md}"
);
}
#[test]
fn a_list_inside_a_step_is_indented_under_the_number() {
let md = render("Gather the following:\n- @plain flour{200%g}\n- @whole milk{250%ml}\n");
assert!(
md.contains("1. Gather the following: - plain flour\n - whole milk\n"),
"the bullet should be indented under the step, in:\n{md}"
);
}
}