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 http = match HttpClient::new(&params.user_agent, params.timeout) {
23        Ok(c) => c,
24        Err(e) => {
25            // Cannot build the client: every feed fails identically (already in url order).
26            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
39/// Fetch many feeds concurrently through a **caller-provided** [`HttpClient`]. The MCP server
40/// builds the client once and shares it across tool calls so their per-host pacing coordinates
41/// (ADR-0016); [`fetch_feeds`] is the thin wrapper that builds a client per call for the CLI.
42pub 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    // Tag each task with its input index so we can restore request order after the
51    // completion-ordered `buffer_unordered` stream — `feeds[]`/`errors[]` are then
52    // deterministic within a run (an agent can address feeds by position). See ADR-0012.
53    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
82/// Fill the top-level aggregate counts from the assembled feeds. Called once both feeds and
83/// per-feed counts are final (post-`limit`/`--since`/truncation).
84fn 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
94/// Fetch and parse a single feed, returning the [`FeedResult`] plus any non-fatal
95/// [`Warning`]s the parse surfaced (e.g. a content-extraction fallback). Callers aggregate
96/// the warnings into [`FetchOutput::warnings`].
97pub 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
130/// Discover feeds advertised on a website homepage.
131pub async fn discover_feeds(
132    site_url: &str,
133    params: &FetchParams,
134) -> Result<DiscoverOutput, RssError> {
135    let http = HttpClient::new(&params.user_agent, params.timeout)?;
136    discover::discover(site_url, &http).await
137}
138
139/// Fetch a feed (cache-first) and return the single item whose `id`, raw `guid`, or resolved
140/// `url` equals `key`, if present.
141///
142/// Used by `rss show` and the MCP `get_item` tool. `id` is namespaced by `feed_url` (see
143/// ADR-0003); a `guid` (e.g. Reddit `t3_…`) is feed-window-independent and is the reliable
144/// key across different feed URLs. The lookup is cache-first (ADR-0014): an item the caller
145/// already saw survives a rolled feed window, but not a later cache-overwriting refetch.
146pub 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(&params.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
159/// Total number of items across every feed in `output`.
160pub fn item_count(output: &FetchOutput) -> usize {
161    output.feeds.iter().map(|f| f.items.len()).sum()
162}
163
164/// Rough token estimate of the *serialized* `output` — i.e. of the payload an MCP client
165/// actually receives (pretty JSON, matching [`crate::mcp`]'s emission). Uses the same
166/// `ceil(chars / 4)` heuristic as per-item content estimates.
167pub 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
172/// Check `output` against a token `budget`, returning the estimate on success.
173///
174/// On overflow, returns [`RssError::ResponseTooLarge`] carrying concrete, machine-readable
175/// retry suggestions (a smaller `limit` and a `max_content_chars`) so the calling agent can
176/// self-recover instead of giving up. This is the cap-and-error path; it never mutates
177/// `output`.
178pub 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    // Scale the item cap down by how far over budget we are, with a 10% safety margin.
189    let suggested_limit = (((n as f64) * (budget_tokens as f64) / (estimated as f64)) * 0.9)
190        .floor()
191        .max(1.0) as usize;
192    // Reserve ~30% of the budget for per-item metadata (titles, urls, ids, …); spread the
193    // rest across items as content characters (~4 chars/token), with a sane floor.
194    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
205/// Build the [`TruncationInfo`] marker for `output`, or `None` when nothing was actually
206/// cut.
207///
208/// The marker is emitted **only when item content was truncated** (or, in future, items
209/// were omitted) — i.e. when the agent is genuinely not seeing the full data. A bare item
210/// cap that dropped nothing is *not* reported here: the MCP `fetch_feed` default of 25 is
211/// documented in the tool description, so a non-`null` `truncation` on an untruncated
212/// response would only mislead. `applied_limit` is recorded for context when the marker
213/// *is* emitted (the MCP server passes its effective limit; the CLI passes `None`).
214pub 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
239/// Determine the appropriate process exit code from a [`FetchOutput`].
240pub 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    // A minimal RSS item with a distinct link and guid so we can match on each key.
280    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        // CacheFirst means show_item never touches the network in this test.
297        let params = FetchParams {
298            cache_policy: CachePolicy::CacheFirst,
299            ..Default::default()
300        };
301
302        // First fetch the id the same way fetch_feed would compute it, then prove guid + url
303        // resolve to the same item.
304        let by_guid = show_item(FEED, "t3_abc", &params, &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            &params,
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, &params, &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        // A tiny budget forces overflow.
378        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        // item() has content_tokens_est = 1.
398        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        // Per-feed counts mirror the aggregate for a single feed.
402        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        // A bare item cap that dropped nothing is NOT reported as truncation, even when an
411        // applied_limit is passed — only actual content truncation emits the marker.
412        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}