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}