Skip to main content

sweet_potator/recipe/
metadata.rs

1use std::{collections::HashMap, fmt};
2
3use serde::Serialize;
4
5use super::{
6    errors::{ParseError, ParseResult},
7    ParseFromStr,
8};
9
10#[derive(Debug, Serialize)]
11pub struct Yield {
12    pub value: u32,
13    pub unit: Option<String>,
14}
15
16impl fmt::Display for Yield {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        write!(f, "{}", self.value)?;
19        if let Some(unit) = &self.unit {
20            write!(f, " {unit}")?;
21        }
22        Ok(())
23    }
24}
25
26impl ParseFromStr for Yield {
27    fn parse_from_str(s: &str) -> ParseResult<Self> {
28        let (value, unit) = s.split_once(' ').map_or((s, None), |(value, unit)| {
29            (value, Some(unit.trim_start().into()))
30        });
31        if let Ok(value) = value.parse() {
32            if value == 0 {
33                Err(format!(
34                    "metadata value for key '{}' must be greater than zero",
35                    Metadata::YIELD_KEY,
36                )
37                .into())
38            } else {
39                Ok(Yield { value, unit })
40            }
41        } else {
42            Err(format!(
43                "metadata value for key '{}' must start with a number",
44                Metadata::YIELD_KEY
45            )
46            .into())
47        }
48    }
49}
50
51#[derive(Debug, Serialize)]
52pub struct Duration {
53    pub hours: u32,
54    pub minutes: u32,
55}
56
57impl Duration {
58    fn parse_unit(text: &str, unit: &str) -> ParseResult<u32> {
59        text.strip_suffix(unit)
60            .and_then(|value| value.parse().ok())
61            .ok_or_else(|| "invalid recipe duration".into())
62    }
63}
64
65impl fmt::Display for Duration {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        let mut duration: Vec<String> = Vec::new();
68        if self.hours > 0 {
69            duration.push(self.hours.to_string() + "h");
70        }
71        if self.minutes > 0 {
72            duration.push(self.minutes.to_string() + "m");
73        }
74        write!(f, "{}", duration.join(" "))
75    }
76}
77
78impl ParseFromStr for Duration {
79    fn parse_from_str(s: &str) -> ParseResult<Self> {
80        let (mut hours, mut minutes) = if let Some((h, m)) = s.split_once(' ') {
81            (
82                Self::parse_unit(h, "h")?,
83                Self::parse_unit(m.trim_start(), "m")?,
84            )
85        } else if let Ok(h) = Self::parse_unit(s, "h") {
86            (h, 0)
87        } else {
88            (0, Self::parse_unit(s, "m")?)
89        };
90        if hours == 0 && minutes == 0 {
91            return Err("recipe duration must be greater than zero".into());
92        }
93        hours += minutes / 60;
94        minutes %= 60;
95        Ok(Duration { hours, minutes })
96    }
97}
98
99#[derive(Debug, Serialize)]
100pub struct Link {
101    pub name: String,
102    pub url: String,
103}
104
105impl fmt::Display for Link {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "{} > {}", self.name, self.url)
108    }
109}
110
111impl ParseFromStr for Link {
112    fn parse_from_str(s: &str) -> ParseResult<Self> {
113        let (name, url) = s.split_once(" > ").ok_or("missing link separator ' > '")?;
114        let name = name.trim_end().to_string();
115        let url = url.trim_start().to_string();
116        if name.is_empty() {
117            return Err(ParseError::empty("link name"));
118        }
119        if url.is_empty() {
120            return Err(ParseError::empty("link url"));
121        }
122        Ok(Self { name, url })
123    }
124}
125
126#[derive(Debug, Serialize)]
127#[serde(rename_all = "snake_case")]
128pub enum Source {
129    Author(String),
130    Book(String),
131    Link(Link),
132}
133
134impl Source {
135    const AUTHOR_KEY: &'static str = "Author";
136    const BOOK_KEY: &'static str = "Book";
137    const LINK_KEY: &'static str = "Link";
138}
139
140impl fmt::Display for Source {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        match self {
143            Self::Author(author) => write!(f, "{}: {}", Self::AUTHOR_KEY, author),
144            Self::Book(book) => write!(f, "{}: {}", Self::BOOK_KEY, book),
145            Self::Link(link) => write!(f, "{}: {}", Self::LINK_KEY, link),
146        }
147    }
148}
149
150#[derive(Debug, Serialize)]
151pub struct Metadata {
152    pub duration: Option<Duration>,
153    #[serde(rename = "yield")]
154    pub yields: Yield,
155    pub source: Option<Source>,
156    pub tags: Vec<String>,
157}
158
159impl Metadata {
160    const DURATION_KEY: &'static str = "Time";
161    const YIELD_KEY: &'static str = "Yield";
162    const TAGS_KEY: &'static str = "Tags";
163}
164
165impl fmt::Display for Metadata {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        writeln!(f, "{}: {}", Self::YIELD_KEY, self.yields)?;
168        if let Some(duration) = &self.duration {
169            writeln!(f, "{}: {}", Self::DURATION_KEY, duration)?;
170        }
171        if let Some(source) = &self.source {
172            writeln!(f, "{source}")?;
173        }
174        if !self.tags.is_empty() {
175            writeln!(f, "{}: {}", Self::TAGS_KEY, self.tags.join(", "))?;
176        }
177        Ok(())
178    }
179}
180
181impl TryFrom<Vec<String>> for Metadata {
182    type Error = ParseError;
183
184    fn try_from(lines: Vec<String>) -> Result<Self, Self::Error> {
185        let mut map = HashMap::new();
186        for line in lines {
187            let (key, value) = parse_mapping(&line)?;
188            if map.insert(key.into(), value.into()).is_some() {
189                return Err(format!("duplicate metadata key '{key}'").into());
190            }
191        }
192        map.try_into()
193    }
194}
195
196impl TryFrom<HashMap<String, String>> for Metadata {
197    type Error = ParseError;
198
199    fn try_from(mut map: HashMap<String, String>) -> Result<Self, Self::Error> {
200        let yields = map
201            .remove(Self::YIELD_KEY)
202            .ok_or_else(|| format!("missing metadata key '{}'", Self::YIELD_KEY))?;
203        let yields = Yield::parse_from_str(&yields)?;
204        let duration = map
205            .remove(Self::DURATION_KEY)
206            .as_deref()
207            .map(Duration::parse_from_str)
208            .transpose()?;
209        let source = if let Some(value) = map.remove(Source::LINK_KEY) {
210            Some(Source::Link(Link::parse_from_str(&value)?))
211        } else {
212            map.remove(Source::AUTHOR_KEY)
213                .map(Source::Author)
214                .or_else(|| map.remove(Source::BOOK_KEY).map(Source::Book))
215        };
216        let tags = map.remove(Self::TAGS_KEY).map_or_else(Vec::new, |value| {
217            value.split(", ").map(|s| s.trim().into()).collect()
218        });
219        if let Some(key) = map.keys().next() {
220            return Err(format!("unknown metadata key '{key}'").into());
221        }
222        let metadata = Self {
223            duration,
224            yields,
225            source,
226            tags,
227        };
228        Ok(metadata)
229    }
230}
231
232fn parse_mapping(line: &str) -> ParseResult<(&str, &str)> {
233    let (key, value) = line
234        .trim()
235        .split_once(": ")
236        .ok_or("missing key-value separator ': ' in metadata")?;
237    let key = key.trim_end();
238    let value = value.trim_start();
239    let mapping = if key.is_empty() {
240        Err(ParseError::empty("metadata key"))
241    } else if value.is_empty() {
242        Err(ParseError::empty("metadata value"))
243    } else {
244        Ok((key, value))
245    }?;
246    Ok(mapping)
247}
248
249#[cfg(test)]
250mod tests {
251
252    use super::*;
253
254    #[test]
255    fn test_display_duration() {
256        let mut duration = Duration {
257            hours: 1,
258            minutes: 0,
259        };
260        assert_eq!(duration.to_string(), "1h");
261        duration.minutes = 30;
262        assert_eq!(duration.to_string(), "1h 30m");
263        duration.hours = 0;
264        assert_eq!(duration.to_string(), "30m");
265    }
266
267    #[test]
268    fn test_parse_duration() {
269        let duration = Duration::parse_from_str("1h").unwrap();
270        assert_eq!(duration.hours, 1);
271        assert_eq!(duration.minutes, 0);
272        let duration = Duration::parse_from_str("1m").unwrap();
273        assert_eq!(duration.hours, 0);
274        assert_eq!(duration.minutes, 1);
275        let duration = Duration::parse_from_str("2h  30m").unwrap();
276        assert_eq!(duration.hours, 2);
277        assert_eq!(duration.minutes, 30);
278        let duration = Duration::parse_from_str("0h  60m").unwrap();
279        assert_eq!(duration.hours, 1);
280        assert_eq!(duration.minutes, 0);
281    }
282
283    #[test]
284    fn test_display_link() {
285        let link = Link {
286            name: "name".into(),
287            url: "url".into(),
288        };
289        assert_eq!(link.to_string(), "name > url");
290    }
291
292    #[test]
293    fn test_parse_link() {
294        let link = Link::parse_from_str("name > url").unwrap();
295        assert_eq!(link.name, "name");
296        assert_eq!(link.url, "url");
297        let link = Link::parse_from_str("name with space  >  url with space").unwrap();
298        assert_eq!(link.name, "name with space");
299        assert_eq!(link.url, "url with space");
300    }
301
302    #[test]
303    fn test_display_source() {
304        let link = Link {
305            name: "name".into(),
306            url: "url".into(),
307        };
308        let source = Source::Link(link);
309        assert_eq!(source.to_string(), "Link: name > url");
310        let source = Source::Author("name".into());
311        assert_eq!(source.to_string(), "Author: name");
312        let source = Source::Book("name".into());
313        assert_eq!(source.to_string(), "Book: name");
314    }
315
316    #[test]
317    fn test_metadata() {
318        let mut map = HashMap::new();
319        map.insert("Yield".into(), "1  unit".into());
320        map.insert("Link".into(), "name> > >url".into());
321        map.insert("Tags".into(), "tag1 ,  tag2".into());
322        let metadata: Metadata = map.try_into().unwrap();
323        assert_eq!(metadata.yields.value, 1);
324        assert_eq!(metadata.yields.unit.as_deref(), Some("unit"));
325        assert!(
326            matches!(&metadata.source, Some(Source::Link(link)) if link.name =="name>" && link.url == ">url")
327        );
328        assert_eq!(&metadata.tags, &["tag1", "tag2"]);
329        assert_eq!(
330            metadata.to_string(),
331            "Yield: 1 unit\nLink: name> > >url\nTags: tag1, tag2\n"
332        );
333    }
334
335    #[test]
336
337    fn test_metadata_empty() {
338        assert!(Metadata::try_from(HashMap::new()).is_err());
339    }
340
341    #[test]
342    fn test_metadata_missing_entries() {
343        let mut map = HashMap::new();
344        map.insert("Yield".into(), "1".into());
345        let metadata: Metadata = map.try_into().unwrap();
346        assert_eq!(metadata.yields.value, 1);
347        assert_eq!(metadata.to_string(), "Yield: 1\n");
348    }
349
350    #[test]
351    fn test_metadata_unkown_entries() {
352        let mut map = HashMap::new();
353        map.insert("Yield".into(), "1".into());
354        map.insert("Unknown".into(), "unknown".into());
355        assert!(Metadata::try_from(map).is_err());
356    }
357}