1use 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#[derive(Debug, Clone)]
28pub struct ParsedFeed {
29 pub title: Option<String>,
30 pub site_url: Option<String>,
31 pub updated: Option<String>,
33 pub items: Vec<Item>,
34 pub warnings: Vec<Warning>,
36}
37
38pub 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 let base = Url::parse(feed_url).ok();
54
55 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 if let Some(since) = params.since {
68 rows.retain(|(_, key, _)| match key {
69 Some(dt) => *dt >= since,
70 None => true,
71 });
72 }
73
74 rows.sort_by_key(|row| std::cmp::Reverse(row.1));
78
79 if let Some(limit) = params.limit {
81 rows.truncate(limit);
82 }
83
84 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
98fn 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
131fn entry_to_item(
135 entry: Entry,
136 feed_url: &str,
137 base: Option<&Url>,
138 params: &FetchParams,
139) -> (Item, bool) {
140 let enclosures = collect_enclosures(&entry);
142
143 let raw_link = pick_link_href(&entry.links);
146 let url = raw_link.map(|href| resolve(base, href));
147
148 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 let format = params.content_format;
165 let mut content_truncated = false;
166 let mut content_fell_back = false;
167 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 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 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
230fn collect_enclosures(entry: &Entry) -> Vec<Enclosure> {
233 let mut enclosures = Vec::new();
234
235 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 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
262fn 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
272fn 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
280fn 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"><p>Atom body</p></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, ¶ms()).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, ¶ms()).unwrap();
345 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, ¶ms()).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, ¶ms()).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, ¶ms()).unwrap();
390 let first = parsed
391 .items
392 .iter()
393 .find(|i| i.title.as_deref() == Some("First Post"))
394 .unwrap();
395
396 assert_eq!(first.id.len(), 16);
398 assert_eq!(first.id_source, IdSource::Link);
399
400 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, ¶ms()).unwrap();
410 let b = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).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, ¶ms()).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 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 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 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 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, ¶ms()).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 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, ¶ms()).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 let again = parse_feed(ATOM.as_bytes(), FEED_URL, ¶ms()).unwrap();
506 assert_eq!(again.items[0].content_hash.as_deref(), Some(hash.as_str()));
507
508 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, ¶ms()).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 let parsed = parse_feed(RSS.as_bytes(), FEED_URL, ¶ms()).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, ¶ms()).unwrap_err();
553 assert!(matches!(err, RssError::Parse(_)));
554 }
555}