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