Skip to main content

rss_cli/
core.rs

1//! Orchestration shared by the CLI and the MCP server.
2//!
3//! This wiring is intentionally implemented up front against the *frozen signatures* of
4//! [`crate::fetch`] and [`crate::parse`]. It compiles before those modules are filled in
5//! and runs unchanged once they are, so there is no integration step for the seam itself.
6
7use 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
17/// Fetch and parse many feeds concurrently, returning the full structured output.
18///
19/// Partial failure is the norm: a feed that errors becomes a [`FeedStatus::Error`] entry
20/// (and is mirrored into [`FetchOutput::errors`]); successful feeds are unaffected.
21pub 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(&params.user_agent, params.timeout) {
25        Ok(c) => c,
26        Err(e) => {
27            // Cannot build the client: every feed fails identically (already in url order).
28            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    // Tag each task with its input index so we can restore request order after the
39    // completion-ordered `buffer_unordered` stream — `feeds[]`/`errors[]` are then
40    // deterministic within a run (an agent can address feeds by position). See ADR-0012.
41    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
73/// Fill the top-level aggregate counts from the assembled feeds. Called once both feeds and
74/// per-feed counts are final (post-`limit`/`--since`/truncation).
75fn 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
85/// Fetch and parse a single feed, returning the [`FeedResult`] plus any non-fatal
86/// [`Warning`]s the parse surfaced (e.g. a content-extraction fallback). Callers aggregate
87/// the warnings into [`FetchOutput::warnings`].
88pub 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
121/// Discover feeds advertised on a website homepage.
122pub async fn discover_feeds(
123    site_url: &str,
124    params: &FetchParams,
125) -> Result<DiscoverOutput, RssError> {
126    let http = HttpClient::new(&params.user_agent, params.timeout)?;
127    discover::discover(site_url, &http).await
128}
129
130/// Fetch a feed (cache-first) and return the single item whose `id`, raw `guid`, or resolved
131/// `url` equals `key`, if present.
132///
133/// Used by `rss show` and the MCP `get_item` tool. `id` is namespaced by `feed_url` (see
134/// ADR-0003); a `guid` (e.g. Reddit `t3_…`) is feed-window-independent and is the reliable
135/// key across different feed URLs. The lookup is cache-first (ADR-0014): an item the caller
136/// already saw survives a rolled feed window, but not a later cache-overwriting refetch.
137pub 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(&params.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
150/// Total number of items across every feed in `output`.
151pub fn item_count(output: &FetchOutput) -> usize {
152    output.feeds.iter().map(|f| f.items.len()).sum()
153}
154
155/// Rough token estimate of the *serialized* `output` — i.e. of the payload an MCP client
156/// actually receives (pretty JSON, matching [`crate::mcp`]'s emission). Uses the same
157/// `ceil(chars / 4)` heuristic as per-item content estimates.
158pub 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
163/// Check `output` against a token `budget`, returning the estimate on success.
164///
165/// On overflow, returns [`RssError::ResponseTooLarge`] carrying concrete, machine-readable
166/// retry suggestions (a smaller `limit` and a `max_content_chars`) so the calling agent can
167/// self-recover instead of giving up. This is the cap-and-error path; it never mutates
168/// `output`.
169pub 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    // Scale the item cap down by how far over budget we are, with a 10% safety margin.
180    let suggested_limit = (((n as f64) * (budget_tokens as f64) / (estimated as f64)) * 0.9)
181        .floor()
182        .max(1.0) as usize;
183    // Reserve ~30% of the budget for per-item metadata (titles, urls, ids, …); spread the
184    // rest across items as content characters (~4 chars/token), with a sane floor.
185    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
196/// Build the [`TruncationInfo`] marker for `output`, or `None` when nothing was actually
197/// cut.
198///
199/// The marker is emitted **only when item content was truncated** (or, in future, items
200/// were omitted) — i.e. when the agent is genuinely not seeing the full data. A bare item
201/// cap that dropped nothing is *not* reported here: the MCP `fetch_feed` default of 25 is
202/// documented in the tool description, so a non-`null` `truncation` on an untruncated
203/// response would only mislead. `applied_limit` is recorded for context when the marker
204/// *is* emitted (the MCP server passes its effective limit; the CLI passes `None`).
205pub 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
230/// Determine the appropriate process exit code from a [`FetchOutput`].
231pub 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    // A minimal RSS item with a distinct link and guid so we can match on each key.
271    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        // CacheFirst means show_item never touches the network in this test.
288        let params = FetchParams {
289            cache_policy: CachePolicy::CacheFirst,
290            ..Default::default()
291        };
292
293        // First fetch the id the same way fetch_feed would compute it, then prove guid + url
294        // resolve to the same item.
295        let by_guid = show_item(FEED, "t3_abc", &params, &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            &params,
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, &params, &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        // A tiny budget forces overflow.
369        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        // item() has content_tokens_est = 1.
389        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        // Per-feed counts mirror the aggregate for a single feed.
393        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        // A bare item cap that dropped nothing is NOT reported as truncation, even when an
402        // applied_limit is passed — only actual content truncation emits the marker.
403        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}