use std::collections::HashSet;
use anyhow::Result;
use crate::lexicon::{Folder, Subscription};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedFeed {
pub feed_url: String,
pub title: Option<String>,
pub site_url: Option<String>,
pub folder: Option<String>,
}
pub fn parse_opml(xml: &str) -> Result<Vec<ImportedFeed>> {
let mut out: Vec<ImportedFeed> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut folder_stack: Vec<Option<String>> = Vec::new();
let bytes = xml.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
let lt = match find_from(bytes, i, b'<') {
Some(p) => p,
None => break,
};
if starts_with_ci(bytes, lt, b"</outline") {
folder_stack.pop();
i = find_from(bytes, lt, b'>')
.map(|p| p + 1)
.unwrap_or(bytes.len());
continue;
}
if !starts_with_ci(bytes, lt, b"<outline") {
i = lt + 1;
continue;
}
let gt = match find_from(bytes, lt, b'>') {
Some(p) => p,
None => break,
};
let attr_start = lt + "<outline".len();
let attr_end = gt; let self_closing = attr_end > attr_start && bytes[attr_end - 1] == b'/';
let attrs = &xml[attr_start..attr_end.min(xml.len())];
let xml_url = attr(attrs, "xmlUrl").filter(|s| !s.is_empty());
if let Some(feed_url) = xml_url {
let folder = folder_stack.iter().rev().find_map(|f| f.clone());
if seen.insert(feed_url.clone()) {
let title = attr(attrs, "title")
.or_else(|| attr(attrs, "text"))
.filter(|s| !s.is_empty());
let site_url = attr(attrs, "htmlUrl").filter(|s| !s.is_empty());
out.push(ImportedFeed {
feed_url,
title,
site_url,
folder,
});
}
if !self_closing {
folder_stack.push(None);
}
} else if !self_closing {
let name = attr(attrs, "text")
.or_else(|| attr(attrs, "title"))
.filter(|s| !s.is_empty());
folder_stack.push(name);
}
i = gt + 1;
}
Ok(out)
}
pub fn to_opml(subs: &[(String, Subscription)], folders: &[(String, Folder)]) -> String {
let mut folder_order: Vec<(&str, &str)> = folders
.iter()
.map(|(uri, f)| (uri.as_str(), f.name.as_str()))
.collect();
folder_order.sort_by(|a, b| {
let pa = folders
.iter()
.find(|(u, _)| u == a.0)
.and_then(|(_, f)| f.position);
let pb = folders
.iter()
.find(|(u, _)| u == b.0)
.and_then(|(_, f)| f.position);
pa.cmp(&pb).then_with(|| a.1.cmp(b.1))
});
let known: HashSet<&str> = folders.iter().map(|(u, _)| u.as_str()).collect();
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<opml version=\"2.0\">\n");
out.push_str(" <head>\n");
out.push_str(" <title>FeatherReader subscriptions</title>\n");
out.push_str(" </head>\n");
out.push_str(" <body>\n");
for (uri, sub) in subs {
let in_known_folder = sub
.folder
.as_deref()
.map(|f| known.contains(f))
.unwrap_or(false);
if !in_known_folder {
let _ = uri;
out.push_str(&feed_outline(sub, 2));
}
}
for (folder_uri, folder_name) in &folder_order {
let members: Vec<&Subscription> = subs
.iter()
.filter(|(_, s)| s.folder.as_deref() == Some(folder_uri))
.map(|(_, s)| s)
.collect();
if members.is_empty() {
continue;
}
let name = escape_attr(folder_name);
out.push_str(&format!(" <outline text=\"{name}\" title=\"{name}\">\n"));
for sub in members {
out.push_str(&feed_outline(sub, 3));
}
out.push_str(" </outline>\n");
}
out.push_str(" </body>\n");
out.push_str("</opml>\n");
out
}
fn feed_outline(sub: &Subscription, depth: usize) -> String {
let indent = " ".repeat(depth);
let label = sub.title.as_deref().unwrap_or(&sub.url);
let label = escape_attr(label);
let xml_url = escape_attr(&sub.url);
let mut line = format!(
"{indent}<outline type=\"rss\" text=\"{label}\" title=\"{label}\" xmlUrl=\"{xml_url}\""
);
if let Some(site) = sub.site_url.as_deref() {
line.push_str(&format!(" htmlUrl=\"{}\"", escape_attr(site)));
}
line.push_str("/>\n");
line
}
fn attr(attrs: &str, name: &str) -> Option<String> {
let bytes = attrs.as_bytes();
let nlen = name.len();
let mut search = 0usize;
while let Some(rel) = attrs[search..].find(name) {
let pos = search + rel;
let boundary_ok = pos == 0 || !is_name_char(bytes[pos - 1]);
let mut j = pos + nlen;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if boundary_ok && j < bytes.len() && bytes[j] == b'=' {
j += 1;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
let quote = bytes[j];
let val_start = j + 1;
if let Some(rel_close) = attrs[val_start..].find(quote as char) {
let val = &attrs[val_start..val_start + rel_close];
return Some(unescape_xml(val));
}
}
}
search = pos + nlen;
}
None
}
fn is_name_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b':' || b == b'.'
}
fn find_from(bytes: &[u8], from: usize, needle: u8) -> Option<usize> {
bytes[from..]
.iter()
.position(|&b| b == needle)
.map(|p| from + p)
}
fn starts_with_ci(bytes: &[u8], at: usize, prefix: &[u8]) -> bool {
if at + prefix.len() > bytes.len() {
return false;
}
bytes[at..at + prefix.len()]
.iter()
.zip(prefix)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
}
fn unescape_xml(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&")
}
fn escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const FLAT_OPML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head><title>Subscriptions</title></head>
<body>
<outline text="Daring Fireball" title="Daring Fireball" type="rss"
xmlUrl="https://daringfireball.net/feeds/main" htmlUrl="https://daringfireball.net/"/>
<outline type="rss" text="Rust Blog & News"
xmlUrl="https://blog.rust-lang.org/feed.xml"/>
<outline text="No Site" title="No Site" xmlUrl="https://example.com/only.xml" />
</body>
</opml>
"#;
const FOLDERED_OPML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head><title>reader export</title></head>
<body>
<outline text="Tech" title="Tech">
<outline type='rss' text='Hacker News' xmlUrl='https://hnrss.org/frontpage' htmlUrl='https://news.ycombinator.com/'/>
<outline type="rss" text="Lobsters" xmlUrl="https://lobste.rs/rss" htmlUrl="https://lobste.rs/"/>
</outline>
<outline text="News" title="News">
<outline type="rss" text="Hacker News" xmlUrl="https://hnrss.org/frontpage"/>
<outline type="rss" text="The Verge" xmlUrl="https://www.theverge.com/rss/index.xml"/>
</outline>
<outline type="rss" text="Top Level Feed" xmlUrl="https://example.org/feed"/>
</body>
</opml>
"#;
#[test]
fn parses_flat_export() {
let feeds = parse_opml(FLAT_OPML).expect("parse");
assert_eq!(feeds.len(), 3);
assert_eq!(feeds[0].feed_url, "https://daringfireball.net/feeds/main");
assert_eq!(feeds[0].title.as_deref(), Some("Daring Fireball"));
assert_eq!(
feeds[0].site_url.as_deref(),
Some("https://daringfireball.net/")
);
assert_eq!(feeds[0].folder, None);
assert_eq!(feeds[1].feed_url, "https://blog.rust-lang.org/feed.xml");
assert_eq!(feeds[1].title.as_deref(), Some("Rust Blog & News"));
assert_eq!(feeds[1].site_url, None);
assert_eq!(feeds[2].feed_url, "https://example.com/only.xml");
}
#[test]
fn parses_foldered_export_with_dedupe() {
let feeds = parse_opml(FOLDERED_OPML).expect("parse");
assert_eq!(feeds.len(), 4);
let by_url = |u: &str| feeds.iter().find(|f| f.feed_url == u).unwrap();
let hn = by_url("https://hnrss.org/frontpage");
assert_eq!(hn.folder.as_deref(), Some("Tech"));
assert_eq!(
hn.site_url.as_deref(),
Some("https://news.ycombinator.com/")
);
assert_eq!(
by_url("https://lobste.rs/rss").folder.as_deref(),
Some("Tech")
);
assert_eq!(
by_url("https://www.theverge.com/rss/index.xml")
.folder
.as_deref(),
Some("News")
);
assert_eq!(by_url("https://example.org/feed").folder, None);
}
#[test]
fn empty_input_yields_no_feeds() {
assert!(parse_opml("").expect("parse").is_empty());
assert!(parse_opml("<opml><body></body></opml>")
.expect("parse")
.is_empty());
assert!(parse_opml(r#"<outline text="Empty"></outline>"#)
.expect("parse")
.is_empty());
}
#[test]
fn export_nests_feeds_under_folders() {
let folders = vec![
(
"at://did:plc:x/community.lexicon.rss.folder/tech".to_string(),
Folder {
r#type: crate::lexicon::nsid::FOLDER.to_string(),
name: "Tech".to_string(),
position: Some(0),
created_at: "2026-07-12T00:00:00.000Z".to_string(),
},
),
(
"at://did:plc:x/community.lexicon.rss.folder/news".to_string(),
Folder {
r#type: crate::lexicon::nsid::FOLDER.to_string(),
name: "News".to_string(),
position: Some(1),
created_at: "2026-07-12T00:00:00.000Z".to_string(),
},
),
];
let mut lobsters = Subscription::new("https://lobste.rs/rss", "2026-07-12T00:00:00.000Z");
lobsters.title = Some("Lobsters".to_string());
lobsters.site_url = Some("https://lobste.rs/".to_string());
lobsters.folder = Some("at://did:plc:x/community.lexicon.rss.folder/tech".to_string());
let mut verge = Subscription::new(
"https://www.theverge.com/rss/index.xml",
"2026-07-12T00:00:00.000Z",
);
verge.title = Some("The Verge".to_string());
verge.folder = Some("at://did:plc:x/community.lexicon.rss.folder/news".to_string());
let mut toplevel =
Subscription::new("https://example.org/feed", "2026-07-12T00:00:00.000Z");
toplevel.title = Some("Top & Level".to_string());
let subs = vec![
(
"at://did:plc:x/community.lexicon.rss.subscription/1".to_string(),
lobsters,
),
(
"at://did:plc:x/community.lexicon.rss.subscription/2".to_string(),
verge,
),
(
"at://did:plc:x/community.lexicon.rss.subscription/3".to_string(),
toplevel,
),
];
let opml = to_opml(&subs, &folders);
assert!(opml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(opml.contains("<opml version=\"2.0\">"));
assert!(opml.contains(
"<outline type=\"rss\" text=\"Top & Level\" title=\"Top & Level\" xmlUrl=\"https://example.org/feed\"/>"
));
let tech_at = opml.find("text=\"Tech\"").expect("tech folder");
let news_at = opml.find("text=\"News\"").expect("news folder");
assert!(tech_at < news_at);
assert!(opml.contains("xmlUrl=\"https://lobste.rs/rss\" htmlUrl=\"https://lobste.rs/\"/>"));
}
#[test]
fn export_import_round_trip() {
let folders = vec![(
"at://did:plc:x/community.lexicon.rss.folder/tech".to_string(),
Folder {
r#type: crate::lexicon::nsid::FOLDER.to_string(),
name: "Tech".to_string(),
position: Some(0),
created_at: "2026-07-12T00:00:00.000Z".to_string(),
},
)];
let mut a = Subscription::new("https://lobste.rs/rss", "2026-07-12T00:00:00.000Z");
a.title = Some("Lobsters".to_string());
a.site_url = Some("https://lobste.rs/".to_string());
a.folder = Some("at://did:plc:x/community.lexicon.rss.folder/tech".to_string());
let mut b = Subscription::new("https://example.org/feed", "2026-07-12T00:00:00.000Z");
b.title = Some("Example".to_string());
let subs = vec![
(
"at://did:plc:x/community.lexicon.rss.subscription/1".to_string(),
a,
),
(
"at://did:plc:x/community.lexicon.rss.subscription/2".to_string(),
b,
),
];
let opml = to_opml(&subs, &folders);
let parsed = parse_opml(&opml).expect("re-parse export");
assert_eq!(parsed.len(), 2);
let lob = parsed
.iter()
.find(|f| f.feed_url == "https://lobste.rs/rss")
.unwrap();
assert_eq!(lob.title.as_deref(), Some("Lobsters"));
assert_eq!(lob.site_url.as_deref(), Some("https://lobste.rs/"));
assert_eq!(lob.folder.as_deref(), Some("Tech"));
let ex = parsed
.iter()
.find(|f| f.feed_url == "https://example.org/feed")
.unwrap();
assert_eq!(ex.title.as_deref(), Some("Example"));
assert_eq!(ex.folder, None);
}
}