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    /// Set MIME type (builder pattern)
519    #[inline]
520    #[must_use]
521    pub fn with_type(mut self, mime_type: impl Into<MimeType>) -> Self {
522        self.link_type = Some(mime_type.into());
523        self
524    }
525}
526
527/// Person (author, contributor, etc.)
528#[derive(Debug, Clone, Default)]
529pub struct Person {
530    /// Person's name (stored inline for names ≤24 bytes)
531    pub name: Option<SmallString>,
532    /// Person's email address
533    pub email: Option<Email>,
534    /// Person's URI/website
535    pub uri: Option<String>,
536    /// Person's avatar image URL (JSON Feed only)
537    pub avatar: Option<String>,
538}
539
540impl Person {
541    /// Create person from just a name
542    ///
543    /// # Examples
544    ///
545    /// ```
546    /// use feedparser_rs::types::Person;
547    ///
548    /// let person = Person::from_name("John Doe");
549    /// assert_eq!(person.name.as_deref(), Some("John Doe"));
550    /// assert!(person.email.is_none());
551    /// assert!(person.uri.is_none());
552    /// ```
553    #[inline]
554    pub fn from_name(name: impl AsRef<str>) -> Self {
555        Self {
556            name: Some(name.as_ref().into()),
557            email: None,
558            uri: None,
559            avatar: None,
560        }
561    }
562
563    /// Build flat author string in `"Name (email)"` format when email is present.
564    ///
565    /// Returns `None` if neither name nor email is set.
566    #[must_use]
567    pub fn flat_string(&self) -> Option<SmallString> {
568        match (&self.name, &self.email) {
569            (Some(name), Some(email)) => Some(format!("{name} ({email})").into()),
570            (Some(name), None) => Some(name.clone()),
571            (None, Some(email)) => Some(email.as_str().into()),
572            (None, None) => None,
573        }
574    }
575}
576
577/// Tag/category
578#[derive(Debug, Clone)]
579pub struct Tag {
580    /// Tag term/label (stored inline for terms ≤24 bytes)
581    pub term: SmallString,
582    /// Tag scheme/domain (stored inline for schemes ≤24 bytes)
583    pub scheme: Option<SmallString>,
584    /// Human-readable tag label (stored inline for labels ≤24 bytes)
585    pub label: Option<SmallString>,
586}
587
588impl Tag {
589    /// Create a simple tag with just term
590    #[inline]
591    pub fn new(term: impl AsRef<str>) -> Self {
592        Self {
593            term: term.as_ref().into(),
594            scheme: None,
595            label: None,
596        }
597    }
598}
599
600/// RSS 2.0 `<cloud>` element — subscription endpoint for updates
601#[derive(Debug, Default, Clone, PartialEq, Eq)]
602pub struct Cloud {
603    /// Domain of the cloud server
604    pub domain: Option<String>,
605    /// Port of the cloud server
606    pub port: Option<String>,
607    /// Path of the cloud server
608    pub path: Option<String>,
609    /// Procedure to register for update notifications
610    pub register_procedure: Option<String>,
611    /// Protocol: xml-rpc, soap, or http-post
612    pub protocol: Option<String>,
613}
614
615/// RSS 2.0 `<textInput>` element — text input form associated with the channel
616#[derive(Debug, Default, Clone, PartialEq, Eq)]
617pub struct TextInput {
618    /// Title of the Submit button in the text input area
619    pub title: Option<String>,
620    /// Explains the text input area
621    pub description: Option<String>,
622    /// The name of the text object in the text input area
623    pub name: Option<String>,
624    /// The URL of the CGI script that processes text input requests
625    pub link: Option<String>,
626}
627
628/// Image metadata
629#[derive(Debug, Clone)]
630pub struct Image {
631    /// Image URL
632    pub url: Url,
633    /// Image title
634    pub title: Option<String>,
635    /// Link associated with the image
636    pub link: Option<String>,
637    /// Image width in pixels
638    pub width: Option<u32>,
639    /// Image height in pixels
640    pub height: Option<u32>,
641    /// Image description
642    pub description: Option<String>,
643}
644
645/// Enclosure (attached media file)
646#[derive(Debug, Clone)]
647pub struct Enclosure {
648    /// Enclosure URL
649    pub url: Url,
650    /// File size in bytes (raw string value, as in Python feedparser)
651    pub length: Option<String>,
652    /// MIME type
653    pub enclosure_type: Option<MimeType>,
654    /// Attachment title (JSON Feed only)
655    pub title: Option<String>,
656    /// Duration in seconds as raw string (JSON Feed `duration_in_seconds`)
657    pub duration: Option<String>,
658}
659
660/// Content block
661#[derive(Debug, Clone)]
662pub struct Content {
663    /// Content body
664    pub value: String,
665    /// Content MIME type
666    pub content_type: Option<MimeType>,
667    /// Content language (stored inline for lang codes ≤24 bytes)
668    pub language: Option<SmallString>,
669    /// Base URL for relative links
670    pub base: Option<String>,
671    /// Out-of-line content URL (Atom `<content src="...">`, RFC 4287 §4.1.3.2)
672    pub src: Option<String>,
673}
674
675impl Content {
676    /// Create HTML content
677    #[inline]
678    pub fn html(value: impl Into<String>) -> Self {
679        Self {
680            value: value.into(),
681            content_type: Some(MimeType::new(MimeType::TEXT_HTML)),
682            language: None,
683            base: None,
684            src: None,
685        }
686    }
687
688    /// Create plain text content
689    #[inline]
690    pub fn plain(value: impl Into<String>) -> Self {
691        Self {
692            value: value.into(),
693            content_type: Some(MimeType::new(MimeType::TEXT_PLAIN)),
694            language: None,
695            base: None,
696            src: None,
697        }
698    }
699}
700
701/// Text construct type (Atom-style)
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub enum TextType {
704    /// Plain text
705    Text,
706    /// HTML content
707    Html,
708    /// XHTML content
709    Xhtml,
710}
711
712/// Text construct with metadata
713#[derive(Debug, Clone)]
714pub struct TextConstruct {
715    /// Text content
716    pub value: String,
717    /// Content type
718    pub content_type: TextType,
719    /// Content language (stored inline for lang codes ≤24 bytes)
720    pub language: Option<SmallString>,
721    /// Base URL for relative links
722    pub base: Option<String>,
723}
724
725impl TextConstruct {
726    /// Create plain text construct
727    #[inline]
728    pub fn text(value: impl Into<String>) -> Self {
729        Self {
730            value: value.into(),
731            content_type: TextType::Text,
732            language: None,
733            base: None,
734        }
735    }
736
737    /// Create HTML text construct
738    #[inline]
739    pub fn html(value: impl Into<String>) -> Self {
740        Self {
741            value: value.into(),
742            content_type: TextType::Html,
743            language: None,
744            base: None,
745        }
746    }
747
748    /// Set language (builder pattern)
749    #[inline]
750    #[must_use]
751    pub fn with_language(mut self, language: impl AsRef<str>) -> Self {
752        self.language = Some(language.as_ref().into());
753        self
754    }
755}
756
757/// Generator metadata
758#[derive(Debug, Clone)]
759pub struct Generator {
760    /// Generator name (text content of the `<generator>` element)
761    pub name: String,
762    /// Generator URI (href attribute)
763    pub href: Option<String>,
764    /// Generator version (stored inline for versions ≤24 bytes)
765    pub version: Option<SmallString>,
766}
767
768/// Source reference (for entries)
769#[derive(Debug, Clone)]
770pub struct Source {
771    /// Source title
772    pub title: Option<String>,
773    /// Primary source URL for RSS `<source url="...">` (RSS-only field)
774    pub href: Option<String>,
775    /// Primary source URL for Atom `<source><link href="..."/>` (Atom-only field)
776    pub link: Option<String>,
777    /// Source author (flat string, Atom `<source><author>`)
778    pub author: Option<String>,
779    /// Source unique identifier
780    pub id: Option<String>,
781    /// All links from the source element
782    pub links: Vec<Link>,
783    /// Last update date (Atom `<updated>`)
784    pub updated: Option<DateTime<Utc>>,
785    /// Original update date string (timezone preserved)
786    pub updated_str: Option<String>,
787    /// Rights/copyright statement (Atom `<rights>`)
788    pub rights: Option<String>,
789    /// Whether `<id>` was used as the link.
790    ///
791    /// `Some(true)` when `<id>` looks like a URL and no explicit `<link>` was present.
792    /// `Some(false)` when `<id>` is present but a `<link>` was also present, or `<id>` is not a URL.
793    /// `None` for RSS sources (RSS `<source>` has no `<id>`).
794    pub guidislink: Option<bool>,
795}
796
797/// Media RSS thumbnail
798#[derive(Debug, Clone)]
799pub struct MediaThumbnail {
800    /// Thumbnail URL
801    ///
802    /// # Security Warning
803    ///
804    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
805    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
806    pub url: Url,
807    /// Thumbnail width in pixels (raw string value, as in Python feedparser)
808    pub width: Option<String>,
809    /// Thumbnail height in pixels (raw string value, as in Python feedparser)
810    pub height: Option<String>,
811    /// Time offset in NTP format (time attribute)
812    ///
813    /// Indicates which frame of the media this thumbnail represents.
814    pub time: Option<String>,
815}
816
817/// Media RSS content
818#[derive(Debug, Clone)]
819pub struct MediaContent {
820    /// Media URL
821    ///
822    /// # Security Warning
823    ///
824    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
825    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
826    pub url: Url,
827    /// MIME type
828    pub content_type: Option<MimeType>,
829    /// Medium type: "image", "video", "audio", "document", "executable"
830    pub medium: Option<String>,
831    /// File size in bytes (raw string value, as in Python feedparser)
832    pub filesize: Option<String>,
833    /// Media width in pixels (raw string value, as in Python feedparser)
834    pub width: Option<String>,
835    /// Media height in pixels (raw string value, as in Python feedparser)
836    pub height: Option<String>,
837    /// Duration in seconds (raw string value, as in Python feedparser)
838    pub duration: Option<String>,
839    /// Bitrate in kilobits per second (raw string value)
840    pub bitrate: Option<String>,
841    /// Language of the media (lang attribute)
842    pub lang: Option<String>,
843    /// Number of audio channels (raw string value)
844    pub channels: Option<String>,
845    /// Codec used to produce the media (codec attribute)
846    pub codec: Option<String>,
847    /// Expression type: "full", "sample", "nonstop"
848    pub expression: Option<String>,
849    /// Whether this is the default media object (isDefault attribute, raw string)
850    pub isdefault: Option<String>,
851    /// Sampling rate in kHz (raw string value)
852    pub samplingrate: Option<String>,
853    /// Frame rate in frames per second (raw string value)
854    pub framerate: Option<String>,
855}
856
857/// Media RSS rating element (`media:rating`)
858///
859/// Describes the permissible audience for the media content.
860/// Commonly uses the MPAA or urn:simple rating schemes.
861#[derive(Debug, Clone, Default, PartialEq, Eq)]
862pub struct MediaRating {
863    /// Rating scheme URI (scheme attribute), e.g. "urn:simple", "urn:mpaa"
864    pub scheme: Option<String>,
865    /// Rating value, e.g. "adult", "nonadult", "pg-13"
866    pub content: String,
867}
868
869impl FromAttributes for Link {
870    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
871    where
872        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
873    {
874        let mut href = None;
875        let mut rel = None;
876        let mut link_type = None;
877        let mut title = None;
878        let mut hreflang = None;
879        let mut length = None;
880        let mut thr_count = None;
881        let mut thr_updated = None;
882
883        for attr in attrs {
884            if attr.value.len() > max_attr_length {
885                continue;
886            }
887            match attr.key.as_ref() {
888                b"href" => href = Some(bytes_to_string(&attr.value)),
889                b"rel" => rel = Some(bytes_to_string(&attr.value)),
890                b"type" => link_type = Some(bytes_to_string(&attr.value)),
891                b"title" => title = Some(bytes_to_string(&attr.value)),
892                b"hreflang" => hreflang = Some(bytes_to_string(&attr.value)),
893                b"length" => length = Some(bytes_to_string(&attr.value)),
894                b"thr:count" => {
895                    thr_count = bytes_to_string(&attr.value).trim().parse::<u32>().ok();
896                }
897                b"thr:updated" => {
898                    thr_updated = parse_date(bytes_to_string(&attr.value).trim());
899                }
900                _ => {}
901            }
902        }
903
904        let rel_str: Option<SmallString> = rel
905            .map(std::convert::Into::into)
906            .or_else(|| Some("alternate".into()));
907        let resolved_type = link_type
908            .map(MimeType::new)
909            .or_else(|| match rel_str.as_deref() {
910                Some("self") => Some(MimeType::new("application/atom+xml")),
911                _ => Some(MimeType::new("text/html")),
912            });
913
914        href.map(|href| Self {
915            href: Url::new(href),
916            rel: rel_str,
917            link_type: resolved_type,
918            title,
919            length,
920            hreflang: hreflang.map(std::convert::Into::into),
921            thr_count,
922            thr_updated,
923        })
924    }
925}
926
927impl FromAttributes for Tag {
928    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
929    where
930        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
931    {
932        let mut term = None;
933        let mut scheme = None;
934        let mut label = None;
935
936        for attr in attrs {
937            if attr.value.len() > max_attr_length {
938                continue;
939            }
940
941            match attr.key.as_ref() {
942                b"term" => term = Some(bytes_to_string(&attr.value)),
943                b"scheme" | b"domain" => scheme = Some(bytes_to_string(&attr.value)),
944                b"label" => label = Some(bytes_to_string(&attr.value)),
945                _ => {}
946            }
947        }
948
949        term.map(|term| Self {
950            term: term.into(),
951            scheme: scheme.map(std::convert::Into::into),
952            label: label.map(std::convert::Into::into),
953        })
954    }
955}
956
957impl FromAttributes for Enclosure {
958    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
959    where
960        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
961    {
962        let mut url = None;
963        let mut length = None;
964        let mut enclosure_type = None;
965
966        for attr in attrs {
967            if attr.value.len() > max_attr_length {
968                continue;
969            }
970
971            match attr.key.as_ref() {
972                b"url" => url = Some(bytes_to_string(&attr.value)),
973                b"length" => length = Some(bytes_to_string(&attr.value)),
974                b"type" => enclosure_type = Some(bytes_to_string(&attr.value)),
975                _ => {}
976            }
977        }
978
979        url.map(|url| Self {
980            url: Url::new(url),
981            length,
982            enclosure_type: enclosure_type.map(MimeType::new),
983            title: None,
984            duration: None,
985        })
986    }
987}
988
989impl FromAttributes for MediaThumbnail {
990    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
991    where
992        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
993    {
994        let mut url = None;
995        let mut width = None;
996        let mut height = None;
997
998        let mut time = None;
999
1000        for attr in attrs {
1001            if attr.value.len() > max_attr_length {
1002                continue;
1003            }
1004
1005            match attr.key.as_ref() {
1006                b"url" => url = Some(bytes_to_string(&attr.value)),
1007                b"width" => width = Some(bytes_to_string(&attr.value)),
1008                b"height" => height = Some(bytes_to_string(&attr.value)),
1009                b"time" => time = Some(bytes_to_string(&attr.value)),
1010                _ => {}
1011            }
1012        }
1013
1014        url.map(|url| Self {
1015            url: Url::new(url),
1016            width,
1017            height,
1018            time,
1019        })
1020    }
1021}
1022
1023impl FromAttributes for MediaContent {
1024    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
1025    where
1026        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
1027    {
1028        let mut url = None;
1029        let mut content_type = None;
1030        let mut medium = None;
1031        let mut filesize = None;
1032        let mut width = None;
1033        let mut height = None;
1034        let mut duration = None;
1035        let mut bitrate = None;
1036        let mut lang = None;
1037        let mut channels = None;
1038        let mut codec = None;
1039        let mut expression = None;
1040        let mut isdefault = None;
1041        let mut samplingrate = None;
1042        let mut framerate = None;
1043
1044        for attr in attrs {
1045            if attr.value.len() > max_attr_length {
1046                continue;
1047            }
1048
1049            match attr.key.as_ref() {
1050                b"url" => url = Some(bytes_to_string(&attr.value)),
1051                b"type" => content_type = Some(bytes_to_string(&attr.value)),
1052                b"medium" => medium = Some(bytes_to_string(&attr.value)),
1053                b"fileSize" => filesize = Some(bytes_to_string(&attr.value)),
1054                b"width" => width = Some(bytes_to_string(&attr.value)),
1055                b"height" => height = Some(bytes_to_string(&attr.value)),
1056                b"duration" => duration = Some(bytes_to_string(&attr.value)),
1057                b"bitrate" => bitrate = Some(bytes_to_string(&attr.value)),
1058                b"lang" => lang = Some(bytes_to_string(&attr.value)),
1059                b"channels" => channels = Some(bytes_to_string(&attr.value)),
1060                b"codec" => codec = Some(bytes_to_string(&attr.value)),
1061                b"expression" => expression = Some(bytes_to_string(&attr.value)),
1062                b"isDefault" => isdefault = Some(bytes_to_string(&attr.value)),
1063                b"samplingrate" => samplingrate = Some(bytes_to_string(&attr.value)),
1064                b"framerate" => framerate = Some(bytes_to_string(&attr.value)),
1065                _ => {}
1066            }
1067        }
1068
1069        url.map(|url| Self {
1070            url: Url::new(url),
1071            content_type: content_type.map(MimeType::new),
1072            medium,
1073            filesize,
1074            width,
1075            height,
1076            duration,
1077            bitrate,
1078            lang,
1079            channels,
1080            codec,
1081            expression,
1082            isdefault,
1083            samplingrate,
1084            framerate,
1085        })
1086    }
1087}
1088
1089// ParseFrom implementations for JSON Feed parsing
1090
1091impl ParseFrom<&Value> for Person {
1092    /// Parse Person from JSON Feed author object
1093    ///
1094    /// JSON Feed format: `{"name": "...", "url": "...", "avatar": "..."}`
1095    fn parse_from(json: &Value) -> Option<Self> {
1096        json.as_object().map(|obj| Self {
1097            name: obj
1098                .get("name")
1099                .and_then(Value::as_str)
1100                .map(std::convert::Into::into),
1101            email: None, // JSON Feed doesn't have email field
1102            uri: obj.get("url").and_then(Value::as_str).map(String::from),
1103            avatar: obj.get("avatar").and_then(Value::as_str).map(String::from),
1104        })
1105    }
1106}
1107
1108impl ParseFrom<&Value> for Enclosure {
1109    /// Parse Enclosure from JSON Feed attachment object
1110    ///
1111    /// JSON Feed format: `{"url": "...", "mime_type": "...", "size_in_bytes": ...}`
1112    fn parse_from(json: &Value) -> Option<Self> {
1113        let obj = json.as_object()?;
1114        let url = obj.get("url").and_then(Value::as_str)?;
1115        Some(Self {
1116            url: Url::new(url),
1117            length: obj
1118                .get("size_in_bytes")
1119                .and_then(Value::as_u64)
1120                .map(|v| v.to_string()),
1121            enclosure_type: obj
1122                .get("mime_type")
1123                .and_then(Value::as_str)
1124                .map(MimeType::new),
1125            title: obj.get("title").and_then(Value::as_str).map(String::from),
1126            duration: obj
1127                .get("duration_in_seconds")
1128                .and_then(Value::as_u64)
1129                .map(|v| v.to_string()),
1130        })
1131    }
1132}
1133
1134/// Media RSS credit element (media:credit)
1135#[derive(Debug, Clone, Default)]
1136pub struct MediaCredit {
1137    /// Credit role (e.g., "author", "producer")
1138    pub role: Option<String>,
1139    /// Credit scheme URI (default: "urn:ebu")
1140    pub scheme: Option<String>,
1141    /// Credit text content (person/entity name)
1142    pub content: String,
1143}
1144
1145/// Media RSS copyright element (media:copyright)
1146#[derive(Debug, Clone, Default)]
1147pub struct MediaCopyright {
1148    /// Copyright URL
1149    pub url: Option<String>,
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use serde_json::json;
1156
1157    #[test]
1158    fn test_link_default() {
1159        let link = Link::default();
1160        assert!(link.href.is_empty());
1161        assert!(link.rel.is_none());
1162    }
1163
1164    #[test]
1165    fn test_link_builders() {
1166        let link = Link::alternate("https://example.com");
1167        assert_eq!(link.href, "https://example.com");
1168        assert_eq!(link.rel.as_deref(), Some("alternate"));
1169
1170        let link = Link::self_link("https://example.com/feed", "application/feed+json");
1171        assert_eq!(link.rel.as_deref(), Some("self"));
1172        assert_eq!(link.link_type.as_deref(), Some("application/feed+json"));
1173
1174        let link = Link::enclosure("https://example.com/audio.mp3", Some("audio/mpeg".into()));
1175        assert_eq!(link.rel.as_deref(), Some("enclosure"));
1176        assert_eq!(link.link_type.as_deref(), Some("audio/mpeg"));
1177
1178        let link = Link::related("https://other.com");
1179        assert_eq!(link.rel.as_deref(), Some("related"));
1180    }
1181
1182    #[test]
1183    fn test_tag_builder() {
1184        let tag = Tag::new("rust");
1185        assert_eq!(tag.term, "rust");
1186        assert!(tag.scheme.is_none());
1187    }
1188
1189    #[test]
1190    fn test_text_construct_builders() {
1191        let text = TextConstruct::text("Hello");
1192        assert_eq!(text.value, "Hello");
1193        assert_eq!(text.content_type, TextType::Text);
1194
1195        let html = TextConstruct::html("<p>Hello</p>");
1196        assert_eq!(html.content_type, TextType::Html);
1197
1198        let with_lang = TextConstruct::text("Hello").with_language("en");
1199        assert_eq!(with_lang.language.as_deref(), Some("en"));
1200    }
1201
1202    #[test]
1203    fn test_content_builders() {
1204        let html = Content::html("<p>Content</p>");
1205        assert_eq!(html.content_type.as_deref(), Some("text/html"));
1206
1207        let plain = Content::plain("Content");
1208        assert_eq!(plain.content_type.as_deref(), Some("text/plain"));
1209    }
1210
1211    #[test]
1212    fn test_person_default() {
1213        let person = Person::default();
1214        assert!(person.name.is_none());
1215        assert!(person.email.is_none());
1216        assert!(person.uri.is_none());
1217    }
1218
1219    #[test]
1220    fn test_person_parse_from_json() {
1221        let json = json!({"name": "John Doe", "url": "https://example.com"});
1222        let person = Person::parse_from(&json).unwrap();
1223        assert_eq!(person.name.as_deref(), Some("John Doe"));
1224        assert_eq!(person.uri.as_deref(), Some("https://example.com"));
1225        assert!(person.email.is_none());
1226    }
1227
1228    #[test]
1229    fn test_person_parse_from_empty_json() {
1230        let json = json!({});
1231        let person = Person::parse_from(&json).unwrap();
1232        assert!(person.name.is_none());
1233    }
1234
1235    #[test]
1236    fn test_enclosure_parse_from_json() {
1237        let json = json!({
1238            "url": "https://example.com/file.mp3",
1239            "mime_type": "audio/mpeg",
1240            "size_in_bytes": 12345
1241        });
1242        let enclosure = Enclosure::parse_from(&json).unwrap();
1243        assert_eq!(enclosure.url, "https://example.com/file.mp3");
1244        assert_eq!(enclosure.enclosure_type.as_deref(), Some("audio/mpeg"));
1245        assert_eq!(enclosure.length.as_deref(), Some("12345"));
1246    }
1247
1248    #[test]
1249    fn test_enclosure_parse_from_json_missing_url() {
1250        let json = json!({"mime_type": "audio/mpeg"});
1251        assert!(Enclosure::parse_from(&json).is_none());
1252    }
1253
1254    #[test]
1255    fn test_text_type_equality() {
1256        assert_eq!(TextType::Text, TextType::Text);
1257        assert_ne!(TextType::Text, TextType::Html);
1258    }
1259
1260    // Newtype tests
1261
1262    #[test]
1263    fn test_url_new() {
1264        let url = Url::new("https://example.com");
1265        assert_eq!(url.as_str(), "https://example.com");
1266    }
1267
1268    #[test]
1269    fn test_url_from_string() {
1270        let url: Url = String::from("https://example.com").into();
1271        assert_eq!(url.as_str(), "https://example.com");
1272    }
1273
1274    #[test]
1275    fn test_url_from_str() {
1276        let url: Url = "https://example.com".into();
1277        assert_eq!(url.as_str(), "https://example.com");
1278    }
1279
1280    #[test]
1281    fn test_url_deref() {
1282        let url = Url::new("https://example.com");
1283        // Deref allows calling str methods directly
1284        assert_eq!(url.len(), 19);
1285        assert!(url.starts_with("https://"));
1286    }
1287
1288    #[test]
1289    fn test_url_into_inner() {
1290        let url = Url::new("https://example.com");
1291        let inner = url.into_inner();
1292        assert_eq!(inner, "https://example.com");
1293    }
1294
1295    #[test]
1296    fn test_url_default() {
1297        let url = Url::default();
1298        assert_eq!(url.as_str(), "");
1299    }
1300
1301    #[test]
1302    fn test_url_clone() {
1303        let url1 = Url::new("https://example.com");
1304        let url2 = url1.clone();
1305        assert_eq!(url1, url2);
1306    }
1307
1308    #[test]
1309    fn test_mime_type_new() {
1310        let mime = MimeType::new("text/html");
1311        assert_eq!(mime.as_str(), "text/html");
1312    }
1313
1314    #[test]
1315    fn test_mime_type_from_string() {
1316        let mime: MimeType = String::from("application/json").into();
1317        assert_eq!(mime.as_str(), "application/json");
1318    }
1319
1320    #[test]
1321    fn test_mime_type_from_str() {
1322        let mime: MimeType = "text/plain".into();
1323        assert_eq!(mime.as_str(), "text/plain");
1324    }
1325
1326    #[test]
1327    fn test_mime_type_deref() {
1328        let mime = MimeType::new("text/html");
1329        assert_eq!(mime.len(), 9);
1330        assert!(mime.starts_with("text/"));
1331    }
1332
1333    #[test]
1334    fn test_mime_type_default() {
1335        let mime = MimeType::default();
1336        assert_eq!(mime.as_str(), "");
1337    }
1338
1339    #[test]
1340    fn test_mime_type_clone() {
1341        let mime1 = MimeType::new("application/xml");
1342        let mime2 = mime1.clone();
1343        assert_eq!(mime1, mime2);
1344        // Arc cloning is cheap - just increments refcount
1345    }
1346
1347    #[test]
1348    fn test_mime_type_constants() {
1349        assert_eq!(MimeType::TEXT_HTML, "text/html");
1350        assert_eq!(MimeType::TEXT_PLAIN, "text/plain");
1351        assert_eq!(MimeType::APPLICATION_XML, "application/xml");
1352        assert_eq!(MimeType::APPLICATION_JSON, "application/json");
1353    }
1354
1355    #[test]
1356    fn test_email_new() {
1357        let email = Email::new("user@example.com");
1358        assert_eq!(email.as_str(), "user@example.com");
1359    }
1360
1361    #[test]
1362    fn test_email_from_string() {
1363        let email: Email = String::from("user@example.com").into();
1364        assert_eq!(email.as_str(), "user@example.com");
1365    }
1366
1367    #[test]
1368    fn test_email_from_str() {
1369        let email: Email = "user@example.com".into();
1370        assert_eq!(email.as_str(), "user@example.com");
1371    }
1372
1373    #[test]
1374    fn test_email_deref() {
1375        let email = Email::new("user@example.com");
1376        assert_eq!(email.len(), 16);
1377        assert!(email.contains('@'));
1378    }
1379
1380    #[test]
1381    fn test_email_into_inner() {
1382        let email = Email::new("user@example.com");
1383        let inner = email.into_inner();
1384        assert_eq!(inner, "user@example.com");
1385    }
1386
1387    #[test]
1388    fn test_email_default() {
1389        let email = Email::default();
1390        assert_eq!(email.as_str(), "");
1391    }
1392
1393    #[test]
1394    fn test_email_clone() {
1395        let email1 = Email::new("user@example.com");
1396        let email2 = email1.clone();
1397        assert_eq!(email1, email2);
1398    }
1399}