futhorc/
feed.rs

1//! Support for creating Atom feeds from a list of posts.
2
3use crate::config::Author;
4use crate::post::Post;
5use atom_syndication::{Entry, Error as AtomError, Feed, Link, Person, Text};
6use chrono::{
7    FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, ParseError, ParseResult,
8    TimeZone, Utc,
9};
10use std::fmt;
11use std::io::Write;
12use url::Url;
13
14/// Bundled configuration for creating a feed.
15pub struct FeedConfig {
16    pub title: String,
17    pub id: String,
18    pub author: Option<Author>,
19    pub home_page: Url,
20}
21
22/// Creates a feed from some configuration ([`FeedConfig`]) and a list of
23/// [`Post`]s and writes the result to a [`std::io::Write`]. This function
24/// takes ownership of the provided [`FeedConfig`].
25pub fn write_feed<W: Write>(
26    config: FeedConfig,
27    posts: &[Post],
28    w: W,
29) -> Result<()> {
30    feed(config, posts)?.write_to(w)?;
31    Ok(())
32}
33
34fn feed(config: FeedConfig, posts: &[Post]) -> ParseResult<Feed> {
35    use std::collections::BTreeMap;
36    Ok(Feed {
37        entries: feed_entries(&config, posts)?,
38        title: Text::plain(config.title),
39        id: config.id,
40        updated: FixedOffset::east(0)
41            .from_utc_datetime(&Utc::now().naive_utc()),
42        authors: author_to_people(config.author),
43        categories: Vec::new(),
44        contributors: Vec::new(),
45        generator: None,
46        icon: None,
47        logo: None,
48        rights: None,
49        subtitle: None,
50        extensions: BTreeMap::new(),
51        namespaces: BTreeMap::new(),
52        links: vec![Link {
53            href: config.home_page.into(),
54            rel: "alternate".to_string(),
55            title: None,
56            hreflang: None,
57            mime_type: None,
58            length: None,
59        }],
60        base: None,
61        lang: None,
62    })
63}
64
65fn feed_entries(
66    config: &FeedConfig,
67    posts: &[Post],
68) -> ParseResult<Vec<Entry>> {
69    use std::collections::BTreeMap;
70    let mut entries: Vec<Entry> = Vec::with_capacity(posts.len());
71
72    for post in posts {
73        let (summary, _) = post.summary();
74
75        // Good grief, `chrono` is ridiculous. If we try to skip this ceremony
76        // and just do FixedOffset::parse_from_str(), we will get a runtime
77        // error because we don't have fully-precise time information or a
78        // timezone. Below I'm intending to use the UTC timezone. I think
79        // that's what `FixedOffset::east(0)` does, but it's hard to
80        // say because chrono is so complicated and the documentation
81        // doesn't provide enough context.
82        let naive_date = NaiveDate::parse_from_str(&post.date, "%Y-%m-%d")?;
83        let naive_time = NaiveTime::from_hms(0, 0, 0);
84        let naive_date_time = NaiveDateTime::new(naive_date, naive_time);
85        let offset = FixedOffset::east(0);
86        let date = offset.from_utc_datetime(&naive_date_time);
87
88        entries.push(Entry {
89            id: post.url.to_string(),
90            title: Text::plain(post.title.clone()),
91            updated: date,
92            authors: author_to_people(config.author.clone()),
93            links: vec![Link {
94                href: post.url.to_string(),
95                rel: "alternate".to_owned(),
96                title: None,
97                mime_type: None,
98                hreflang: None,
99                length: None,
100            }],
101            rights: None,
102            summary: Some(Text::html(summary.to_owned())),
103            categories: Vec::new(),
104            contributors: Vec::new(),
105            published: Some(date),
106            source: None,
107            content: None,
108            extensions: BTreeMap::new(),
109        })
110    }
111    Ok(entries)
112}
113
114fn author_to_people(author: Option<Author>) -> Vec<Person> {
115    match author {
116        Some(author) => vec![Person {
117            name: author.name,
118            email: author.email,
119            uri: None,
120        }],
121        None => Vec::new(),
122    }
123}
124
125type Result<T> = std::result::Result<T, Error>;
126
127/// Represents a problem creating a feed. Variants inlude I/O, Atom, and
128/// date-time parsing issues.
129#[derive(Debug)]
130pub enum Error {
131    /// Returned when there is a generic I/O error.
132    Io(std::io::Error),
133
134    /// Returned when there is an Atom-related error.
135    Atom(AtomError),
136
137    /// Returned when there is an issue parsing a post's date.
138    DateTimeParse(ParseError),
139}
140
141impl fmt::Display for Error {
142    /// Implements [`fmt::Display`] for [`Error`].
143    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
144        match self {
145            Error::Io(err) => err.fmt(f),
146            Error::Atom(err) => err.fmt(f),
147            Error::DateTimeParse(err) => err.fmt(f),
148        }
149    }
150}
151
152impl std::error::Error for Error {
153    /// Implements [`std::error::Error`] for [`Error`].
154    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
155        match self {
156            Error::Io(err) => Some(err),
157            Error::Atom(err) => Some(err),
158            Error::DateTimeParse(err) => Some(err),
159        }
160    }
161}
162
163impl From<std::io::Error> for Error {
164    /// Converts [`std::io::Error`]s into [`Error`]. This allows us to use the
165    /// `?` operator in fallible feed operations.
166    fn from(err: std::io::Error) -> Error {
167        Error::Io(err)
168    }
169}
170
171impl From<AtomError> for Error {
172    /// Converts [`AtomError`]s into [`Error`]. This allows us to use the `?`
173    /// operator in fallible feed operations.
174    fn from(err: AtomError) -> Error {
175        Error::Atom(err)
176    }
177}
178
179impl From<ParseError> for Error {
180    /// Converts [`ParseError`]s into [`Error`]. This allows us to use the `?`
181    /// operator in fallible feed operations.
182    fn from(err: ParseError) -> Error {
183        Error::DateTimeParse(err)
184    }
185}