1use std::{fmt::Write, io};
4
5use cooklang::{
6 convert::Converter,
7 metadata::Metadata,
8 model::{Item, Section, Step},
9 ScaledRecipe,
10};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, thiserror::Error)]
14pub enum Error {
15 #[error(transparent)]
16 Io(#[from] io::Error),
17 #[error("Error serializing YAML frontmatter")]
18 Metadata(
19 #[from]
20 #[source]
21 serde_yaml::Error,
22 ),
23}
24
25pub type Result<T = (), E = Error> = std::result::Result<T, E>;
26
27#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
32#[serde(default)]
33#[non_exhaustive]
34pub struct Options {
35 pub tags: bool,
44 #[serde(deserialize_with = "des_or_bool")]
49 pub description: DescriptionStyle,
50 pub escape_step_numbers: bool,
60 pub italic_amounts: bool,
65 #[serde(deserialize_with = "des_or_bool")]
69 pub front_matter_name: FrontMatterName,
70 pub heading: Headings,
72 pub optional_marker: String,
74}
75
76impl Default for Options {
77 fn default() -> Self {
78 Self {
79 tags: true,
80 description: DescriptionStyle::Blockquote,
81 escape_step_numbers: false,
82 italic_amounts: true,
83 front_matter_name: FrontMatterName::default(),
84 heading: Headings::default(),
85 optional_marker: "(optional)".to_string(),
86 }
87 }
88}
89
90#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
91#[serde(rename_all = "snake_case")]
92pub enum DescriptionStyle {
93 Hidden,
95 #[default]
97 #[serde(alias = "default")]
98 Blockquote,
99 Heading,
101}
102
103impl From<bool> for DescriptionStyle {
104 fn from(value: bool) -> Self {
105 match value {
106 true => Self::default(),
107 false => Self::Hidden,
108 }
109 }
110}
111
112#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
113#[serde(transparent)]
114pub struct FrontMatterName(pub Option<String>);
115
116impl Default for FrontMatterName {
117 fn default() -> Self {
118 Self(Some("name".to_string()))
119 }
120}
121
122impl From<bool> for FrontMatterName {
123 fn from(value: bool) -> Self {
124 match value {
125 true => Self::default(),
126 false => Self(None),
127 }
128 }
129}
130
131#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
132#[serde(default)]
133pub struct Headings {
134 pub section: String,
138 pub ingredients: String,
140 pub cookware: String,
142 pub steps: String,
144 pub description: String,
148}
149
150impl Default for Headings {
151 fn default() -> Self {
152 Self {
153 section: "Section %n".into(),
154 ingredients: "Ingredients".into(),
155 cookware: "Cookware".into(),
156 steps: "Steps".into(),
157 description: "Description".into(),
158 }
159 }
160}
161
162fn des_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
163where
164 D: serde::Deserializer<'de>,
165 T: serde::Deserialize<'de> + From<bool>,
166{
167 #[derive(Deserialize)]
168 #[serde(untagged)]
169 enum Wrapper<T> {
170 Bool(bool),
171 Thing(T),
172 }
173
174 let v = match Wrapper::deserialize(deserializer)? {
175 Wrapper::Bool(v) => T::from(v),
176 Wrapper::Thing(val) => val,
177 };
178 Ok(v)
179}
180
181pub fn print_md(
186 recipe: &ScaledRecipe,
187 name: &str,
188 converter: &Converter,
189 writer: impl io::Write,
190) -> Result {
191 print_md_with_options(recipe, name, &Options::default(), converter, writer)
192}
193
194pub fn print_md_with_options(
203 recipe: &ScaledRecipe,
204 name: &str,
205 opts: &Options,
206 converter: &Converter,
207 mut writer: impl io::Write,
208) -> Result {
209 frontmatter(&mut writer, &recipe.metadata, name, opts)?;
210
211 writeln!(writer, "# {}\n", name)?;
212
213 if opts.tags {
214 if let Some(tags) = recipe.metadata.tags() {
215 for (i, tag) in tags.iter().enumerate() {
216 write!(writer, "#{tag}")?;
217 if i < tags.len() - 1 {
218 write!(writer, " ")?;
219 }
220 }
221 writeln!(writer, "\n")?;
222 }
223 }
224
225 if let Some(desc) = recipe.metadata.description() {
226 match opts.description {
227 DescriptionStyle::Hidden => {}
228 DescriptionStyle::Blockquote => {
229 print_wrapped_with_options(&mut writer, desc, |o| {
230 o.initial_indent("> ").subsequent_indent("> ")
231 })?;
232 writeln!(writer)?;
233 }
234 DescriptionStyle::Heading => {
235 writeln!(writer, "## {}\n", opts.heading.description)?;
236 print_wrapped(&mut writer, desc)?;
237 writeln!(writer)?;
238 }
239 }
240 }
241
242 ingredients(&mut writer, recipe, converter, opts)?;
243 cookware(&mut writer, recipe, opts)?;
244 sections(&mut writer, recipe, opts)?;
245
246 Ok(())
247}
248
249fn frontmatter(
250 mut w: impl io::Write,
251 metadata: &Metadata,
252 name: &str,
253 opts: &Options,
254) -> Result<()> {
255 if metadata.map.is_empty() {
256 return Ok(());
257 }
258
259 let mut map = metadata.map.clone();
260
261 if let Some(name_key) = &opts.front_matter_name.0 {
262 map.insert(name_key.as_str().into(), name.into());
264 }
265
266 const FRONTMATTER_FENCE: &str = "---";
267 writeln!(w, "{}", FRONTMATTER_FENCE)?;
268 serde_yaml::to_writer(&mut w, &map)?;
269 writeln!(w, "{}\n", FRONTMATTER_FENCE)?;
270 Ok(())
271}
272
273fn ingredients(
274 w: &mut impl io::Write,
275 recipe: &ScaledRecipe,
276 converter: &Converter,
277 opts: &Options,
278) -> Result {
279 if recipe.ingredients.is_empty() {
280 return Ok(());
281 }
282
283 writeln!(w, "## {}\n", opts.heading.ingredients)?;
284
285 for entry in recipe.group_ingredients(converter) {
286 let ingredient = entry.ingredient;
287
288 if !ingredient.modifiers().should_be_listed() {
289 continue;
290 }
291
292 write!(w, "- ")?;
293 if !entry.quantity.is_empty() {
294 if opts.italic_amounts {
295 write!(w, "*{}* ", entry.quantity)?;
296 } else {
297 write!(w, "{} ", entry.quantity)?;
298 }
299 }
300
301 write!(w, "{}", ingredient.display_name())?;
302
303 if ingredient.modifiers().is_optional() {
304 write!(w, " {}", opts.optional_marker)?;
305 }
306
307 if let Some(note) = &ingredient.note {
308 write!(w, " ({note})")?;
309 }
310 writeln!(w)?;
311 }
312 writeln!(w)?;
313
314 Ok(())
315}
316
317fn cookware(w: &mut impl io::Write, recipe: &ScaledRecipe, opts: &Options) -> Result {
318 if recipe.cookware.is_empty() {
319 return Ok(());
320 }
321
322 writeln!(w, "## {}\n", opts.heading.cookware)?;
323 for item in recipe.group_cookware() {
324 let cw = item.cookware;
325 write!(w, "- ")?;
326 if !item.amount.is_empty() {
327 if opts.italic_amounts {
328 write!(w, "*{} * ", item.amount)?;
329 } else {
330 write!(w, "{} ", item.amount)?;
331 }
332 }
333 write!(w, "{}", cw.display_name())?;
334
335 if cw.modifiers().is_optional() {
336 write!(w, " {}", opts.optional_marker)?;
337 }
338
339 if let Some(note) = &cw.note {
340 write!(w, " ({note})")?;
341 }
342 writeln!(w)?;
343 }
344
345 writeln!(w)?;
346 Ok(())
347}
348
349fn sections(w: &mut impl io::Write, recipe: &ScaledRecipe, opts: &Options) -> Result<()> {
350 writeln!(w, "## {}\n", opts.heading.steps)?;
351 for (idx, section) in recipe.sections.iter().enumerate() {
352 w_section(w, section, recipe, idx + 1, opts)?;
353 }
354 Ok(())
355}
356
357fn w_section(
358 w: &mut impl io::Write,
359 section: &Section,
360 recipe: &ScaledRecipe,
361 num: usize,
362 opts: &Options,
363) -> Result {
364 if section.name.is_some() || recipe.sections.len() > 1 {
365 if let Some(name) = §ion.name {
366 writeln!(w, "### {name}\n")?;
367 } else {
368 let s = opts.heading.section.replace("%n", &num.to_string());
369 writeln!(w, "### {s}\n")?;
370 }
371 }
372 for content in §ion.content {
373 match content {
374 cooklang::Content::Step(step) => w_step(w, step, recipe, opts)?,
375 cooklang::Content::Text(text) => print_wrapped(w, text)?,
376 };
377 writeln!(w)?;
378 }
379 Ok(())
380}
381
382fn w_step(w: &mut impl io::Write, step: &Step, recipe: &ScaledRecipe, opts: &Options) -> Result {
383 let mut step_str = step.number.to_string();
384 if opts.escape_step_numbers {
385 step_str.push_str("\\. ")
386 } else {
387 step_str.push_str(". ")
388 }
389
390 for item in &step.items {
391 match item {
392 Item::Text { value } => step_str.push_str(value),
393 &Item::Ingredient { index } => {
394 let igr = &recipe.ingredients[index];
395 step_str.push_str(igr.display_name().as_ref());
396 }
397 &Item::Cookware { index } => {
398 let cw = &recipe.cookware[index];
399 step_str.push_str(&cw.name);
400 }
401 &Item::Timer { index } => {
402 let t = &recipe.timers[index];
403 if let Some(name) = &t.name {
404 write!(&mut step_str, "({name})").unwrap();
405 }
406 if let Some(quantity) = &t.quantity {
407 write!(&mut step_str, "{}", quantity).unwrap();
408 }
409 }
410 &Item::InlineQuantity { index } => {
411 let q = &recipe.inline_quantities[index];
412 if opts.italic_amounts {
413 write!(&mut step_str, "*{q}*").unwrap();
414 } else {
415 write!(&mut step_str, "{q}").unwrap();
416 }
417 }
418 }
419 }
420 print_wrapped(w, &step_str)?;
421 Ok(())
422}
423
424fn print_wrapped(w: &mut impl io::Write, text: &str) -> Result {
425 print_wrapped_with_options(w, text, |o| o)
426}
427
428static TERM_WIDTH: std::sync::LazyLock<usize> =
429 std::sync::LazyLock::new(|| textwrap::termwidth().min(80));
430
431fn print_wrapped_with_options<F>(w: &mut impl io::Write, text: &str, f: F) -> Result
432where
433 F: FnOnce(textwrap::Options) -> textwrap::Options,
434{
435 let options = f(textwrap::Options::new(*TERM_WIDTH));
436 let lines = textwrap::wrap(text, options);
437 for line in lines {
438 writeln!(w, "{}", line)?;
439 }
440 Ok(())
441}