1use chrono::Utc;
8use futures::stream::{self, StreamExt};
9
10use crate::cache::Cache;
11use crate::config::FetchParams;
12use crate::error::RssError;
13use crate::fetch::HttpClient;
14use crate::model::{DiscoverOutput, FeedResult, FeedStatus, FetchOutput, TruncationInfo, Warning};
15use crate::{discover, parse};
16
17pub async fn fetch_feeds(urls: &[String], params: &FetchParams, cache: &Cache) -> FetchOutput {
22 let http = match HttpClient::new(¶ms.user_agent, params.timeout) {
23 Ok(c) => c,
24 Err(e) => {
25 let mut output = FetchOutput::new(now_rfc3339());
27 for url in urls {
28 let obj = e.to_error_obj(Some(url));
29 output.errors.push(obj.clone());
30 output.feeds.push(FeedResult::error(url.clone(), obj));
31 }
32 populate_totals(&mut output);
33 return output;
34 }
35 };
36 fetch_feeds_with(urls, params, cache, &http).await
37}
38
39pub async fn fetch_feeds_with(
43 urls: &[String],
44 params: &FetchParams,
45 cache: &Cache,
46 http: &HttpClient,
47) -> FetchOutput {
48 let mut output = FetchOutput::new(now_rfc3339());
49
50 let mut results: Vec<(usize, FeedResult, Vec<Warning>)> =
54 stream::iter(urls.iter().cloned().enumerate())
55 .map(|(idx, url)| async move {
56 match fetch_one(&url, http, params, cache).await {
57 Ok((fr, warnings)) => (idx, fr, warnings),
58 Err(e) => (
59 idx,
60 FeedResult::error(url.clone(), e.to_error_obj(Some(&url))),
61 Vec::new(),
62 ),
63 }
64 })
65 .buffer_unordered(params.concurrency.max(1))
66 .collect()
67 .await;
68
69 results.sort_by_key(|(idx, _, _)| *idx);
70
71 for (_, fr, warnings) in results {
72 if let Some(err) = &fr.error {
73 output.errors.push(err.clone());
74 }
75 output.warnings.extend(warnings);
76 output.feeds.push(fr);
77 }
78 populate_totals(&mut output);
79 output
80}
81
82fn populate_totals(output: &mut FetchOutput) {
85 output.total_items = output.feeds.iter().map(|f| f.items.len()).sum();
86 output.total_content_tokens_est = output
87 .feeds
88 .iter()
89 .flat_map(|f| &f.items)
90 .map(|i| u64::from(i.content_tokens_est))
91 .sum();
92}
93
94pub async fn fetch_one(
98 url: &str,
99 http: &HttpClient,
100 params: &FetchParams,
101 cache: &Cache,
102) -> Result<(FeedResult, Vec<Warning>), RssError> {
103 let raw = http.fetch(url, cache, params.cache_policy).await?;
104 let parsed = parse::parse_feed(&raw.body, url, params)?;
105 let item_count = parsed.items.len();
106 let content_tokens_est_total = parsed
107 .items
108 .iter()
109 .map(|i| u64::from(i.content_tokens_est))
110 .sum();
111 let fr = FeedResult {
112 feed_url: url.to_string(),
113 status: if raw.not_modified {
114 FeedStatus::NotModified
115 } else {
116 FeedStatus::Ok
117 },
118 from_cache: raw.from_cache,
119 title: parsed.title,
120 site_url: parsed.site_url,
121 updated: parsed.updated,
122 item_count,
123 content_tokens_est_total,
124 items: parsed.items,
125 error: None,
126 };
127 Ok((fr, parsed.warnings))
128}
129
130pub async fn discover_feeds(
132 site_url: &str,
133 params: &FetchParams,
134) -> Result<DiscoverOutput, RssError> {
135 let http = HttpClient::new(¶ms.user_agent, params.timeout)?;
136 discover::discover(site_url, &http).await
137}
138
139pub async fn show_item(
147 feed_url: &str,
148 key: &str,
149 params: &FetchParams,
150 cache: &Cache,
151) -> Result<Option<crate::model::Item>, RssError> {
152 let http = HttpClient::new(¶ms.user_agent, params.timeout)?;
153 let (fr, _warnings) = fetch_one(feed_url, &http, params, cache).await?;
154 Ok(fr.items.into_iter().find(|it| {
155 it.id == key || it.guid.as_deref() == Some(key) || it.url.as_deref() == Some(key)
156 }))
157}
158
159pub fn item_count(output: &FetchOutput) -> usize {
161 output.feeds.iter().map(|f| f.items.len()).sum()
162}
163
164pub fn estimate_response_tokens(output: &FetchOutput) -> usize {
168 let json = serde_json::to_string_pretty(output).unwrap_or_default();
169 json.chars().count().div_ceil(4)
170}
171
172pub fn enforce_response_budget(
179 output: &FetchOutput,
180 budget_tokens: usize,
181) -> Result<usize, RssError> {
182 let estimated = estimate_response_tokens(output);
183 if estimated <= budget_tokens {
184 return Ok(estimated);
185 }
186
187 let n = item_count(output).max(1);
188 let suggested_limit = (((n as f64) * (budget_tokens as f64) / (estimated as f64)) * 0.9)
190 .floor()
191 .max(1.0) as usize;
192 let content_budget_tokens = budget_tokens * 7 / 10;
195 let suggested_max_content_chars = (content_budget_tokens.saturating_mul(4) / n).max(200);
196
197 Err(RssError::ResponseTooLarge {
198 estimated_tokens: estimated,
199 budget_tokens,
200 suggested_limit,
201 suggested_max_content_chars,
202 })
203}
204
205pub fn truncation_marker(
215 output: &FetchOutput,
216 applied_limit: Option<usize>,
217 suggestion: Option<String>,
218) -> Option<TruncationInfo> {
219 let items_content_truncated = output
220 .feeds
221 .iter()
222 .flat_map(|f| &f.items)
223 .filter(|i| i.content_truncated)
224 .count();
225
226 if items_content_truncated == 0 {
227 return None;
228 }
229
230 Some(TruncationInfo {
231 applied_limit,
232 items_content_truncated,
233 items_omitted: 0,
234 estimated_tokens: None,
235 suggestion,
236 })
237}
238
239pub fn exit_code_for(output: &FetchOutput) -> i32 {
241 use crate::error::exit;
242 let total = output.feeds.len();
243 let failed = output
244 .feeds
245 .iter()
246 .filter(|f| f.status == FeedStatus::Error)
247 .count();
248 if total == 0 || failed == 0 {
249 exit::OK
250 } else if failed == total {
251 exit::ALL_FAILED
252 } else {
253 exit::PARTIAL
254 }
255}
256
257fn now_rfc3339() -> String {
258 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::cache::{Cache, CacheMeta};
265 use crate::config::CachePolicy;
266 use crate::model::{ContentFormat, IdSource, Item};
267
268 fn seed(cache: &Cache, url: &str, body: &[u8]) {
269 let meta = CacheMeta {
270 feed_url: url.to_string(),
271 etag: None,
272 last_modified: None,
273 fetched_at: "2020-01-01T00:00:00Z".to_string(),
274 content_type: Some("application/rss+xml".to_string()),
275 };
276 cache.put(&meta, body).expect("seed");
277 }
278
279 const FEED: &str = "https://t.example/r/x/.rss";
281 const BODY: &str = r#"<rss version="2.0"><channel><title>x</title>
282 <item><title>Post</title>
283 <link>https://t.example/r/x/comments/abc/post/</link>
284 <guid>t3_abc</guid>
285 <pubDate>Mon, 02 Jun 2026 00:00:00 GMT</pubDate>
286 <description>full body here</description></item>
287 </channel></rss>"#;
288
289 #[tokio::test]
290 async fn show_item_matches_by_guid_and_url_cache_first() {
291 let dir = std::env::temp_dir().join(format!("rss-core-{}", std::process::id()));
292 std::fs::create_dir_all(&dir).unwrap();
293 let cache = Cache::open(Some(dir.clone())).unwrap();
294 seed(&cache, FEED, BODY.as_bytes());
295
296 let params = FetchParams {
298 cache_policy: CachePolicy::CacheFirst,
299 ..Default::default()
300 };
301
302 let by_guid = show_item(FEED, "t3_abc", ¶ms, &cache).await.unwrap();
305 assert!(by_guid.is_some(), "guid lookup should resolve");
306 let item = by_guid.unwrap();
307 assert_eq!(item.guid.as_deref(), Some("t3_abc"));
308
309 let by_url = show_item(
310 FEED,
311 "https://t.example/r/x/comments/abc/post/",
312 ¶ms,
313 &cache,
314 )
315 .await
316 .unwrap();
317 assert_eq!(by_url.map(|i| i.id), Some(item.id.clone()));
318
319 let by_id = show_item(FEED, &item.id, ¶ms, &cache).await.unwrap();
320 assert_eq!(by_id.map(|i| i.id), Some(item.id));
321
322 std::fs::remove_dir_all(&dir).ok();
323 }
324
325 fn item(content_truncated: bool) -> Item {
326 Item {
327 id: "deadbeefdeadbeef".to_string(),
328 id_source: IdSource::Link,
329 feed_url: "https://example.com/feed.xml".to_string(),
330 title: Some("Title".to_string()),
331 url: Some("https://example.com/a".to_string()),
332 authors: vec![],
333 published: Some("2026-01-01T00:00:00Z".to_string()),
334 updated: None,
335 summary: None,
336 content: Some("body".to_string()),
337 content_format: ContentFormat::Markdown,
338 content_tokens_est: 1,
339 content_truncated,
340 content_hash: Some("00112233aabbccdd".to_string()),
341 categories: vec![],
342 enclosures: vec![],
343 guid: None,
344 }
345 }
346
347 fn output_with(items: Vec<Item>) -> FetchOutput {
348 let mut out = FetchOutput::new("2026-06-01T00:00:00Z".to_string());
349 let item_count = items.len();
350 let content_tokens_est_total = items.iter().map(|i| u64::from(i.content_tokens_est)).sum();
351 out.feeds.push(FeedResult {
352 feed_url: "https://example.com/feed.xml".to_string(),
353 status: FeedStatus::Ok,
354 from_cache: false,
355 title: Some("Feed".to_string()),
356 site_url: None,
357 updated: None,
358 item_count,
359 content_tokens_est_total,
360 items,
361 error: None,
362 });
363 populate_totals(&mut out);
364 out
365 }
366
367 #[test]
368 fn budget_ok_under_limit() {
369 let out = output_with(vec![item(false)]);
370 let est = enforce_response_budget(&out, 100_000).expect("under budget");
371 assert!(est > 0);
372 }
373
374 #[test]
375 fn budget_overflow_yields_actionable_error() {
376 let out = output_with(vec![item(false), item(false), item(false)]);
377 let err = enforce_response_budget(&out, 1).unwrap_err();
379 match err {
380 RssError::ResponseTooLarge {
381 budget_tokens,
382 suggested_limit,
383 suggested_max_content_chars,
384 estimated_tokens,
385 } => {
386 assert_eq!(budget_tokens, 1);
387 assert!(estimated_tokens > 1);
388 assert!(suggested_limit >= 1);
389 assert!(suggested_max_content_chars >= 200);
390 }
391 other => panic!("expected ResponseTooLarge, got {other:?}"),
392 }
393 }
394
395 #[test]
396 fn populate_totals_sums_items_and_tokens() {
397 let out = output_with(vec![item(false), item(false), item(true)]);
399 assert_eq!(out.total_items, 3);
400 assert_eq!(out.total_content_tokens_est, 3);
401 assert_eq!(out.feeds[0].item_count, 3);
403 assert_eq!(out.feeds[0].content_tokens_est_total, 3);
404 }
405
406 #[test]
407 fn marker_none_when_nothing_bounded() {
408 let out = output_with(vec![item(false)]);
409 assert!(truncation_marker(&out, None, None).is_none());
410 assert!(truncation_marker(&out, Some(25), None).is_none());
413 }
414
415 #[test]
416 fn marker_reports_applied_limit_and_truncated_count() {
417 let out = output_with(vec![item(true), item(false)]);
418 let m = truncation_marker(&out, Some(25), Some("hint".to_string())).expect("marker");
419 assert_eq!(m.applied_limit, Some(25));
420 assert_eq!(m.items_content_truncated, 1);
421 assert_eq!(m.items_omitted, 0);
422 assert_eq!(m.suggestion.as_deref(), Some("hint"));
423 }
424}