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>,
}
pub fn parse(xml: &str) -> Result<Vec<Story>> {
let mut reader = quick_xml::Reader::from_str(xml);
reader.config_mut().trim_text(false);
let mut stories = Vec::new();
let mut in_item = false;
let mut field: Option<Field> = None;
let mut current = Partial::default();
loop {
match reader.read_event().context("reading the feed as XML")? {
XmlEvent::Start(tag) => {
match local_name(tag.name().as_ref()) {
b"item" => {
in_item = true;
current = Partial::default();
}
b"title" if in_item => field = Some(Field::Title),
b"link" if in_item => field = Some(Field::Link),
b"pubDate" if in_item => field = Some(Field::Date),
_ => {}
}
}
XmlEvent::End(tag) => match local_name(tag.name().as_ref()) {
b"item" => {
in_item = false;
if let Some(story) = current.take() {
stories.push(story);
}
}
_ => field = None,
},
XmlEvent::Text(text) => {
if let Some(which) = field {
let value = text.decode().unwrap_or_default().into_owned();
current.set(which, &value);
}
}
XmlEvent::CData(data) => {
if let Some(which) = field {
let value = String::from_utf8_lossy(&data).into_owned();
current.set(which, &value);
}
}
XmlEvent::GeneralRef(reference) => {
if let Some(which) = field
&& let Some(text) = resolve_entity(&reference)
{
current.set(which, &text);
}
}
XmlEvent::Eof => break,
_ => {}
}
}
Ok(stories)
}
#[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),
link: clip(self.link.trim(), MAX_LINK),
published: parse_date(self.date.trim()),
})
}
}
const MAX_TITLE: usize = 400;
const MAX_LINK: usize = 1_000;
pub const MAX_SOURCE: usize = 80;
fn clip(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
text.chars().take(limit).collect()
}
fn resolve_entity(reference: &quick_xml::events::BytesRef<'_>) -> Option<String> {
if let Ok(Some(character)) = reference.resolve_char_ref() {
return Some(character.to_string());
}
let name = reference.decode().ok()?;
Some(
match name.as_ref() {
"amp" => "&",
"lt" => "<",
"gt" => ">",
"quot" => "\"",
"apos" => "'",
"nbsp" => " ",
_ => return None,
}
.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: &[u8]) -> &[u8] {
match name.iter().rposition(|byte| *byte == b':') {
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");
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 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");
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");
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");
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");
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");
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_or_an_empty_list() {
assert!(parse("<<<not xml").is_err() || parse("<<<not xml").unwrap().is_empty());
assert!(parse("<html><body>hello</body></html>").unwrap().is_empty());
}
#[test]
fn a_namespaced_element_is_matched_by_its_local_name() {
assert_eq!(local_name(b"dc:creator"), b"creator");
assert_eq!(local_name(b"title"), b"title");
}
}