Skip to main content

feedparser_rs/types/
feed.rs

1use super::{
2    common::{Cloud, Generator, Image, Link, MediaRating, Person, Tag, TextConstruct, TextInput},
3    entry::Entry,
4    generics::LimitedCollectionExt,
5    podcast::{ItunesFeedMeta, PodcastMeta},
6    version::FeedVersion,
7};
8use crate::namespace::syndication::SyndicationMeta;
9use crate::{ParserLimits, error::Result};
10use chrono::{DateTime, Utc};
11use quick_xml::Reader;
12use std::collections::HashMap;
13
14/// Feed metadata
15#[derive(Debug, Clone, Default)]
16pub struct FeedMeta {
17    /// Feed title
18    pub title: Option<String>,
19    /// Detailed title with metadata
20    pub title_detail: Option<TextConstruct>,
21    /// Primary feed link
22    pub link: Option<String>,
23    /// All links associated with this feed
24    pub links: Vec<Link>,
25    /// Feed subtitle/description
26    pub subtitle: Option<String>,
27    /// Detailed subtitle with metadata
28    pub subtitle_detail: Option<TextConstruct>,
29    /// Feed summary (populated from itunes:summary when present)
30    pub summary: Option<String>,
31    /// Detailed summary with metadata
32    pub summary_detail: Option<TextConstruct>,
33    /// Last update date
34    pub updated: Option<DateTime<Utc>>,
35    /// Original update date string as found in the feed (timezone preserved)
36    pub updated_str: Option<String>,
37    /// Initial publication date (RSS pubDate, Atom published)
38    pub published: Option<DateTime<Utc>>,
39    /// Original publication date string as found in the feed (timezone preserved)
40    pub published_str: Option<String>,
41    /// Primary author name (stored inline for names ≤24 bytes)
42    pub author: Option<super::common::SmallString>,
43    /// Detailed author information
44    pub author_detail: Option<Person>,
45    /// All authors
46    pub authors: Vec<Person>,
47    /// Contributors
48    pub contributors: Vec<Person>,
49    /// Publisher name (stored inline for names ≤24 bytes)
50    pub publisher: Option<super::common::SmallString>,
51    /// Detailed publisher information
52    pub publisher_detail: Option<Person>,
53    /// Feed language (e.g., "en-us") - stored inline as lang codes are ≤24 bytes
54    pub language: Option<super::common::SmallString>,
55    /// Copyright/rights statement
56    pub rights: Option<String>,
57    /// Detailed rights with metadata
58    pub rights_detail: Option<TextConstruct>,
59    /// Generator name
60    pub generator: Option<String>,
61    /// Detailed generator information
62    pub generator_detail: Option<Generator>,
63    /// Feed image
64    pub image: Option<Image>,
65    /// Icon URL (small image)
66    pub icon: Option<String>,
67    /// Logo URL (larger image)
68    pub logo: Option<String>,
69    /// Feed-level tags/categories
70    pub tags: Vec<Tag>,
71    /// Unique feed identifier
72    pub id: Option<String>,
73    /// Time-to-live (update frequency hint) in minutes (kept as string for API compatibility)
74    pub ttl: Option<String>,
75    /// URL of documentation for the RSS format used
76    pub docs: Option<String>,
77    /// iTunes podcast metadata (if present)
78    pub itunes: Option<Box<ItunesFeedMeta>>,
79    /// Podcast 2.0 namespace metadata (if present)
80    pub podcast: Option<Box<PodcastMeta>>,
81    /// Dublin Core creator (author fallback) - stored inline for names ≤24 bytes
82    pub dc_creator: Option<super::common::SmallString>,
83    /// Dublin Core publisher (stored inline for names ≤24 bytes)
84    pub dc_publisher: Option<super::common::SmallString>,
85    /// Dublin Core rights (copyright)
86    pub dc_rights: Option<String>,
87    /// License URL (Creative Commons, etc.)
88    pub license: Option<String>,
89    /// Syndication module metadata (RSS 1.0)
90    pub syndication: Option<Box<SyndicationMeta>>,
91    /// Geographic location from `GeoRSS` namespace (feed level, exposed as `where` per Python feedparser API)
92    pub r#where: Option<Box<crate::namespace::georss::GeoLocation>>,
93    /// W3C Basic Geo latitude (`geo:lat`)
94    pub geo_lat: Option<String>,
95    /// W3C Basic Geo longitude (`geo:long`)
96    pub geo_long: Option<String>,
97    /// Pagination URL for the next page of results (JSON Feed `next_url`, RFC 5005 `<link rel="next">`)
98    pub next_url: Option<String>,
99    /// Media RSS thumbnails at feed/channel level
100    pub media_thumbnail: Vec<super::common::MediaThumbnail>,
101    /// Media RSS content items at feed/channel level
102    pub media_content: Vec<super::common::MediaContent>,
103    /// Media RSS rating (`media:rating`) at feed level
104    pub media_rating: Option<MediaRating>,
105    /// Media RSS keywords (`media:keywords`) at feed level, comma-separated string
106    pub media_keywords: Option<String>,
107    /// RSS 2.0 `<cloud>` element — subscription endpoint for notifications
108    pub cloud: Option<Cloud>,
109    /// RSS 2.0 `<textInput>` element — text input form associated with the channel
110    pub textinput: Option<TextInput>,
111    /// RSS 2.0 `<skipHours>` — hours of the day when the channel may be skipped (0–23)
112    pub skiphours: Vec<u32>,
113    /// RSS 2.0 `<skipDays>` — days of the week when the channel may be skipped
114    pub skipdays: Vec<String>,
115    /// Custom JSON Feed extension objects captured from the feed's top level.
116    ///
117    /// JSON Feed 1.1 permits custom object keys anywhere in a feed, provided the
118    /// key starts with `_` followed by a letter (e.g. `_cast`). Only the
119    /// feed-root scope is captured here — nested custom objects under array
120    /// scopes such as `authors[]`, `attachments[]`, or `hubs[]` are not (MVP
121    /// limitation; see `parser::json::extract_json_extensions`). Values are
122    /// captured verbatim and never interpreted. Only the JSON Feed parser
123    /// populates this field; RSS and Atom feeds always leave it empty.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use feedparser_rs::parse;
129    ///
130    /// let json = br#"{
131    ///     "version": "https://jsonfeed.org/version/1.1",
132    ///     "title": "Podcast Feed",
133    ///     "_cast": {"subcategory": "Tech News"},
134    ///     "items": []
135    /// }"#;
136    /// let feed = parse(json).unwrap();
137    /// assert_eq!(
138    ///     feed.feed.json_extensions["_cast"]["subcategory"],
139    ///     "Tech News"
140    /// );
141    /// ```
142    pub json_extensions: HashMap<String, serde_json::Value>,
143}
144
145/// Parsed feed result
146///
147/// This is the main result type returned by the parser, analogous to
148/// Python feedparser's `FeedParserDict`.
149#[derive(Debug, Clone, Default)]
150pub struct ParsedFeed {
151    /// Feed metadata
152    pub feed: FeedMeta,
153    /// Feed entries/items
154    pub entries: Vec<Entry>,
155    /// True if parsing encountered errors
156    pub bozo: bool,
157    /// Description of parsing error (if bozo is true)
158    pub bozo_exception: Option<String>,
159    /// Detected or declared encoding
160    pub encoding: String,
161    /// Detected feed format version
162    pub version: FeedVersion,
163    /// XML namespaces (prefix -> URI)
164    pub namespaces: HashMap<String, String>,
165    /// HTTP status code (if fetched from URL)
166    pub status: Option<u16>,
167    /// Final URL after redirects (if fetched from URL)
168    pub href: Option<String>,
169    /// `ETag` header from HTTP response
170    pub etag: Option<String>,
171    /// Last-Modified header from HTTP response
172    pub modified: Option<String>,
173    /// HTTP response headers (if fetched from URL)
174    #[cfg(feature = "http")]
175    pub headers: Option<HashMap<String, String>>,
176}
177
178impl ParsedFeed {
179    /// Creates a new `ParsedFeed` with default UTF-8 encoding
180    #[must_use]
181    pub fn new() -> Self {
182        Self {
183            encoding: String::from("utf-8"),
184            ..Default::default()
185        }
186    }
187
188    /// Creates a `ParsedFeed` with pre-allocated capacity for entries
189    ///
190    /// This method pre-allocates space for the expected number of entries,
191    /// reducing memory allocations during parsing.
192    ///
193    /// # Arguments
194    ///
195    /// * `entry_count` - Expected number of entries in the feed
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use feedparser_rs::ParsedFeed;
201    ///
202    /// let feed = ParsedFeed::with_capacity(50);
203    /// assert_eq!(feed.encoding, "utf-8");
204    /// ```
205    #[must_use]
206    pub fn with_capacity(entry_count: usize) -> Self {
207        Self {
208            entries: Vec::with_capacity(entry_count),
209            namespaces: HashMap::with_capacity(8), // Typical feeds have 3-8 namespaces
210            encoding: String::from("utf-8"),
211            ..Default::default()
212        }
213    }
214
215    /// Check if entry limit is reached, set bozo flag and skip element if so
216    ///
217    /// This helper consolidates the duplicate entry limit checking logic used in
218    /// RSS and Atom parsers. If the entry limit is reached, it:
219    /// - Sets `bozo` flag to true
220    /// - Sets `bozo_exception` with descriptive error message
221    /// - Skips the entry element
222    /// - Returns `Ok(false)` to signal that the entry should not be processed
223    ///
224    /// # Arguments
225    ///
226    /// * `reader` - XML reader positioned at the entry element
227    /// * `buf` - Buffer for XML event reading
228    /// * `limits` - Parser limits including `max_entries`
229    /// * `depth` - Current nesting depth (will be decremented)
230    ///
231    /// # Returns
232    ///
233    /// * `Ok(true)` - Entry can be processed (limit not reached)
234    /// * `Ok(false)` - Entry limit reached, element was skipped
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if:
239    /// - Skipping the entry element fails (e.g., malformed XML)
240    /// - Nesting depth exceeds limits while skipping
241    ///
242    /// # Examples
243    ///
244    /// ```ignore
245    /// // In parser:
246    /// if !feed.check_entry_limit(reader, &mut buf, limits, depth)? {
247    ///     continue;
248    /// }
249    /// // Process entry...
250    /// ```
251    #[inline]
252    pub fn check_entry_limit(
253        &mut self,
254        reader: &mut Reader<&[u8]>,
255        buf: &mut Vec<u8>,
256        limits: &ParserLimits,
257        depth: &mut usize,
258    ) -> Result<bool> {
259        use crate::parser::skip_element;
260
261        if self.entries.is_at_limit(limits.max_entries) {
262            self.bozo = true;
263            self.bozo_exception = Some(format!("Entry limit exceeded: {}", limits.max_entries));
264            skip_element(reader, buf, limits, *depth)?;
265            *depth = depth.saturating_sub(1);
266            Ok(false)
267        } else {
268            Ok(true)
269        }
270    }
271}
272
273impl FeedMeta {
274    /// Creates `FeedMeta` with capacity hints for typical RSS 2.0 feeds
275    ///
276    /// Pre-allocates collections based on common RSS 2.0 field usage:
277    /// - 1-2 links (channel link, self link)
278    /// - 1 author (managingEditor)
279    /// - 0-3 tags (categories)
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// use feedparser_rs::FeedMeta;
285    ///
286    /// let meta = FeedMeta::with_rss_capacity();
287    /// ```
288    #[must_use]
289    pub fn with_rss_capacity() -> Self {
290        Self {
291            links: Vec::with_capacity(2),
292            authors: Vec::with_capacity(1),
293            contributors: Vec::new(),
294            tags: Vec::with_capacity(3),
295            ..Default::default()
296        }
297    }
298
299    /// Creates `FeedMeta` with capacity hints for typical Atom 1.0 feeds
300    ///
301    /// Pre-allocates collections based on common Atom 1.0 field usage:
302    /// - 3-5 links (alternate, self, related, etc.)
303    /// - 1-2 authors
304    /// - 1 contributor
305    /// - 3-5 tags (categories)
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use feedparser_rs::FeedMeta;
311    ///
312    /// let meta = FeedMeta::with_atom_capacity();
313    /// ```
314    #[must_use]
315    pub fn with_atom_capacity() -> Self {
316        Self {
317            links: Vec::with_capacity(4),
318            authors: Vec::with_capacity(2),
319            contributors: Vec::with_capacity(1),
320            tags: Vec::with_capacity(5),
321            ..Default::default()
322        }
323    }
324
325    /// Sets title field with `TextConstruct`, storing both simple and detailed versions
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// use feedparser_rs::{FeedMeta, TextConstruct};
331    ///
332    /// let mut meta = FeedMeta::default();
333    /// meta.set_title(TextConstruct::text("Example Feed"));
334    /// assert_eq!(meta.title.as_deref(), Some("Example Feed"));
335    /// ```
336    #[inline]
337    pub fn set_title(&mut self, text: TextConstruct) {
338        self.title = Some(text.value.clone());
339        self.title_detail = Some(text);
340    }
341
342    /// Sets subtitle field with `TextConstruct`, storing both simple and detailed versions
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use feedparser_rs::{FeedMeta, TextConstruct};
348    ///
349    /// let mut meta = FeedMeta::default();
350    /// meta.set_subtitle(TextConstruct::text("A great feed"));
351    /// assert_eq!(meta.subtitle.as_deref(), Some("A great feed"));
352    /// ```
353    #[inline]
354    pub fn set_subtitle(&mut self, text: TextConstruct) {
355        self.subtitle = Some(text.value.clone());
356        self.subtitle_detail = Some(text);
357    }
358
359    /// Sets summary field with `TextConstruct`, storing both simple and detailed versions
360    ///
361    /// # Examples
362    ///
363    /// ```
364    /// use feedparser_rs::{FeedMeta, TextConstruct};
365    ///
366    /// let mut meta = FeedMeta::default();
367    /// meta.set_summary(TextConstruct::text("A detailed description"));
368    /// assert_eq!(meta.summary.as_deref(), Some("A detailed description"));
369    /// ```
370    #[inline]
371    pub fn set_summary(&mut self, text: TextConstruct) {
372        self.summary = Some(text.value.clone());
373        self.summary_detail = Some(text);
374    }
375
376    /// Sets rights field with `TextConstruct`, storing both simple and detailed versions
377    ///
378    /// # Examples
379    ///
380    /// ```
381    /// use feedparser_rs::{FeedMeta, TextConstruct};
382    ///
383    /// let mut meta = FeedMeta::default();
384    /// meta.set_rights(TextConstruct::text("© 2025 Example"));
385    /// assert_eq!(meta.rights.as_deref(), Some("© 2025 Example"));
386    /// ```
387    #[inline]
388    pub fn set_rights(&mut self, text: TextConstruct) {
389        self.rights = Some(text.value.clone());
390        self.rights_detail = Some(text);
391    }
392
393    /// Sets generator field with `Generator`, storing both simple and detailed versions
394    ///
395    /// # Examples
396    ///
397    /// ```
398    /// use feedparser_rs::{FeedMeta, Generator};
399    ///
400    /// # fn main() {
401    /// let mut meta = FeedMeta::default();
402    /// let generator = Generator {
403    ///     name: "Example Generator".to_string(),
404    ///     href: None,
405    ///     version: None,
406    /// };
407    /// meta.set_generator(generator);
408    /// assert_eq!(meta.generator.as_deref(), Some("Example Generator"));
409    /// # }
410    /// ```
411    #[inline]
412    pub fn set_generator(&mut self, generator: Generator) {
413        // Clone the name for the flat `generator` field; the detail struct keeps its own copy.
414        self.generator = Some(generator.name.clone());
415        self.generator_detail = Some(generator);
416    }
417
418    /// Sets author field with `Person`, storing both simple and detailed versions
419    ///
420    /// # Examples
421    ///
422    /// ```
423    /// use feedparser_rs::{FeedMeta, Person};
424    ///
425    /// let mut meta = FeedMeta::default();
426    /// meta.set_author(Person::from_name("John Doe"));
427    /// assert_eq!(meta.author.as_deref(), Some("John Doe"));
428    /// ```
429    #[inline]
430    pub fn set_author(&mut self, person: Person) {
431        self.author = person.flat_string();
432        self.author_detail = Some(person);
433    }
434
435    /// Sets publisher field with `Person`, storing both simple and detailed versions
436    ///
437    /// # Examples
438    ///
439    /// ```
440    /// use feedparser_rs::{FeedMeta, Person};
441    ///
442    /// let mut meta = FeedMeta::default();
443    /// meta.set_publisher(Person::from_name("ACME Corp"));
444    /// assert_eq!(meta.publisher.as_deref(), Some("ACME Corp"));
445    /// ```
446    #[inline]
447    pub fn set_publisher(&mut self, person: Person) {
448        self.publisher.clone_from(&person.name);
449        self.publisher_detail = Some(person);
450    }
451
452    /// Sets the primary link and adds it to the links collection
453    ///
454    /// This is a convenience method that:
455    /// 1. Sets the `link` field (if not already set)
456    /// 2. Adds an "alternate" link to the `links` collection
457    ///
458    /// # Examples
459    ///
460    /// ```
461    /// use feedparser_rs::FeedMeta;
462    ///
463    /// let mut meta = FeedMeta::default();
464    /// meta.set_alternate_link("https://example.com".to_string(), 10);
465    /// assert_eq!(meta.link.as_deref(), Some("https://example.com"));
466    /// assert_eq!(meta.links.len(), 1);
467    /// assert_eq!(meta.links[0].rel.as_deref(), Some("alternate"));
468    /// ```
469    #[inline]
470    pub fn set_alternate_link(&mut self, href: String, max_links: usize) {
471        if self.link.is_none() {
472            self.link = Some(href.clone());
473        }
474        self.links.try_push_limited(
475            Link {
476                href: href.into(),
477                rel: Some("alternate".into()),
478                ..Default::default()
479            },
480            max_links,
481        );
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_feed_meta_default() {
491        let meta = FeedMeta::default();
492        assert!(meta.title.is_none());
493        assert!(meta.links.is_empty());
494        assert!(meta.authors.is_empty());
495    }
496
497    #[test]
498    fn test_parsed_feed_default() {
499        let feed = ParsedFeed::default();
500        assert!(!feed.bozo);
501        assert!(feed.bozo_exception.is_none());
502        assert_eq!(feed.version, FeedVersion::Unknown);
503        assert!(feed.entries.is_empty());
504    }
505
506    #[test]
507    fn test_parsed_feed_new() {
508        let feed = ParsedFeed::new();
509        assert_eq!(feed.encoding, "utf-8");
510        assert!(!feed.bozo);
511    }
512
513    #[test]
514    fn test_parsed_feed_clone() {
515        let feed = ParsedFeed {
516            version: FeedVersion::Rss20,
517            bozo: true,
518            ..ParsedFeed::new()
519        };
520
521        assert_eq!(feed.version, FeedVersion::Rss20);
522        assert!(feed.bozo);
523    }
524}