use super::html_to_text::decode_entities;
const MAX_ITEMS: usize = 50;
const SUMMARY_CHARS: usize = 280;
pub struct FeedDoc {
pub title: Option<String>,
pub markdown: String,
}
pub fn looks_like_feed(content_type: &str, body: &str) -> bool {
let ct = content_type.to_ascii_lowercase();
if ct.contains("rss") || ct.contains("atom") {
return true;
}
if ct.contains("html") {
return false;
}
let head: String = body
.chars()
.take(1024)
.collect::<String>()
.to_ascii_lowercase();
head.contains("<rss") || head.contains("<feed") || head.contains("<rdf:rdf")
}
pub fn parse(xml: &str, _source_url: &str) -> FeedDoc {
let doc = Xml::new(xml);
let title = feed_title(&doc);
let mut out = String::new();
if let Some(t) = &title {
out.push_str("# ");
out.push_str(t);
out.push_str("\n\n");
}
let blocks = doc.all_inner("item");
let blocks = if blocks.is_empty() {
doc.all_inner("entry")
} else {
blocks
};
let total = blocks.len();
let mut rendered = 0;
for block in blocks.into_iter().take(MAX_ITEMS) {
let item = Xml::new(block);
let it = parse_item(&item);
if it.title.is_empty() && it.link.is_none() {
continue;
}
out.push_str(&it.render());
out.push_str("\n\n");
rendered += 1;
}
if total > rendered {
out.push_str(&format!("_…and {} more item(s)._", total - rendered));
}
if rendered == 0 && title.is_none() {
out.push_str("No feed items found.");
}
FeedDoc {
title,
markdown: out.trim_end().to_string(),
}
}
struct Item {
title: String,
link: Option<String>,
date: Option<String>,
summary: Option<String>,
}
impl Item {
fn render(&self) -> String {
let heading = match (&self.link, self.title.is_empty()) {
(Some(link), false) => format!("## [{}]({link})", self.title),
(Some(link), true) => format!("## [{link}]({link})"),
(None, false) => format!("## {}", self.title),
(None, true) => "## (untitled)".to_string(),
};
let mut meta = Vec::new();
if let Some(d) = &self.date {
meta.push(d.clone());
}
if let Some(s) = &self.summary {
meta.push(s.clone());
}
if meta.is_empty() {
heading
} else {
format!("{heading}\n{}", meta.join(" · "))
}
}
}
fn parse_item(item: &Xml) -> Item {
let title = item
.inner_text("title")
.map(|t| clean_inline(&t))
.unwrap_or_default();
let link = item
.inner_text("link")
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.or_else(|| atom_link(item));
let date = ["pubdate", "published", "updated", "dc:date", "date"]
.into_iter()
.find_map(|tag| item.inner_text(tag))
.map(|d| clean_inline(&d))
.filter(|d| !d.is_empty());
let summary = ["description", "summary", "content"]
.into_iter()
.find_map(|tag| item.inner_text(tag))
.map(|s| summarize(&s))
.filter(|s| !s.is_empty());
Item {
title,
link,
date,
summary,
}
}
fn atom_link(item: &Xml) -> Option<String> {
let mut fallback = None;
let mut from = 0;
while let Some((open, content_or_end)) = item.open_tag("link", from) {
from = content_or_end;
let href = attr(open, "href")?;
if href.is_empty() {
continue;
}
match attr(open, "rel").as_deref() {
None | Some("alternate") => return Some(href),
Some(_) => {
if fallback.is_none() {
fallback = Some(href);
}
}
}
}
fallback
}
fn feed_title(doc: &Xml) -> Option<String> {
let cut = [doc.find("<item"), doc.find("<entry")]
.into_iter()
.flatten()
.min()
.unwrap_or(doc.raw.len());
let head = Xml::new(&doc.raw[..cut]);
head.inner_text("title")
.map(|t| clean_inline(&t))
.filter(|s| !s.is_empty())
}
fn summarize(raw: &str) -> String {
let unwrapped = strip_cdata(raw);
let once = strip_tags(&unwrapped);
let decoded = decode_entities(&once);
let twice = strip_tags(&decoded);
let text = collapse_ws(&twice);
let text = text.trim();
if text.chars().count() > SUMMARY_CHARS {
let truncated: String = text.chars().take(SUMMARY_CHARS).collect();
format!("{}…", truncated.trim_end())
} else {
text.to_string()
}
}
fn clean_inline(raw: &str) -> String {
collapse_ws(&decode_entities(&strip_cdata(raw)))
.trim()
.to_string()
}
fn strip_cdata(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("<![CDATA[") {
out.push_str(&rest[..start]);
let after = &rest[start + "<![CDATA[".len()..];
let Some(end) = after.find("]]>") else {
out.push_str(after);
return out;
};
out.push_str(&after[..end]);
rest = &after[end + 3..];
}
out.push_str(rest);
out
}
fn strip_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' => {
in_tag = false;
out.push(' ');
}
_ if !in_tag => out.push(c),
_ => {}
}
}
out
}
fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for c in s.chars() {
if c.is_whitespace() {
if !prev_space {
out.push(' ');
prev_space = true;
}
} else {
out.push(c);
prev_space = false;
}
}
out
}
fn attr(open_tag: &str, key: &str) -> Option<String> {
let lower = open_tag.to_ascii_lowercase();
let mut from = 0;
while let Some(pos) = lower[from..].find(key) {
let idx = from + pos;
let boundary = idx == 0 || lower.as_bytes()[idx - 1].is_ascii_whitespace();
let after = idx + key.len();
let rest = open_tag[after..].trim_start();
if boundary && rest.starts_with('=') {
let val = rest[1..].trim_start();
let bytes = val.as_bytes();
if let Some(&q) = bytes.first()
&& (q == b'"' || q == b'\'')
{
let quote = q as char;
return val[1..]
.find(quote)
.map(|end| val[1..=end].to_string())
.or_else(|| Some(val[1..].to_string()));
}
return val
.split_whitespace()
.next()
.map(|v| v.trim_end_matches("/>").to_string());
}
from = after;
}
None
}
struct Xml<'a> {
raw: &'a str,
lower: String,
}
impl<'a> Xml<'a> {
fn new(raw: &'a str) -> Self {
Self {
raw,
lower: raw.to_ascii_lowercase(),
}
}
fn find(&self, needle: &str) -> Option<usize> {
self.lower.find(needle)
}
fn open_tag(&self, tag: &str, from: usize) -> Option<(&'a str, usize)> {
let needle = format!("<{}", tag.to_ascii_lowercase());
let mut search = from;
loop {
let rel = self.lower[search..].find(&needle)?;
let pos = search + rel;
let after = pos + needle.len();
let delim_ok = self.lower[after..]
.chars()
.next()
.is_some_and(|c| matches!(c, '>' | ' ' | '\t' | '\n' | '\r' | '/'));
if delim_ok {
let gt = self.lower[pos..].find('>')? + pos;
return Some((&self.raw[pos..=gt], gt + 1));
}
search = after;
}
}
fn inner_text(&self, tag: &str) -> Option<String> {
let (_, content_start) = self.open_tag(tag, 0)?;
let close = format!("</{}", tag.to_ascii_lowercase());
let end = self.lower[content_start..].find(&close)? + content_start;
Some(self.raw[content_start..end].to_string())
}
fn all_inner(&self, tag: &str) -> Vec<&'a str> {
let mut out = Vec::new();
let close = format!("</{}", tag.to_ascii_lowercase());
let mut from = 0;
while let Some((_, content_start)) = self.open_tag(tag, from) {
let Some(rel) = self.lower[content_start..].find(&close) else {
break;
};
let end = content_start + rel;
out.push(&self.raw[content_start..end]);
from = end + close.len();
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_feed_by_mime_and_sniff() {
assert!(looks_like_feed("application/rss+xml", ""));
assert!(looks_like_feed("application/atom+xml", ""));
assert!(looks_like_feed(
"text/xml",
"<?xml version='1.0'?><rss version='2.0'>"
));
assert!(looks_like_feed(
"",
"<feed xmlns='http://www.w3.org/2005/Atom'>"
));
assert!(!looks_like_feed("text/html", "<!doctype html><html><body>"));
assert!(!looks_like_feed(
"text/html",
"<html><body>How to read <rss> feeds</body></html>"
));
}
#[test]
fn parses_rss_items_into_markdown() {
let xml = r#"<?xml version="1.0"?>
<rss version="2.0"><channel>
<title>My Feed</title>
<link>https://feed.example/</link>
<description>Channel desc</description>
<item>
<title>First post</title>
<link>https://feed.example/1</link>
<pubDate>Mon, 02 Jun 2026 10:00:00 GMT</pubDate>
<description><![CDATA[<p>Hello <b>world</b> with detail.</p>]]></description>
</item>
<item>
<title>Second & last</title>
<link>https://feed.example/2</link>
<description>Plain summary</description>
</item>
</channel></rss>"#;
let doc = parse(xml, "https://feed.example/feed.xml");
assert_eq!(doc.title.as_deref(), Some("My Feed"));
assert!(doc.markdown.starts_with("# My Feed"));
assert!(
doc.markdown
.contains("## [First post](https://feed.example/1)"),
"item must be a linked heading: {}",
doc.markdown
);
assert!(doc.markdown.contains("Mon, 02 Jun 2026 10:00:00 GMT"));
assert!(
doc.markdown.contains("Hello world with detail."),
"CDATA HTML summary must be stripped to text: {}",
doc.markdown
);
assert!(
doc.markdown
.contains("## [Second & last](https://feed.example/2)"),
"entities in titles must decode: {}",
doc.markdown
);
}
#[test]
fn parses_atom_entries_with_href_links() {
let xml = r#"<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Feed</title>
<entry>
<title>Atom entry</title>
<link href="https://a.example/self" rel="self"/>
<link href="https://a.example/post" rel="alternate"/>
<updated>2026-06-02T00:00:00Z</updated>
<summary>An atom summary.</summary>
</entry>
</feed>"#;
let doc = parse(xml, "https://a.example/atom");
assert_eq!(doc.title.as_deref(), Some("Atom Feed"));
assert!(
doc.markdown
.contains("## [Atom entry](https://a.example/post)"),
"must prefer rel=alternate link: {}",
doc.markdown
);
assert!(doc.markdown.contains("An atom summary."));
}
#[test]
fn strips_entity_encoded_html_in_atom_summary() {
let xml = r#"<feed xmlns="http://www.w3.org/2005/Atom">
<title>F</title>
<entry>
<title>E</title>
<link href="https://e/1"/>
<content type="html"><p>Hello <a href="https://x">link</a> there.</p></content>
</entry>
</feed>"#;
let doc = parse(xml, "https://e");
assert!(
doc.markdown.contains("Hello link there."),
"entity-encoded HTML must be stripped to text: {}",
doc.markdown
);
assert!(
!doc.markdown.contains("<p>") && !doc.markdown.contains("<"),
"no raw/encoded tags may remain: {}",
doc.markdown
);
}
#[test]
fn truncates_long_summaries() {
let long = "x ".repeat(400);
let xml = format!(
"<rss><channel><title>F</title><item><title>T</title>\
<link>https://e/1</link><description>{long}</description></item></channel></rss>"
);
let doc = parse(&xml, "https://e/feed");
assert!(
doc.markdown.contains('…'),
"long summary should be truncated"
);
}
#[test]
fn handles_empty_feed_gracefully() {
let doc = parse(
"<rss><channel><title>Empty</title></channel></rss>",
"https://e",
);
assert_eq!(doc.title.as_deref(), Some("Empty"));
assert!(doc.markdown.contains("# Empty"));
}
}