use chrono::{DateTime, SecondsFormat, Utc};
use feed_rs::model::{Entry, Link};
use url::Url;
use crate::config::FetchParams;
use crate::content;
use crate::error::RssError;
use crate::identity;
use crate::model::{ContentFormat, Enclosure, Item, Warning};
#[derive(Debug, Clone)]
pub struct ParsedFeed {
pub title: Option<String>,
pub site_url: Option<String>,
pub updated: Option<String>,
pub items: Vec<Item>,
pub warnings: Vec<Warning>,
}
pub fn parse_feed(
body: &[u8],
feed_url: &str,
params: &FetchParams,
) -> Result<ParsedFeed, RssError> {
let feed = feed_rs::parser::parse(body).map_err(|e| RssError::Parse(e.to_string()))?;
let title = feed.title.map(|t| t.content);
let updated = feed.updated.map(rfc3339);
let site_url = pick_link_href(&feed.links).map(|s| s.to_string());
let base = Url::parse(feed_url).ok();
let mut rows: Vec<(Item, Option<DateTime<Utc>>, bool)> = Vec::with_capacity(feed.entries.len());
for entry in feed.entries {
let sort_key = entry.published.or(entry.updated);
let (item, fell_back) = entry_to_item(entry, feed_url, base.as_ref(), params);
rows.push((item, sort_key, fell_back));
}
if let Some(since) = params.since {
rows.retain(|(_, key, _)| match key {
Some(dt) => *dt >= since,
None => true,
});
}
rows.sort_by_key(|row| std::cmp::Reverse(row.1));
if let Some(limit) = params.limit {
rows.truncate(limit);
}
let warnings = collect_warnings(feed_url, &rows);
let items = rows.into_iter().map(|(item, _, _)| item).collect();
Ok(ParsedFeed {
title,
site_url,
updated,
items,
warnings,
})
}
fn collect_warnings(feed_url: &str, rows: &[(Item, Option<DateTime<Utc>>, bool)]) -> Vec<Warning> {
let mut warnings = Vec::new();
let fell_back = rows.iter().filter(|(_, _, fb)| *fb).count();
if fell_back > 0 {
warnings.push(Warning {
feed_url: Some(feed_url.to_string()),
code: "CONTENT_EXTRACTION_FALLBACK".to_string(),
message: format!(
"HTML conversion failed for {fell_back} item(s); fell back to a plain tag \
strip (content may be lower fidelity)"
),
});
}
if !rows.is_empty() && rows.iter().all(|(_, key, _)| key.is_none()) {
warnings.push(Warning {
feed_url: Some(feed_url.to_string()),
code: "UNDATED_ITEMS".to_string(),
message: format!(
"all {} returned item(s) lack a published/updated date; newest-first ordering \
falls back to the feed's original order",
rows.len()
),
});
}
warnings
}
fn entry_to_item(
entry: Entry,
feed_url: &str,
base: Option<&Url>,
params: &FetchParams,
) -> (Item, bool) {
let enclosures = collect_enclosures(&entry);
let raw_link = pick_link_href(&entry.links);
let url = raw_link.map(|href| resolve(base, href));
let guid = if entry.id.is_empty() {
None
} else {
Some(entry.id.clone())
};
let title = entry.title.map(|t| t.content);
let published = entry.published.map(rfc3339);
let updated = entry.updated.map(rfc3339);
let authors = entry.authors.into_iter().map(|p| p.name).collect();
let categories = entry.categories.into_iter().map(|c| c.term).collect();
let summary = entry.summary.map(|t| t.content);
let format = params.content_format;
let mut content_truncated = false;
let mut content_fell_back = false;
let mut content_hash = None;
let content = if format == ContentFormat::None {
None
} else {
let content_html = entry.content.and_then(|c| c.body);
let source = content_html
.as_deref()
.filter(|h| !h.trim().is_empty())
.or_else(|| summary.as_deref().filter(|s| !s.trim().is_empty()));
source.map(|html| {
let (extracted, fell_back) = content::extract(html, format);
content_fell_back = fell_back;
content_hash = Some(content::content_hash(&extracted));
match params.max_content_chars {
Some(max) => {
let (capped, cut) = content::truncate_to_chars(&extracted, max);
content_truncated = cut;
capped
}
None => extracted,
}
})
};
let content_tokens_est = content
.as_deref()
.map(content::estimate_tokens)
.unwrap_or(0);
let (id, id_source) = identity::item_id(
feed_url,
url.as_deref(),
guid.as_deref(),
title.as_deref(),
published.as_deref(),
);
let item = Item {
id,
id_source,
feed_url: feed_url.to_string(),
title,
url,
authors,
published,
updated,
summary,
content,
content_format: format,
content_tokens_est,
content_truncated,
content_hash,
categories,
enclosures,
guid,
};
(item, content_fell_back)
}
fn collect_enclosures(entry: &Entry) -> Vec<Enclosure> {
let mut enclosures = Vec::new();
for media in &entry.media {
for content in &media.content {
if let Some(url) = &content.url {
enclosures.push(Enclosure {
url: url.to_string(),
mime: content.content_type.as_ref().map(|m| m.to_string()),
length: content.size,
});
}
}
}
for link in &entry.links {
if link.rel.as_deref() == Some("enclosure") && !link.href.is_empty() {
enclosures.push(Enclosure {
url: link.href.clone(),
mime: link.media_type.clone(),
length: link.length,
});
}
}
enclosures
}
fn pick_link_href(links: &[Link]) -> Option<&str> {
links
.iter()
.find(|l| l.rel.as_deref() == Some("alternate"))
.or_else(|| links.first())
.map(|l| l.href.as_str())
}
fn resolve(base: Option<&Url>, href: &str) -> String {
base.and_then(|b| b.join(href).ok())
.map(|u| u.to_string())
.unwrap_or_else(|| href.to_string())
}
fn rfc3339(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::IdSource;
use chrono::TimeZone;
const FEED_URL: &str = "https://example.com/feed.xml";
fn params() -> FetchParams {
FetchParams::default()
}
const RSS: &str = r#"<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Example Blog</title>
<link>https://example.com/</link>
<item>
<title>First Post</title>
<link>/posts/first</link>
<guid>tag:example.com,2026:1</guid>
<pubDate>Mon, 05 Jan 2026 10:00:00 GMT</pubDate>
<description><![CDATA[<p>Hello <b>world</b></p>]]></description>
<enclosure url="https://example.com/audio.mp3" type="audio/mpeg" length="1234"/>
</item>
<item>
<title>Second Post</title>
<link>https://example.com/posts/second</link>
<pubDate>Tue, 06 Jan 2026 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>"#;
const ATOM: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Example</title>
<updated>2026-02-01T12:00:00Z</updated>
<link rel="alternate" href="https://atom.example.com/"/>
<entry>
<title>Atom Entry</title>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<link rel="alternate" href="/articles/atom"/>
<updated>2026-02-01T12:00:00Z</updated>
<published>2026-02-01T11:00:00Z</published>
<content type="html"><p>Atom body</p></content>
</entry>
</feed>"#;
#[test]
fn parses_rss_metadata_and_items() {
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert_eq!(parsed.title.as_deref(), Some("Example Blog"));
assert_eq!(parsed.site_url.as_deref(), Some("https://example.com/"));
assert_eq!(parsed.items.len(), 2);
}
#[test]
fn rss_relative_link_resolved_to_absolute() {
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
let first = parsed
.items
.iter()
.find(|i| i.title.as_deref() == Some("First Post"))
.unwrap();
assert_eq!(
first.url.as_deref(),
Some("https://example.com/posts/first")
);
}
#[test]
fn rss_items_sorted_newest_first() {
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert_eq!(parsed.items[0].title.as_deref(), Some("Second Post"));
assert_eq!(parsed.items[1].title.as_deref(), Some("First Post"));
}
const MEDIA_RSS: &str = r#"<?xml version="1.0"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>Media Feed</title>
<item>
<title>Podcast Episode</title>
<link>https://example.com/ep1</link>
<media:content url="https://cdn.example.com/ep1.mp3" type="audio/mpeg" fileSize="5000"/>
</item>
</channel>
</rss>"#;
#[test]
fn media_content_maps_to_enclosure() {
let parsed = parse_feed(MEDIA_RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
let item = &parsed.items[0];
assert_eq!(item.enclosures.len(), 1);
let enc = &item.enclosures[0];
assert_eq!(enc.url, "https://cdn.example.com/ep1.mp3");
assert_eq!(enc.mime.as_deref(), Some("audio/mpeg"));
assert_eq!(enc.length, Some(5000));
}
#[test]
fn rss_enclosure_and_stable_id_present() {
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
let first = parsed
.items
.iter()
.find(|i| i.title.as_deref() == Some("First Post"))
.unwrap();
assert_eq!(first.id.len(), 16);
assert_eq!(first.id_source, IdSource::Link);
assert_eq!(first.enclosures.len(), 1);
assert_eq!(first.enclosures[0].url, "https://example.com/audio.mp3");
assert_eq!(first.enclosures[0].mime.as_deref(), Some("audio/mpeg"));
assert_eq!(first.enclosures[0].length, Some(1234));
}
#[test]
fn id_is_deterministic_across_parses() {
let a = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
let b = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert_eq!(a.items[0].id, b.items[0].id);
assert_eq!(a.items[1].id, b.items[1].id);
}
#[test]
fn parses_atom_and_resolves_relative_link() {
let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert_eq!(parsed.title.as_deref(), Some("Atom Example"));
assert_eq!(
parsed.site_url.as_deref(),
Some("https://atom.example.com/")
);
assert_eq!(parsed.updated.as_deref(), Some("2026-02-01T12:00:00Z"));
assert_eq!(parsed.items.len(), 1);
let entry = &parsed.items[0];
assert_eq!(entry.title.as_deref(), Some("Atom Entry"));
assert_eq!(
entry.url.as_deref(),
Some("https://example.com/articles/atom")
);
assert_eq!(entry.published.as_deref(), Some("2026-02-01T11:00:00Z"));
assert!(entry.content.as_deref().unwrap().contains("Atom body"));
assert!(entry.content_tokens_est > 0);
assert_eq!(
entry.guid.as_deref(),
Some("urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a")
);
}
#[test]
fn since_drops_older_items() {
let mut p = params();
p.since = Some(Utc.with_ymd_and_hms(2026, 1, 6, 0, 0, 0).unwrap());
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &p).unwrap();
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0].title.as_deref(), Some("Second Post"));
}
#[test]
fn limit_keeps_newest_n() {
let mut p = params();
p.limit = Some(1);
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &p).unwrap();
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0].title.as_deref(), Some("Second Post"));
}
#[test]
fn max_content_chars_truncates_and_flags() {
let mut p = params();
p.max_content_chars = Some(4);
let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, &p).unwrap();
let item = &parsed.items[0];
assert!(
item.content_truncated,
"content should be flagged truncated"
);
let content = item.content.as_deref().unwrap();
assert!(content.ends_with(content::TRUNCATION_MARKER));
assert_eq!(item.content_tokens_est, content::estimate_tokens(content));
}
#[test]
fn no_cap_leaves_content_untruncated() {
let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert!(!parsed.items[0].content_truncated);
}
#[test]
fn content_none_leaves_content_null() {
let mut p = params();
p.content_format = ContentFormat::None;
let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, &p).unwrap();
assert!(parsed.items[0].content.is_none());
assert_eq!(parsed.items[0].content_tokens_est, 0);
assert!(parsed.items[0].content_hash.is_none());
}
#[test]
fn content_hash_present_stable_and_truncation_independent() {
let full = parse_feed(ATOM.as_bytes(), FEED_URL, ¶ms()).unwrap();
let hash = full.items[0]
.content_hash
.clone()
.expect("content present → hash present");
assert_eq!(hash.len(), 16);
let again = parse_feed(ATOM.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert_eq!(again.items[0].content_hash.as_deref(), Some(hash.as_str()));
let mut p = params();
p.max_content_chars = Some(4);
let capped = parse_feed(ATOM.as_bytes(), FEED_URL, &p).unwrap();
assert!(capped.items[0].content_truncated);
assert_eq!(
capped.items[0].content_hash.as_deref(),
Some(hash.as_str()),
"truncating the served body must not change the content hash"
);
}
const UNDATED_RSS: &str = r#"<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Undated Feed</title>
<link>https://example.com/</link>
<item><title>A</title><link>https://example.com/a</link></item>
<item><title>B</title><link>https://example.com/b</link></item>
</channel>
</rss>"#;
#[test]
fn all_undated_items_emit_single_warning() {
let parsed = parse_feed(UNDATED_RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert_eq!(parsed.items.len(), 2);
let undated: Vec<_> = parsed
.warnings
.iter()
.filter(|w| w.code == "UNDATED_ITEMS")
.collect();
assert_eq!(undated.len(), 1, "exactly one aggregated undated warning");
assert_eq!(undated[0].feed_url.as_deref(), Some(FEED_URL));
}
#[test]
fn dated_feed_has_no_undated_warning() {
let parsed = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).unwrap();
assert!(parsed.warnings.iter().all(|w| w.code != "UNDATED_ITEMS"));
}
#[test]
fn garbage_input_is_parse_error_not_panic() {
let err = parse_feed(b"not a feed at all", FEED_URL, ¶ms()).unwrap_err();
assert!(matches!(err, RssError::Parse(_)));
}
}