feedparser_rs/types/
common.rs

1use super::generics::{FromAttributes, ParseFrom};
2use crate::util::text::bytes_to_string;
3use serde_json::Value;
4
5/// Link in feed or entry
6#[derive(Debug, Clone, Default)]
7pub struct Link {
8    /// Link URL
9    pub href: String,
10    /// Link relationship type (e.g., "alternate", "enclosure", "self")
11    pub rel: Option<String>,
12    /// MIME type of the linked resource
13    pub link_type: Option<String>,
14    /// Human-readable link title
15    pub title: Option<String>,
16    /// Length of the linked resource in bytes
17    pub length: Option<u64>,
18    /// Language of the linked resource
19    pub hreflang: Option<String>,
20}
21
22impl Link {
23    /// Create a new link with just URL and relation type
24    #[inline]
25    pub fn new(href: impl Into<String>, rel: impl Into<String>) -> Self {
26        Self {
27            href: href.into(),
28            rel: Some(rel.into()),
29            link_type: None,
30            title: None,
31            length: None,
32            hreflang: None,
33        }
34    }
35
36    /// Create an alternate link (common for entry URLs)
37    #[inline]
38    pub fn alternate(href: impl Into<String>) -> Self {
39        Self::new(href, "alternate")
40    }
41
42    /// Create a self link (for feed URLs)
43    #[inline]
44    pub fn self_link(href: impl Into<String>, mime_type: impl Into<String>) -> Self {
45        Self {
46            href: href.into(),
47            rel: Some("self".to_string()),
48            link_type: Some(mime_type.into()),
49            title: None,
50            length: None,
51            hreflang: None,
52        }
53    }
54
55    /// Create an enclosure link (for media)
56    #[inline]
57    pub fn enclosure(href: impl Into<String>, mime_type: Option<String>) -> Self {
58        Self {
59            href: href.into(),
60            rel: Some("enclosure".to_string()),
61            link_type: mime_type,
62            title: None,
63            length: None,
64            hreflang: None,
65        }
66    }
67
68    /// Create a related link
69    #[inline]
70    pub fn related(href: impl Into<String>) -> Self {
71        Self::new(href, "related")
72    }
73
74    /// Set MIME type (builder pattern)
75    #[inline]
76    #[must_use]
77    pub fn with_type(mut self, mime_type: impl Into<String>) -> Self {
78        self.link_type = Some(mime_type.into());
79        self
80    }
81}
82
83/// Person (author, contributor, etc.)
84#[derive(Debug, Clone, Default)]
85pub struct Person {
86    /// Person's name
87    pub name: Option<String>,
88    /// Person's email address
89    pub email: Option<String>,
90    /// Person's URI/website
91    pub uri: Option<String>,
92}
93
94impl Person {
95    /// Create person from just a name
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use feedparser_rs::types::Person;
101    ///
102    /// let person = Person::from_name("John Doe");
103    /// assert_eq!(person.name.as_deref(), Some("John Doe"));
104    /// assert!(person.email.is_none());
105    /// assert!(person.uri.is_none());
106    /// ```
107    #[inline]
108    pub fn from_name(name: impl Into<String>) -> Self {
109        Self {
110            name: Some(name.into()),
111            email: None,
112            uri: None,
113        }
114    }
115}
116
117/// Tag/category
118#[derive(Debug, Clone)]
119pub struct Tag {
120    /// Tag term/label
121    pub term: String,
122    /// Tag scheme/domain
123    pub scheme: Option<String>,
124    /// Human-readable tag label
125    pub label: Option<String>,
126}
127
128impl Tag {
129    /// Create a simple tag with just term
130    #[inline]
131    pub fn new(term: impl Into<String>) -> Self {
132        Self {
133            term: term.into(),
134            scheme: None,
135            label: None,
136        }
137    }
138}
139
140/// Image metadata
141#[derive(Debug, Clone)]
142pub struct Image {
143    /// Image URL
144    pub url: String,
145    /// Image title
146    pub title: Option<String>,
147    /// Link associated with the image
148    pub link: Option<String>,
149    /// Image width in pixels
150    pub width: Option<u32>,
151    /// Image height in pixels
152    pub height: Option<u32>,
153    /// Image description
154    pub description: Option<String>,
155}
156
157/// Enclosure (attached media file)
158#[derive(Debug, Clone)]
159pub struct Enclosure {
160    /// Enclosure URL
161    pub url: String,
162    /// File size in bytes
163    pub length: Option<u64>,
164    /// MIME type
165    pub enclosure_type: Option<String>,
166}
167
168/// Content block
169#[derive(Debug, Clone)]
170pub struct Content {
171    /// Content body
172    pub value: String,
173    /// Content MIME type
174    pub content_type: Option<String>,
175    /// Content language
176    pub language: Option<String>,
177    /// Base URL for relative links
178    pub base: Option<String>,
179}
180
181impl Content {
182    /// Create HTML content
183    #[inline]
184    pub fn html(value: impl Into<String>) -> Self {
185        Self {
186            value: value.into(),
187            content_type: Some("text/html".to_string()),
188            language: None,
189            base: None,
190        }
191    }
192
193    /// Create plain text content
194    #[inline]
195    pub fn plain(value: impl Into<String>) -> Self {
196        Self {
197            value: value.into(),
198            content_type: Some("text/plain".to_string()),
199            language: None,
200            base: None,
201        }
202    }
203}
204
205/// Text construct type (Atom-style)
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum TextType {
208    /// Plain text
209    Text,
210    /// HTML content
211    Html,
212    /// XHTML content
213    Xhtml,
214}
215
216/// Text construct with metadata
217#[derive(Debug, Clone)]
218pub struct TextConstruct {
219    /// Text content
220    pub value: String,
221    /// Content type
222    pub content_type: TextType,
223    /// Content language
224    pub language: Option<String>,
225    /// Base URL for relative links
226    pub base: Option<String>,
227}
228
229impl TextConstruct {
230    /// Create plain text construct
231    #[inline]
232    pub fn text(value: impl Into<String>) -> Self {
233        Self {
234            value: value.into(),
235            content_type: TextType::Text,
236            language: None,
237            base: None,
238        }
239    }
240
241    /// Create HTML text construct
242    #[inline]
243    pub fn html(value: impl Into<String>) -> Self {
244        Self {
245            value: value.into(),
246            content_type: TextType::Html,
247            language: None,
248            base: None,
249        }
250    }
251
252    /// Set language (builder pattern)
253    #[inline]
254    #[must_use]
255    pub fn with_language(mut self, language: impl Into<String>) -> Self {
256        self.language = Some(language.into());
257        self
258    }
259}
260
261/// Generator metadata
262#[derive(Debug, Clone)]
263pub struct Generator {
264    /// Generator name
265    pub value: String,
266    /// Generator URI
267    pub uri: Option<String>,
268    /// Generator version
269    pub version: Option<String>,
270}
271
272/// Source reference (for entries)
273#[derive(Debug, Clone)]
274pub struct Source {
275    /// Source title
276    pub title: Option<String>,
277    /// Source link
278    pub link: Option<String>,
279    /// Source ID
280    pub id: Option<String>,
281}
282
283/// Media RSS thumbnail
284#[derive(Debug, Clone)]
285pub struct MediaThumbnail {
286    /// Thumbnail URL
287    pub url: String,
288    /// Thumbnail width in pixels
289    pub width: Option<u32>,
290    /// Thumbnail height in pixels
291    pub height: Option<u32>,
292}
293
294/// Media RSS content
295#[derive(Debug, Clone)]
296pub struct MediaContent {
297    /// Media URL
298    pub url: String,
299    /// MIME type
300    pub content_type: Option<String>,
301    /// File size in bytes
302    pub filesize: Option<u64>,
303    /// Media width in pixels
304    pub width: Option<u32>,
305    /// Media height in pixels
306    pub height: Option<u32>,
307    /// Duration in seconds (for audio/video)
308    pub duration: Option<u64>,
309}
310
311impl FromAttributes for Link {
312    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
313    where
314        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
315    {
316        let mut href = None;
317        let mut rel = None;
318        let mut link_type = None;
319        let mut title = None;
320        let mut hreflang = None;
321        let mut length = None;
322
323        for attr in attrs {
324            if attr.value.len() > max_attr_length {
325                continue;
326            }
327            match attr.key.as_ref() {
328                b"href" => href = Some(bytes_to_string(&attr.value)),
329                b"rel" => rel = Some(bytes_to_string(&attr.value)),
330                b"type" => link_type = Some(bytes_to_string(&attr.value)),
331                b"title" => title = Some(bytes_to_string(&attr.value)),
332                b"hreflang" => hreflang = Some(bytes_to_string(&attr.value)),
333                b"length" => length = bytes_to_string(&attr.value).parse().ok(),
334                _ => {}
335            }
336        }
337
338        href.map(|href| Self {
339            href,
340            rel: rel.or_else(|| Some("alternate".to_string())),
341            link_type,
342            title,
343            length,
344            hreflang,
345        })
346    }
347}
348
349impl FromAttributes for Tag {
350    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
351    where
352        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
353    {
354        let mut term = None;
355        let mut scheme = None;
356        let mut label = None;
357
358        for attr in attrs {
359            if attr.value.len() > max_attr_length {
360                continue;
361            }
362
363            match attr.key.as_ref() {
364                b"term" => term = Some(bytes_to_string(&attr.value)),
365                b"scheme" | b"domain" => scheme = Some(bytes_to_string(&attr.value)),
366                b"label" => label = Some(bytes_to_string(&attr.value)),
367                _ => {}
368            }
369        }
370
371        term.map(|term| Self {
372            term,
373            scheme,
374            label,
375        })
376    }
377}
378
379impl FromAttributes for Enclosure {
380    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
381    where
382        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
383    {
384        let mut url = None;
385        let mut length = None;
386        let mut enclosure_type = None;
387
388        for attr in attrs {
389            if attr.value.len() > max_attr_length {
390                continue;
391            }
392
393            match attr.key.as_ref() {
394                b"url" => url = Some(bytes_to_string(&attr.value)),
395                b"length" => length = bytes_to_string(&attr.value).parse().ok(),
396                b"type" => enclosure_type = Some(bytes_to_string(&attr.value)),
397                _ => {}
398            }
399        }
400
401        url.map(|url| Self {
402            url,
403            length,
404            enclosure_type,
405        })
406    }
407}
408
409impl FromAttributes for MediaThumbnail {
410    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
411    where
412        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
413    {
414        let mut url = None;
415        let mut width = None;
416        let mut height = None;
417
418        for attr in attrs {
419            if attr.value.len() > max_attr_length {
420                continue;
421            }
422
423            match attr.key.as_ref() {
424                b"url" => url = Some(bytes_to_string(&attr.value)),
425                b"width" => width = bytes_to_string(&attr.value).parse().ok(),
426                b"height" => height = bytes_to_string(&attr.value).parse().ok(),
427                _ => {}
428            }
429        }
430
431        url.map(|url| Self { url, width, height })
432    }
433}
434
435impl FromAttributes for MediaContent {
436    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
437    where
438        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
439    {
440        let mut url = None;
441        let mut content_type = None;
442        let mut filesize = None;
443        let mut width = None;
444        let mut height = None;
445        let mut duration = None;
446
447        for attr in attrs {
448            if attr.value.len() > max_attr_length {
449                continue;
450            }
451
452            match attr.key.as_ref() {
453                b"url" => url = Some(bytes_to_string(&attr.value)),
454                b"type" => content_type = Some(bytes_to_string(&attr.value)),
455                b"fileSize" => filesize = bytes_to_string(&attr.value).parse().ok(),
456                b"width" => width = bytes_to_string(&attr.value).parse().ok(),
457                b"height" => height = bytes_to_string(&attr.value).parse().ok(),
458                b"duration" => duration = bytes_to_string(&attr.value).parse().ok(),
459                _ => {}
460            }
461        }
462
463        url.map(|url| Self {
464            url,
465            content_type,
466            filesize,
467            width,
468            height,
469            duration,
470        })
471    }
472}
473
474// ParseFrom implementations for JSON Feed parsing
475
476impl ParseFrom<&Value> for Person {
477    /// Parse Person from JSON Feed author object
478    ///
479    /// JSON Feed format: `{"name": "...", "url": "...", "avatar": "..."}`
480    fn parse_from(json: &Value) -> Option<Self> {
481        json.as_object().map(|obj| Self {
482            name: obj.get("name").and_then(Value::as_str).map(String::from),
483            email: None, // JSON Feed doesn't have email field
484            uri: obj.get("url").and_then(Value::as_str).map(String::from),
485        })
486    }
487}
488
489impl ParseFrom<&Value> for Enclosure {
490    /// Parse Enclosure from JSON Feed attachment object
491    ///
492    /// JSON Feed format: `{"url": "...", "mime_type": "...", "size_in_bytes": ...}`
493    fn parse_from(json: &Value) -> Option<Self> {
494        let obj = json.as_object()?;
495        let url = obj.get("url").and_then(Value::as_str)?;
496        Some(Self {
497            url: url.to_string(),
498            length: obj.get("size_in_bytes").and_then(Value::as_u64),
499            enclosure_type: obj
500                .get("mime_type")
501                .and_then(Value::as_str)
502                .map(String::from),
503        })
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use serde_json::json;
511
512    #[test]
513    fn test_link_default() {
514        let link = Link::default();
515        assert!(link.href.is_empty());
516        assert!(link.rel.is_none());
517    }
518
519    #[test]
520    fn test_link_builders() {
521        let link = Link::alternate("https://example.com");
522        assert_eq!(link.href, "https://example.com");
523        assert_eq!(link.rel.as_deref(), Some("alternate"));
524
525        let link = Link::self_link("https://example.com/feed", "application/feed+json");
526        assert_eq!(link.rel.as_deref(), Some("self"));
527        assert_eq!(link.link_type.as_deref(), Some("application/feed+json"));
528
529        let link = Link::enclosure(
530            "https://example.com/audio.mp3",
531            Some("audio/mpeg".to_string()),
532        );
533        assert_eq!(link.rel.as_deref(), Some("enclosure"));
534        assert_eq!(link.link_type.as_deref(), Some("audio/mpeg"));
535
536        let link = Link::related("https://other.com");
537        assert_eq!(link.rel.as_deref(), Some("related"));
538    }
539
540    #[test]
541    fn test_tag_builder() {
542        let tag = Tag::new("rust");
543        assert_eq!(tag.term, "rust");
544        assert!(tag.scheme.is_none());
545    }
546
547    #[test]
548    fn test_text_construct_builders() {
549        let text = TextConstruct::text("Hello");
550        assert_eq!(text.value, "Hello");
551        assert_eq!(text.content_type, TextType::Text);
552
553        let html = TextConstruct::html("<p>Hello</p>");
554        assert_eq!(html.content_type, TextType::Html);
555
556        let with_lang = TextConstruct::text("Hello").with_language("en");
557        assert_eq!(with_lang.language.as_deref(), Some("en"));
558    }
559
560    #[test]
561    fn test_content_builders() {
562        let html = Content::html("<p>Content</p>");
563        assert_eq!(html.content_type.as_deref(), Some("text/html"));
564
565        let plain = Content::plain("Content");
566        assert_eq!(plain.content_type.as_deref(), Some("text/plain"));
567    }
568
569    #[test]
570    fn test_person_default() {
571        let person = Person::default();
572        assert!(person.name.is_none());
573        assert!(person.email.is_none());
574        assert!(person.uri.is_none());
575    }
576
577    #[test]
578    fn test_person_parse_from_json() {
579        let json = json!({"name": "John Doe", "url": "https://example.com"});
580        let person = Person::parse_from(&json).unwrap();
581        assert_eq!(person.name.as_deref(), Some("John Doe"));
582        assert_eq!(person.uri.as_deref(), Some("https://example.com"));
583        assert!(person.email.is_none());
584    }
585
586    #[test]
587    fn test_person_parse_from_empty_json() {
588        let json = json!({});
589        let person = Person::parse_from(&json).unwrap();
590        assert!(person.name.is_none());
591    }
592
593    #[test]
594    fn test_enclosure_parse_from_json() {
595        let json = json!({
596            "url": "https://example.com/file.mp3",
597            "mime_type": "audio/mpeg",
598            "size_in_bytes": 12345
599        });
600        let enclosure = Enclosure::parse_from(&json).unwrap();
601        assert_eq!(enclosure.url, "https://example.com/file.mp3");
602        assert_eq!(enclosure.enclosure_type.as_deref(), Some("audio/mpeg"));
603        assert_eq!(enclosure.length, Some(12345));
604    }
605
606    #[test]
607    fn test_enclosure_parse_from_json_missing_url() {
608        let json = json!({"mime_type": "audio/mpeg"});
609        assert!(Enclosure::parse_from(&json).is_none());
610    }
611
612    #[test]
613    fn test_text_type_equality() {
614        assert_eq!(TextType::Text, TextType::Text);
615        assert_ne!(TextType::Text, TextType::Html);
616    }
617}