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}