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