Skip to main content

rss_cli/
parse.rs

1//! `feed-rs` → [`crate::model`] conversion. **Owner: `parser` agent.**
2//!
3//! Frozen public interface — [`crate::core`] depends on these exact signatures.
4//!
5//! ## Requirements
6//! - Parse `body` with `feed_rs::parser::parse` (auto-detects RSS/Atom/JSON Feed). Be
7//!   lenient: malformed feeds should yield [`RssError::Parse`], never a panic.
8//! - Normalize all dates to RFC-3339 UTC strings (`feed-rs` yields `chrono::DateTime`).
9//! - Resolve relative item links to absolute URLs against `feed_url` (use the `url` crate).
10//! - For each item, compute the stable id via [`crate::identity::item_id`] and the content
11//!   via [`crate::content`], honoring `params.content_format`.
12//! - Apply `params.since` (drop older items) and `params.limit` (keep newest N) — sort by
13//!   `published` (fallback `updated`) descending before limiting.
14//! - Populate `title`, `site_url` (the feed's `<link>`/`homepage`), and `updated`.
15
16use chrono::{DateTime, SecondsFormat, Utc};
17use feed_rs::model::{Entry, Link};
18use url::Url;
19
20use crate::config::FetchParams;
21use crate::content;
22use crate::error::RssError;
23use crate::identity;
24use crate::model::{ContentFormat, Enclosure, Item, Warning};
25
26/// Parsed feed metadata plus its items, ready to drop into a [`crate::model::FeedResult`].
27#[derive(Debug, Clone)]
28pub struct ParsedFeed {
29    pub title: Option<String>,
30    pub site_url: Option<String>,
31    /// Feed-level updated timestamp, RFC-3339 UTC.
32    pub updated: Option<String>,
33    pub items: Vec<Item>,
34    /// Non-fatal data-quality warnings about this feed (already carry `feed_url`).
35    pub warnings: Vec<Warning>,
36}
37
38/// Parse raw feed bytes into a [`ParsedFeed`]. `feed_url` is the (post-redirect) URL the
39/// bytes came from, used both for relative-link resolution and as the id namespace.
40pub fn parse_feed(
41    body: &[u8],
42    feed_url: &str,
43    params: &FetchParams,
44) -> Result<ParsedFeed, RssError> {
45    let feed = feed_rs::parser::parse(body).map_err(|e| RssError::Parse(e.to_string()))?;
46
47    let title = feed.title.map(|t| t.content);
48    let updated = feed.updated.map(rfc3339);
49    let site_url = pick_link_href(&feed.links).map(|s| s.to_string());
50
51    // Base URL for resolving relative item links. If `feed_url` itself is unparseable we
52    // simply keep raw hrefs rather than failing the whole parse.
53    let base = Url::parse(feed_url).ok();
54
55    // Build (item, sort-key, content_fell_back) triples so we can filter/sort by the
56    // underlying instant before discarding it. The sort key is `published` falling back to
57    // `updated`; `fell_back` records a lower-fidelity content extraction for warnings.
58    let mut rows: Vec<(Item, Option<DateTime<Utc>>, bool)> = Vec::with_capacity(feed.entries.len());
59    for entry in feed.entries {
60        let sort_key = entry.published.or(entry.updated);
61        let (item, fell_back) = entry_to_item(entry, feed_url, base.as_ref(), params);
62        rows.push((item, sort_key, fell_back));
63    }
64
65    // `--since`: drop items whose known instant is older than the cutoff. Items with no
66    // date are retained (we cannot prove they are older).
67    if let Some(since) = params.since {
68        rows.retain(|(_, key, _)| match key {
69            Some(dt) => *dt >= since,
70            None => true,
71        });
72    }
73
74    // Sort newest-first. Reversing the key sorts dated items descending and places undated
75    // items (`None`, the largest under `Reverse`) last. `sort_by_key` is stable, so the
76    // original feed order is preserved within ties.
77    rows.sort_by_key(|row| std::cmp::Reverse(row.1));
78
79    // `--limit`: keep the newest N after sorting.
80    if let Some(limit) = params.limit {
81        rows.truncate(limit);
82    }
83
84    // Derive non-fatal warnings from the *surviving* rows (so a dropped item never warns).
85    let warnings = collect_warnings(feed_url, &rows);
86
87    let items = rows.into_iter().map(|(item, _, _)| item).collect();
88
89    Ok(ParsedFeed {
90        title,
91        site_url,
92        updated,
93        items,
94        warnings,
95    })
96}
97
98/// Build the non-fatal [`Warning`]s for a feed from its returned rows. Kept deliberately
99/// rare so the channel stays meaningful: a single aggregated warning when content
100/// extraction degraded, and an ordering caveat only when *every* returned item is undated.
101fn collect_warnings(feed_url: &str, rows: &[(Item, Option<DateTime<Utc>>, bool)]) -> Vec<Warning> {
102    let mut warnings = Vec::new();
103
104    let fell_back = rows.iter().filter(|(_, _, fb)| *fb).count();
105    if fell_back > 0 {
106        warnings.push(Warning {
107            feed_url: Some(feed_url.to_string()),
108            code: "CONTENT_EXTRACTION_FALLBACK".to_string(),
109            message: format!(
110                "HTML conversion failed for {fell_back} item(s); fell back to a plain tag \
111                 strip (content may be lower fidelity)"
112            ),
113        });
114    }
115
116    if !rows.is_empty() && rows.iter().all(|(_, key, _)| key.is_none()) {
117        warnings.push(Warning {
118            feed_url: Some(feed_url.to_string()),
119            code: "UNDATED_ITEMS".to_string(),
120            message: format!(
121                "all {} returned item(s) lack a published/updated date; newest-first ordering \
122                 falls back to the feed's original order",
123                rows.len()
124            ),
125        });
126    }
127
128    warnings
129}
130
131/// Convert a single `feed-rs` [`Entry`] into our serialized [`Item`], returning
132/// `(item, content_fell_back)` — the flag is `true` when content extraction degraded to a
133/// tag strip (see [`content::extract`]) so the caller can warn.
134fn entry_to_item(
135    entry: Entry,
136    feed_url: &str,
137    base: Option<&Url>,
138    params: &FetchParams,
139) -> (Item, bool) {
140    // Collect attachments while `entry` is still fully owned (borrows `media`/`links`).
141    let enclosures = collect_enclosures(&entry);
142
143    // Resolve the item permalink to an absolute URL against the feed URL; keep the raw
144    // href if resolution is impossible.
145    let raw_link = pick_link_href(&entry.links);
146    let url = raw_link.map(|href| resolve(base, href));
147
148    // The raw feed-provided guid/id (kept for reference; not necessarily stable).
149    let guid = if entry.id.is_empty() {
150        None
151    } else {
152        Some(entry.id.clone())
153    };
154
155    let title = entry.title.map(|t| t.content);
156    let published = entry.published.map(rfc3339);
157    let updated = entry.updated.map(rfc3339);
158    let authors = entry.authors.into_iter().map(|p| p.name).collect();
159    let categories = entry.categories.into_iter().map(|c| c.term).collect();
160    let summary = entry.summary.map(|t| t.content);
161
162    // Content body: prefer the entry's <content>, fall back to the summary HTML when the
163    // content element is absent/empty. Skipped entirely when extraction is disabled.
164    let format = params.content_format;
165    let mut content_truncated = false;
166    let mut content_fell_back = false;
167    // SHA-256 of the *pre-truncation* extracted body, so the hash reflects the real content
168    // regardless of `max_content_chars`. `None` when there is no content.
169    let mut content_hash = None;
170    let content = if format == ContentFormat::None {
171        None
172    } else {
173        let content_html = entry.content.and_then(|c| c.body);
174        let source = content_html
175            .as_deref()
176            .filter(|h| !h.trim().is_empty())
177            .or_else(|| summary.as_deref().filter(|s| !s.trim().is_empty()));
178        source.map(|html| {
179            let (extracted, fell_back) = content::extract(html, format);
180            content_fell_back = fell_back;
181            content_hash = Some(content::content_hash(&extracted));
182            // Apply the per-item character cap to the *extracted* body (not source HTML).
183            match params.max_content_chars {
184                Some(max) => {
185                    let (capped, cut) = content::truncate_to_chars(&extracted, max);
186                    content_truncated = cut;
187                    capped
188                }
189                None => extracted,
190            }
191        })
192    };
193    // Estimate tokens from the final (possibly truncated) content, so agents budget on what
194    // they actually receive.
195    let content_tokens_est = content
196        .as_deref()
197        .map(content::estimate_tokens)
198        .unwrap_or(0);
199
200    let (id, id_source) = identity::item_id(
201        feed_url,
202        url.as_deref(),
203        guid.as_deref(),
204        title.as_deref(),
205        published.as_deref(),
206    );
207
208    let item = Item {
209        id,
210        id_source,
211        feed_url: feed_url.to_string(),
212        title,
213        url,
214        authors,
215        published,
216        updated,
217        summary,
218        content,
219        content_format: format,
220        content_tokens_est,
221        content_truncated,
222        content_hash,
223        categories,
224        enclosures,
225        guid,
226    };
227    (item, content_fell_back)
228}
229
230/// Gather media attachments from both `media:content` blocks and `rel="enclosure"` links.
231/// Entries without a URL are skipped.
232fn collect_enclosures(entry: &Entry) -> Vec<Enclosure> {
233    let mut enclosures = Vec::new();
234
235    // RSS Media spec: <media:content> / <media:group>.
236    for media in &entry.media {
237        for content in &media.content {
238            if let Some(url) = &content.url {
239                enclosures.push(Enclosure {
240                    url: url.to_string(),
241                    mime: content.content_type.as_ref().map(|m| m.to_string()),
242                    length: content.size,
243                });
244            }
245        }
246    }
247
248    // RSS 2.0 / Atom: <enclosure> surfaces as a link with rel="enclosure".
249    for link in &entry.links {
250        if link.rel.as_deref() == Some("enclosure") && !link.href.is_empty() {
251            enclosures.push(Enclosure {
252                url: link.href.clone(),
253                mime: link.media_type.clone(),
254                length: link.length,
255            });
256        }
257    }
258
259    enclosures
260}
261
262/// Choose the best href from a set of links: the `rel="alternate"` entry if present,
263/// otherwise the first link. Returns `None` for an empty set.
264fn pick_link_href(links: &[Link]) -> Option<&str> {
265    links
266        .iter()
267        .find(|l| l.rel.as_deref() == Some("alternate"))
268        .or_else(|| links.first())
269        .map(|l| l.href.as_str())
270}
271
272/// Resolve `href` to an absolute URL against `base`; keep `href` verbatim on failure
273/// (e.g. when there is no base or the join is invalid).
274fn resolve(base: Option<&Url>, href: &str) -> String {
275    base.and_then(|b| b.join(href).ok())
276        .map(|u| u.to_string())
277        .unwrap_or_else(|| href.to_string())
278}
279
280/// Normalize a `chrono` timestamp to an RFC-3339 UTC string with second precision and a
281/// trailing `Z` (e.g. `2026-01-02T03:04:05Z`).
282fn rfc3339(dt: DateTime<Utc>) -> String {
283    dt.to_rfc3339_opts(SecondsFormat::Secs, true)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::model::IdSource;
290    use chrono::TimeZone;
291
292    const FEED_URL: &str = "https://example.com/feed.xml";
293
294    fn params() -> FetchParams {
295        FetchParams::default()
296    }
297
298    const RSS: &str = r#"<?xml version="1.0"?>
299<rss version="2.0">
300  <channel>
301    <title>Example Blog</title>
302    <link>https://example.com/</link>
303    <item>
304      <title>First Post</title>
305      <link>/posts/first</link>
306      <guid>tag:example.com,2026:1</guid>
307      <pubDate>Mon, 05 Jan 2026 10:00:00 GMT</pubDate>
308      <description><![CDATA[<p>Hello <b>world</b></p>]]></description>
309      <enclosure url="https://example.com/audio.mp3" type="audio/mpeg" length="1234"/>
310    </item>
311    <item>
312      <title>Second Post</title>
313      <link>https://example.com/posts/second</link>
314      <pubDate>Tue, 06 Jan 2026 10:00:00 GMT</pubDate>
315    </item>
316  </channel>
317</rss>"#;
318
319    const ATOM: &str = r#"<?xml version="1.0" encoding="utf-8"?>
320<feed xmlns="http://www.w3.org/2005/Atom">
321  <title>Atom Example</title>
322  <updated>2026-02-01T12:00:00Z</updated>
323  <link rel="alternate" href="https://atom.example.com/"/>
324  <entry>
325    <title>Atom Entry</title>
326    <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
327    <link rel="alternate" href="/articles/atom"/>
328    <updated>2026-02-01T12:00:00Z</updated>
329    <published>2026-02-01T11:00:00Z</published>
330    <content type="html">&lt;p&gt;Atom body&lt;/p&gt;</content>
331  </entry>
332</feed>"#;
333
334    #[test]
335    fn parses_rss_metadata_and_items() {
336        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
337        assert_eq!(parsed.title.as_deref(), Some("Example Blog"));
338        assert_eq!(parsed.site_url.as_deref(), Some("https://example.com/"));
339        assert_eq!(parsed.items.len(), 2);
340    }
341
342    #[test]
343    fn rss_relative_link_resolved_to_absolute() {
344        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
345        // Newest-first: "Second Post" (Jan 6) precedes "First Post" (Jan 5).
346        let first = parsed
347            .items
348            .iter()
349            .find(|i| i.title.as_deref() == Some("First Post"))
350            .unwrap();
351        assert_eq!(
352            first.url.as_deref(),
353            Some("https://example.com/posts/first")
354        );
355    }
356
357    #[test]
358    fn rss_items_sorted_newest_first() {
359        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
360        assert_eq!(parsed.items[0].title.as_deref(), Some("Second Post"));
361        assert_eq!(parsed.items[1].title.as_deref(), Some("First Post"));
362    }
363
364    const MEDIA_RSS: &str = r#"<?xml version="1.0"?>
365<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
366  <channel>
367    <title>Media Feed</title>
368    <item>
369      <title>Podcast Episode</title>
370      <link>https://example.com/ep1</link>
371      <media:content url="https://cdn.example.com/ep1.mp3" type="audio/mpeg" fileSize="5000"/>
372    </item>
373  </channel>
374</rss>"#;
375
376    #[test]
377    fn media_content_maps_to_enclosure() {
378        let parsed = parse_feed(MEDIA_RSS.as_bytes(), FEED_URL, &params()).unwrap();
379        let item = &parsed.items[0];
380        assert_eq!(item.enclosures.len(), 1);
381        let enc = &item.enclosures[0];
382        assert_eq!(enc.url, "https://cdn.example.com/ep1.mp3");
383        assert_eq!(enc.mime.as_deref(), Some("audio/mpeg"));
384        assert_eq!(enc.length, Some(5000));
385    }
386
387    #[test]
388    fn rss_enclosure_and_stable_id_present() {
389        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
390        let first = parsed
391            .items
392            .iter()
393            .find(|i| i.title.as_deref() == Some("First Post"))
394            .unwrap();
395
396        // Stable id present, 16 lowercase hex chars, derived from the resolved link.
397        assert_eq!(first.id.len(), 16);
398        assert_eq!(first.id_source, IdSource::Link);
399
400        // Enclosure mapped from the RSS <enclosure> element.
401        assert_eq!(first.enclosures.len(), 1);
402        assert_eq!(first.enclosures[0].url, "https://example.com/audio.mp3");
403        assert_eq!(first.enclosures[0].mime.as_deref(), Some("audio/mpeg"));
404        assert_eq!(first.enclosures[0].length, Some(1234));
405    }
406
407    #[test]
408    fn id_is_deterministic_across_parses() {
409        let a = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
410        let b = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
411        assert_eq!(a.items[0].id, b.items[0].id);
412        assert_eq!(a.items[1].id, b.items[1].id);
413    }
414
415    #[test]
416    fn parses_atom_and_resolves_relative_link() {
417        let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, &params()).unwrap();
418        assert_eq!(parsed.title.as_deref(), Some("Atom Example"));
419        assert_eq!(
420            parsed.site_url.as_deref(),
421            Some("https://atom.example.com/")
422        );
423        assert_eq!(parsed.updated.as_deref(), Some("2026-02-01T12:00:00Z"));
424        assert_eq!(parsed.items.len(), 1);
425
426        let entry = &parsed.items[0];
427        assert_eq!(entry.title.as_deref(), Some("Atom Entry"));
428        // Relative entry link resolved against the feed URL.
429        assert_eq!(
430            entry.url.as_deref(),
431            Some("https://example.com/articles/atom")
432        );
433        assert_eq!(entry.published.as_deref(), Some("2026-02-01T11:00:00Z"));
434        // Markdown content extracted from the inline HTML body.
435        assert!(entry.content.as_deref().unwrap().contains("Atom body"));
436        assert!(entry.content_tokens_est > 0);
437        assert_eq!(
438            entry.guid.as_deref(),
439            Some("urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a")
440        );
441    }
442
443    #[test]
444    fn since_drops_older_items() {
445        let mut p = params();
446        // Cutoff between the two posts (Jan 5 vs Jan 6).
447        p.since = Some(Utc.with_ymd_and_hms(2026, 1, 6, 0, 0, 0).unwrap());
448        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &p).unwrap();
449        assert_eq!(parsed.items.len(), 1);
450        assert_eq!(parsed.items[0].title.as_deref(), Some("Second Post"));
451    }
452
453    #[test]
454    fn limit_keeps_newest_n() {
455        let mut p = params();
456        p.limit = Some(1);
457        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &p).unwrap();
458        assert_eq!(parsed.items.len(), 1);
459        assert_eq!(parsed.items[0].title.as_deref(), Some("Second Post"));
460    }
461
462    #[test]
463    fn max_content_chars_truncates_and_flags() {
464        let mut p = params();
465        p.max_content_chars = Some(4);
466        let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, &p).unwrap();
467        let item = &parsed.items[0];
468        assert!(
469            item.content_truncated,
470            "content should be flagged truncated"
471        );
472        let content = item.content.as_deref().unwrap();
473        assert!(content.ends_with(content::TRUNCATION_MARKER));
474        // Token estimate reflects the truncated body, not the original.
475        assert_eq!(item.content_tokens_est, content::estimate_tokens(content));
476    }
477
478    #[test]
479    fn no_cap_leaves_content_untruncated() {
480        let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, &params()).unwrap();
481        assert!(!parsed.items[0].content_truncated);
482    }
483
484    #[test]
485    fn content_none_leaves_content_null() {
486        let mut p = params();
487        p.content_format = ContentFormat::None;
488        let parsed = parse_feed(ATOM.as_bytes(), FEED_URL, &p).unwrap();
489        assert!(parsed.items[0].content.is_none());
490        assert_eq!(parsed.items[0].content_tokens_est, 0);
491        // No content → no hash.
492        assert!(parsed.items[0].content_hash.is_none());
493    }
494
495    #[test]
496    fn content_hash_present_stable_and_truncation_independent() {
497        let full = parse_feed(ATOM.as_bytes(), FEED_URL, &params()).unwrap();
498        let hash = full.items[0]
499            .content_hash
500            .clone()
501            .expect("content present → hash present");
502        assert_eq!(hash.len(), 16);
503
504        // Re-parsing yields the same hash (deterministic).
505        let again = parse_feed(ATOM.as_bytes(), FEED_URL, &params()).unwrap();
506        assert_eq!(again.items[0].content_hash.as_deref(), Some(hash.as_str()));
507
508        // The hash is over the *pre-truncation* body, so a cap does not change it.
509        let mut p = params();
510        p.max_content_chars = Some(4);
511        let capped = parse_feed(ATOM.as_bytes(), FEED_URL, &p).unwrap();
512        assert!(capped.items[0].content_truncated);
513        assert_eq!(
514            capped.items[0].content_hash.as_deref(),
515            Some(hash.as_str()),
516            "truncating the served body must not change the content hash"
517        );
518    }
519
520    const UNDATED_RSS: &str = r#"<?xml version="1.0"?>
521<rss version="2.0">
522  <channel>
523    <title>Undated Feed</title>
524    <link>https://example.com/</link>
525    <item><title>A</title><link>https://example.com/a</link></item>
526    <item><title>B</title><link>https://example.com/b</link></item>
527  </channel>
528</rss>"#;
529
530    #[test]
531    fn all_undated_items_emit_single_warning() {
532        let parsed = parse_feed(UNDATED_RSS.as_bytes(), FEED_URL, &params()).unwrap();
533        assert_eq!(parsed.items.len(), 2);
534        let undated: Vec<_> = parsed
535            .warnings
536            .iter()
537            .filter(|w| w.code == "UNDATED_ITEMS")
538            .collect();
539        assert_eq!(undated.len(), 1, "exactly one aggregated undated warning");
540        assert_eq!(undated[0].feed_url.as_deref(), Some(FEED_URL));
541    }
542
543    #[test]
544    fn dated_feed_has_no_undated_warning() {
545        // RSS has pubDates, so ordering is reliable and no warning should fire.
546        let parsed = parse_feed(RSS.as_bytes(), FEED_URL, &params()).unwrap();
547        assert!(parsed.warnings.iter().all(|w| w.code != "UNDATED_ITEMS"));
548    }
549
550    #[test]
551    fn garbage_input_is_parse_error_not_panic() {
552        let err = parse_feed(b"not a feed at all", FEED_URL, &params()).unwrap_err();
553        assert!(matches!(err, RssError::Parse(_)));
554    }
555}