use chrono::Utc;
use futures::stream::{self, StreamExt};
use crate::cache::Cache;
use crate::config::FetchParams;
use crate::error::RssError;
use crate::fetch::HttpClient;
use crate::model::{DiscoverOutput, FeedResult, FeedStatus, FetchOutput, TruncationInfo, Warning};
use crate::{discover, parse};
pub async fn fetch_feeds(urls: &[String], params: &FetchParams, cache: &Cache) -> FetchOutput {
let http = match HttpClient::new(¶ms.user_agent, params.timeout) {
Ok(c) => c,
Err(e) => {
let mut output = FetchOutput::new(now_rfc3339());
for url in urls {
let obj = e.to_error_obj(Some(url));
output.errors.push(obj.clone());
output.feeds.push(FeedResult::error(url.clone(), obj));
}
populate_totals(&mut output);
return output;
}
};
fetch_feeds_with(urls, params, cache, &http).await
}
pub async fn fetch_feeds_with(
urls: &[String],
params: &FetchParams,
cache: &Cache,
http: &HttpClient,
) -> FetchOutput {
let mut output = FetchOutput::new(now_rfc3339());
let mut results: Vec<(usize, FeedResult, Vec<Warning>)> =
stream::iter(urls.iter().cloned().enumerate())
.map(|(idx, url)| async move {
match fetch_one(&url, http, params, cache).await {
Ok((fr, warnings)) => (idx, fr, warnings),
Err(e) => (
idx,
FeedResult::error(url.clone(), e.to_error_obj(Some(&url))),
Vec::new(),
),
}
})
.buffer_unordered(params.concurrency.max(1))
.collect()
.await;
results.sort_by_key(|(idx, _, _)| *idx);
for (_, fr, warnings) in results {
if let Some(err) = &fr.error {
output.errors.push(err.clone());
}
output.warnings.extend(warnings);
output.feeds.push(fr);
}
populate_totals(&mut output);
output
}
fn populate_totals(output: &mut FetchOutput) {
output.total_items = output.feeds.iter().map(|f| f.items.len()).sum();
output.total_content_tokens_est = output
.feeds
.iter()
.flat_map(|f| &f.items)
.map(|i| u64::from(i.content_tokens_est))
.sum();
}
pub async fn fetch_one(
url: &str,
http: &HttpClient,
params: &FetchParams,
cache: &Cache,
) -> Result<(FeedResult, Vec<Warning>), RssError> {
let raw = http.fetch(url, cache, params.cache_policy).await?;
let parsed = parse::parse_feed(&raw.body, url, params)?;
let item_count = parsed.items.len();
let content_tokens_est_total = parsed
.items
.iter()
.map(|i| u64::from(i.content_tokens_est))
.sum();
let fr = FeedResult {
feed_url: url.to_string(),
status: if raw.not_modified {
FeedStatus::NotModified
} else {
FeedStatus::Ok
},
from_cache: raw.from_cache,
title: parsed.title,
site_url: parsed.site_url,
updated: parsed.updated,
item_count,
content_tokens_est_total,
items: parsed.items,
error: None,
};
Ok((fr, parsed.warnings))
}
pub async fn discover_feeds(
site_url: &str,
params: &FetchParams,
) -> Result<DiscoverOutput, RssError> {
let http = HttpClient::new(¶ms.user_agent, params.timeout)?;
discover::discover(site_url, &http).await
}
pub async fn show_item(
feed_url: &str,
key: &str,
params: &FetchParams,
cache: &Cache,
) -> Result<Option<crate::model::Item>, RssError> {
let http = HttpClient::new(¶ms.user_agent, params.timeout)?;
let (fr, _warnings) = fetch_one(feed_url, &http, params, cache).await?;
Ok(fr.items.into_iter().find(|it| {
it.id == key || it.guid.as_deref() == Some(key) || it.url.as_deref() == Some(key)
}))
}
pub fn item_count(output: &FetchOutput) -> usize {
output.feeds.iter().map(|f| f.items.len()).sum()
}
pub fn estimate_response_tokens(output: &FetchOutput) -> usize {
let json = serde_json::to_string_pretty(output).unwrap_or_default();
json.chars().count().div_ceil(4)
}
pub fn enforce_response_budget(
output: &FetchOutput,
budget_tokens: usize,
) -> Result<usize, RssError> {
let estimated = estimate_response_tokens(output);
if estimated <= budget_tokens {
return Ok(estimated);
}
let n = item_count(output).max(1);
let suggested_limit = (((n as f64) * (budget_tokens as f64) / (estimated as f64)) * 0.9)
.floor()
.max(1.0) as usize;
let content_budget_tokens = budget_tokens * 7 / 10;
let suggested_max_content_chars = (content_budget_tokens.saturating_mul(4) / n).max(200);
Err(RssError::ResponseTooLarge {
estimated_tokens: estimated,
budget_tokens,
suggested_limit,
suggested_max_content_chars,
})
}
pub fn truncation_marker(
output: &FetchOutput,
applied_limit: Option<usize>,
suggestion: Option<String>,
) -> Option<TruncationInfo> {
let items_content_truncated = output
.feeds
.iter()
.flat_map(|f| &f.items)
.filter(|i| i.content_truncated)
.count();
if items_content_truncated == 0 {
return None;
}
Some(TruncationInfo {
applied_limit,
items_content_truncated,
items_omitted: 0,
estimated_tokens: None,
suggestion,
})
}
pub fn exit_code_for(output: &FetchOutput) -> i32 {
use crate::error::exit;
let total = output.feeds.len();
let failed = output
.feeds
.iter()
.filter(|f| f.status == FeedStatus::Error)
.count();
if total == 0 || failed == 0 {
exit::OK
} else if failed == total {
exit::ALL_FAILED
} else {
exit::PARTIAL
}
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{Cache, CacheMeta};
use crate::config::CachePolicy;
use crate::model::{ContentFormat, IdSource, Item};
fn seed(cache: &Cache, url: &str, body: &[u8]) {
let meta = CacheMeta {
feed_url: url.to_string(),
etag: None,
last_modified: None,
fetched_at: "2020-01-01T00:00:00Z".to_string(),
content_type: Some("application/rss+xml".to_string()),
};
cache.put(&meta, body).expect("seed");
}
const FEED: &str = "https://t.example/r/x/.rss";
const BODY: &str = r#"<rss version="2.0"><channel><title>x</title>
<item><title>Post</title>
<link>https://t.example/r/x/comments/abc/post/</link>
<guid>t3_abc</guid>
<pubDate>Mon, 02 Jun 2026 00:00:00 GMT</pubDate>
<description>full body here</description></item>
</channel></rss>"#;
#[tokio::test]
async fn show_item_matches_by_guid_and_url_cache_first() {
let dir = std::env::temp_dir().join(format!("rss-core-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cache = Cache::open(Some(dir.clone())).unwrap();
seed(&cache, FEED, BODY.as_bytes());
let params = FetchParams {
cache_policy: CachePolicy::CacheFirst,
..Default::default()
};
let by_guid = show_item(FEED, "t3_abc", ¶ms, &cache).await.unwrap();
assert!(by_guid.is_some(), "guid lookup should resolve");
let item = by_guid.unwrap();
assert_eq!(item.guid.as_deref(), Some("t3_abc"));
let by_url = show_item(
FEED,
"https://t.example/r/x/comments/abc/post/",
¶ms,
&cache,
)
.await
.unwrap();
assert_eq!(by_url.map(|i| i.id), Some(item.id.clone()));
let by_id = show_item(FEED, &item.id, ¶ms, &cache).await.unwrap();
assert_eq!(by_id.map(|i| i.id), Some(item.id));
std::fs::remove_dir_all(&dir).ok();
}
fn item(content_truncated: bool) -> Item {
Item {
id: "deadbeefdeadbeef".to_string(),
id_source: IdSource::Link,
feed_url: "https://example.com/feed.xml".to_string(),
title: Some("Title".to_string()),
url: Some("https://example.com/a".to_string()),
authors: vec![],
published: Some("2026-01-01T00:00:00Z".to_string()),
updated: None,
summary: None,
content: Some("body".to_string()),
content_format: ContentFormat::Markdown,
content_tokens_est: 1,
content_truncated,
content_hash: Some("00112233aabbccdd".to_string()),
categories: vec![],
enclosures: vec![],
guid: None,
}
}
fn output_with(items: Vec<Item>) -> FetchOutput {
let mut out = FetchOutput::new("2026-06-01T00:00:00Z".to_string());
let item_count = items.len();
let content_tokens_est_total = items.iter().map(|i| u64::from(i.content_tokens_est)).sum();
out.feeds.push(FeedResult {
feed_url: "https://example.com/feed.xml".to_string(),
status: FeedStatus::Ok,
from_cache: false,
title: Some("Feed".to_string()),
site_url: None,
updated: None,
item_count,
content_tokens_est_total,
items,
error: None,
});
populate_totals(&mut out);
out
}
#[test]
fn budget_ok_under_limit() {
let out = output_with(vec![item(false)]);
let est = enforce_response_budget(&out, 100_000).expect("under budget");
assert!(est > 0);
}
#[test]
fn budget_overflow_yields_actionable_error() {
let out = output_with(vec![item(false), item(false), item(false)]);
let err = enforce_response_budget(&out, 1).unwrap_err();
match err {
RssError::ResponseTooLarge {
budget_tokens,
suggested_limit,
suggested_max_content_chars,
estimated_tokens,
} => {
assert_eq!(budget_tokens, 1);
assert!(estimated_tokens > 1);
assert!(suggested_limit >= 1);
assert!(suggested_max_content_chars >= 200);
}
other => panic!("expected ResponseTooLarge, got {other:?}"),
}
}
#[test]
fn populate_totals_sums_items_and_tokens() {
let out = output_with(vec![item(false), item(false), item(true)]);
assert_eq!(out.total_items, 3);
assert_eq!(out.total_content_tokens_est, 3);
assert_eq!(out.feeds[0].item_count, 3);
assert_eq!(out.feeds[0].content_tokens_est_total, 3);
}
#[test]
fn marker_none_when_nothing_bounded() {
let out = output_with(vec![item(false)]);
assert!(truncation_marker(&out, None, None).is_none());
assert!(truncation_marker(&out, Some(25), None).is_none());
}
#[test]
fn marker_reports_applied_limit_and_truncated_count() {
let out = output_with(vec![item(true), item(false)]);
let m = truncation_marker(&out, Some(25), Some("hint".to_string())).expect("marker");
assert_eq!(m.applied_limit, Some(25));
assert_eq!(m.items_content_truncated, 1);
assert_eq!(m.items_omitted, 0);
assert_eq!(m.suggestion.as_deref(), Some("hint"));
}
}