use crate::error::Result;
use crate::types::news::ArchNewsItem;
use super::article::fetch_bounded_text;
use super::date::normalize_feed_date;
pub const ARCH_NEWS_FEED_URL: &str = "https://archlinux.org/feeds/news/";
pub fn extract_between(s: &str, start: &str, end: &str) -> Option<String> {
let i = s.find(start)? + start.len();
let j = s[i..].find(end)? + i;
Some(s[i..j].to_string())
}
pub fn unescape_xml(s: &str) -> String {
let s = s.trim();
let s = s
.strip_prefix("<![CDATA[")
.and_then(|rest| rest.strip_suffix("]]>"))
.unwrap_or(s);
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&")
}
#[must_use]
pub fn parse_arch_news_rss(
body: &str,
limit: usize,
cutoff_date: Option<&str>,
) -> Vec<ArchNewsItem> {
let mut items: Vec<ArchNewsItem> = Vec::new();
let mut pos = 0;
while items.len() < limit {
let Some(start) = body[pos..].find("<item>") else {
break;
};
let s = pos + start;
let end = body[s..].find("</item>").map_or(body.len(), |e| s + e + 7);
let chunk = &body[s..end];
let title = extract_between(chunk, "<title>", "</title>")
.map(|t| unescape_xml(&t))
.unwrap_or_default();
let link = extract_between(chunk, "<link>", "</link>")
.map(|l| l.trim().to_string())
.unwrap_or_default();
let raw_date = extract_between(chunk, "<pubDate>", "</pubDate>")
.map(|d| d.trim().to_string())
.unwrap_or_default();
let date = normalize_feed_date(&raw_date);
if let Some(cutoff) = cutoff_date
&& date.as_str() < cutoff
{
break;
}
items.push(ArchNewsItem {
date,
title,
url: link,
});
pos = end;
}
items
}
pub const MAX_FEED_RESPONSE_BYTES: usize = 512 * 1024;
pub async fn fetch_arch_news(
client: &reqwest::Client,
limit: usize,
cutoff_date: Option<&str>,
) -> Result<Vec<ArchNewsItem>> {
fetch_arch_news_from(client, ARCH_NEWS_FEED_URL, limit, cutoff_date).await
}
pub async fn fetch_arch_news_from(
client: &reqwest::Client,
feed_url: &str,
limit: usize,
cutoff_date: Option<&str>,
) -> Result<Vec<ArchNewsItem>> {
let body = fetch_bounded_text(client, feed_url, MAX_FEED_RESPONSE_BYTES, "news feed").await?;
tracing::debug!(bytes = body.len(), "fetched arch news feed");
Ok(parse_arch_news_rss(&body, limit, cutoff_date))
}
pub async fn fetch_arch_news_cached(
client: &reqwest::Client,
limit: usize,
cutoff_date: Option<&str>,
cache: Option<&dyn super::FeedCache>,
) -> Result<Vec<ArchNewsItem>> {
fetch_arch_news_cached_from(client, ARCH_NEWS_FEED_URL, limit, cutoff_date, cache).await
}
pub async fn fetch_arch_news_cached_from(
client: &reqwest::Client,
feed_url: &str,
limit: usize,
cutoff_date: Option<&str>,
cache: Option<&dyn super::FeedCache>,
) -> Result<Vec<ArchNewsItem>> {
let body = fetch_cached_feed_text(client, feed_url, "arch-news", "news feed", cache).await?;
Ok(parse_arch_news_rss(&body, limit, cutoff_date))
}
pub(super) async fn fetch_cached_feed_text(
client: &reqwest::Client,
feed_url: &str,
feed_kind: &str,
resource_name: &str,
cache: Option<&dyn super::FeedCache>,
) -> Result<String> {
let cache_key = feed_cache_key(feed_kind, feed_url);
if let Some(cache) = cache
&& let Some(body) = cache.get(&cache_key)?
{
return Ok(body);
}
let body = fetch_bounded_text(client, feed_url, MAX_FEED_RESPONSE_BYTES, resource_name).await?;
if let Some(cache) = cache {
cache.put(&cache_key, &body)?;
}
Ok(body)
}
pub(super) fn feed_cache_key(feed_kind: &str, feed_url: &str) -> String {
format!("{feed_kind}:{feed_url}")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"><channel>
<item>
<title>Package & repo changes</title>
<link>https://archlinux.org/news/pkg-repo-changes/</link>
<pubDate>Thu, 21 Aug 2025 12:34:56 +0000</pubDate>
</item>
<item>
<title><![CDATA[Manual intervention required]]></title>
<link>https://archlinux.org/news/manual-intervention/</link>
<pubDate>Mon, 04 Aug 2025 09:00:00 +0000</pubDate>
</item>
<item>
<title>Old news</title>
<link>https://archlinux.org/news/old-news/</link>
<pubDate>Wed, 01 Jan 2025 00:00:00 +0000</pubDate>
</item>
</channel></rss>"#;
#[test]
fn parses_items() {
let items = parse_arch_news_rss(SAMPLE_RSS, 10, None);
assert_eq!(items.len(), 3);
assert_eq!(items[0].title, "Package & repo changes");
assert_eq!(items[0].date, "2025-08-21");
assert_eq!(items[0].url, "https://archlinux.org/news/pkg-repo-changes/");
assert_eq!(items[1].title, "Manual intervention required");
assert_eq!(items[1].date, "2025-08-04");
}
#[test]
fn respects_limit() {
let items = parse_arch_news_rss(SAMPLE_RSS, 1, None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].date, "2025-08-21");
}
#[test]
fn respects_cutoff() {
let items = parse_arch_news_rss(SAMPLE_RSS, 10, Some("2025-06-01"));
assert_eq!(items.len(), 2);
assert!(items.iter().all(|i| i.date.as_str() >= "2025-06-01"));
}
#[test]
fn handles_garbage() {
assert!(parse_arch_news_rss("", 10, None).is_empty());
assert!(parse_arch_news_rss("not xml at all", 10, None).is_empty());
assert!(parse_arch_news_rss("<item>unclosed", 10, None).len() <= 1);
}
#[test]
fn unescaping() {
assert_eq!(unescape_xml("a & b"), "a & b");
assert_eq!(unescape_xml("<tag>"), "<tag>");
assert_eq!(unescape_xml("&lt;"), "<");
assert_eq!(unescape_xml("<![CDATA[raw & text]]>"), "raw & text");
assert_eq!(unescape_xml("it's"), "it's");
}
}