gemini_feed/
lib.rs

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/// Represents a single gemfeed entry
18#[derive(Debug)]
19pub struct Entry {
20    /// Feed entry publish date
21    pub published_at: NaiveDate,
22
23    /// URL for the entry
24    ///
25    /// Note: not guaranteed to be a full path.
26    /// Take this and the parent feed's URL to
27    /// construct the entry's full URL.
28    pub url: String,
29
30    /// Feed entry title
31    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/// Represents a gemini feed
90#[derive(Debug)]
91pub struct Feed {
92    /// Base URL for the feed
93    ///
94    /// Combine with entry feeds to construct
95    /// full entry URLs.
96    pub base_url: String,
97
98    /// Feed's title
99    pub title: String,
100
101    /// Feed's optional subtitle
102    pub subtitle: Option<String>,
103
104    /// Feed's entries
105    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}