use anyhow::{Context, Result};
use jiff::Zoned;
use quick_xml::events::Event as XmlEvent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Story {
pub source: String,
pub title: String,
pub link: String,
pub published: Option<Zoned>,
}
#[derive(Debug, Default)]
pub struct Feed {
pub stories: Vec<Story>,
pub fault: Option<String>,
}
pub fn parse(xml: &str) -> Result<Feed> {
let mut reader = quick_xml::Reader::from_str(xml);
reader.config_mut().trim_text(false);
reader.config_mut().allow_dangling_amp = true;
let mut stories = Vec::new();
let mut in_item = false;
let mut root_seen = false;
let mut saw_item = false;
let mut saw_channel = false;
let mut field: Option<Field> = None;
let mut current = Partial::default();
loop {
let event = match reader.read_event() {
Ok(event) => event,
Err(e) if !stories.is_empty() => {
return Ok(Feed {
stories,
fault: Some(format!("partly unreadable: {e}")),
});
}
Err(e) => return Err(e).context("reading the feed as XML"),
};
match event {
XmlEvent::Start(tag) => {
let root = !std::mem::replace(&mut root_seen, true);
match local_name(tag.name().as_ref()) {
"feed" if root => {
anyhow::bail!("an Atom feed; mirador reads RSS 2.0 only");
}
"item" => {
in_item = true;
saw_item = true;
current = Partial::default();
}
"channel" => saw_channel = true,
"title" if in_item => field = Some(Field::Title),
"link" if in_item => field = Some(Field::Link),
"pubDate" if in_item => field = Some(Field::Date),
_ => {}
}
}
XmlEvent::End(tag) => match local_name(tag.name().as_ref()) {
"item" => {
in_item = false;
if let Some(story) = current.take() {
stories.push(story);
}
}
_ => field = None,
},
XmlEvent::Text(text) => {
if let Some(which) = field {
current.set(which, text.as_ref());
}
}
XmlEvent::CData(data) => {
if let Some(which) = field {
current.set(which, data.as_ref());
}
}
XmlEvent::GeneralRef(reference) => {
if let Some(which) = field {
current.set(which, &resolve_entity(&reference));
}
}
XmlEvent::Eof => break,
_ => {}
}
}
if !saw_item && !saw_channel {
anyhow::bail!("not an RSS feed: no <channel> or <item> in it");
}
Ok(Feed {
stories,
fault: None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Title,
Link,
Date,
}
#[derive(Debug, Default)]
struct Partial {
title: String,
link: String,
date: String,
}
impl Partial {
fn set(&mut self, which: Field, value: &str) {
let slot = match which {
Field::Title => &mut self.title,
Field::Link => &mut self.link,
Field::Date => &mut self.date,
};
slot.push_str(value);
}
fn take(&mut self) -> Option<Story> {
let title = self.title.trim();
if title.is_empty() {
return None;
}
Some(Story {
source: String::new(),
title: clip(title, MAX_TITLE).to_string(),
link: clip(self.link.trim(), MAX_LINK).to_string(),
published: parse_date(self.date.trim()),
})
}
}
const MAX_TITLE: usize = 400;
const MAX_LINK: usize = 1_000;
pub const MAX_SOURCE: usize = 80;
pub(crate) fn clip(text: &str, limit: usize) -> &str {
match text.char_indices().nth(limit) {
Some((end, _)) => &text[..end],
None => text,
}
}
fn resolve_entity(reference: &quick_xml::events::BytesRef<'_>) -> String {
if let Ok(Some(character)) = reference.resolve_char_ref() {
return character.to_string();
}
match reference.as_ref() {
"amp" => "&",
"lt" => "<",
"gt" => ">",
"quot" => "\"",
"apos" => "'",
"nbsp" => " ",
name => return format!("&{name};"),
}
.to_string()
}
fn parse_date(raw: &str) -> Option<Zoned> {
if raw.is_empty() {
return None;
}
jiff::fmt::rfc2822::parse(raw).ok()
}
fn local_name(name: &str) -> &str {
match name.rfind(':') {
Some(colon) => &name[colon + 1..],
None => name,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hostile_feed_cannot_decide_how_much_work_the_dashboard_does() {
let huge = "word ".repeat(400_000);
let xml = format!(
"<rss><channel><item><title>{huge}</title>\
<link>http://example.com/{huge}</link></item></channel></rss>"
);
let stories = parse(&xml).expect("parses").stories;
let story = stories.first().expect("one story");
assert!(
story.title.chars().count() <= MAX_TITLE,
"a {} character headline survived",
story.title.chars().count()
);
assert!(
story.link.chars().count() <= MAX_LINK,
"a {} character link survived",
story.link.chars().count()
);
let wrapped = crate::grid::wrap(&story.title, 50);
assert!(
wrapped.len() <= 40,
"the headline still wraps to {} lines a frame",
wrapped.len()
);
}
#[test]
fn clip_keeps_exactly_the_first_limit_characters() {
for (text, limit, kept) in [
("", 0, ""),
("", 3, ""),
("abc", 0, ""),
("abc", 2, "ab"),
("abc", 3, "abc"),
("abc", 4, "abc"),
("日本語", 2, "日本"),
("日本語", 3, "日本語"),
("é🌞x", 2, "é🌞"),
] {
let clipped = clip(text, limit);
assert_eq!(clipped, kept, "{text:?} at {limit}");
assert!(
std::ptr::eq(clipped.as_ptr(), text.as_ptr()),
"{text:?} at {limit} was copied"
);
}
}
#[test]
fn a_headline_of_ordinary_length_is_not_clipped() {
let title = "Astronomers find a planet where it rains glass sideways, \
and the discovery may rewrite how we think gas giants form";
let xml = format!("<rss><channel><item><title>{title}</title></item></channel></rss>");
let stories = parse(&xml).expect("parses").stories;
assert_eq!(stories[0].title, title, "a real headline was clipped");
}
const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>The Feed Itself</title>
<link>https://example.org</link>
<item>
<title><![CDATA[Wildfire now nine miles from Bordeaux, mayor warns]]></title>
<description><![CDATA[Some prose that must not be read.]]></description>
<link>https://example.org/a?at_medium=RSS&at_campaign=x</link>
<pubDate>Mon, 27 Jul 2026 17:50:42 GMT</pubDate>
</item>
<item>
<title>‘Dead stars’ may have surprisingly healthy appetites</title>
<link>https://example.org/f</link>
<pubDate>Mon, 27 Jul 2026 12:00:00 GMT</pubDate>
</item>
<item>
<title>AT&T reverses course on the physical button</title>
<link>https://example.org/b</link>
<pubDate>Mon, 27 Jul 2026 15:58:34 +0000</pubDate>
</item>
<item>
<title>Europa Clipper returns its first images</title>
<link>https://example.org/c</link>
<pubDate>Mon, 27 Jul 2026 15:40:06 EDT</pubDate>
</item>
<item>
<title>A story its feed forgot to date</title>
<link>https://example.org/d</link>
</item>
<item>
<link>https://example.org/e</link>
<pubDate>Mon, 27 Jul 2026 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"#;
#[test]
fn the_feeds_own_title_is_not_a_story() {
let stories = parse(SAMPLE).expect("parses").stories;
assert!(
!stories.iter().any(|s| s.title == "The Feed Itself"),
"the channel's own title became a headline: {:?}",
stories.iter().map(|s| &s.title).collect::<Vec<_>>()
);
assert_eq!(stories.len(), 5, "five items have titles, one does not");
}
#[test]
fn cdata_and_entities_come_out_as_text() {
let stories = parse(SAMPLE).expect("parses").stories;
assert_eq!(
stories[0].title,
"Wildfire now nine miles from Bordeaux, mayor warns"
);
assert_eq!(
stories[0].link, "https://example.org/a?at_medium=RSS&at_campaign=x",
"& in a URL is decoded, not left as written"
);
assert_eq!(
stories[2].title, "AT&T reverses course on the physical button",
"an entity splits the text into several events and must be rejoined"
);
}
#[test]
fn a_space_next_to_an_entity_survives() {
let stories = parse(SAMPLE).expect("parses").stories;
let quoted = stories
.iter()
.find(|s| s.title.contains("Dead stars"))
.expect("present");
assert_eq!(
quoted.title,
"\u{2018}Dead stars\u{2019} may have surprisingly healthy appetites"
);
}
#[test]
fn the_date_forms_feeds_actually_emit_all_read() {
let stories = parse(SAMPLE).expect("parses").stories;
let by_title = |want: &str| {
stories
.iter()
.find(|s| s.title.starts_with(want))
.unwrap_or_else(|| panic!("`{want}` is missing"))
};
assert!(by_title("Wildfire").published.is_some(), "GMT reads");
assert!(by_title("AT&T").published.is_some(), "+0000 reads");
let europa = by_title("Europa").published.as_ref().expect("EDT reads");
assert_eq!(
europa.offset().seconds(),
-4 * 3600,
"EDT is -04:00, not UTC"
);
assert!(
by_title("A story its feed forgot").published.is_none(),
"a missing date costs the age, not the story"
);
}
#[test]
fn something_that_is_not_a_feed_is_an_error_naming_the_cause() {
assert!(parse("<<<not xml").is_err());
let err = parse("<html><body>hello</body></html>").expect_err("no items is not a feed");
assert!(format!("{err:#}").contains("<item>"), "got `{err:#}`");
}
#[test]
fn an_empty_channel_is_an_empty_feed_not_a_failure() {
let feed = parse("<rss version=\"2.0\"><channel><title>Quiet</title></channel></rss>")
.expect("an empty channel is still a feed");
assert!(feed.stories.is_empty(), "{:?}", feed.stories);
assert_eq!(feed.fault, None);
}
#[test]
fn an_atom_feed_says_so_rather_than_looking_empty() {
let atom = r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Releases</title>
<entry>
<title>v1.2.0</title>
<link href="https://example.org/releases/v1.2.0"/>
<updated>2026-10-01T12:00:00Z</updated>
</entry>
</feed>"#;
let err = parse(atom).expect_err("Atom is not read");
assert!(
format!("{err:#}").contains("Atom"),
"the failure should name the cause, got `{err:#}`"
);
}
#[test]
fn a_bare_ampersand_is_read_as_one() {
let xml = SAMPLE.replace("AT&T", "AT&T");
let feed = parse(&xml).expect("a bare `&` is not worth the whole feed");
assert_eq!(feed.stories.len(), 5, "every story survives");
assert_eq!(
feed.stories[2].title,
"AT&T reverses course on the physical button"
);
assert_eq!(feed.fault, None, "nothing was lost, so nothing is reported");
}
#[test]
fn a_bare_ampersand_before_a_semicolon_keeps_the_text_between() {
let xml = "<rss><channel>\
<item><title>R&D spending rises; analysts wary</title>\
<link>https://example.com/a?id=1&x=2&page=3;view=full</link></item>\
<item><title>Q&A: why it matters; more</title></item>\
<item><title>AT&T; Verizon merge</title></item>\
<item><title>Wait… what</title></item>\
</channel></rss>";
let feed = parse(xml).expect("parses");
let titles: Vec<&str> = feed.stories.iter().map(|s| s.title.as_str()).collect();
assert_eq!(
titles,
[
"R&D spending rises; analysts wary",
"Q&A: why it matters; more",
"AT&T; Verizon merge",
"Wait… what",
],
"the text between an `&` and a later `;` is part of the headline"
);
assert_eq!(
feed.stories[0].link, "https://example.com/a?id=1&x=2&page=3;view=full",
"a link must reach the panel as the address it was"
);
assert_eq!(feed.fault, None, "nothing was lost, so nothing is reported");
}
#[test]
fn a_fault_part_way_down_keeps_the_stories_before_it() {
let xml = SAMPLE.replace(
"<title>AT&T reverses course on the physical button</title>",
"<title>AT&T reverses course on the physical button</titel>",
);
let feed = parse(&xml).expect("two good stories are still news");
let titles: Vec<&str> = feed.stories.iter().map(|s| s.title.as_str()).collect();
assert_eq!(
titles,
[
"Wildfire now nine miles from Bordeaux, mayor warns",
"\u{2018}Dead stars\u{2019} may have surprisingly healthy appetites",
],
"the stories before the fault, and nothing after it"
);
assert!(
feed.fault.as_deref().is_some_and(|f| f.contains("partly")),
"the fault must be reported, not swallowed: {:?}",
feed.fault
);
assert!(parse("<rss><channel><item><title>x</titel></item></channel></rss>").is_err());
}
#[test]
fn a_namespaced_element_is_matched_by_its_local_name() {
assert_eq!(local_name("dc:creator"), "creator");
assert_eq!(local_name("title"), "title");
}
}