1use std::convert::TryFrom;
2use std::str::FromStr;
3
4use anyhow::Result;
5use chrono::NaiveDate;
6use lazy_static::lazy_static;
7use regex::Regex;
8use thiserror::Error;
9
10use gemini_fetch::Page;
11
12lazy_static! {
13 static ref ENTRY_REGEX: Regex =
14 Regex::new(r"^=>\s+([^\s]+)\s+(\d{4}-\d{2}-\d{2})\s+(-\s+)?(.+)$").unwrap();
15}
16
17#[derive(Debug)]
19pub struct Entry {
20 pub published_at: NaiveDate,
22
23 pub url: String,
29
30 pub title: String,
32}
33
34#[derive(Debug, Error)]
35pub enum ParseEntryError {
36 #[error("malformed entry string")]
37 MalformedEntry,
38 #[error("missing year")]
39 MissingYear,
40 #[error("invalid year \"{0}\"")]
41 InvalidYear(String),
42 #[error("missing month")]
43 MissingMonth,
44 #[error("invalid month \"{0}\"")]
45 InvalidMonth(String),
46 #[error("missing day")]
47 MissingDay,
48 #[error("invalid day \"{0}\"")]
49 InvalidDay(String),
50}
51
52impl FromStr for Entry {
53 type Err = ParseEntryError;
54
55 fn from_str(s: &str) -> Result<Self, Self::Err> {
56 let capture = ENTRY_REGEX
57 .captures_iter(s)
58 .next()
59 .ok_or(ParseEntryError::MalformedEntry)?;
60
61 let url = capture[1].to_string();
62 let title = capture[4].to_string();
63
64 let date_parts: Vec<&str> = capture[2].split('-').collect();
65
66 let year = date_parts.get(0).ok_or(ParseEntryError::MissingYear)?;
67 let year: i32 = year
68 .parse()
69 .map_err(|_| ParseEntryError::InvalidYear(year.to_string()))?;
70
71 let month = date_parts.get(1).ok_or(ParseEntryError::MissingMonth)?;
72 let month: u32 = month
73 .parse()
74 .map_err(|_| ParseEntryError::InvalidMonth(month.to_string()))?;
75
76 let day = date_parts.get(2).ok_or(ParseEntryError::MissingDay)?;
77 let day: u32 = day
78 .parse()
79 .map_err(|_| ParseEntryError::InvalidDay(day.to_string()))?;
80
81 Ok(Entry {
82 published_at: NaiveDate::from_ymd(year, month, day),
83 url,
84 title,
85 })
86 }
87}
88
89#[derive(Debug)]
91pub struct Feed {
92 pub base_url: String,
97
98 pub title: String,
100
101 pub subtitle: Option<String>,
103
104 pub entries: Vec<Entry>,
106}
107
108#[derive(Debug, Error)]
109pub enum TryFromPageError {
110 #[error("page is empty")]
111 EmptyPage,
112 #[error("header missing prefix (should be impossible)")]
113 HeaderMissingPrefix,
114 #[error("page is missing a title")]
115 MissingTitle,
116}
117
118impl TryFrom<Page> for Feed {
119 type Error = TryFromPageError;
120
121 fn try_from(page: Page) -> Result<Self, Self::Error> {
122 let body = page.body.ok_or(TryFromPageError::EmptyPage)?;
123
124 let mut title: Option<String> = None;
125 let mut title_line: Option<usize> = None;
126 let mut subtitle: Option<String> = None;
127 let mut entries = Vec::new();
128
129 let lines = body.lines();
130
131 for (i, line) in lines.enumerate() {
132 if line.starts_with("# ") {
133 if let None = title {
134 title = Some(
135 line.strip_prefix("# ")
136 .ok_or(TryFromPageError::HeaderMissingPrefix)?
137 .to_string(),
138 );
139 title_line = Some(i);
140 }
141 } else if line.starts_with("## ") {
142 if let (None, Some(title_idx)) = (&subtitle, title_line) {
143 if title_idx == i - 1 {
144 subtitle = Some(
145 line.strip_prefix("## ")
146 .ok_or(TryFromPageError::HeaderMissingPrefix)?
147 .to_string(),
148 );
149 }
150 }
151 } else if line.starts_with("=> ") {
152 if let Ok(entry) = line.parse::<Entry>() {
153 entries.push(entry);
154 }
155 }
156 }
157
158 Ok(Feed {
159 base_url: page.url,
160 title: title.ok_or(TryFromPageError::MissingTitle)?,
161 subtitle,
162 entries,
163 })
164 }
165}