Skip to main content

feedparser_rs/types/
entry.rs

1use super::{
2    common::{
3        Content, Enclosure, Link, MediaContent, MediaThumbnail, Person, Source, Tag, TextConstruct,
4    },
5    generics::LimitedCollectionExt,
6    podcast::{ItunesEntryMeta, PodcastEntryMeta, PodcastPerson, PodcastTranscript},
7    thread::InReplyTo,
8};
9use chrono::{DateTime, Utc};
10
11/// Feed entry/item
12#[derive(Debug, Clone, Default)]
13pub struct Entry {
14    /// Unique entry identifier (stored inline for IDs ≤24 bytes)
15    pub id: Option<super::common::SmallString>,
16    /// Entry title
17    pub title: Option<String>,
18    /// Detailed title with metadata
19    pub title_detail: Option<TextConstruct>,
20    /// Primary link
21    pub link: Option<String>,
22    /// All links associated with this entry
23    pub links: Vec<Link>,
24    /// Entry subtitle (Atom §4.2.12 at entry level)
25    pub subtitle: Option<String>,
26    /// Detailed subtitle with metadata
27    pub subtitle_detail: Option<TextConstruct>,
28    /// Rights/copyright statement
29    pub rights: Option<String>,
30    /// Detailed rights with metadata
31    pub rights_detail: Option<TextConstruct>,
32    /// Short description/summary
33    pub summary: Option<String>,
34    /// Detailed summary with metadata
35    pub summary_detail: Option<TextConstruct>,
36    /// Full content blocks
37    pub content: Vec<Content>,
38    /// Publication date
39    pub published: Option<DateTime<Utc>>,
40    /// Original publication date string as found in the feed (timezone preserved)
41    pub published_str: Option<String>,
42    /// Last update date
43    pub updated: Option<DateTime<Utc>>,
44    /// Original update date string as found in the feed (timezone preserved)
45    pub updated_str: Option<String>,
46    /// Creation date
47    pub created: Option<DateTime<Utc>>,
48    /// Expiration date
49    pub expired: Option<DateTime<Utc>>,
50    /// Primary author name (stored inline for names ≤24 bytes)
51    pub author: Option<super::common::SmallString>,
52    /// Detailed author information
53    pub author_detail: Option<Person>,
54    /// All authors
55    pub authors: Vec<Person>,
56    /// Contributors
57    pub contributors: Vec<Person>,
58    /// Publisher name (stored inline for names ≤24 bytes)
59    pub publisher: Option<super::common::SmallString>,
60    /// Detailed publisher information
61    pub publisher_detail: Option<Person>,
62    /// Tags/categories
63    pub tags: Vec<Tag>,
64    /// Media enclosures (audio, video, etc.)
65    pub enclosures: Vec<Enclosure>,
66    /// Comments URL or text
67    pub comments: Option<String>,
68    /// Source feed reference
69    pub source: Option<Source>,
70    /// iTunes episode metadata (if present)
71    pub itunes: Option<Box<ItunesEntryMeta>>,
72    /// Dublin Core creator (author fallback) - stored inline for names ≤24 bytes
73    pub dc_creator: Option<super::common::SmallString>,
74    /// Dublin Core date (publication date fallback)
75    pub dc_date: Option<DateTime<Utc>>,
76    /// Dublin Core subjects (tags)
77    pub dc_subject: Vec<String>,
78    /// Dublin Core rights (copyright)
79    pub dc_rights: Option<String>,
80    /// Media RSS thumbnails
81    pub media_thumbnail: Vec<MediaThumbnail>,
82    /// Media RSS content items
83    pub media_content: Vec<MediaContent>,
84    /// Podcast 2.0 transcripts for this episode
85    pub podcast_transcripts: Vec<PodcastTranscript>,
86    /// Podcast 2.0 persons for this episode (hosts, guests, etc.)
87    pub podcast_persons: Vec<PodcastPerson>,
88    /// Podcast 2.0 episode metadata
89    pub podcast: Option<Box<PodcastEntryMeta>>,
90    /// `GeoRSS` location data (exposed as `where` per Python feedparser API)
91    pub r#where: Option<Box<crate::namespace::georss::GeoLocation>>,
92    /// License URL (Creative Commons, etc.)
93    pub license: Option<String>,
94    /// Atom Threading Extensions: entries this is a reply to (thr:in-reply-to)
95    pub in_reply_to: Vec<InReplyTo>,
96    /// Atom Threading Extensions: total response count (thr:total)
97    ///
98    /// Stored as u32 in Rust for type safety. Python binding converts
99    /// to string to match Python feedparser's API.
100    pub thr_total: Option<u32>,
101    /// Slash namespace: comment count (`slash:comments`)
102    pub slash_comments: Option<u32>,
103    /// WFW namespace: comment RSS feed URL (`wfw:commentRss`)
104    pub wfw_comment_rss: Option<String>,
105    /// Whether the RSS `<guid>` is a permalink (`isPermaLink` attribute).
106    ///
107    /// `true` when `isPermaLink="true"` or the attribute is absent (RSS 2.0 default).
108    /// `false` when `isPermaLink="false"`. `None` when no `<guid>` element is present.
109    pub guidislink: Option<bool>,
110}
111
112impl Entry {
113    /// Creates `Entry` with pre-allocated capacity for collections
114    ///
115    /// Pre-allocates space for typical entry fields:
116    /// - 1-2 links (alternate, related)
117    /// - 1 content block
118    /// - 1 author
119    /// - 2-3 tags
120    /// - 0-1 enclosures
121    /// - 2 podcast transcripts (typical for podcasts with multiple languages)
122    /// - 4 podcast persons (host, co-hosts, guests)
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use feedparser_rs::Entry;
128    ///
129    /// let entry = Entry::with_capacity();
130    /// ```
131    #[must_use]
132    pub fn with_capacity() -> Self {
133        Self {
134            links: Vec::with_capacity(2),
135            content: Vec::with_capacity(1),
136            authors: Vec::with_capacity(1),
137            contributors: Vec::with_capacity(0),
138            tags: Vec::with_capacity(3),
139            enclosures: Vec::with_capacity(1),
140            dc_subject: Vec::with_capacity(2),
141            media_thumbnail: Vec::with_capacity(1),
142            media_content: Vec::with_capacity(1),
143            podcast_transcripts: Vec::with_capacity(2),
144            podcast_persons: Vec::with_capacity(4),
145            // Most entries reply to at most one parent
146            in_reply_to: Vec::with_capacity(1),
147            ..Default::default()
148        }
149    }
150
151    /// Sets title field with `TextConstruct`, storing both simple and detailed versions
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use feedparser_rs::{Entry, TextConstruct};
157    ///
158    /// let mut entry = Entry::default();
159    /// entry.set_title(TextConstruct::text("Great Article"));
160    /// assert_eq!(entry.title.as_deref(), Some("Great Article"));
161    /// ```
162    #[inline]
163    pub fn set_title(&mut self, text: TextConstruct) {
164        self.title = Some(text.value.clone());
165        self.title_detail = Some(text);
166    }
167
168    /// Sets subtitle field with `TextConstruct`, storing both simple and detailed versions
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use feedparser_rs::{Entry, TextConstruct};
174    ///
175    /// let mut entry = Entry::default();
176    /// entry.set_subtitle(TextConstruct::text("A teaser"));
177    /// assert_eq!(entry.subtitle.as_deref(), Some("A teaser"));
178    /// ```
179    #[inline]
180    pub fn set_subtitle(&mut self, text: TextConstruct) {
181        self.subtitle = Some(text.value.clone());
182        self.subtitle_detail = Some(text);
183    }
184
185    /// Sets rights field with `TextConstruct`, storing both simple and detailed versions
186    #[inline]
187    pub fn set_rights(&mut self, text: TextConstruct) {
188        self.rights = Some(text.value.clone());
189        self.rights_detail = Some(text);
190    }
191
192    /// Sets summary field with `TextConstruct`, storing both simple and detailed versions
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use feedparser_rs::{Entry, TextConstruct};
198    ///
199    /// let mut entry = Entry::default();
200    /// entry.set_summary(TextConstruct::text("A summary"));
201    /// assert_eq!(entry.summary.as_deref(), Some("A summary"));
202    /// ```
203    #[inline]
204    pub fn set_summary(&mut self, text: TextConstruct) {
205        self.summary = Some(text.value.clone());
206        self.summary_detail = Some(text);
207    }
208
209    /// Sets author field with `Person`, storing both simple and detailed versions
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use feedparser_rs::{Entry, Person};
215    ///
216    /// let mut entry = Entry::default();
217    /// entry.set_author(Person::from_name("Jane Doe"));
218    /// assert_eq!(entry.author.as_deref(), Some("Jane Doe"));
219    /// ```
220    #[inline]
221    pub fn set_author(&mut self, person: Person) {
222        self.author.clone_from(&person.name);
223        self.author_detail = Some(person);
224    }
225
226    /// Sets publisher field with `Person`, storing both simple and detailed versions
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use feedparser_rs::{Entry, Person};
232    ///
233    /// let mut entry = Entry::default();
234    /// entry.set_publisher(Person::from_name("ACME Corp"));
235    /// assert_eq!(entry.publisher.as_deref(), Some("ACME Corp"));
236    /// ```
237    #[inline]
238    pub fn set_publisher(&mut self, person: Person) {
239        self.publisher.clone_from(&person.name);
240        self.publisher_detail = Some(person);
241    }
242
243    /// Sets the primary link and adds it to the links collection
244    ///
245    /// This is a convenience method that:
246    /// 1. Sets the `link` field (if not already set)
247    /// 2. Adds an "alternate" link to the `links` collection
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use feedparser_rs::Entry;
253    ///
254    /// let mut entry = Entry::default();
255    /// entry.set_alternate_link("https://example.com/post/1".to_string(), 10);
256    /// assert_eq!(entry.link.as_deref(), Some("https://example.com/post/1"));
257    /// assert_eq!(entry.links.len(), 1);
258    /// assert_eq!(entry.links[0].rel.as_deref(), Some("alternate"));
259    /// ```
260    #[inline]
261    pub fn set_alternate_link(&mut self, href: String, max_links: usize) {
262        if self.link.is_none() {
263            self.link = Some(href.clone());
264        }
265        self.links.try_push_limited(
266            Link {
267                href: href.into(),
268                rel: Some("alternate".into()),
269                ..Default::default()
270            },
271            max_links,
272        );
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_entry_default() {
282        let entry = Entry::default();
283        assert!(entry.id.is_none());
284        assert!(entry.title.is_none());
285        assert!(entry.links.is_empty());
286        assert!(entry.content.is_empty());
287        assert!(entry.authors.is_empty());
288    }
289
290    #[test]
291    #[allow(clippy::redundant_clone)]
292    fn test_entry_clone() {
293        fn create_entry() -> Entry {
294            Entry {
295                title: Some("Test".to_string()),
296                links: vec![Link::default()],
297                ..Default::default()
298            }
299        }
300        let entry = create_entry();
301        let cloned = entry.clone();
302        assert_eq!(cloned.title.as_deref(), Some("Test"));
303        assert_eq!(cloned.links.len(), 1);
304    }
305}