1use crate::quantity::grouped_quantity_fmt;
34use std::{fmt::Write, io};
35
36use cooklang::{
37 convert::Converter,
38 metadata::Metadata,
39 model::{Item, Section, Step},
40 Recipe,
41};
42use serde::{Deserialize, Serialize};
43
44#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
53#[serde(default)]
54#[non_exhaustive]
55pub(crate) struct Options {
56 pub(crate) tags: bool,
65 #[serde(deserialize_with = "des_or_bool")]
70 pub(crate) description: DescriptionStyle,
71 pub(crate) escape_step_numbers: bool,
81 pub(crate) italic_amounts: bool,
86 #[serde(deserialize_with = "des_or_bool")]
90 pub(crate) front_matter_name: FrontMatterName,
91 pub(crate) heading: Headings,
93 pub(crate) optional_marker: String,
95}
96
97impl Default for Options {
98 fn default() -> Self {
99 Self {
100 tags: true,
101 description: DescriptionStyle::Blockquote,
102 escape_step_numbers: false,
103 italic_amounts: true,
104 front_matter_name: FrontMatterName::default(),
105 heading: Headings::default(),
106 optional_marker: "(optional)".to_string(),
107 }
108 }
109}
110
111#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
116#[serde(rename_all = "snake_case")]
117#[non_exhaustive]
118pub(crate) enum DescriptionStyle {
119 Hidden,
121 #[default]
123 #[serde(alias = "default")]
124 Blockquote,
125 Heading,
127}
128
129impl From<bool> for DescriptionStyle {
130 fn from(value: bool) -> Self {
131 match value {
132 true => Self::default(),
133 false => Self::Hidden,
134 }
135 }
136}
137
138#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
143#[serde(transparent)]
144pub(crate) struct FrontMatterName(
145 pub(crate) Option<String>,
147);
148
149impl Default for FrontMatterName {
150 fn default() -> Self {
151 Self(Some("name".to_string()))
152 }
153}
154
155impl From<bool> for FrontMatterName {
156 fn from(value: bool) -> Self {
157 match value {
158 true => Self::default(),
159 false => Self(None),
160 }
161 }
162}
163
164#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
169#[serde(default)]
170pub(crate) struct Headings {
171 pub(crate) section: String,
175 pub(crate) ingredients: String,
177 pub(crate) cookware: String,
179 pub(crate) steps: String,
181 pub(crate) description: String,
185}
186
187impl Default for Headings {
188 fn default() -> Self {
189 Self {
190 section: "Section %n".into(),
191 ingredients: "Ingredients".into(),
192 cookware: "Cookware".into(),
193 steps: "Steps".into(),
194 description: "Description".into(),
195 }
196 }
197}
198
199fn des_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
200where
201 D: serde::Deserializer<'de>,
202 T: serde::Deserialize<'de> + From<bool>,
203{
204 #[derive(Deserialize)]
205 #[serde(untagged)]
206 enum Wrapper<T> {
207 Bool(bool),
208 Thing(T),
209 }
210
211 let v = match Wrapper::deserialize(deserializer)? {
212 Wrapper::Bool(v) => T::from(v),
213 Wrapper::Thing(val) => val,
214 };
215 Ok(v)
216}
217
218pub fn print_md(
223 recipe: &Recipe,
224 name: &str,
225 scale: f64,
226 converter: &Converter,
227 writer: impl io::Write,
228) -> io::Result<()> {
229 print_md_with_options(recipe, name, scale, &Options::default(), converter, writer)
230}
231
232pub(crate) fn print_md_with_options(
241 recipe: &Recipe,
242 name: &str,
243 scale: f64,
244 opts: &Options,
245 converter: &Converter,
246 mut writer: impl io::Write,
247) -> io::Result<()> {
248 frontmatter(&mut writer, &recipe.metadata, name, opts)?;
249
250 writeln!(
251 writer,
252 "# {}{}\n",
253 name,
254 if scale != 1.0 {
255 format!(" @ {scale}")
256 } else {
257 "".to_string()
258 }
259 )?;
260
261 if opts.tags {
262 if let Some(tags) = recipe.metadata.tags() {
263 for (i, tag) in tags.iter().enumerate() {
264 write!(writer, "#{tag}")?;
265 if i < tags.len() - 1 {
266 write!(writer, " ")?;
267 }
268 }
269 writeln!(writer, "\n")?;
270 }
271 }
272
273 if let Some(desc) = recipe.metadata.description() {
274 match opts.description {
275 DescriptionStyle::Hidden => {}
276 DescriptionStyle::Blockquote => {
277 write_block(&mut writer, desc, "> ", "> ")?;
278 writeln!(writer)?;
279 }
280 DescriptionStyle::Heading => {
281 writeln!(writer, "## {}\n", opts.heading.description)?;
282 write_block(&mut writer, desc, "", "")?;
283 writeln!(writer)?;
284 }
285 }
286 }
287
288 ingredients(&mut writer, recipe, converter, opts)?;
289 cookware(&mut writer, recipe, opts, converter)?;
290 sections(&mut writer, recipe, opts)?;
291
292 Ok(())
293}
294
295fn frontmatter(
296 mut w: impl io::Write,
297 metadata: &Metadata,
298 name: &str,
299 opts: &Options,
300) -> io::Result<()> {
301 if metadata.map.is_empty() {
302 return Ok(());
303 }
304
305 let mut map = metadata.map.clone();
306
307 if let Some(name_key) = &opts.front_matter_name.0 {
308 map.insert(name_key.as_str().into(), name.into());
310 }
311
312 const FRONTMATTER_FENCE: &str = "---";
313 writeln!(w, "{FRONTMATTER_FENCE}")?;
314 serde_yaml::to_writer(&mut w, &map)
315 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
316 writeln!(w, "{FRONTMATTER_FENCE}\n")?;
317 Ok(())
318}
319
320fn ingredients(
321 w: &mut impl io::Write,
322 recipe: &Recipe,
323 converter: &Converter,
324 opts: &Options,
325) -> io::Result<()> {
326 if recipe.ingredients.is_empty() {
327 return Ok(());
328 }
329
330 writeln!(w, "## {}\n", opts.heading.ingredients)?;
331
332 for entry in recipe.group_ingredients(converter) {
333 let ingredient = entry.ingredient;
334
335 if !ingredient.modifiers().should_be_listed() {
336 continue;
337 }
338
339 write!(w, "- ")?;
340 if !entry.quantity.is_empty() {
341 let quantity = grouped_quantity_fmt(&entry.quantity);
342 if opts.italic_amounts {
343 write!(w, "*{quantity}* ")?;
344 } else {
345 write!(w, "{quantity} ")?;
346 }
347 }
348
349 if let Some(reference) = &ingredient.reference {
350 let sep = crate::REFERENCE_SEPARATOR;
351 let path = reference.components.join(sep);
352 write!(
353 w,
354 "[{}]({}{}{})",
355 ingredient.display_name(),
356 path,
357 sep,
358 ingredient.name
359 )?;
360 } else {
361 write!(w, "{}", ingredient.display_name())?;
362 }
363
364 if ingredient.modifiers().is_optional() {
365 write!(w, " {}", opts.optional_marker)?;
366 }
367
368 if let Some(note) = &ingredient.note {
369 write!(w, " ({note})")?;
370 }
371 writeln!(w)?;
372 }
373 writeln!(w)?;
374
375 Ok(())
376}
377
378fn cookware(
379 w: &mut impl io::Write,
380 recipe: &Recipe,
381 opts: &Options,
382 converter: &Converter,
383) -> io::Result<()> {
384 if recipe.cookware.is_empty() {
385 return Ok(());
386 }
387
388 writeln!(w, "## {}\n", opts.heading.cookware)?;
389 for item in recipe.group_cookware(converter) {
390 let cw = item.cookware;
391 write!(w, "- ")?;
392 if !item.quantity.is_empty() {
393 let quantity = grouped_quantity_fmt(&item.quantity);
394 if opts.italic_amounts {
395 write!(w, "*{quantity}* ")?;
396 } else {
397 write!(w, "{quantity} ")?;
398 }
399 }
400 write!(w, "{}", cw.display_name())?;
401
402 if cw.modifiers().is_optional() {
403 write!(w, " {}", opts.optional_marker)?;
404 }
405
406 if let Some(note) = &cw.note {
407 write!(w, " ({note})")?;
408 }
409 writeln!(w)?;
410 }
411
412 writeln!(w)?;
413 Ok(())
414}
415
416fn sections(w: &mut impl io::Write, recipe: &Recipe, opts: &Options) -> io::Result<()> {
417 writeln!(w, "## {}\n", opts.heading.steps)?;
418 for (idx, section) in recipe.sections.iter().enumerate() {
419 w_section(w, section, recipe, idx + 1, opts)?;
420 }
421 Ok(())
422}
423
424fn w_section(
425 w: &mut impl io::Write,
426 section: &Section,
427 recipe: &Recipe,
428 num: usize,
429 opts: &Options,
430) -> io::Result<()> {
431 if section.name.is_some() || recipe.sections.len() > 1 {
432 if let Some(name) = §ion.name {
433 writeln!(w, "### {name}\n")?;
434 } else {
435 let s = opts.heading.section.replace("%n", &num.to_string());
436 writeln!(w, "### {s}\n")?;
437 }
438 }
439 for content in §ion.content {
440 match content {
441 cooklang::Content::Step(step) => w_step(w, step, recipe, opts)?,
442 cooklang::Content::Text(text) => {
443 if text.trim() == "-" {
445 writeln!(w)?
447 } else {
448 writeln!(w, "> **Note:** {}", text.trim())?
450 }
451 }
452 };
453 writeln!(w)?;
454 }
455 Ok(())
456}
457
458fn w_step(w: &mut impl io::Write, step: &Step, recipe: &Recipe, opts: &Options) -> io::Result<()> {
459 let mut marker = step.number.to_string();
460 if opts.escape_step_numbers {
461 marker.push_str("\\. ")
462 } else {
463 marker.push_str(". ")
464 }
465
466 let mut step_str = String::new();
467 for item in &step.items {
468 match item {
469 Item::Text { value } => {
470 if value.trim() == "-" {
472 step_str.push_str("\n- ");
473 } else {
474 step_str.push_str(value);
475 }
476 }
477 &Item::Ingredient { index } => {
478 let igr = &recipe.ingredients[index];
479 step_str.push_str(igr.display_name().as_ref());
480 }
481 &Item::Cookware { index } => {
482 let cw = &recipe.cookware[index];
483 step_str.push_str(&cw.name);
484 }
485 &Item::Timer { index } => {
486 let t = &recipe.timers[index];
487 if let Some(name) = &t.name {
488 write!(&mut step_str, "({name})").expect("writing to a String is infallible");
489 }
490 if let Some(quantity) = &t.quantity {
491 write!(&mut step_str, "{quantity}").expect("writing to a String is infallible");
492 }
493 }
494 &Item::InlineQuantity { index } => {
495 let q = &recipe.inline_quantities[index];
496 if opts.italic_amounts {
497 write!(&mut step_str, "*{q}*").expect("writing to a String is infallible");
498 } else {
499 write!(&mut step_str, "{q}").expect("writing to a String is infallible");
500 }
501 }
502 }
503 }
504 let indent = " ".repeat(marker.chars().count());
507 write_block(w, &step_str, &marker, &indent)?;
508 Ok(())
509}
510
511fn write_block(w: &mut impl io::Write, text: &str, first: &str, rest: &str) -> io::Result<()> {
520 for (i, line) in text.trim_end_matches('\n').split('\n').enumerate() {
523 let prefix = if i == 0 { first } else { rest };
524 writeln!(w, "{}", format!("{prefix}{line}").trim_end())?;
528 }
529 Ok(())
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::test_support::{parse_recipe, PARSER};
536
537 const FIXTURE: &str = "\
541---
542title: Long Lines
543description: A description that is comfortably longer than eighty columns so that any hard wrapping shows up as an extra line.
544---
545
546Place the base on a lightly floured surface and spread @San Marzano tomato \
547sauce{5%tbsp} on it. Add some fresh @basil leaves{} and fresh \
548@mozzarella cheese{100%grams}.
549";
550
551 fn render(text: &str) -> String {
552 let recipe = parse_recipe(text, "Long Lines", 1.0)
553 .expect("the fixture parses")
554 .value;
555 let mut buf = Vec::new();
556 print_md(&recipe, "Long Lines", 1.0, PARSER.converter(), &mut buf).expect("formats");
557 String::from_utf8(buf).expect("utf8")
558 }
559
560 #[test]
564 fn step_text_is_not_hard_wrapped() {
565 let md = render(FIXTURE);
566
567 let step = md
568 .lines()
569 .find(|l| l.starts_with("1. "))
570 .expect("the step is numbered");
571 assert_eq!(
572 step,
573 "1. Place the base on a lightly floured surface and spread San Marzano tomato \
574sauce on it. Add some fresh basil leaves and fresh mozzarella cheese.",
575 "the step should be one line, in:\n{md}"
576 );
577 }
578
579 #[test]
580 fn description_is_not_hard_wrapped() {
581 let md = render(FIXTURE);
582
583 let quoted: Vec<_> = md.lines().filter(|l| l.starts_with("> ")).collect();
584 assert_eq!(
585 quoted,
586 [
587 "> A description that is comfortably longer than eighty columns so that any hard \
588wrapping shows up as an extra line."
589 ],
590 "the description should be one blockquote line, in:\n{md}"
591 );
592 }
593
594 #[test]
597 fn a_list_inside_a_step_is_indented_under_the_number() {
598 let md = render("Gather the following:\n- @plain flour{200%g}\n- @whole milk{250%ml}\n");
599
600 assert!(
601 md.contains("1. Gather the following: - plain flour\n - whole milk\n"),
602 "the bullet should be indented under the step, in:\n{md}"
603 );
604 }
605}