Skip to main content

feedparser_rs/types/
common.rs

1use super::generics::{FromAttributes, ParseFrom};
2use crate::util::{date::parse_date, text::bytes_to_string};
3use chrono::{DateTime, Utc};
4use compact_str::CompactString;
5use serde_json::Value;
6use std::ops::Deref;
7use std::sync::Arc;
8
9/// Optimized string type for small strings (≤24 bytes stored inline)
10///
11/// Uses `CompactString` which stores strings up to 24 bytes inline without heap allocation.
12/// This significantly reduces allocations for common short strings like language codes,
13/// author names, category terms, and other metadata fields.
14///
15/// `CompactString` implements `Deref<Target=str>`, so it can be used transparently as a string.
16///
17/// # Examples
18///
19/// ```
20/// use feedparser_rs::types::SmallString;
21///
22/// let s: SmallString = "en-US".into();
23/// assert_eq!(s.as_str(), "en-US");
24/// assert_eq!(s.len(), 5); // Stored inline, no heap allocation
25/// ```
26pub type SmallString = CompactString;
27
28/// URL newtype for type-safe URL handling
29///
30/// Provides a semantic wrapper around string URLs without validation.
31/// Following the bozo pattern, URLs are not validated during parsing.
32///
33/// # Examples
34///
35/// ```
36/// use feedparser_rs::Url;
37///
38/// let url = Url::new("https://example.com");
39/// assert_eq!(url.as_str(), "https://example.com");
40///
41/// // Deref coercion allows transparent string access
42/// let len: usize = url.len();
43/// assert_eq!(len, 19);
44/// ```
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
46#[serde(transparent)]
47pub struct Url(String);
48
49impl Url {
50    /// Creates a new URL from any type that can be converted to a String
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use feedparser_rs::Url;
56    ///
57    /// let url1 = Url::new("https://example.com");
58    /// let url2 = Url::new(String::from("https://example.com"));
59    /// assert_eq!(url1, url2);
60    /// ```
61    #[inline]
62    pub fn new(s: impl Into<String>) -> Self {
63        Self(s.into())
64    }
65
66    /// Returns the URL as a string slice
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use feedparser_rs::Url;
72    ///
73    /// let url = Url::new("https://example.com");
74    /// assert_eq!(url.as_str(), "https://example.com");
75    /// ```
76    #[inline]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80
81    /// Consumes the `Url` and returns the inner `String`
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use feedparser_rs::Url;
87    ///
88    /// let url = Url::new("https://example.com");
89    /// let inner: String = url.into_inner();
90    /// assert_eq!(inner, "https://example.com");
91    /// ```
92    #[inline]
93    pub fn into_inner(self) -> String {
94        self.0
95    }
96}
97
98impl Deref for Url {
99    type Target = str;
100
101    #[inline]
102    fn deref(&self) -> &str {
103        &self.0
104    }
105}
106
107impl From<String> for Url {
108    #[inline]
109    fn from(s: String) -> Self {
110        Self(s)
111    }
112}
113
114impl From<&str> for Url {
115    #[inline]
116    fn from(s: &str) -> Self {
117        Self(s.to_string())
118    }
119}
120
121impl AsRef<str> for Url {
122    #[inline]
123    fn as_ref(&self) -> &str {
124        &self.0
125    }
126}
127
128impl std::fmt::Display for Url {
129    #[inline]
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        self.0.fmt(f)
132    }
133}
134
135impl PartialEq<str> for Url {
136    fn eq(&self, other: &str) -> bool {
137        self.0 == other
138    }
139}
140
141impl PartialEq<&str> for Url {
142    fn eq(&self, other: &&str) -> bool {
143        self.0 == *other
144    }
145}
146
147impl PartialEq<String> for Url {
148    fn eq(&self, other: &String) -> bool {
149        &self.0 == other
150    }
151}
152
153/// MIME type newtype with string interning
154///
155/// Uses `Arc<str>` for efficient cloning of common MIME types.
156/// Multiple references to the same MIME type share the same allocation.
157///
158/// # Examples
159///
160/// ```
161/// use feedparser_rs::MimeType;
162///
163/// let mime = MimeType::new("text/html");
164/// assert_eq!(mime.as_str(), "text/html");
165///
166/// // Cloning is cheap (just increments reference count)
167/// let clone = mime.clone();
168/// assert_eq!(mime, clone);
169/// ```
170#[derive(Debug, Clone, PartialEq, Eq, Hash)]
171pub struct MimeType(Arc<str>);
172
173// Custom serde implementation for MimeType since Arc<str> doesn't implement Serialize/Deserialize
174impl serde::Serialize for MimeType {
175    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
176    where
177        S: serde::Serializer,
178    {
179        serializer.serialize_str(&self.0)
180    }
181}
182
183impl<'de> serde::Deserialize<'de> for MimeType {
184    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
185    where
186        D: serde::Deserializer<'de>,
187    {
188        let s = <String as serde::Deserialize>::deserialize(deserializer)?;
189        Ok(Self::new(s))
190    }
191}
192
193impl MimeType {
194    /// Creates a new MIME type from any string-like type
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use feedparser_rs::MimeType;
200    ///
201    /// let mime = MimeType::new("application/json");
202    /// assert_eq!(mime.as_str(), "application/json");
203    /// ```
204    #[inline]
205    pub fn new(s: impl AsRef<str>) -> Self {
206        Self(Arc::from(s.as_ref()))
207    }
208
209    /// Returns the MIME type as a string slice
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use feedparser_rs::MimeType;
215    ///
216    /// let mime = MimeType::new("text/plain");
217    /// assert_eq!(mime.as_str(), "text/plain");
218    /// ```
219    #[inline]
220    pub fn as_str(&self) -> &str {
221        &self.0
222    }
223
224    /// Common MIME type constants for convenience.
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use feedparser_rs::MimeType;
230    ///
231    /// let html = MimeType::new(MimeType::TEXT_HTML);
232    /// assert_eq!(html.as_str(), "text/html");
233    /// ```
234    pub const TEXT_HTML: &'static str = "text/html";
235
236    /// `text/plain` MIME type constant
237    pub const TEXT_PLAIN: &'static str = "text/plain";
238
239    /// `application/xml` MIME type constant
240    pub const APPLICATION_XML: &'static str = "application/xml";
241
242    /// `application/json` MIME type constant
243    pub const APPLICATION_JSON: &'static str = "application/json";
244}
245
246impl Default for MimeType {
247    #[inline]
248    fn default() -> Self {
249        Self(Arc::from(""))
250    }
251}
252
253impl Deref for MimeType {
254    type Target = str;
255
256    #[inline]
257    fn deref(&self) -> &str {
258        &self.0
259    }
260}
261
262impl From<String> for MimeType {
263    #[inline]
264    fn from(s: String) -> Self {
265        Self(Arc::from(s.as_str()))
266    }
267}
268
269impl From<&str> for MimeType {
270    #[inline]
271    fn from(s: &str) -> Self {
272        Self(Arc::from(s))
273    }
274}
275
276impl AsRef<str> for MimeType {
277    #[inline]
278    fn as_ref(&self) -> &str {
279        &self.0
280    }
281}
282
283impl std::fmt::Display for MimeType {
284    #[inline]
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        self.0.fmt(f)
287    }
288}
289
290impl PartialEq<str> for MimeType {
291    fn eq(&self, other: &str) -> bool {
292        &*self.0 == other
293    }
294}
295
296impl PartialEq<&str> for MimeType {
297    fn eq(&self, other: &&str) -> bool {
298        &*self.0 == *other
299    }
300}
301
302impl PartialEq<String> for MimeType {
303    fn eq(&self, other: &String) -> bool {
304        &*self.0 == other
305    }
306}
307
308/// Email newtype for type-safe email handling
309///
310/// Provides a semantic wrapper around email addresses without validation.
311/// Following the bozo pattern, emails are not validated during parsing.
312///
313/// # Examples
314///
315/// ```
316/// use feedparser_rs::Email;
317///
318/// let email = Email::new("user@example.com");
319/// assert_eq!(email.as_str(), "user@example.com");
320/// ```
321#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
322#[serde(transparent)]
323pub struct Email(String);
324
325impl Email {
326    /// Creates a new email from any type that can be converted to a String
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// use feedparser_rs::Email;
332    ///
333    /// let email = Email::new("user@example.com");
334    /// assert_eq!(email.as_str(), "user@example.com");
335    /// ```
336    #[inline]
337    pub fn new(s: impl Into<String>) -> Self {
338        Self(s.into())
339    }
340
341    /// Returns the email as a string slice
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// use feedparser_rs::Email;
347    ///
348    /// let email = Email::new("user@example.com");
349    /// assert_eq!(email.as_str(), "user@example.com");
350    /// ```
351    #[inline]
352    pub fn as_str(&self) -> &str {
353        &self.0
354    }
355
356    /// Consumes the `Email` and returns the inner `String`
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// use feedparser_rs::Email;
362    ///
363    /// let email = Email::new("user@example.com");
364    /// let inner: String = email.into_inner();
365    /// assert_eq!(inner, "user@example.com");
366    /// ```
367    #[inline]
368    pub fn into_inner(self) -> String {
369        self.0
370    }
371}
372
373impl Deref for Email {
374    type Target = str;
375
376    #[inline]
377    fn deref(&self) -> &str {
378        &self.0
379    }
380}
381
382impl From<String> for Email {
383    #[inline]
384    fn from(s: String) -> Self {
385        Self(s)
386    }
387}
388
389impl From<&str> for Email {
390    #[inline]
391    fn from(s: &str) -> Self {
392        Self(s.to_string())
393    }
394}
395
396impl AsRef<str> for Email {
397    #[inline]
398    fn as_ref(&self) -> &str {
399        &self.0
400    }
401}
402
403impl std::fmt::Display for Email {
404    #[inline]
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        self.0.fmt(f)
407    }
408}
409
410impl PartialEq<str> for Email {
411    fn eq(&self, other: &str) -> bool {
412        self.0 == other
413    }
414}
415
416impl PartialEq<&str> for Email {
417    fn eq(&self, other: &&str) -> bool {
418        self.0 == *other
419    }
420}
421
422impl PartialEq<String> for Email {
423    fn eq(&self, other: &String) -> bool {
424        &self.0 == other
425    }
426}
427
428/// Link in feed or entry
429#[derive(Debug, Clone, Default)]
430pub struct Link {
431    /// Link URL
432    pub href: Url,
433    /// Link relationship type (e.g., "alternate", "enclosure", "self")
434    /// Stored inline as these are typically short (≤24 bytes)
435    pub rel: Option<SmallString>,
436    /// MIME type of the linked resource
437    pub link_type: Option<MimeType>,
438    /// Human-readable link title
439    pub title: Option<String>,
440    /// Length of the linked resource in bytes (raw string value, as in Python feedparser)
441    pub length: Option<String>,
442    /// Language of the linked resource (stored inline for lang codes ≤24 bytes)
443    pub hreflang: Option<SmallString>,
444    /// RFC 4685 §4: number of replies at the IRI
445    pub thr_count: Option<u32>,
446    /// RFC 4685 §4: when the reply resource was last modified
447    pub thr_updated: Option<DateTime<Utc>>,
448}
449
450impl Link {
451    /// Create a new link with just URL and relation type
452    #[inline]
453    pub fn new(href: impl Into<Url>, rel: impl AsRef<str>) -> Self {
454        Self {
455            href: href.into(),
456            rel: Some(rel.as_ref().into()),
457            link_type: None,
458            title: None,
459            length: None,
460            hreflang: None,
461            thr_count: None,
462            thr_updated: None,
463        }
464    }
465
466    /// Create an alternate link (common for entry URLs)
467    #[inline]
468    pub fn alternate(href: impl Into<Url>) -> Self {
469        Self::new(href, "alternate")
470    }
471
472    /// Create a self link (for feed URLs)
473    #[inline]
474    pub fn self_link(href: impl Into<Url>, mime_type: impl Into<MimeType>) -> Self {
475        Self {
476            href: href.into(),
477            rel: Some("self".into()),
478            link_type: Some(mime_type.into()),
479            title: None,
480            length: None,
481            hreflang: None,
482            thr_count: None,
483            thr_updated: None,
484        }
485    }
486
487    /// Create an enclosure link (for media)
488    #[inline]
489    pub fn enclosure(href: impl Into<Url>, mime_type: Option<MimeType>) -> Self {
490        Self {
491            href: href.into(),
492            rel: Some("enclosure".into()),
493            link_type: mime_type,
494            title: None,
495            length: None,
496            hreflang: None,
497            thr_count: None,
498            thr_updated: None,
499        }
500    }
501
502    /// Create a related link
503    #[inline]
504    pub fn related(href: impl Into<Url>) -> Self {
505        Self::new(href, "related")
506    }
507
508    /// Create a banner image link (JSON Feed `banner_image`, project-internal convention)
509    ///
510    /// Note: `rel="banner"` is not a standard link relation. It is used here as a project
511    /// convention to store JSON Feed `banner_image` in the existing `Link` type. Consumers
512    /// must search `entry.links` for `rel="banner"` to retrieve this field.
513    #[inline]
514    pub fn banner(href: impl Into<Url>) -> Self {
515        Self::new(href, "banner")
516    }
517
518    /// Create a hub link (JSON Feed 1.1 `hubs` array, `WebSub` convention)
519    #[inline]
520    pub fn hub(href: impl Into<Url>) -> Self {
521        Self::new(href, "hub")
522    }
523
524    /// Set MIME type (builder pattern)
525    #[inline]
526    #[must_use]
527    pub fn with_type(mut self, mime_type: impl Into<MimeType>) -> Self {
528        self.link_type = Some(mime_type.into());
529        self
530    }
531}
532
533/// Person (author, contributor, etc.)
534#[derive(Debug, Clone, Default)]
535pub struct Person {
536    /// Person's name (stored inline for names ≤24 bytes)
537    pub name: Option<SmallString>,
538    /// Person's email address
539    pub email: Option<Email>,
540    /// Person's URI/website
541    pub uri: Option<String>,
542    /// Person's avatar image URL (JSON Feed only)
543    pub avatar: Option<String>,
544}
545
546impl Person {
547    /// Create person from just a name
548    ///
549    /// # Examples
550    ///
551    /// ```
552    /// use feedparser_rs::types::Person;
553    ///
554    /// let person = Person::from_name("John Doe");
555    /// assert_eq!(person.name.as_deref(), Some("John Doe"));
556    /// assert!(person.email.is_none());
557    /// assert!(person.uri.is_none());
558    /// ```
559    #[inline]
560    pub fn from_name(name: impl AsRef<str>) -> Self {
561        Self {
562            name: Some(name.as_ref().into()),
563            email: None,
564            uri: None,
565            avatar: None,
566        }
567    }
568
569    /// Build flat author string in `"Name (email)"` format when email is present.
570    ///
571    /// Returns `None` if neither name nor email is set.
572    #[must_use]
573    pub fn flat_string(&self) -> Option<SmallString> {
574        match (&self.name, &self.email) {
575            (Some(name), Some(email)) => Some(format!("{name} ({email})").into()),
576            (Some(name), None) => Some(name.clone()),
577            (None, Some(email)) => Some(email.as_str().into()),
578            (None, None) => None,
579        }
580    }
581}
582
583/// Tag/category
584#[derive(Debug, Clone)]
585pub struct Tag {
586    /// Tag term/label (stored inline for terms ≤24 bytes)
587    pub term: SmallString,
588    /// Tag scheme/domain (stored inline for schemes ≤24 bytes)
589    pub scheme: Option<SmallString>,
590    /// Human-readable tag label (stored inline for labels ≤24 bytes)
591    pub label: Option<SmallString>,
592}
593
594impl Tag {
595    /// Create a simple tag with just term
596    #[inline]
597    pub fn new(term: impl AsRef<str>) -> Self {
598        Self {
599            term: term.as_ref().into(),
600            scheme: None,
601            label: None,
602        }
603    }
604}
605
606/// RSS 2.0 `<cloud>` element — subscription endpoint for updates
607#[derive(Debug, Default, Clone, PartialEq, Eq)]
608pub struct Cloud {
609    /// Domain of the cloud server
610    pub domain: Option<String>,
611    /// Port of the cloud server
612    pub port: Option<String>,
613    /// Path of the cloud server
614    pub path: Option<String>,
615    /// Procedure to register for update notifications
616    pub register_procedure: Option<String>,
617    /// Protocol: xml-rpc, soap, or http-post
618    pub protocol: Option<String>,
619}
620
621/// RSS 2.0 `<textInput>` element — text input form associated with the channel
622#[derive(Debug, Default, Clone, PartialEq, Eq)]
623pub struct TextInput {
624    /// Title of the Submit button in the text input area
625    pub title: Option<String>,
626    /// Explains the text input area
627    pub description: Option<String>,
628    /// The name of the text object in the text input area
629    pub name: Option<String>,
630    /// The URL of the CGI script that processes text input requests
631    pub link: Option<String>,
632}
633
634/// Image metadata
635#[derive(Debug, Clone)]
636pub struct Image {
637    /// Image URL
638    pub url: Url,
639    /// Image title
640    pub title: Option<String>,
641    /// Link associated with the image
642    pub link: Option<String>,
643    /// Image width in pixels
644    pub width: Option<u32>,
645    /// Image height in pixels
646    pub height: Option<u32>,
647    /// Image description
648    pub description: Option<String>,
649}
650
651/// Enclosure (attached media file)
652#[derive(Debug, Clone)]
653pub struct Enclosure {
654    /// Enclosure URL
655    pub url: Url,
656    /// File size in bytes (raw string value, as in Python feedparser)
657    pub length: Option<String>,
658    /// MIME type
659    pub enclosure_type: Option<MimeType>,
660    /// Attachment title (JSON Feed only)
661    pub title: Option<String>,
662    /// Duration in seconds as raw string (JSON Feed `duration_in_seconds`)
663    pub duration: Option<String>,
664}
665
666/// Content block
667#[derive(Debug, Clone)]
668pub struct Content {
669    /// Content body
670    pub value: String,
671    /// Content MIME type
672    pub content_type: Option<MimeType>,
673    /// Content language (stored inline for lang codes ≤24 bytes)
674    pub language: Option<SmallString>,
675    /// Base URL for relative links
676    pub base: Option<String>,
677    /// Out-of-line content URL (Atom `<content src="...">`, RFC 4287 §4.1.3.2)
678    pub src: Option<String>,
679}
680
681impl Content {
682    /// Create HTML content
683    #[inline]
684    pub fn html(value: impl Into<String>) -> Self {
685        Self {
686            value: value.into(),
687            content_type: Some(MimeType::new(MimeType::TEXT_HTML)),
688            language: None,
689            base: None,
690            src: None,
691        }
692    }
693
694    /// Create plain text content
695    #[inline]
696    pub fn plain(value: impl Into<String>) -> Self {
697        Self {
698            value: value.into(),
699            content_type: Some(MimeType::new(MimeType::TEXT_PLAIN)),
700            language: None,
701            base: None,
702            src: None,
703        }
704    }
705}
706
707/// Text construct type (Atom-style)
708#[derive(Debug, Clone, Copy, PartialEq, Eq)]
709pub enum TextType {
710    /// Plain text
711    Text,
712    /// HTML content
713    Html,
714    /// XHTML content
715    Xhtml,
716}
717
718/// Text construct with metadata
719#[derive(Debug, Clone)]
720pub struct TextConstruct {
721    /// Text content
722    pub value: String,
723    /// Content type
724    pub content_type: TextType,
725    /// Content language (stored inline for lang codes ≤24 bytes)
726    pub language: Option<SmallString>,
727    /// Base URL for relative links
728    pub base: Option<String>,
729}
730
731impl TextConstruct {
732    /// Create plain text construct
733    #[inline]
734    pub fn text(value: impl Into<String>) -> Self {
735        Self {
736            value: value.into(),
737            content_type: TextType::Text,
738            language: None,
739            base: None,
740        }
741    }
742
743    /// Create HTML text construct
744    #[inline]
745    pub fn html(value: impl Into<String>) -> Self {
746        Self {
747            value: value.into(),
748            content_type: TextType::Html,
749            language: None,
750            base: None,
751        }
752    }
753
754    /// Set language (builder pattern)
755    #[inline]
756    #[must_use]
757    pub fn with_language(mut self, language: impl AsRef<str>) -> Self {
758        self.language = Some(language.as_ref().into());
759        self
760    }
761}
762
763/// Generator metadata
764#[derive(Debug, Clone)]
765pub struct Generator {
766    /// Generator name (text content of the `<generator>` element)
767    pub name: String,
768    /// Generator URI (href attribute)
769    pub href: Option<String>,
770    /// Generator version (stored inline for versions ≤24 bytes)
771    pub version: Option<SmallString>,
772}
773
774/// Source reference (for entries)
775#[derive(Debug, Clone)]
776pub struct Source {
777    /// Source title
778    pub title: Option<String>,
779    /// Primary source URL for RSS `<source url="...">` (RSS-only field)
780    pub href: Option<String>,
781    /// Primary source URL for Atom `<source><link href="..."/>` (Atom-only field)
782    pub link: Option<String>,
783    /// Source author (flat string, Atom `<source><author>`)
784    pub author: Option<String>,
785    /// Source unique identifier
786    pub id: Option<String>,
787    /// All links from the source element
788    pub links: Vec<Link>,
789    /// Last update date (Atom `<updated>`)
790    pub updated: Option<DateTime<Utc>>,
791    /// Original update date string (timezone preserved)
792    pub updated_str: Option<String>,
793    /// Rights/copyright statement (Atom `<rights>`)
794    pub rights: Option<String>,
795    /// Whether `<id>` was used as the link.
796    ///
797    /// `Some(true)` when `<id>` looks like a URL and no explicit `<link>` was present.
798    /// `Some(false)` when `<id>` is present but a `<link>` was also present, or `<id>` is not a URL.
799    /// `None` for RSS sources (RSS `<source>` has no `<id>`).
800    pub guidislink: Option<bool>,
801}
802
803/// Media RSS thumbnail
804#[derive(Debug, Clone)]
805pub struct MediaThumbnail {
806    /// Thumbnail URL
807    ///
808    /// # Security Warning
809    ///
810    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
811    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
812    pub url: Url,
813    /// Thumbnail width in pixels (raw string value, as in Python feedparser)
814    pub width: Option<String>,
815    /// Thumbnail height in pixels (raw string value, as in Python feedparser)
816    pub height: Option<String>,
817    /// Time offset in NTP format (time attribute)
818    ///
819    /// Indicates which frame of the media this thumbnail represents.
820    pub time: Option<String>,
821}
822
823/// Media RSS content
824#[derive(Debug, Clone)]
825pub struct MediaContent {
826    /// Media URL
827    ///
828    /// # Security Warning
829    ///
830    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
831    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
832    pub url: Url,
833    /// MIME type
834    pub content_type: Option<MimeType>,
835    /// Medium type: "image", "video", "audio", "document", "executable"
836    pub medium: Option<String>,
837    /// File size in bytes (raw string value, as in Python feedparser)
838    pub filesize: Option<String>,
839    /// Media width in pixels (raw string value, as in Python feedparser)
840    pub width: Option<String>,
841    /// Media height in pixels (raw string value, as in Python feedparser)
842    pub height: Option<String>,
843    /// Duration in seconds (raw string value, as in Python feedparser)
844    pub duration: Option<String>,
845    /// Bitrate in kilobits per second (raw string value)
846    pub bitrate: Option<String>,
847    /// Language of the media (lang attribute)
848    pub lang: Option<String>,
849    /// Number of audio channels (raw string value)
850    pub channels: Option<String>,
851    /// Codec used to produce the media (codec attribute)
852    pub codec: Option<String>,
853    /// Expression type: "full", "sample", "nonstop"
854    pub expression: Option<String>,
855    /// Whether this is the default media object (isDefault attribute, raw string)
856    pub isdefault: Option<String>,
857    /// Sampling rate in kHz (raw string value)
858    pub samplingrate: Option<String>,
859    /// Frame rate in frames per second (raw string value)
860    pub framerate: Option<String>,
861}
862
863/// Media RSS rating element (`media:rating`)
864///
865/// Describes the permissible audience for the media content.
866/// Commonly uses the MPAA or urn:simple rating schemes.
867#[derive(Debug, Clone, Default, PartialEq, Eq)]
868pub struct MediaRating {
869    /// Rating scheme URI (scheme attribute), e.g. "urn:simple", "urn:mpaa"
870    pub scheme: Option<String>,
871    /// Rating value, e.g. "adult", "nonadult", "pg-13"
872    pub content: String,
873}
874
875impl FromAttributes for Link {
876    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
877    where
878        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
879    {
880        let mut href = None;
881        let mut rel = None;
882        let mut link_type = None;
883        let mut title = None;
884        let mut hreflang = None;
885        let mut length = None;
886        let mut thr_count = None;
887        let mut thr_updated = None;
888
889        for attr in attrs {
890            if attr.value.len() > max_attr_length {
891                continue;
892            }
893            match attr.key.as_ref() {
894                b"href" => href = Some(bytes_to_string(&attr.value)),
895                b"rel" => rel = Some(bytes_to_string(&attr.value)),
896                b"type" => link_type = Some(bytes_to_string(&attr.value)),
897                b"title" => title = Some(bytes_to_string(&attr.value)),
898                b"hreflang" => hreflang = Some(bytes_to_string(&attr.value)),
899                b"length" => length = Some(bytes_to_string(&attr.value)),
900                b"thr:count" => {
901                    thr_count = bytes_to_string(&attr.value).trim().parse::<u32>().ok();
902                }
903                b"thr:updated" => {
904                    thr_updated = parse_date(bytes_to_string(&attr.value).trim());
905                }
906                _ => {}
907            }
908        }
909
910        let rel_str: Option<SmallString> = rel
911            .map(std::convert::Into::into)
912            .or_else(|| Some("alternate".into()));
913        let resolved_type = link_type
914            .map(MimeType::new)
915            .or_else(|| match rel_str.as_deref() {
916                Some("self") => Some(MimeType::new("application/atom+xml")),
917                _ => Some(MimeType::new("text/html")),
918            });
919
920        href.map(|href| Self {
921            href: Url::new(href),
922            rel: rel_str,
923            link_type: resolved_type,
924            title,
925            length,
926            hreflang: hreflang.map(std::convert::Into::into),
927            thr_count,
928            thr_updated,
929        })
930    }
931}
932
933impl FromAttributes for Tag {
934    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
935    where
936        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
937    {
938        let mut term = None;
939        let mut scheme = None;
940        let mut label = None;
941
942        for attr in attrs {
943            if attr.value.len() > max_attr_length {
944                continue;
945            }
946
947            match attr.key.as_ref() {
948                b"term" => term = Some(bytes_to_string(&attr.value)),
949                b"scheme" | b"domain" => scheme = Some(bytes_to_string(&attr.value)),
950                b"label" => label = Some(bytes_to_string(&attr.value)),
951                _ => {}
952            }
953        }
954
955        term.map(|term| Self {
956            term: term.into(),
957            scheme: scheme.map(std::convert::Into::into),
958            label: label.map(std::convert::Into::into),
959        })
960    }
961}
962
963impl FromAttributes for Enclosure {
964    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
965    where
966        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
967    {
968        let mut url = None;
969        let mut length = None;
970        let mut enclosure_type = None;
971
972        for attr in attrs {
973            if attr.value.len() > max_attr_length {
974                continue;
975            }
976
977            match attr.key.as_ref() {
978                b"url" => url = Some(bytes_to_string(&attr.value)),
979                b"length" => length = Some(bytes_to_string(&attr.value)),
980                b"type" => enclosure_type = Some(bytes_to_string(&attr.value)),
981                _ => {}
982            }
983        }
984
985        url.map(|url| Self {
986            url: Url::new(url),
987            length,
988            enclosure_type: enclosure_type.map(MimeType::new),
989            title: None,
990            duration: None,
991        })
992    }
993}
994
995impl FromAttributes for MediaThumbnail {
996    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
997    where
998        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
999    {
1000        let mut url = None;
1001        let mut width = None;
1002        let mut height = None;
1003
1004        let mut time = None;
1005
1006        for attr in attrs {
1007            if attr.value.len() > max_attr_length {
1008                continue;
1009            }
1010
1011            match attr.key.as_ref() {
1012                b"url" => url = Some(bytes_to_string(&attr.value)),
1013                b"width" => width = Some(bytes_to_string(&attr.value)),
1014                b"height" => height = Some(bytes_to_string(&attr.value)),
1015                b"time" => time = Some(bytes_to_string(&attr.value)),
1016                _ => {}
1017            }
1018        }
1019
1020        url.map(|url| Self {
1021            url: Url::new(url),
1022            width,
1023            height,
1024            time,
1025        })
1026    }
1027}
1028
1029impl FromAttributes for MediaContent {
1030    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
1031    where
1032        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
1033    {
1034        let mut url = None;
1035        let mut content_type = None;
1036        let mut medium = None;
1037        let mut filesize = None;
1038        let mut width = None;
1039        let mut height = None;
1040        let mut duration = None;
1041        let mut bitrate = None;
1042        let mut lang = None;
1043        let mut channels = None;
1044        let mut codec = None;
1045        let mut expression = None;
1046        let mut isdefault = None;
1047        let mut samplingrate = None;
1048        let mut framerate = None;
1049
1050        for attr in attrs {
1051            if attr.value.len() > max_attr_length {
1052                continue;
1053            }
1054
1055            match attr.key.as_ref() {
1056                b"url" => url = Some(bytes_to_string(&attr.value)),
1057                b"type" => content_type = Some(bytes_to_string(&attr.value)),
1058                b"medium" => medium = Some(bytes_to_string(&attr.value)),
1059                b"fileSize" => filesize = Some(bytes_to_string(&attr.value)),
1060                b"width" => width = Some(bytes_to_string(&attr.value)),
1061                b"height" => height = Some(bytes_to_string(&attr.value)),
1062                b"duration" => duration = Some(bytes_to_string(&attr.value)),
1063                b"bitrate" => bitrate = Some(bytes_to_string(&attr.value)),
1064                b"lang" => lang = Some(bytes_to_string(&attr.value)),
1065                b"channels" => channels = Some(bytes_to_string(&attr.value)),
1066                b"codec" => codec = Some(bytes_to_string(&attr.value)),
1067                b"expression" => expression = Some(bytes_to_string(&attr.value)),
1068                b"isDefault" => isdefault = Some(bytes_to_string(&attr.value)),
1069                b"samplingrate" => samplingrate = Some(bytes_to_string(&attr.value)),
1070                b"framerate" => framerate = Some(bytes_to_string(&attr.value)),
1071                _ => {}
1072            }
1073        }
1074
1075        url.map(|url| Self {
1076            url: Url::new(url),
1077            content_type: content_type.map(MimeType::new),
1078            medium,
1079            filesize,
1080            width,
1081            height,
1082            duration,
1083            bitrate,
1084            lang,
1085            channels,
1086            codec,
1087            expression,
1088            isdefault,
1089            samplingrate,
1090            framerate,
1091        })
1092    }
1093}
1094
1095// ParseFrom implementations for JSON Feed parsing
1096
1097impl ParseFrom<&Value> for Person {
1098    /// Parse Person from JSON Feed author object
1099    ///
1100    /// JSON Feed format: `{"name": "...", "url": "...", "avatar": "..."}`
1101    fn parse_from(json: &Value) -> Option<Self> {
1102        json.as_object().map(|obj| Self {
1103            name: obj
1104                .get("name")
1105                .and_then(Value::as_str)
1106                .map(std::convert::Into::into),
1107            email: None, // JSON Feed doesn't have email field
1108            uri: obj.get("url").and_then(Value::as_str).map(String::from),
1109            avatar: obj.get("avatar").and_then(Value::as_str).map(String::from),
1110        })
1111    }
1112}
1113
1114impl ParseFrom<&Value> for Enclosure {
1115    /// Parse Enclosure from JSON Feed attachment object
1116    ///
1117    /// JSON Feed format: `{"url": "...", "mime_type": "...", "size_in_bytes": ...}`
1118    fn parse_from(json: &Value) -> Option<Self> {
1119        let obj = json.as_object()?;
1120        let url = obj.get("url").and_then(Value::as_str)?;
1121        Some(Self {
1122            url: Url::new(url),
1123            length: obj
1124                .get("size_in_bytes")
1125                .and_then(Value::as_u64)
1126                .map(|v| v.to_string()),
1127            enclosure_type: obj
1128                .get("mime_type")
1129                .and_then(Value::as_str)
1130                .map(MimeType::new),
1131            title: obj.get("title").and_then(Value::as_str).map(String::from),
1132            duration: obj
1133                .get("duration_in_seconds")
1134                .and_then(Value::as_u64)
1135                .map(|v| v.to_string()),
1136        })
1137    }
1138}
1139
1140/// Media RSS credit element (media:credit)
1141#[derive(Debug, Clone, Default)]
1142pub struct MediaCredit {
1143    /// Credit role (e.g., "author", "producer")
1144    pub role: Option<String>,
1145    /// Credit scheme URI (default: "urn:ebu")
1146    pub scheme: Option<String>,
1147    /// Credit text content (person/entity name)
1148    pub content: String,
1149}
1150
1151/// Media RSS copyright element (media:copyright)
1152#[derive(Debug, Clone, Default)]
1153pub struct MediaCopyright {
1154    /// Copyright URL
1155    pub url: Option<String>,
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161    use serde_json::json;
1162
1163    #[test]
1164    fn test_link_default() {
1165        let link = Link::default();
1166        assert!(link.href.is_empty());
1167        assert!(link.rel.is_none());
1168    }
1169
1170    #[test]
1171    fn test_link_builders() {
1172        let link = Link::alternate("https://example.com");
1173        assert_eq!(link.href, "https://example.com");
1174        assert_eq!(link.rel.as_deref(), Some("alternate"));
1175
1176        let link = Link::self_link("https://example.com/feed", "application/feed+json");
1177        assert_eq!(link.rel.as_deref(), Some("self"));
1178        assert_eq!(link.link_type.as_deref(), Some("application/feed+json"));
1179
1180        let link = Link::enclosure("https://example.com/audio.mp3", Some("audio/mpeg".into()));
1181        assert_eq!(link.rel.as_deref(), Some("enclosure"));
1182        assert_eq!(link.link_type.as_deref(), Some("audio/mpeg"));
1183
1184        let link = Link::related("https://other.com");
1185        assert_eq!(link.rel.as_deref(), Some("related"));
1186    }
1187
1188    #[test]
1189    fn test_tag_builder() {
1190        let tag = Tag::new("rust");
1191        assert_eq!(tag.term, "rust");
1192        assert!(tag.scheme.is_none());
1193    }
1194
1195    #[test]
1196    fn test_text_construct_builders() {
1197        let text = TextConstruct::text("Hello");
1198        assert_eq!(text.value, "Hello");
1199        assert_eq!(text.content_type, TextType::Text);
1200
1201        let html = TextConstruct::html("<p>Hello</p>");
1202        assert_eq!(html.content_type, TextType::Html);
1203
1204        let with_lang = TextConstruct::text("Hello").with_language("en");
1205        assert_eq!(with_lang.language.as_deref(), Some("en"));
1206    }
1207
1208    #[test]
1209    fn test_content_builders() {
1210        let html = Content::html("<p>Content</p>");
1211        assert_eq!(html.content_type.as_deref(), Some("text/html"));
1212
1213        let plain = Content::plain("Content");
1214        assert_eq!(plain.content_type.as_deref(), Some("text/plain"));
1215    }
1216
1217    #[test]
1218    fn test_person_default() {
1219        let person = Person::default();
1220        assert!(person.name.is_none());
1221        assert!(person.email.is_none());
1222        assert!(person.uri.is_none());
1223    }
1224
1225    #[test]
1226    fn test_person_parse_from_json() {
1227        let json = json!({"name": "John Doe", "url": "https://example.com"});
1228        let person = Person::parse_from(&json).unwrap();
1229        assert_eq!(person.name.as_deref(), Some("John Doe"));
1230        assert_eq!(person.uri.as_deref(), Some("https://example.com"));
1231        assert!(person.email.is_none());
1232    }
1233
1234    #[test]
1235    fn test_person_parse_from_empty_json() {
1236        let json = json!({});
1237        let person = Person::parse_from(&json).unwrap();
1238        assert!(person.name.is_none());
1239    }
1240
1241    #[test]
1242    fn test_enclosure_parse_from_json() {
1243        let json = json!({
1244            "url": "https://example.com/file.mp3",
1245            "mime_type": "audio/mpeg",
1246            "size_in_bytes": 12345
1247        });
1248        let enclosure = Enclosure::parse_from(&json).unwrap();
1249        assert_eq!(enclosure.url, "https://example.com/file.mp3");
1250        assert_eq!(enclosure.enclosure_type.as_deref(), Some("audio/mpeg"));
1251        assert_eq!(enclosure.length.as_deref(), Some("12345"));
1252    }
1253
1254    #[test]
1255    fn test_enclosure_parse_from_json_missing_url() {
1256        let json = json!({"mime_type": "audio/mpeg"});
1257        assert!(Enclosure::parse_from(&json).is_none());
1258    }
1259
1260    #[test]
1261    fn test_text_type_equality() {
1262        assert_eq!(TextType::Text, TextType::Text);
1263        assert_ne!(TextType::Text, TextType::Html);
1264    }
1265
1266    // Newtype tests
1267
1268    #[test]
1269    fn test_url_new() {
1270        let url = Url::new("https://example.com");
1271        assert_eq!(url.as_str(), "https://example.com");
1272    }
1273
1274    #[test]
1275    fn test_url_from_string() {
1276        let url: Url = String::from("https://example.com").into();
1277        assert_eq!(url.as_str(), "https://example.com");
1278    }
1279
1280    #[test]
1281    fn test_url_from_str() {
1282        let url: Url = "https://example.com".into();
1283        assert_eq!(url.as_str(), "https://example.com");
1284    }
1285
1286    #[test]
1287    fn test_url_deref() {
1288        let url = Url::new("https://example.com");
1289        // Deref allows calling str methods directly
1290        assert_eq!(url.len(), 19);
1291        assert!(url.starts_with("https://"));
1292    }
1293
1294    #[test]
1295    fn test_url_into_inner() {
1296        let url = Url::new("https://example.com");
1297        let inner = url.into_inner();
1298        assert_eq!(inner, "https://example.com");
1299    }
1300
1301    #[test]
1302    fn test_url_default() {
1303        let url = Url::default();
1304        assert_eq!(url.as_str(), "");
1305    }
1306
1307    #[test]
1308    fn test_url_clone() {
1309        let url1 = Url::new("https://example.com");
1310        let url2 = url1.clone();
1311        assert_eq!(url1, url2);
1312    }
1313
1314    #[test]
1315    fn test_mime_type_new() {
1316        let mime = MimeType::new("text/html");
1317        assert_eq!(mime.as_str(), "text/html");
1318    }
1319
1320    #[test]
1321    fn test_mime_type_from_string() {
1322        let mime: MimeType = String::from("application/json").into();
1323        assert_eq!(mime.as_str(), "application/json");
1324    }
1325
1326    #[test]
1327    fn test_mime_type_from_str() {
1328        let mime: MimeType = "text/plain".into();
1329        assert_eq!(mime.as_str(), "text/plain");
1330    }
1331
1332    #[test]
1333    fn test_mime_type_deref() {
1334        let mime = MimeType::new("text/html");
1335        assert_eq!(mime.len(), 9);
1336        assert!(mime.starts_with("text/"));
1337    }
1338
1339    #[test]
1340    fn test_mime_type_default() {
1341        let mime = MimeType::default();
1342        assert_eq!(mime.as_str(), "");
1343    }
1344
1345    #[test]
1346    fn test_mime_type_clone() {
1347        let mime1 = MimeType::new("application/xml");
1348        let mime2 = mime1.clone();
1349        assert_eq!(mime1, mime2);
1350        // Arc cloning is cheap - just increments refcount
1351    }
1352
1353    #[test]
1354    fn test_mime_type_constants() {
1355        assert_eq!(MimeType::TEXT_HTML, "text/html");
1356        assert_eq!(MimeType::TEXT_PLAIN, "text/plain");
1357        assert_eq!(MimeType::APPLICATION_XML, "application/xml");
1358        assert_eq!(MimeType::APPLICATION_JSON, "application/json");
1359    }
1360
1361    #[test]
1362    fn test_email_new() {
1363        let email = Email::new("user@example.com");
1364        assert_eq!(email.as_str(), "user@example.com");
1365    }
1366
1367    #[test]
1368    fn test_email_from_string() {
1369        let email: Email = String::from("user@example.com").into();
1370        assert_eq!(email.as_str(), "user@example.com");
1371    }
1372
1373    #[test]
1374    fn test_email_from_str() {
1375        let email: Email = "user@example.com".into();
1376        assert_eq!(email.as_str(), "user@example.com");
1377    }
1378
1379    #[test]
1380    fn test_email_deref() {
1381        let email = Email::new("user@example.com");
1382        assert_eq!(email.len(), 16);
1383        assert!(email.contains('@'));
1384    }
1385
1386    #[test]
1387    fn test_email_into_inner() {
1388        let email = Email::new("user@example.com");
1389        let inner = email.into_inner();
1390        assert_eq!(inner, "user@example.com");
1391    }
1392
1393    #[test]
1394    fn test_email_default() {
1395        let email = Email::default();
1396        assert_eq!(email.as_str(), "");
1397    }
1398
1399    #[test]
1400    fn test_email_clone() {
1401        let email1 = Email::new("user@example.com");
1402        let email2 = email1.clone();
1403        assert_eq!(email1, email2);
1404    }
1405}