use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
const MAX_FEED_SIZE: u64 = 10 * 1024 * 1024;
const MAX_ARTICLE_SIZE: u64 = 5 * 1024 * 1024;
pub const DEFAULT_LIST_LIMIT: usize = 20;
#[derive(Debug, Clone)]
pub struct FeedEntry {
pub id: u32,
pub title: String,
pub url: String,
pub published: String,
pub summary: String,
}
#[derive(Debug, Clone, Default)]
pub struct EntryStore {
inner: Arc<Mutex<StoreInner>>,
}
#[derive(Debug, Default)]
struct StoreInner {
next_id: u32,
entries: HashMap<u32, StoredEntry>,
}
#[derive(Debug, Clone)]
struct StoredEntry {
url: String,
title: String,
}
impl EntryStore {
pub fn new() -> Self {
Self::default()
}
pub async fn store_entries(&self, entries: Vec<ParsedEntry>) -> anyhow::Result<Vec<FeedEntry>> {
let mut inner = self.inner.lock().await;
inner.entries.clear();
inner.next_id = 1;
let mut result = Vec::with_capacity(entries.len());
for e in entries {
let id = inner.next_id;
inner.next_id = inner
.next_id
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("entry ID overflow"))?;
inner.entries.insert(
id,
StoredEntry {
url: e.url.clone(),
title: e.title.clone(),
},
);
result.push(FeedEntry {
id,
title: e.title,
url: e.url,
published: e.published,
summary: e.summary,
});
}
Ok(result)
}
pub async fn get_url(&self, id: u32) -> Option<(String, String)> {
let inner = self.inner.lock().await;
inner
.entries
.get(&id)
.map(|e| (e.url.clone(), e.title.clone()))
}
}
#[derive(Debug)]
pub struct ParsedEntry {
pub title: String,
pub url: String,
pub published: String,
pub summary: String,
}
pub fn build_http_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
}
fn validate_url(url: &str) -> anyhow::Result<reqwest::Url> {
let parsed = reqwest::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid URL: {e}"))?;
match parsed.scheme() {
"http" | "https" => Ok(parsed),
scheme => anyhow::bail!("unsupported URL scheme: {scheme}"),
}
}
fn check_content_length(resp: &reqwest::Response, limit: u64) -> anyhow::Result<()> {
if let Some(len) = resp.content_length() {
if len > limit {
anyhow::bail!("response too large: {len} bytes (limit: {limit} bytes)");
}
}
Ok(())
}
pub async fn fetch_and_parse_feed(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<(String, Vec<ParsedEntry>)> {
let validated = validate_url(url)?;
let resp = client.get(validated).send().await?;
check_content_length(&resp, MAX_FEED_SIZE)?;
let bytes = resp.bytes().await?;
let feed = feed_rs::parser::parse(&bytes[..])?;
let feed_title = feed
.title
.map(|t| t.content)
.unwrap_or_else(|| "(untitled feed)".to_string());
let entries = feed
.entries
.into_iter()
.map(|entry| {
let title = entry
.title
.map(|t| t.content)
.unwrap_or_else(|| "(no title)".to_string());
let url = entry
.links
.first()
.map(|l| l.href.clone())
.unwrap_or_default();
let published = entry
.published
.or(entry.updated)
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "-".to_string());
let summary = entry
.summary
.map(|s| truncate_text(&strip_html_simple(&s.content), 80))
.unwrap_or_default();
ParsedEntry {
title,
url,
published,
summary,
}
})
.collect();
Ok((feed_title, entries))
}
pub fn format_entries_as_markdown(feed_title: &str, entries: &[FeedEntry], total: usize) -> String {
let mut out = String::with_capacity(entries.len() * 100);
out.push_str(&format!("## {feed_title}\n\n"));
out.push_str("| # | Title | Date |\n");
out.push_str("|---|-------|------|\n");
for e in entries {
let title_display = truncate_text(&e.title, 60);
out.push_str(&format!(
"| {} | [{}]({}) | {} |\n",
e.id, title_display, e.url, e.published
));
}
if total > entries.len() {
out.push_str(&format!(
"\n*Showing {} of {} articles. Use `limit` to see more. Use `get` with # to read.*",
entries.len(),
total
));
} else {
out.push_str(&format!(
"\n*{} articles. Use `get` with # to read.*",
entries.len()
));
}
out
}
pub async fn fetch_article_text(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
let validated = validate_url(url)?;
let resp = client
.get(validated)
.header("User-Agent", "rss-fetch-mcp/0.1")
.send()
.await?;
check_content_length(&resp, MAX_ARTICLE_SIZE)?;
let html = resp.text().await?;
let text = html2text::from_read(html.as_bytes(), 80)?;
Ok(text)
}
fn strip_html_simple(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut in_skip = false;
let mut tag_buf = String::new();
for ch in html.chars() {
if ch == '<' {
in_tag = true;
tag_buf.clear();
continue;
}
if ch == '>' {
in_tag = false;
let tag_name = tag_buf
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
if tag_name == "script" || tag_name == "style" {
in_skip = true;
} else if tag_name == "/script" || tag_name == "/style" {
in_skip = false;
}
continue;
}
if in_tag {
tag_buf.push(ch);
continue;
}
if !in_skip {
result.push(ch);
}
}
result
}
fn truncate_text(s: &str, max: usize) -> String {
let char_count = s.chars().count();
if char_count <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_html_basic() {
assert_eq!(
strip_html_simple("<p>Hello <b>world</b></p>"),
"Hello world"
);
}
#[test]
fn strip_html_empty() {
assert_eq!(strip_html_simple(""), "");
}
#[test]
fn strip_html_skips_script() {
let html = "<p>before</p><script>alert('xss')</script><p>after</p>";
assert_eq!(strip_html_simple(html), "beforeafter");
}
#[test]
fn strip_html_skips_style() {
let html = "<p>text</p><style>body{color:red}</style><p>more</p>";
assert_eq!(strip_html_simple(html), "textmore");
}
#[test]
fn truncate_short_text() {
assert_eq!(truncate_text("hello", 10), "hello");
}
#[test]
fn truncate_long_text() {
let result = truncate_text("this is a long sentence", 10);
assert!(result.ends_with("..."));
assert!(result.chars().count() <= 10);
}
#[test]
fn truncate_multibyte() {
let result = truncate_text("あいうえおかきくけこ", 5);
assert!(result.ends_with("..."));
assert!(result.chars().count() <= 5);
}
#[test]
fn validate_url_accepts_https() {
assert!(validate_url("https://example.com/feed.xml").is_ok());
}
#[test]
fn validate_url_accepts_http() {
assert!(validate_url("http://example.com/feed.xml").is_ok());
}
#[test]
fn validate_url_rejects_file() {
let err = validate_url("file:///etc/passwd").unwrap_err();
assert!(err.to_string().contains("unsupported URL scheme"));
}
#[test]
fn validate_url_rejects_ftp() {
let err = validate_url("ftp://example.com/data").unwrap_err();
assert!(err.to_string().contains("unsupported URL scheme"));
}
#[test]
fn validate_url_rejects_invalid() {
assert!(validate_url("not a url").is_err());
}
#[test]
fn format_markdown_table() {
let entries = vec![FeedEntry {
id: 1,
title: "Test Article".to_string(),
url: "https://example.com/1".to_string(),
published: "2026-02-14".to_string(),
summary: "A test".to_string(),
}];
let md = format_entries_as_markdown("Test Feed", &entries, 1);
assert!(md.contains("| 1 |"));
assert!(md.contains("Test Article"));
assert!(md.contains("## Test Feed"));
assert!(md.contains("1 articles."));
}
#[test]
fn format_markdown_table_truncated() {
let entries = vec![FeedEntry {
id: 1,
title: "Article".to_string(),
url: "https://example.com/1".to_string(),
published: "2026-02-14".to_string(),
summary: String::new(),
}];
let md = format_entries_as_markdown("Feed", &entries, 50);
assert!(md.contains("Showing 1 of 50 articles"));
assert!(md.contains("Use `limit` to see more"));
}
#[tokio::test]
async fn entry_store_roundtrip() {
let store = EntryStore::new();
let parsed = vec![
ParsedEntry {
title: "Article A".to_string(),
url: "https://a.com".to_string(),
published: "2026-01-01".to_string(),
summary: "sumA".to_string(),
},
ParsedEntry {
title: "Article B".to_string(),
url: "https://b.com".to_string(),
published: "2026-01-02".to_string(),
summary: "sumB".to_string(),
},
];
let entries = store.store_entries(parsed).await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, 1);
assert_eq!(entries[1].id, 2);
let (url, title) = store.get_url(1).await.unwrap();
assert_eq!(url, "https://a.com");
assert_eq!(title, "Article A");
assert!(store.get_url(99).await.is_none());
}
#[tokio::test]
async fn store_clears_on_new_list() {
let store = EntryStore::new();
let first = vec![ParsedEntry {
title: "Old".to_string(),
url: "https://old.com".to_string(),
published: "-".to_string(),
summary: String::new(),
}];
store.store_entries(first).await.unwrap();
let second = vec![ParsedEntry {
title: "New".to_string(),
url: "https://new.com".to_string(),
published: "-".to_string(),
summary: String::new(),
}];
store.store_entries(second).await.unwrap();
let (url, _) = store.get_url(1).await.unwrap();
assert_eq!(url, "https://new.com");
}
}