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
718impl TextType {
719    /// Determine the text type from an Atom `type` attribute value.
720    ///
721    /// Per RFC 4287 §3.1.1, the recognized values are `text`, `html`, and
722    /// `xhtml`; this also accepts the equivalent MIME spellings (`text/html`,
723    /// `application/xhtml+xml`) some feeds use in practice. Comparison is
724    /// case-insensitive. Any other value maps to `Html` — fail-closed, so a
725    /// bogus or unrecognized type label cannot be used to smuggle content past
726    /// downstream HTML sanitization by masquerading as plain text.
727    ///
728    /// # Examples
729    ///
730    /// ```
731    /// use feedparser_rs::types::TextType;
732    ///
733    /// assert_eq!(TextType::from_type_attr("text"), TextType::Text);
734    /// assert_eq!(TextType::from_type_attr("HTML"), TextType::Html);
735    /// assert_eq!(TextType::from_type_attr("text/html"), TextType::Html);
736    /// assert_eq!(TextType::from_type_attr("application/xhtml+xml"), TextType::Xhtml);
737    /// assert_eq!(TextType::from_type_attr("bogus"), TextType::Html);
738    /// ```
739    #[must_use]
740    pub fn from_type_attr(raw: &str) -> Self {
741        match raw.trim().to_ascii_lowercase().as_str() {
742            "text" | "text/plain" => Self::Text,
743            "xhtml" | "application/xhtml+xml" => Self::Xhtml,
744            _ => Self::Html,
745        }
746    }
747}
748
749/// Text construct with metadata
750#[derive(Debug, Clone)]
751pub struct TextConstruct {
752    /// Text content
753    pub value: String,
754    /// Content type
755    pub content_type: TextType,
756    /// Content language (stored inline for lang codes ≤24 bytes)
757    pub language: Option<SmallString>,
758    /// Base URL for relative links
759    pub base: Option<String>,
760}
761
762impl TextConstruct {
763    /// Create plain text construct
764    #[inline]
765    pub fn text(value: impl Into<String>) -> Self {
766        Self {
767            value: value.into(),
768            content_type: TextType::Text,
769            language: None,
770            base: None,
771        }
772    }
773
774    /// Create HTML text construct
775    #[inline]
776    pub fn html(value: impl Into<String>) -> Self {
777        Self {
778            value: value.into(),
779            content_type: TextType::Html,
780            language: None,
781            base: None,
782        }
783    }
784
785    /// Set language (builder pattern)
786    #[inline]
787    #[must_use]
788    pub fn with_language(mut self, language: impl AsRef<str>) -> Self {
789        self.language = Some(language.as_ref().into());
790        self
791    }
792}
793
794/// Generator metadata
795#[derive(Debug, Clone)]
796pub struct Generator {
797    /// Generator name (text content of the `<generator>` element)
798    pub name: String,
799    /// Generator URI (href attribute)
800    pub href: Option<String>,
801    /// Generator version (stored inline for versions ≤24 bytes)
802    pub version: Option<SmallString>,
803}
804
805/// Source reference (for entries)
806#[derive(Debug, Clone)]
807pub struct Source {
808    /// Source title
809    pub title: Option<String>,
810    /// Primary source URL for RSS `<source url="...">` (RSS-only field)
811    pub href: Option<String>,
812    /// Primary source URL for Atom `<source><link href="..."/>` (Atom-only field)
813    pub link: Option<String>,
814    /// Source author (flat string, Atom `<source><author>`)
815    pub author: Option<String>,
816    /// Source unique identifier
817    pub id: Option<String>,
818    /// All links from the source element
819    pub links: Vec<Link>,
820    /// Last update date (Atom `<updated>`)
821    pub updated: Option<DateTime<Utc>>,
822    /// Original update date string (timezone preserved)
823    pub updated_str: Option<String>,
824    /// Rights/copyright statement (Atom `<rights>`)
825    pub rights: Option<String>,
826    /// Whether `<id>` was used as the link.
827    ///
828    /// `Some(true)` when `<id>` looks like a URL and no explicit `<link>` was present.
829    /// `Some(false)` when `<id>` is present but a `<link>` was also present, or `<id>` is not a URL.
830    /// `None` for RSS sources (RSS `<source>` has no `<id>`).
831    pub guidislink: Option<bool>,
832}
833
834/// Media RSS thumbnail
835#[derive(Debug, Clone)]
836pub struct MediaThumbnail {
837    /// Thumbnail URL
838    ///
839    /// # Security Warning
840    ///
841    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
842    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
843    pub url: Url,
844    /// Thumbnail width in pixels (raw string value, as in Python feedparser)
845    pub width: Option<String>,
846    /// Thumbnail height in pixels (raw string value, as in Python feedparser)
847    pub height: Option<String>,
848    /// Time offset in NTP format (time attribute)
849    ///
850    /// Indicates which frame of the media this thumbnail represents.
851    pub time: Option<String>,
852}
853
854/// Media RSS content
855#[derive(Debug, Clone)]
856pub struct MediaContent {
857    /// Media URL
858    ///
859    /// # Security Warning
860    ///
861    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
862    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
863    pub url: Url,
864    /// MIME type
865    pub content_type: Option<MimeType>,
866    /// Medium type: "image", "video", "audio", "document", "executable"
867    pub medium: Option<String>,
868    /// File size in bytes (raw string value, as in Python feedparser)
869    pub filesize: Option<String>,
870    /// Media width in pixels (raw string value, as in Python feedparser)
871    pub width: Option<String>,
872    /// Media height in pixels (raw string value, as in Python feedparser)
873    pub height: Option<String>,
874    /// Duration in seconds (raw string value, as in Python feedparser)
875    pub duration: Option<String>,
876    /// Bitrate in kilobits per second (raw string value)
877    pub bitrate: Option<String>,
878    /// Language of the media (lang attribute)
879    pub lang: Option<String>,
880    /// Number of audio channels (raw string value)
881    pub channels: Option<String>,
882    /// Codec used to produce the media (codec attribute)
883    pub codec: Option<String>,
884    /// Expression type: "full", "sample", "nonstop"
885    pub expression: Option<String>,
886    /// Whether this is the default media object (isDefault attribute, raw string)
887    pub isdefault: Option<String>,
888    /// Sampling rate in kHz (raw string value)
889    pub samplingrate: Option<String>,
890    /// Frame rate in frames per second (raw string value)
891    pub framerate: Option<String>,
892}
893
894/// Media RSS rating element (`media:rating`)
895///
896/// Describes the permissible audience for the media content.
897/// Commonly uses the MPAA or urn:simple rating schemes.
898#[derive(Debug, Clone, Default, PartialEq, Eq)]
899pub struct MediaRating {
900    /// Rating scheme URI (scheme attribute), e.g. "urn:simple", "urn:mpaa"
901    pub scheme: Option<String>,
902    /// Rating value, e.g. "adult", "nonadult", "pg-13"
903    pub content: String,
904}
905
906impl FromAttributes for Link {
907    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
908    where
909        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
910    {
911        let mut href = None;
912        let mut rel = None;
913        let mut link_type = None;
914        let mut title = None;
915        let mut hreflang = None;
916        let mut length = None;
917        let mut thr_count = None;
918        let mut thr_updated = None;
919
920        for attr in attrs {
921            if attr.value.len() > max_attr_length {
922                continue;
923            }
924            match attr.key.as_ref() {
925                b"href" => href = Some(bytes_to_string(&attr.value)),
926                b"rel" => rel = Some(bytes_to_string(&attr.value)),
927                b"type" => link_type = Some(bytes_to_string(&attr.value)),
928                b"title" => title = Some(bytes_to_string(&attr.value)),
929                b"hreflang" => hreflang = Some(bytes_to_string(&attr.value)),
930                b"length" => length = Some(bytes_to_string(&attr.value)),
931                b"thr:count" => {
932                    thr_count = bytes_to_string(&attr.value).trim().parse::<u32>().ok();
933                }
934                b"thr:updated" => {
935                    thr_updated = parse_date(bytes_to_string(&attr.value).trim());
936                }
937                _ => {}
938            }
939        }
940
941        let rel_str: Option<SmallString> = rel
942            .map(std::convert::Into::into)
943            .or_else(|| Some("alternate".into()));
944        let resolved_type = link_type
945            .map(MimeType::new)
946            .or_else(|| match rel_str.as_deref() {
947                Some("self") => Some(MimeType::new("application/atom+xml")),
948                _ => Some(MimeType::new("text/html")),
949            });
950
951        href.map(|href| Self {
952            href: Url::new(href),
953            rel: rel_str,
954            link_type: resolved_type,
955            title,
956            length,
957            hreflang: hreflang.map(std::convert::Into::into),
958            thr_count,
959            thr_updated,
960        })
961    }
962}
963
964impl FromAttributes for Tag {
965    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
966    where
967        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
968    {
969        let mut term = None;
970        let mut scheme = None;
971        let mut label = None;
972
973        for attr in attrs {
974            if attr.value.len() > max_attr_length {
975                continue;
976            }
977
978            match attr.key.as_ref() {
979                b"term" => term = Some(bytes_to_string(&attr.value)),
980                b"scheme" | b"domain" => scheme = Some(bytes_to_string(&attr.value)),
981                b"label" => label = Some(bytes_to_string(&attr.value)),
982                _ => {}
983            }
984        }
985
986        term.map(|term| Self {
987            term: term.into(),
988            scheme: scheme.map(std::convert::Into::into),
989            label: label.map(std::convert::Into::into),
990        })
991    }
992}
993
994impl FromAttributes for Enclosure {
995    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
996    where
997        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
998    {
999        let mut url = None;
1000        let mut length = None;
1001        let mut enclosure_type = None;
1002
1003        for attr in attrs {
1004            if attr.value.len() > max_attr_length {
1005                continue;
1006            }
1007
1008            match attr.key.as_ref() {
1009                b"url" => url = Some(bytes_to_string(&attr.value)),
1010                b"length" => length = Some(bytes_to_string(&attr.value)),
1011                b"type" => enclosure_type = Some(bytes_to_string(&attr.value)),
1012                _ => {}
1013            }
1014        }
1015
1016        url.map(|url| Self {
1017            url: Url::new(url),
1018            length,
1019            enclosure_type: enclosure_type.map(MimeType::new),
1020            title: None,
1021            duration: None,
1022        })
1023    }
1024}
1025
1026impl FromAttributes for MediaThumbnail {
1027    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
1028    where
1029        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
1030    {
1031        let mut url = None;
1032        let mut width = None;
1033        let mut height = None;
1034
1035        let mut time = None;
1036
1037        for attr in attrs {
1038            if attr.value.len() > max_attr_length {
1039                continue;
1040            }
1041
1042            match attr.key.as_ref() {
1043                b"url" => url = Some(bytes_to_string(&attr.value)),
1044                b"width" => width = Some(bytes_to_string(&attr.value)),
1045                b"height" => height = Some(bytes_to_string(&attr.value)),
1046                b"time" => time = Some(bytes_to_string(&attr.value)),
1047                _ => {}
1048            }
1049        }
1050
1051        url.map(|url| Self {
1052            url: Url::new(url),
1053            width,
1054            height,
1055            time,
1056        })
1057    }
1058}
1059
1060impl FromAttributes for MediaContent {
1061    fn from_attributes<'a, I>(attrs: I, max_attr_length: usize) -> Option<Self>
1062    where
1063        I: Iterator<Item = quick_xml::events::attributes::Attribute<'a>>,
1064    {
1065        let mut url = None;
1066        let mut content_type = None;
1067        let mut medium = None;
1068        let mut filesize = None;
1069        let mut width = None;
1070        let mut height = None;
1071        let mut duration = None;
1072        let mut bitrate = None;
1073        let mut lang = None;
1074        let mut channels = None;
1075        let mut codec = None;
1076        let mut expression = None;
1077        let mut isdefault = None;
1078        let mut samplingrate = None;
1079        let mut framerate = None;
1080
1081        for attr in attrs {
1082            if attr.value.len() > max_attr_length {
1083                continue;
1084            }
1085
1086            match attr.key.as_ref() {
1087                b"url" => url = Some(bytes_to_string(&attr.value)),
1088                b"type" => content_type = Some(bytes_to_string(&attr.value)),
1089                b"medium" => medium = Some(bytes_to_string(&attr.value)),
1090                b"fileSize" => filesize = Some(bytes_to_string(&attr.value)),
1091                b"width" => width = Some(bytes_to_string(&attr.value)),
1092                b"height" => height = Some(bytes_to_string(&attr.value)),
1093                b"duration" => duration = Some(bytes_to_string(&attr.value)),
1094                b"bitrate" => bitrate = Some(bytes_to_string(&attr.value)),
1095                b"lang" => lang = Some(bytes_to_string(&attr.value)),
1096                b"channels" => channels = Some(bytes_to_string(&attr.value)),
1097                b"codec" => codec = Some(bytes_to_string(&attr.value)),
1098                b"expression" => expression = Some(bytes_to_string(&attr.value)),
1099                b"isDefault" => isdefault = Some(bytes_to_string(&attr.value)),
1100                b"samplingrate" => samplingrate = Some(bytes_to_string(&attr.value)),
1101                b"framerate" => framerate = Some(bytes_to_string(&attr.value)),
1102                _ => {}
1103            }
1104        }
1105
1106        url.map(|url| Self {
1107            url: Url::new(url),
1108            content_type: content_type.map(MimeType::new),
1109            medium,
1110            filesize,
1111            width,
1112            height,
1113            duration,
1114            bitrate,
1115            lang,
1116            channels,
1117            codec,
1118            expression,
1119            isdefault,
1120            samplingrate,
1121            framerate,
1122        })
1123    }
1124}
1125
1126// ParseFrom implementations for JSON Feed parsing
1127
1128impl ParseFrom<&Value> for Person {
1129    /// Parse Person from JSON Feed author object
1130    ///
1131    /// JSON Feed format: `{"name": "...", "url": "...", "avatar": "..."}`
1132    fn parse_from(json: &Value) -> Option<Self> {
1133        json.as_object().map(|obj| Self {
1134            name: obj
1135                .get("name")
1136                .and_then(Value::as_str)
1137                .map(std::convert::Into::into),
1138            email: None, // JSON Feed doesn't have email field
1139            uri: obj.get("url").and_then(Value::as_str).map(String::from),
1140            avatar: obj.get("avatar").and_then(Value::as_str).map(String::from),
1141        })
1142    }
1143}
1144
1145impl ParseFrom<&Value> for Enclosure {
1146    /// Parse Enclosure from JSON Feed attachment object
1147    ///
1148    /// JSON Feed format: `{"url": "...", "mime_type": "...", "size_in_bytes": ...}`
1149    fn parse_from(json: &Value) -> Option<Self> {
1150        let obj = json.as_object()?;
1151        let url = obj.get("url").and_then(Value::as_str)?;
1152        Some(Self {
1153            url: Url::new(url),
1154            length: obj
1155                .get("size_in_bytes")
1156                .and_then(Value::as_u64)
1157                .map(|v| v.to_string()),
1158            enclosure_type: obj
1159                .get("mime_type")
1160                .and_then(Value::as_str)
1161                .map(MimeType::new),
1162            title: obj.get("title").and_then(Value::as_str).map(String::from),
1163            duration: obj
1164                .get("duration_in_seconds")
1165                .and_then(Value::as_u64)
1166                .map(|v| v.to_string()),
1167        })
1168    }
1169}
1170
1171/// Media RSS credit element (media:credit)
1172#[derive(Debug, Clone, Default)]
1173pub struct MediaCredit {
1174    /// Credit role (e.g., "author", "producer")
1175    pub role: Option<String>,
1176    /// Credit scheme URI (default: "urn:ebu")
1177    pub scheme: Option<String>,
1178    /// Credit text content (person/entity name)
1179    pub content: String,
1180}
1181
1182/// Media RSS copyright element (media:copyright)
1183#[derive(Debug, Clone, Default)]
1184pub struct MediaCopyright {
1185    /// Copyright URL
1186    pub url: Option<String>,
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192    use serde_json::json;
1193
1194    #[test]
1195    fn test_link_default() {
1196        let link = Link::default();
1197        assert!(link.href.is_empty());
1198        assert!(link.rel.is_none());
1199    }
1200
1201    #[test]
1202    fn test_link_builders() {
1203        let link = Link::alternate("https://example.com");
1204        assert_eq!(link.href, "https://example.com");
1205        assert_eq!(link.rel.as_deref(), Some("alternate"));
1206
1207        let link = Link::self_link("https://example.com/feed", "application/feed+json");
1208        assert_eq!(link.rel.as_deref(), Some("self"));
1209        assert_eq!(link.link_type.as_deref(), Some("application/feed+json"));
1210
1211        let link = Link::enclosure("https://example.com/audio.mp3", Some("audio/mpeg".into()));
1212        assert_eq!(link.rel.as_deref(), Some("enclosure"));
1213        assert_eq!(link.link_type.as_deref(), Some("audio/mpeg"));
1214
1215        let link = Link::related("https://other.com");
1216        assert_eq!(link.rel.as_deref(), Some("related"));
1217    }
1218
1219    #[test]
1220    fn test_tag_builder() {
1221        let tag = Tag::new("rust");
1222        assert_eq!(tag.term, "rust");
1223        assert!(tag.scheme.is_none());
1224    }
1225
1226    #[test]
1227    fn test_text_construct_builders() {
1228        let text = TextConstruct::text("Hello");
1229        assert_eq!(text.value, "Hello");
1230        assert_eq!(text.content_type, TextType::Text);
1231
1232        let html = TextConstruct::html("<p>Hello</p>");
1233        assert_eq!(html.content_type, TextType::Html);
1234
1235        let with_lang = TextConstruct::text("Hello").with_language("en");
1236        assert_eq!(with_lang.language.as_deref(), Some("en"));
1237    }
1238
1239    #[test]
1240    fn test_content_builders() {
1241        let html = Content::html("<p>Content</p>");
1242        assert_eq!(html.content_type.as_deref(), Some("text/html"));
1243
1244        let plain = Content::plain("Content");
1245        assert_eq!(plain.content_type.as_deref(), Some("text/plain"));
1246    }
1247
1248    #[test]
1249    fn test_person_default() {
1250        let person = Person::default();
1251        assert!(person.name.is_none());
1252        assert!(person.email.is_none());
1253        assert!(person.uri.is_none());
1254    }
1255
1256    #[test]
1257    fn test_person_parse_from_json() {
1258        let json = json!({"name": "John Doe", "url": "https://example.com"});
1259        let person = Person::parse_from(&json).unwrap();
1260        assert_eq!(person.name.as_deref(), Some("John Doe"));
1261        assert_eq!(person.uri.as_deref(), Some("https://example.com"));
1262        assert!(person.email.is_none());
1263    }
1264
1265    #[test]
1266    fn test_person_parse_from_empty_json() {
1267        let json = json!({});
1268        let person = Person::parse_from(&json).unwrap();
1269        assert!(person.name.is_none());
1270    }
1271
1272    #[test]
1273    fn test_enclosure_parse_from_json() {
1274        let json = json!({
1275            "url": "https://example.com/file.mp3",
1276            "mime_type": "audio/mpeg",
1277            "size_in_bytes": 12345
1278        });
1279        let enclosure = Enclosure::parse_from(&json).unwrap();
1280        assert_eq!(enclosure.url, "https://example.com/file.mp3");
1281        assert_eq!(enclosure.enclosure_type.as_deref(), Some("audio/mpeg"));
1282        assert_eq!(enclosure.length.as_deref(), Some("12345"));
1283    }
1284
1285    #[test]
1286    fn test_enclosure_parse_from_json_missing_url() {
1287        let json = json!({"mime_type": "audio/mpeg"});
1288        assert!(Enclosure::parse_from(&json).is_none());
1289    }
1290
1291    #[test]
1292    fn test_text_type_equality() {
1293        assert_eq!(TextType::Text, TextType::Text);
1294        assert_ne!(TextType::Text, TextType::Html);
1295    }
1296
1297    // Newtype tests
1298
1299    #[test]
1300    fn test_url_new() {
1301        let url = Url::new("https://example.com");
1302        assert_eq!(url.as_str(), "https://example.com");
1303    }
1304
1305    #[test]
1306    fn test_url_from_string() {
1307        let url: Url = String::from("https://example.com").into();
1308        assert_eq!(url.as_str(), "https://example.com");
1309    }
1310
1311    #[test]
1312    fn test_url_from_str() {
1313        let url: Url = "https://example.com".into();
1314        assert_eq!(url.as_str(), "https://example.com");
1315    }
1316
1317    #[test]
1318    fn test_url_deref() {
1319        let url = Url::new("https://example.com");
1320        // Deref allows calling str methods directly
1321        assert_eq!(url.len(), 19);
1322        assert!(url.starts_with("https://"));
1323    }
1324
1325    #[test]
1326    fn test_url_into_inner() {
1327        let url = Url::new("https://example.com");
1328        let inner = url.into_inner();
1329        assert_eq!(inner, "https://example.com");
1330    }
1331
1332    #[test]
1333    fn test_url_default() {
1334        let url = Url::default();
1335        assert_eq!(url.as_str(), "");
1336    }
1337
1338    #[test]
1339    fn test_url_clone() {
1340        let url1 = Url::new("https://example.com");
1341        let url2 = url1.clone();
1342        assert_eq!(url1, url2);
1343    }
1344
1345    #[test]
1346    fn test_mime_type_new() {
1347        let mime = MimeType::new("text/html");
1348        assert_eq!(mime.as_str(), "text/html");
1349    }
1350
1351    #[test]
1352    fn test_mime_type_from_string() {
1353        let mime: MimeType = String::from("application/json").into();
1354        assert_eq!(mime.as_str(), "application/json");
1355    }
1356
1357    #[test]
1358    fn test_mime_type_from_str() {
1359        let mime: MimeType = "text/plain".into();
1360        assert_eq!(mime.as_str(), "text/plain");
1361    }
1362
1363    #[test]
1364    fn test_mime_type_deref() {
1365        let mime = MimeType::new("text/html");
1366        assert_eq!(mime.len(), 9);
1367        assert!(mime.starts_with("text/"));
1368    }
1369
1370    #[test]
1371    fn test_mime_type_default() {
1372        let mime = MimeType::default();
1373        assert_eq!(mime.as_str(), "");
1374    }
1375
1376    #[test]
1377    fn test_mime_type_clone() {
1378        let mime1 = MimeType::new("application/xml");
1379        let mime2 = mime1.clone();
1380        assert_eq!(mime1, mime2);
1381        // Arc cloning is cheap - just increments refcount
1382    }
1383
1384    #[test]
1385    fn test_mime_type_constants() {
1386        assert_eq!(MimeType::TEXT_HTML, "text/html");
1387        assert_eq!(MimeType::TEXT_PLAIN, "text/plain");
1388        assert_eq!(MimeType::APPLICATION_XML, "application/xml");
1389        assert_eq!(MimeType::APPLICATION_JSON, "application/json");
1390    }
1391
1392    #[test]
1393    fn test_email_new() {
1394        let email = Email::new("user@example.com");
1395        assert_eq!(email.as_str(), "user@example.com");
1396    }
1397
1398    #[test]
1399    fn test_email_from_string() {
1400        let email: Email = String::from("user@example.com").into();
1401        assert_eq!(email.as_str(), "user@example.com");
1402    }
1403
1404    #[test]
1405    fn test_email_from_str() {
1406        let email: Email = "user@example.com".into();
1407        assert_eq!(email.as_str(), "user@example.com");
1408    }
1409
1410    #[test]
1411    fn test_email_deref() {
1412        let email = Email::new("user@example.com");
1413        assert_eq!(email.len(), 16);
1414        assert!(email.contains('@'));
1415    }
1416
1417    #[test]
1418    fn test_email_into_inner() {
1419        let email = Email::new("user@example.com");
1420        let inner = email.into_inner();
1421        assert_eq!(inner, "user@example.com");
1422    }
1423
1424    #[test]
1425    fn test_email_default() {
1426        let email = Email::default();
1427        assert_eq!(email.as_str(), "");
1428    }
1429
1430    #[test]
1431    fn test_email_clone() {
1432        let email1 = Email::new("user@example.com");
1433        let email2 = email1.clone();
1434        assert_eq!(email1, email2);
1435    }
1436}