Skip to main content

rss_gen/
data.rs

1// Copyright © 2024 RSS Gen. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4// src/data.rs
5
6//! This module contains the core data structures and functionality for RSS feeds.
7//!
8//! It includes definitions for RSS versions, RSS data, and RSS items, as well as
9//! utility functions for URL validation and date parsing.
10
11use crate::{
12    error::{Result, RssError, ValidationError},
13    MAX_FEED_SIZE, MAX_GENERAL_LENGTH,
14};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::fmt;
18use std::str::FromStr;
19use time::{
20    format_description::well_known::Iso8601,
21    format_description::well_known::Rfc2822, OffsetDateTime,
22};
23use url::Url;
24
25/// Represents the different versions of RSS.
26#[derive(
27    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
28)]
29#[non_exhaustive]
30#[derive(Default)]
31pub enum RssVersion {
32    /// RSS version 0.90
33    RSS0_90,
34    /// RSS version 0.91
35    RSS0_91,
36    /// RSS version 0.92
37    RSS0_92,
38    /// RSS version 1.0
39    RSS1_0,
40    /// RSS version 2.0
41    #[default]
42    RSS2_0,
43}
44
45impl RssVersion {
46    /// Returns the string representation of the RSS version.
47    ///
48    /// # Returns
49    ///
50    /// A static string slice representing the RSS version.
51    #[must_use]
52    pub const fn as_str(&self) -> &'static str {
53        match self {
54            Self::RSS0_90 => "0.90",
55            Self::RSS0_91 => "0.91",
56            Self::RSS0_92 => "0.92",
57            Self::RSS1_0 => "1.0",
58            Self::RSS2_0 => "2.0",
59        }
60    }
61}
62
63impl fmt::Display for RssVersion {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "{}", self.as_str())
66    }
67}
68
69impl FromStr for RssVersion {
70    type Err = RssError;
71
72    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
73        match s {
74            "0.90" => Ok(Self::RSS0_90),
75            "0.91" => Ok(Self::RSS0_91),
76            "0.92" => Ok(Self::RSS0_92),
77            "1.0" => Ok(Self::RSS1_0),
78            "2.0" => Ok(Self::RSS2_0),
79            _ => Err(RssError::InvalidRssVersion(s.to_string())),
80        }
81    }
82}
83
84/// Represents the main structure for an RSS feed.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
86#[non_exhaustive]
87pub struct RssData {
88    /// The Atom link of the RSS feed.
89    pub atom_link: String,
90    /// The author of the RSS feed.
91    pub author: String,
92    /// The category of the RSS feed.
93    pub category: String,
94    /// The copyright notice for the content of the feed.
95    pub copyright: String,
96    /// The description of the RSS feed.
97    pub description: String,
98    /// The docs link of the RSS feed.
99    pub docs: String,
100    /// The generator of the RSS feed.
101    pub generator: String,
102    /// The GUID of the RSS feed.
103    pub guid: String,
104    /// The image title of the RSS feed.
105    pub image_title: String,
106    /// The image URL of the RSS feed.
107    pub image_url: String,
108    /// The image link of the RSS feed.
109    pub image_link: String,
110    /// The language of the RSS feed.
111    pub language: String,
112    /// The last build date of the RSS feed.
113    pub last_build_date: String,
114    /// The main link to the RSS feed.
115    pub link: String,
116    /// The managing editor of the RSS feed.
117    pub managing_editor: String,
118    /// The publication date of the RSS feed.
119    pub pub_date: String,
120    /// The title of the RSS feed.
121    pub title: String,
122    /// Time To Live (TTL), the number of minutes the feed should be cached before refreshing.
123    pub ttl: String,
124    /// The webmaster of the RSS feed.
125    pub webmaster: String,
126    /// A collection of additional items in the RSS feed.
127    pub items: Vec<RssItem>,
128    /// The version of the RSS feed.
129    pub version: RssVersion,
130    /// The creator of the RSS feed.
131    pub creator: String,
132    /// The date the RSS feed was created.
133    pub date: String,
134}
135
136impl RssData {
137    /// Creates a new `RssData` instance with default values and a specified RSS version.
138    ///
139    /// # Arguments
140    ///
141    /// * `version` - An optional `RssVersion` specifying the RSS version for the feed.
142    ///
143    /// # Returns
144    ///
145    /// A new `RssData` instance.
146    #[must_use]
147    pub fn new(version: Option<RssVersion>) -> Self {
148        Self {
149            version: version.unwrap_or_default(),
150            ..Default::default()
151        }
152    }
153
154    /// Sets the value of a specified field and returns the `RssData` instance for method chaining.
155    ///
156    /// # Arguments
157    ///
158    /// * `field` - The field to set.
159    /// * `value` - The value to assign to the field.
160    ///
161    /// # Returns
162    ///
163    /// The updated `RssData` instance.
164    #[must_use]
165    pub fn set<T: Into<String>>(
166        mut self,
167        field: RssDataField,
168        value: T,
169    ) -> Self {
170        let value = sanitize_input(&value.into());
171        match field {
172            RssDataField::AtomLink => self.atom_link = value,
173            RssDataField::Author => self.author = value,
174            RssDataField::Category => self.category = value,
175            RssDataField::Copyright => self.copyright = value,
176            RssDataField::Description => self.description = value,
177            RssDataField::Docs => self.docs = value,
178            RssDataField::Generator => self.generator = value,
179            RssDataField::Guid => self.guid = value,
180            RssDataField::ImageTitle => self.image_title = value,
181            RssDataField::ImageUrl => self.image_url = value,
182            RssDataField::ImageLink => self.image_link = value,
183            RssDataField::Language => self.language = value,
184            RssDataField::LastBuildDate => self.last_build_date = value,
185            RssDataField::Link => self.link = value,
186            RssDataField::ManagingEditor => {
187                self.managing_editor = value;
188            }
189            RssDataField::PubDate => self.pub_date = value,
190            RssDataField::Title => self.title = value,
191            RssDataField::Ttl => self.ttl = value,
192            RssDataField::Webmaster => self.webmaster = value,
193        }
194        self
195    }
196
197    /// Sets the value of a specified field for the last `RssItem` and updates it.
198    ///
199    /// # Arguments
200    ///
201    /// * `field` - The field to set for the `RssItem`.
202    /// * `value` - The value to assign to the field.
203    ///
204    /// # Panics
205    ///
206    /// This function will panic if `self.items` is empty, as it uses `.unwrap()` to
207    /// retrieve the last mutable item in the list.
208    ///
209    /// Ensure that `self.items` contains at least one `RssItem` before calling this method.
210    pub fn set_item_field<T: Into<String>>(
211        &mut self,
212        field: RssItemField,
213        value: T,
214    ) {
215        let value = sanitize_input(&value.into());
216        if self.items.is_empty() {
217            self.items.push(RssItem::new());
218        }
219        let item = self.items.last_mut().unwrap();
220        match field {
221            RssItemField::Guid => item.guid = value,
222            RssItemField::Category => item.category = Some(value),
223            RssItemField::Description => item.description = value,
224            RssItemField::Link => item.link = value,
225            RssItemField::PubDate => item.pub_date = value,
226            RssItemField::Title => item.title = value,
227            RssItemField::Author => item.author = value,
228            RssItemField::Comments => item.comments = Some(value),
229            RssItemField::Enclosure => item.enclosure = Some(value),
230            RssItemField::Source => item.source = Some(value),
231        }
232    }
233
234    /// Validates the size of the RSS feed to ensure it does not exceed the maximum allowed size.
235    ///
236    /// # Returns
237    ///
238    /// * `Ok(())` if the feed size is valid.
239    /// * `Err(RssError)` if the feed size exceeds the maximum allowed size.
240    ///
241    /// # Errors
242    ///
243    /// This function returns an `Err(RssError::InvalidInput)` if the total size of the feed
244    /// exceeds the maximum allowed size (`MAX_FEED_SIZE`).
245    pub fn validate_size(&self) -> Result<()> {
246        let mut total_size = 0;
247        total_size += self.title.len();
248        total_size += self.link.len();
249        total_size += self.description.len();
250        // Add sizes of other fields...
251
252        for item in &self.items {
253            total_size += item.title.len();
254            total_size += item.link.len();
255            total_size += item.description.len();
256            // Add sizes of other item fields...
257        }
258
259        if total_size > MAX_FEED_SIZE {
260            return Err(RssError::InvalidInput(
261                format!("Total feed size exceeds maximum allowed size of {MAX_FEED_SIZE} bytes")
262            ));
263        }
264
265        Ok(())
266    }
267
268    /// Sets the image for the RSS feed.
269    ///
270    /// # Arguments
271    ///
272    /// * `title` - The title of the image.
273    /// * `url` - The URL of the image.
274    /// * `link` - The link associated with the image.
275    pub fn set_image(&mut self, title: &str, url: &str, link: &str) {
276        self.image_title = sanitize_input(title);
277        self.image_url = sanitize_input(url);
278        self.image_link = sanitize_input(link);
279    }
280
281    /// Adds an item to the RSS feed.
282    ///
283    /// This method appends the given `RssItem` to the `items` vector of the `RssData` struct.
284    ///
285    /// # Arguments
286    ///
287    /// * `item` - The `RssItem` to be added to the feed.
288    pub fn add_item(&mut self, item: RssItem) {
289        self.items.push(item);
290    }
291
292    /// Removes an item from the RSS feed by its GUID.
293    ///
294    /// # Arguments
295    ///
296    /// * `guid` - The GUID of the item to remove.
297    ///
298    /// # Returns
299    ///
300    /// `true` if an item was removed, `false` otherwise.
301    pub fn remove_item(&mut self, guid: &str) -> bool {
302        let initial_len = self.items.len();
303        self.items.retain(|item| item.guid != guid);
304        self.items.len() < initial_len
305    }
306
307    /// Returns the number of items in the RSS feed.
308    #[must_use]
309    pub fn item_count(&self) -> usize {
310        self.items.len()
311    }
312
313    /// Clears all items from the RSS feed.
314    pub fn clear_items(&mut self) {
315        self.items.clear();
316    }
317
318    /// Validates the `RssData` to ensure that all required fields are set and valid.
319    ///
320    /// # Returns
321    ///
322    /// * `Ok(())` if the `RssData` is valid.
323    /// * `Err(RssError)` if any validation errors are found.
324    ///
325    /// # Errors
326    ///
327    /// This function returns an `Err(RssError)` in the following cases:
328    ///
329    /// * `RssError::InvalidInput` if the category exceeds the maximum allowed length.
330    /// * `RssError::ValidationErrors` if there are missing or invalid fields (e.g., title, link, description, publication date).
331    ///
332    /// Additionally, it can return an error if the link format is invalid or the publication date cannot be parsed.
333    pub fn validate(&self) -> Result<()> {
334        let mut errors: Vec<ValidationError> = Vec::new();
335
336        // Issue #34: prefix errors with `channel.` so downstream
337        // tooling can distinguish channel-level failures from
338        // per-item failures.
339        if self.title.is_empty() {
340            errors.push(ValidationError::new(
341                "channel.title",
342                "channel.title is missing",
343            ));
344        }
345
346        if self.link.is_empty() {
347            errors.push(ValidationError::new(
348                "channel.link",
349                "channel.link is missing",
350            ));
351        } else if let Err(e) = validate_url(&self.link) {
352            // RSS 2.0 §5.1 requires the channel link to be an
353            // absolute browser-followable URL. Keep `validate_url`
354            // (http/https only) for the channel pass.
355            errors.push(ValidationError::new(
356                "channel.link",
357                format!("Invalid channel.link: {e}"),
358            ));
359        }
360
361        if self.description.is_empty() {
362            errors.push(ValidationError::new(
363                "channel.description",
364                "channel.description is missing",
365            ));
366        }
367
368        // Check category length
369        if self.category.len() > MAX_GENERAL_LENGTH {
370            return Err(RssError::InvalidInput(format!(
371            "channel.category exceeds maximum allowed length of {MAX_GENERAL_LENGTH} characters"
372        )));
373        }
374
375        if !self.pub_date.is_empty() {
376            if let Err(e) = parse_date(&self.pub_date) {
377                errors.push(ValidationError::new(
378                    "channel.pub_date",
379                    format!("Invalid channel.pub_date: {e}"),
380                ));
381            }
382        }
383
384        if !errors.is_empty() {
385            return Err(RssError::ValidationErrors(errors));
386        }
387
388        Ok(())
389    }
390
391    /// Converts the `RssData` into a `HashMap<String, String>` for easier manipulation.
392    ///
393    /// # Returns
394    ///
395    /// A `HashMap<String, String>` containing the RSS feed data.
396    #[must_use]
397    pub fn to_hash_map(&self) -> HashMap<String, String> {
398        let mut map = HashMap::new();
399        map.insert("atom_link".to_string(), self.atom_link.clone());
400        map.insert("author".to_string(), self.author.clone());
401        map.insert("category".to_string(), self.category.clone());
402        map.insert("copyright".to_string(), self.copyright.clone());
403        map.insert("description".to_string(), self.description.clone());
404        map.insert("docs".to_string(), self.docs.clone());
405        map.insert("generator".to_string(), self.generator.clone());
406        map.insert("guid".to_string(), self.guid.clone());
407        map.insert("image_title".to_string(), self.image_title.clone());
408        map.insert("image_url".to_string(), self.image_url.clone());
409        map.insert("image_link".to_string(), self.image_link.clone());
410        map.insert("language".to_string(), self.language.clone());
411        map.insert(
412            "last_build_date".to_string(),
413            self.last_build_date.clone(),
414        );
415        map.insert("link".to_string(), self.link.clone());
416        map.insert(
417            "managing_editor".to_string(),
418            self.managing_editor.clone(),
419        );
420        map.insert("pub_date".to_string(), self.pub_date.clone());
421        map.insert("title".to_string(), self.title.clone());
422        map.insert("ttl".to_string(), self.ttl.clone());
423        map.insert("webmaster".to_string(), self.webmaster.clone());
424        map
425    }
426
427    // Field setter methods
428
429    /// Sets the RSS version.
430    #[must_use]
431    pub fn version(mut self, version: RssVersion) -> Self {
432        self.version = version;
433        self
434    }
435
436    /// Sets the Atom link.
437    #[must_use]
438    pub fn atom_link<T: Into<String>>(self, value: T) -> Self {
439        self.set(RssDataField::AtomLink, value)
440    }
441
442    /// Sets the author.
443    #[must_use]
444    pub fn author<T: Into<String>>(self, value: T) -> Self {
445        self.set(RssDataField::Author, value)
446    }
447
448    /// Sets the category.
449    #[must_use]
450    pub fn category<T: Into<String>>(self, value: T) -> Self {
451        self.set(RssDataField::Category, value)
452    }
453
454    /// Sets the copyright.
455    #[must_use]
456    pub fn copyright<T: Into<String>>(self, value: T) -> Self {
457        self.set(RssDataField::Copyright, value)
458    }
459
460    /// Sets the description.
461    #[must_use]
462    pub fn description<T: Into<String>>(self, value: T) -> Self {
463        self.set(RssDataField::Description, value)
464    }
465
466    /// Sets the docs link.
467    #[must_use]
468    pub fn docs<T: Into<String>>(self, value: T) -> Self {
469        self.set(RssDataField::Docs, value)
470    }
471
472    /// Sets the generator.
473    #[must_use]
474    pub fn generator<T: Into<String>>(self, value: T) -> Self {
475        self.set(RssDataField::Generator, value)
476    }
477
478    /// Sets the GUID.
479    #[must_use]
480    pub fn guid<T: Into<String>>(self, value: T) -> Self {
481        self.set(RssDataField::Guid, value)
482    }
483
484    /// Sets the image title.
485    #[must_use]
486    pub fn image_title<T: Into<String>>(self, value: T) -> Self {
487        self.set(RssDataField::ImageTitle, value)
488    }
489
490    /// Sets the image URL.
491    #[must_use]
492    pub fn image_url<T: Into<String>>(self, value: T) -> Self {
493        self.set(RssDataField::ImageUrl, value)
494    }
495
496    /// Sets the image link.
497    #[must_use]
498    pub fn image_link<T: Into<String>>(self, value: T) -> Self {
499        self.set(RssDataField::ImageLink, value)
500    }
501
502    /// Sets the language.
503    #[must_use]
504    pub fn language<T: Into<String>>(self, value: T) -> Self {
505        self.set(RssDataField::Language, value)
506    }
507
508    /// Sets the last build date.
509    #[must_use]
510    pub fn last_build_date<T: Into<String>>(self, value: T) -> Self {
511        self.set(RssDataField::LastBuildDate, value)
512    }
513
514    /// Sets the main link.
515    #[must_use]
516    pub fn link<T: Into<String>>(self, value: T) -> Self {
517        self.set(RssDataField::Link, value)
518    }
519
520    /// Sets the managing editor.
521    #[must_use]
522    pub fn managing_editor<T: Into<String>>(self, value: T) -> Self {
523        self.set(RssDataField::ManagingEditor, value)
524    }
525
526    /// Sets the publication date.
527    #[must_use]
528    pub fn pub_date<T: Into<String>>(self, value: T) -> Self {
529        self.set(RssDataField::PubDate, value)
530    }
531
532    /// Sets the title.
533    #[must_use]
534    pub fn title<T: Into<String>>(self, value: T) -> Self {
535        self.set(RssDataField::Title, value)
536    }
537
538    /// Sets the TTL (Time To Live).
539    #[must_use]
540    pub fn ttl<T: Into<String>>(self, value: T) -> Self {
541        self.set(RssDataField::Ttl, value)
542    }
543
544    /// Sets the webmaster.
545    #[must_use]
546    pub fn webmaster<T: Into<String>>(self, value: T) -> Self {
547        self.set(RssDataField::Webmaster, value)
548    }
549}
550
551/// Represents the fields of an RSS data structure.
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
553pub enum RssDataField {
554    /// The Atom link of the RSS feed.
555    AtomLink,
556    /// The author of the RSS feed.
557    Author,
558    /// The category of the RSS feed.
559    Category,
560    /// The copyright notice.
561    Copyright,
562    /// The description of the RSS feed.
563    Description,
564    /// The docs link of the RSS feed.
565    Docs,
566    /// The generator of the RSS feed.
567    Generator,
568    /// The GUID of the RSS feed.
569    Guid,
570    /// The image title of the RSS feed.
571    ImageTitle,
572    /// The image URL of the RSS feed.
573    ImageUrl,
574    /// The image link of the RSS feed.
575    ImageLink,
576    /// The language of the RSS feed.
577    Language,
578    /// The last build date of the RSS feed.
579    LastBuildDate,
580    /// The main link to the RSS feed.
581    Link,
582    /// The managing editor of the RSS feed.
583    ManagingEditor,
584    /// The publication date of the RSS feed.
585    PubDate,
586    /// The title of the RSS feed.
587    Title,
588    /// Time To Live (TTL), the number of minutes the feed should be cached before refreshing.
589    Ttl,
590    /// The webmaster of the RSS feed.
591    Webmaster,
592}
593
594/// Represents an item in the RSS feed.
595#[derive(
596    Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize,
597)]
598#[non_exhaustive]
599pub struct RssItem {
600    /// The GUID of the RSS item (unique identifier).
601    pub guid: String,
602    /// The category of the RSS item.
603    pub category: Option<String>,
604    /// The description of the RSS item.
605    pub description: String,
606    /// The link to the RSS item.
607    pub link: String,
608    /// The publication date of the RSS item.
609    pub pub_date: String,
610    /// The title of the RSS item.
611    pub title: String,
612    /// The author of the RSS item.
613    pub author: String,
614    /// The comments URL related to the RSS item (optional).
615    pub comments: Option<String>,
616    /// The enclosure (typically for media like podcasts) (optional).
617    pub enclosure: Option<String>,
618    /// The source of the RSS item (optional).
619    pub source: Option<String>,
620    /// The creator of the RSS item (optional).
621    pub creator: Option<String>,
622    /// The date the RSS item was created (optional).
623    pub date: Option<String>,
624}
625
626impl RssItem {
627    /// Creates a new `RssItem` with default values.
628    #[must_use]
629    pub fn new() -> Self {
630        Self::default()
631    }
632
633    /// Sets the value of a field and returns the `RssItem` instance for method chaining.
634    ///
635    /// # Arguments
636    ///
637    /// * `field` - The field to set.
638    /// * `value` - The value to assign to the field.
639    ///
640    /// # Returns
641    ///
642    /// The updated `RssItem` instance.
643    #[must_use]
644    pub fn set<T: Into<String>>(
645        mut self,
646        field: RssItemField,
647        value: T,
648    ) -> Self {
649        let value = sanitize_input(&value.into());
650        match field {
651            RssItemField::Guid => self.guid = value,
652            RssItemField::Category => self.category = Some(value),
653            RssItemField::Description => self.description = value,
654            RssItemField::Link => self.link = value,
655            RssItemField::PubDate => self.pub_date = value,
656            RssItemField::Title => self.title = value,
657            RssItemField::Author => self.author = value,
658            RssItemField::Comments => self.comments = Some(value),
659            RssItemField::Enclosure => self.enclosure = Some(value),
660            RssItemField::Source => self.source = Some(value),
661        }
662        self
663    }
664
665    /// Validates the `RssData` to ensure that all required fields are set and valid.
666    ///
667    /// # Returns
668    ///
669    /// * `Ok(())` if the `RssData` is valid.
670    /// * `Err(RssError)` if any validation errors are found.
671    ///
672    /// # Errors
673    ///
674    /// This function returns an `Err(RssError)` in the following cases:
675    ///
676    /// * `RssError::InvalidInput` if any fields such as `title`, `link`, or `description` are missing or invalid.
677    /// * `RssError::ValidationErrors` if there are multiple validation issues found (e.g., invalid link, missing title, etc.).
678    /// * `RssError::DateParseError` if the `pub_date` cannot be parsed into a valid date.
679    ///
680    /// Additionally, it can return an error if any of the custom validation rules are violated (e.g., maximum length for certain fields).
681    pub fn validate(&self) -> Result<()> {
682        let mut errors: Vec<ValidationError> = Vec::new();
683
684        // Issue #34: prefix errors with `item.` so downstream tooling
685        // can distinguish per-item failures from channel-level ones.
686        if self.title.is_empty() {
687            errors.push(ValidationError::new(
688                "item.title",
689                "item.title is missing",
690            ));
691        }
692
693        if self.link.is_empty() {
694            errors.push(ValidationError::new(
695                "item.link",
696                "item.link is missing",
697            ));
698        } else if let Err(e) = validate_link_field(&self.link) {
699            // RSS 2.0 §5.7 allows item links to be relative — use the
700            // lenient `validate_link_field` instead of the strict
701            // `validate_url` we apply to channel.link.
702            errors.push(ValidationError::new(
703                "item.link",
704                format!("Invalid item.link: {e}"),
705            ));
706        }
707
708        if self.description.is_empty() {
709            errors.push(ValidationError::new(
710                "item.description",
711                "item.description is missing",
712            ));
713        }
714
715        if !errors.is_empty() {
716            return Err(RssError::ValidationErrors(errors));
717        }
718
719        Ok(())
720    }
721
722    /// Parses the `pub_date` string into a [`time::OffsetDateTime`].
723    ///
724    /// Delegates to [`parse_date`], which accepts both RFC 2822
725    /// and ISO 8601 inputs.
726    ///
727    /// # Errors
728    ///
729    /// Returns [`RssError::DateParseError`] when the `pub_date` field
730    /// matches neither RFC 2822 nor ISO 8601.
731    pub fn pub_date_parsed(&self) -> Result<OffsetDateTime> {
732        parse_date(&self.pub_date)
733    }
734
735    // Field setter methods
736
737    /// Sets the GUID.
738    #[must_use]
739    pub fn guid<T: Into<String>>(self, value: T) -> Self {
740        self.set(RssItemField::Guid, value)
741    }
742
743    /// Sets the category.
744    #[must_use]
745    pub fn category<T: Into<String>>(self, value: T) -> Self {
746        self.set(RssItemField::Category, value)
747    }
748
749    /// Sets the description.
750    #[must_use]
751    pub fn description<T: Into<String>>(self, value: T) -> Self {
752        self.set(RssItemField::Description, value)
753    }
754
755    /// Sets the link.
756    #[must_use]
757    pub fn link<T: Into<String>>(self, value: T) -> Self {
758        self.set(RssItemField::Link, value)
759    }
760
761    /// Sets the publication date.
762    #[must_use]
763    pub fn pub_date<T: Into<String>>(self, value: T) -> Self {
764        self.set(RssItemField::PubDate, value)
765    }
766
767    /// Sets the title.
768    #[must_use]
769    pub fn title<T: Into<String>>(self, value: T) -> Self {
770        self.set(RssItemField::Title, value)
771    }
772
773    /// Sets the author.
774    #[must_use]
775    pub fn author<T: Into<String>>(self, value: T) -> Self {
776        self.set(RssItemField::Author, value)
777    }
778
779    /// Sets the comments URL.
780    #[must_use]
781    pub fn comments<T: Into<String>>(self, value: T) -> Self {
782        self.set(RssItemField::Comments, value)
783    }
784
785    /// Sets the enclosure.
786    #[must_use]
787    pub fn enclosure<T: Into<String>>(self, value: T) -> Self {
788        self.set(RssItemField::Enclosure, value)
789    }
790
791    /// Sets the source.
792    #[must_use]
793    pub fn source<T: Into<String>>(self, value: T) -> Self {
794        self.set(RssItemField::Source, value)
795    }
796}
797
798/// Represents the fields of an RSS item.
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
800pub enum RssItemField {
801    /// The GUID of the RSS item.
802    Guid,
803    /// The category of the RSS item.
804    Category,
805    /// The description of the RSS item.
806    Description,
807    /// The link to the RSS item.
808    Link,
809    /// The publication date of the RSS item.
810    PubDate,
811    /// The title of the RSS item.
812    Title,
813    /// The author of the RSS item.
814    Author,
815    /// The comments URL related to the RSS item.
816    Comments,
817    /// The enclosure (typically for media like podcasts).
818    Enclosure,
819    /// The source of the RSS item.
820    Source,
821}
822
823/// Validates a URL string.
824///
825/// # Arguments
826///
827/// * `url` - A string slice that holds the URL to validate.
828///
829/// # Returns
830///
831/// * `Ok(())` if the URL is valid.
832/// * `Err(RssError)` if the URL is invalid.
833///
834/// # Errors
835///
836/// This function returns an `Err(RssError::InvalidUrl)` if the URL is not valid or
837/// if it does not use the `http` or `https` protocol.
838pub fn validate_url(url: &str) -> Result<()> {
839    let parsed_url = Url::parse(url)
840        .map_err(|_| RssError::InvalidUrl(url.to_string()))?;
841
842    if parsed_url.scheme() != "http" && parsed_url.scheme() != "https" {
843        return Err(RssError::InvalidUrl(
844            "URL must use http or https protocol".to_string(),
845        ));
846    }
847
848    Ok(())
849}
850
851/// Validates a value used as an item-level link field.
852///
853/// Per RSS 2.0 §5.7 item-level link URLs may be relative. This helper
854/// accepts:
855///
856///   * absolute URLs (any scheme that `url::Url` parses), e.g.
857///     `https://example.com/post`
858///   * root-relative paths starting with `/`, e.g. `/tags/`
859///   * bare paths without a leading slash, e.g. `articles/foo.html`
860///
861/// Rejects:
862///
863///   * the empty string (caller should check `is_empty()` first)
864///   * any string containing whitespace or ASCII control characters —
865///     those would round-trip into the rendered RSS XML and break
866///     well-formedness or feed-reader parsing.
867///
868/// # Errors
869///
870/// Returns `RssError::InvalidUrl` with a descriptive message when the
871/// input contains whitespace or control characters, or is empty.
872pub fn validate_link_field(value: &str) -> Result<()> {
873    if value.is_empty() {
874        return Err(RssError::InvalidUrl(
875            "empty link is not a valid relative or absolute URL"
876                .to_string(),
877        ));
878    }
879    if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
880        return Err(RssError::InvalidUrl(format!(
881            "link contains whitespace or control characters: {value:?}"
882        )));
883    }
884    // Anything else — absolute URL OR relative path — is accepted.
885    // `Url::parse` would reject relative paths because there's no
886    // base, so we don't call it here. RSS 2.0 §5.7 explicitly allows
887    // both shapes for item links.
888    Ok(())
889}
890
891/// Parses a date string into a [`time::OffsetDateTime`].
892///
893/// Accepts both RFC 2822 (the historical RSS 2.0 wire format —
894/// `Mon, 01 Jan 2024 00:00:00 GMT` / `… +0000` / `… +0530`) and ISO 8601
895/// (`2024-01-01T00:00:00Z`, used by Atom and Dublin Core). The previous
896/// implementation collapsed every successfully-parsed date to a UTC
897/// sentinel; this revision returns the actual parsed value so callers can
898/// inspect the timezone offset and the exact instant.
899///
900/// # Arguments
901///
902/// * `date_str` - A string slice that holds the date to parse.
903///
904/// # Errors
905///
906/// Returns [`RssError::DateParseError`] when the input matches neither
907/// RFC 2822 nor ISO 8601.
908pub fn parse_date(date_str: &str) -> Result<OffsetDateTime> {
909    if let Ok(parsed) = OffsetDateTime::parse(date_str, &Rfc2822) {
910        return Ok(parsed);
911    }
912
913    if let Ok(parsed) =
914        OffsetDateTime::parse(date_str, &Iso8601::DEFAULT)
915    {
916        return Ok(parsed);
917    }
918
919    Err(RssError::DateParseError(date_str.to_string()))
920}
921
922/// Sanitizes input by escaping HTML special characters.
923///
924/// # Arguments
925///
926/// * `input` - A string slice containing the input to sanitize.
927///
928/// # Returns
929///
930/// A `String` with HTML special characters escaped.
931fn sanitize_input(input: &str) -> String {
932    input
933        .replace('&', "&amp;")
934        .replace('<', "&lt;")
935        .replace('>', "&gt;")
936        .replace('"', "&quot;")
937        .replace('\'', "&#x27;")
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943    use quick_xml::de::from_str;
944
945    #[derive(Debug, Deserialize, PartialEq)]
946    struct Image {
947        title: String,
948        url: String,
949        link: String,
950    }
951
952    #[derive(Debug, Deserialize, PartialEq)]
953    struct Channel {
954        title: String,
955        link: String,
956        description: String,
957        image: Image,
958    }
959
960    #[derive(Debug, Deserialize, PartialEq)]
961    struct Rss {
962        #[serde(rename = "channel")]
963        channel: Channel,
964    }
965
966    #[test]
967    fn test_rss_version() {
968        assert_eq!(RssVersion::RSS2_0.as_str(), "2.0");
969        assert_eq!(RssVersion::default(), RssVersion::RSS2_0);
970        assert_eq!(RssVersion::RSS1_0.to_string(), "1.0");
971        assert!(matches!(
972            "2.0".parse::<RssVersion>(),
973            Ok(RssVersion::RSS2_0)
974        ));
975        assert!("3.0".parse::<RssVersion>().is_err());
976    }
977
978    #[test]
979    fn test_rss_data_new() {
980        let rss_data = RssData::new(Some(RssVersion::RSS2_0));
981        assert_eq!(rss_data.version, RssVersion::RSS2_0);
982    }
983
984    #[test]
985    fn test_rss_data_setters() {
986        let rss_data = RssData::new(None)
987            .title("Test Feed")
988            .link("https://example.com")
989            .description("A test feed")
990            .generator("RSS Gen")
991            .guid("unique-guid")
992            .pub_date("2024-03-21T12:00:00Z")
993            .language("en");
994
995        assert_eq!(rss_data.title, "Test Feed");
996        assert_eq!(rss_data.link, "https://example.com");
997        assert_eq!(rss_data.description, "A test feed");
998        assert_eq!(rss_data.generator, "RSS Gen");
999        assert_eq!(rss_data.guid, "unique-guid");
1000        assert_eq!(rss_data.pub_date, "2024-03-21T12:00:00Z");
1001        assert_eq!(rss_data.language, "en");
1002    }
1003
1004    #[test]
1005    fn test_rss_data_validate() {
1006        let valid_rss_data = RssData::new(None)
1007            .title("Valid Feed")
1008            .link("https://example.com")
1009            .description("A valid RSS feed");
1010
1011        assert!(valid_rss_data.validate().is_ok());
1012
1013        let invalid_rss_data = RssData::new(None)
1014            .title("Invalid Feed")
1015            .link("not a valid url")
1016            .description("An invalid RSS feed");
1017
1018        let result = invalid_rss_data.validate();
1019        assert!(result.is_err());
1020        if let Err(RssError::ValidationErrors(errors)) = result {
1021            assert!(errors.iter().any(|e| e.field == "channel.link"
1022                && e.message.contains("Invalid channel.link")),
1023                "Expected a structured ValidationError on `channel.link`, got: {errors:?}");
1024        } else {
1025            panic!("Expected ValidationErrors");
1026        }
1027    }
1028
1029    #[test]
1030    fn test_add_item() {
1031        let mut rss_data = RssData::new(None)
1032            .title("Test RSS Feed")
1033            .link("https://example.com")
1034            .description("A test RSS feed");
1035
1036        let item = RssItem::new()
1037            .title("Test Item")
1038            .link("https://example.com/item")
1039            .description("A test item")
1040            .guid("unique-id-1")
1041            .pub_date("2024-03-21");
1042
1043        rss_data.add_item(item);
1044
1045        assert_eq!(rss_data.items.len(), 1);
1046        assert_eq!(rss_data.items[0].title, "Test Item");
1047        assert_eq!(rss_data.items[0].link, "https://example.com/item");
1048        assert_eq!(rss_data.items[0].description, "A test item");
1049        assert_eq!(rss_data.items[0].guid, "unique-id-1");
1050        assert_eq!(rss_data.items[0].pub_date, "2024-03-21");
1051    }
1052
1053    #[test]
1054    fn test_remove_item() {
1055        let mut rss_data = RssData::new(None)
1056            .title("Test RSS Feed")
1057            .link("https://example.com")
1058            .description("A test RSS feed");
1059
1060        let item1 = RssItem::new()
1061            .title("Item 1")
1062            .link("https://example.com/item1")
1063            .description("First item")
1064            .guid("guid1");
1065
1066        let item2 = RssItem::new()
1067            .title("Item 2")
1068            .link("https://example.com/item2")
1069            .description("Second item")
1070            .guid("guid2");
1071
1072        rss_data.add_item(item1);
1073        rss_data.add_item(item2);
1074
1075        assert_eq!(rss_data.item_count(), 2);
1076
1077        assert!(rss_data.remove_item("guid1"));
1078        assert_eq!(rss_data.item_count(), 1);
1079        assert_eq!(rss_data.items[0].title, "Item 2");
1080
1081        assert!(!rss_data.remove_item("non-existent-guid"));
1082        assert_eq!(rss_data.item_count(), 1);
1083    }
1084
1085    #[test]
1086    fn test_clear_items() {
1087        let mut rss_data = RssData::new(None)
1088            .title("Test RSS Feed")
1089            .link("https://example.com")
1090            .description("A test RSS feed");
1091
1092        rss_data.add_item(RssItem::new().title("Item 1").guid("guid1"));
1093        rss_data.add_item(RssItem::new().title("Item 2").guid("guid2"));
1094
1095        assert_eq!(rss_data.item_count(), 2);
1096
1097        rss_data.clear_items();
1098
1099        assert_eq!(rss_data.item_count(), 0);
1100    }
1101
1102    #[test]
1103    fn test_rss_item_validate() {
1104        let valid_item = RssItem::new()
1105            .title("Valid Item")
1106            .link("https://example.com/valid")
1107            .description("A valid item")
1108            .guid("valid-guid");
1109
1110        assert!(valid_item.validate().is_ok());
1111
1112        let invalid_item = RssItem::new()
1113            .title("Invalid Item")
1114            .description("An invalid item");
1115
1116        let result = invalid_item.validate();
1117        assert!(result.is_err());
1118
1119        if let Err(RssError::ValidationErrors(errors)) = result {
1120            assert_eq!(errors.len(), 1);
1121            assert!(
1122                errors.iter().any(|e| e.field == "item.link"
1123                    && e.message == "item.link is missing"),
1124                "expected `item.link is missing`, got: {errors:?}"
1125            );
1126        } else {
1127            panic!("Expected ValidationErrors");
1128        }
1129    }
1130
1131    #[test]
1132    fn test_validate_url() {
1133        assert!(validate_url("https://example.com").is_ok());
1134        assert!(validate_url("not a url").is_err());
1135    }
1136
1137    #[test]
1138    fn test_parse_date() {
1139        assert!(parse_date("Mon, 01 Jan 2024 00:00:00 GMT").is_ok());
1140        assert!(parse_date("2024-03-21T12:00:00Z").is_ok());
1141        assert!(parse_date("invalid date").is_err());
1142    }
1143
1144    #[test]
1145    fn test_sanitize_input() {
1146        let input = "Test <script>alert('XSS')</script>";
1147        let sanitized = sanitize_input(input);
1148        assert_eq!(
1149            sanitized,
1150            "Test &lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_rss_data_set_with_enum() {
1156        let rss_data = RssData::new(None)
1157            .set(RssDataField::Title, "Test Title")
1158            .set(RssDataField::Link, "https://example.com")
1159            .set(RssDataField::Description, "Test Description");
1160
1161        assert_eq!(rss_data.title, "Test Title");
1162        assert_eq!(rss_data.link, "https://example.com");
1163        assert_eq!(rss_data.description, "Test Description");
1164    }
1165
1166    #[test]
1167    fn test_rss_item_set_with_enum() {
1168        let item = RssItem::new()
1169            .set(RssItemField::Title, "Test Item")
1170            .set(RssItemField::Link, "https://example.com/item")
1171            .set(RssItemField::Guid, "unique-id");
1172
1173        assert_eq!(item.title, "Test Item");
1174        assert_eq!(item.link, "https://example.com/item");
1175        assert_eq!(item.guid, "unique-id");
1176    }
1177
1178    #[test]
1179    fn test_to_hash_map() {
1180        let rss_data = RssData::new(None)
1181            .title("Test Title")
1182            .link("https://example.com/rss")
1183            .description("A test RSS feed")
1184            .atom_link("https://example.com/atom")
1185            .language("en")
1186            .managing_editor("editor@example.com")
1187            .webmaster("webmaster@example.com")
1188            .last_build_date("2024-03-21T12:00:00Z")
1189            .pub_date("2024-03-21T12:00:00Z")
1190            .ttl("60")
1191            .generator("RSS Gen")
1192            .guid("unique-guid")
1193            .image_title("Image Title".to_string())
1194            .docs("https://docs.example.com");
1195
1196        let map = rss_data.to_hash_map();
1197
1198        assert_eq!(map.get("title").unwrap(), "Test Title");
1199        assert_eq!(map.get("link").unwrap(), "https://example.com/rss");
1200        assert_eq!(
1201            map.get("atom_link").unwrap(),
1202            "https://example.com/atom"
1203        );
1204        assert_eq!(map.get("language").unwrap(), "en");
1205        assert_eq!(
1206            map.get("managing_editor").unwrap(),
1207            "editor@example.com"
1208        );
1209        assert_eq!(
1210            map.get("webmaster").unwrap(),
1211            "webmaster@example.com"
1212        );
1213        assert_eq!(
1214            map.get("last_build_date").unwrap(),
1215            "2024-03-21T12:00:00Z"
1216        );
1217        assert_eq!(
1218            map.get("pub_date").unwrap(),
1219            "2024-03-21T12:00:00Z"
1220        );
1221        assert_eq!(map.get("ttl").unwrap(), "60");
1222        assert_eq!(map.get("generator").unwrap(), "RSS Gen");
1223        assert_eq!(map.get("guid").unwrap(), "unique-guid");
1224        assert_eq!(map.get("image_title").unwrap(), "Image Title");
1225        assert_eq!(
1226            map.get("docs").unwrap(),
1227            "https://docs.example.com"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_set_image() {
1233        let mut rss_data = RssData::new(None);
1234        rss_data.set_image(
1235            "Test Image Title",
1236            "https://example.com/image.jpg",
1237            "https://example.com",
1238        );
1239        rss_data.title = "RSS Feed Title".to_string();
1240
1241        assert_eq!(rss_data.image_title, "Test Image Title");
1242        assert_eq!(rss_data.image_url, "https://example.com/image.jpg");
1243        assert_eq!(rss_data.image_link, "https://example.com");
1244        assert_eq!(rss_data.title, "RSS Feed Title");
1245    }
1246
1247    #[test]
1248    fn test_rss_feed_parsing() {
1249        let rss_xml = r#"
1250        <?xml version="1.0" encoding="UTF-8"?>
1251        <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"
1252             xmlns:dc="http://purl.org/dc/elements/1.1/"
1253             xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
1254             xmlns:taxo="http://purl.org/rss/1.0/modules/taxonomy/">
1255          <channel>
1256            <title>GETS Open Tenders or Quotes</title>
1257            <link>https://www.gets.govt.nz//ExternalIndex.htm</link>
1258            <description>This feed lists the current open tenders or requests for quote listed on the GETS.</description>
1259            <image>
1260              <title>Open tenders or Requests for Quote from GETS</title>
1261              <url>https://www.gets.govt.nz//ext/default/img/getsLogo.jpg</url>
1262              <link>https://www.gets.govt.nz//ExternalIndex.htm</link>
1263            </image>
1264          </channel>
1265        </rss>
1266        "#;
1267
1268        let parsed: Rss =
1269            from_str(rss_xml).expect("Failed to parse RSS XML");
1270
1271        assert_eq!(parsed.channel.title, "GETS Open Tenders or Quotes");
1272        assert_eq!(
1273            parsed.channel.link,
1274            "https://www.gets.govt.nz//ExternalIndex.htm"
1275        );
1276        assert_eq!(parsed.channel.description, "This feed lists the current open tenders or requests for quote listed on the GETS.");
1277        assert_eq!(
1278            parsed.channel.image.title,
1279            "Open tenders or Requests for Quote from GETS"
1280        );
1281        assert_eq!(
1282            parsed.channel.image.url,
1283            "https://www.gets.govt.nz//ext/default/img/getsLogo.jpg"
1284        );
1285        assert_eq!(
1286            parsed.channel.image.link,
1287            "https://www.gets.govt.nz//ExternalIndex.htm"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_rss_version_from_str() {
1293        assert_eq!(
1294            RssVersion::from_str("0.90").unwrap(),
1295            RssVersion::RSS0_90
1296        );
1297        assert_eq!(
1298            RssVersion::from_str("0.91").unwrap(),
1299            RssVersion::RSS0_91
1300        );
1301        assert_eq!(
1302            RssVersion::from_str("0.92").unwrap(),
1303            RssVersion::RSS0_92
1304        );
1305        assert_eq!(
1306            RssVersion::from_str("1.0").unwrap(),
1307            RssVersion::RSS1_0
1308        );
1309        assert_eq!(
1310            RssVersion::from_str("2.0").unwrap(),
1311            RssVersion::RSS2_0
1312        );
1313        assert!(RssVersion::from_str("3.0").is_err());
1314    }
1315
1316    #[test]
1317    fn test_rss_version_display() {
1318        assert_eq!(format!("{}", RssVersion::RSS0_90), "0.90");
1319        assert_eq!(format!("{}", RssVersion::RSS0_91), "0.91");
1320        assert_eq!(format!("{}", RssVersion::RSS0_92), "0.92");
1321        assert_eq!(format!("{}", RssVersion::RSS1_0), "1.0");
1322        assert_eq!(format!("{}", RssVersion::RSS2_0), "2.0");
1323    }
1324
1325    #[test]
1326    fn test_rss_data_set_methods() {
1327        let rss_data = RssData::new(None)
1328            .atom_link("https://example.com/atom")
1329            .author("John Doe")
1330            .category("Technology")
1331            .copyright("© 2024 Example Inc.")
1332            .description("A sample RSS feed")
1333            .docs("https://example.com/rss-docs")
1334            .generator("RSS Gen v1.0")
1335            .guid("unique-guid-123")
1336            .image_title("Feed Image")
1337            .image_url("https://example.com/image.jpg")
1338            .image_link("https://example.com")
1339            .language("en-US")
1340            .last_build_date("2024-03-21T12:00:00Z")
1341            .link("https://example.com")
1342            .managing_editor("editor@example.com")
1343            .pub_date("2024-03-21T00:00:00Z")
1344            .title("Sample Feed")
1345            .ttl("60")
1346            .webmaster("webmaster@example.com");
1347
1348        assert_eq!(rss_data.atom_link, "https://example.com/atom");
1349        assert_eq!(rss_data.author, "John Doe");
1350        assert_eq!(rss_data.category, "Technology");
1351        assert_eq!(rss_data.copyright, "© 2024 Example Inc.");
1352        assert_eq!(rss_data.description, "A sample RSS feed");
1353        assert_eq!(rss_data.docs, "https://example.com/rss-docs");
1354        assert_eq!(rss_data.generator, "RSS Gen v1.0");
1355        assert_eq!(rss_data.guid, "unique-guid-123");
1356        assert_eq!(rss_data.image_title, "Feed Image");
1357        assert_eq!(rss_data.image_url, "https://example.com/image.jpg");
1358        assert_eq!(rss_data.image_link, "https://example.com");
1359        assert_eq!(rss_data.language, "en-US");
1360        assert_eq!(rss_data.last_build_date, "2024-03-21T12:00:00Z");
1361        assert_eq!(rss_data.link, "https://example.com");
1362        assert_eq!(rss_data.managing_editor, "editor@example.com");
1363        assert_eq!(rss_data.pub_date, "2024-03-21T00:00:00Z");
1364        assert_eq!(rss_data.title, "Sample Feed");
1365        assert_eq!(rss_data.ttl, "60");
1366        assert_eq!(rss_data.webmaster, "webmaster@example.com");
1367    }
1368
1369    #[test]
1370    fn test_rss_data_empty() {
1371        let rss_data = RssData::new(None);
1372        assert!(rss_data.title.is_empty());
1373        assert!(rss_data.link.is_empty());
1374        assert!(rss_data.description.is_empty());
1375        assert_eq!(rss_data.items.len(), 0);
1376    }
1377
1378    #[test]
1379    fn test_rss_item_empty() {
1380        let item = RssItem::new();
1381        assert!(item.title.is_empty());
1382        assert!(item.link.is_empty());
1383        assert!(item.guid.is_empty());
1384        assert!(item.description.is_empty());
1385    }
1386
1387    #[test]
1388    fn test_rss_data_to_hash_map() {
1389        let rss_data = RssData::new(None)
1390            .title("Test Feed")
1391            .link("https://example.com")
1392            .description("A test feed");
1393
1394        let hash_map = rss_data.to_hash_map();
1395        assert_eq!(hash_map.get("title").unwrap(), "Test Feed");
1396        assert_eq!(
1397            hash_map.get("link").unwrap(),
1398            "https://example.com"
1399        );
1400        assert_eq!(hash_map.get("description").unwrap(), "A test feed");
1401    }
1402
1403    #[test]
1404    fn test_rss_data_version_setter() {
1405        let rss_data = RssData::new(None).version(RssVersion::RSS1_0);
1406        assert_eq!(rss_data.version, RssVersion::RSS1_0);
1407    }
1408
1409    #[test]
1410    fn test_remove_item_not_found() {
1411        let mut rss_data = RssData::new(None);
1412        let item = RssItem::new().guid("existing-guid");
1413        rss_data.add_item(item);
1414
1415        // Try removing an item with a non-existent GUID
1416        let removed = rss_data.remove_item("non-existent-guid");
1417        assert!(!removed);
1418        assert_eq!(rss_data.items.len(), 1);
1419    }
1420
1421    #[test]
1422    fn test_set_item_field_empty_items() {
1423        let mut rss_data = RssData::new(None);
1424        rss_data.set_item_field(RssItemField::Title, "Test Item Title");
1425
1426        assert_eq!(rss_data.items.len(), 1);
1427        assert_eq!(rss_data.items[0].title, "Test Item Title");
1428    }
1429
1430    #[test]
1431    fn test_set_image_empty() {
1432        let mut rss_data = RssData::new(None);
1433        rss_data.set_image("", "", "");
1434
1435        assert!(rss_data.image_title.is_empty());
1436        assert!(rss_data.image_url.is_empty());
1437        assert!(rss_data.image_link.is_empty());
1438    }
1439
1440    #[test]
1441    fn test_rss_item_set_empty_field() {
1442        let item = RssItem::new().set(RssItemField::Title, "");
1443        assert!(item.title.is_empty());
1444    }
1445
1446    #[test]
1447    fn test_rss_data_validate_invalid_pub_date() {
1448        let rss_data = RssData::new(None)
1449            .title("Test Feed")
1450            .link("https://example.com")
1451            .description("A test feed")
1452            .pub_date("not a valid date");
1453
1454        let result = rss_data.validate();
1455        assert!(result.is_err());
1456        if let Err(RssError::ValidationErrors(errors)) = result {
1457            assert!(
1458                errors.iter().any(|e| e.field == "channel.pub_date"
1459                    && e.message.contains("Invalid channel.pub_date")),
1460                "Expected structured `channel.pub_date` error, got: {errors:?}"
1461            );
1462        } else {
1463            panic!("Expected ValidationErrors");
1464        }
1465    }
1466
1467    #[test]
1468    fn test_rss_item_validate_invalid_link() {
1469        // Issue #34: relative URLs are now allowed for item links
1470        // (RSS 2.0 §5.7). Only whitespace / control chars are
1471        // rejected because they break XML well-formedness.
1472        let item = RssItem::new()
1473            .title("Item")
1474            .link("not a valid url with spaces")
1475            .description("Desc");
1476
1477        let result = item.validate();
1478        assert!(result.is_err());
1479        if let Err(RssError::ValidationErrors(errors)) = result {
1480            assert!(
1481                errors.iter().any(|e| e.field == "item.link"
1482                    && e.message.contains("Invalid item.link")),
1483                "Expected structured `item.link` error, got: {errors:?}"
1484            );
1485        } else {
1486            panic!("Expected ValidationErrors");
1487        }
1488    }
1489
1490    #[test]
1491    fn test_validate_size_exceeds_max() {
1492        let mut rss_data = RssData::new(Some(RssVersion::RSS2_0));
1493        // Fill description with enough data to exceed MAX_FEED_SIZE (1MB)
1494        rss_data.description = "x".repeat(MAX_FEED_SIZE + 1);
1495        let result = rss_data.validate_size();
1496        assert!(result.is_err());
1497        if let Err(RssError::InvalidInput(msg)) = result {
1498            assert!(msg.contains("exceeds maximum allowed size"));
1499        } else {
1500            panic!("Expected InvalidInput error");
1501        }
1502    }
1503
1504    #[test]
1505    fn test_validate_size_ok() {
1506        let rss_data = RssData::new(Some(RssVersion::RSS2_0))
1507            .title("Test")
1508            .link("https://example.com")
1509            .description("Short description");
1510        assert!(rss_data.validate_size().is_ok());
1511    }
1512
1513    #[test]
1514    fn test_set_item_field_all_variants() {
1515        let mut rss_data = RssData::new(None);
1516        rss_data.set_item_field(RssItemField::Title, "Title");
1517        rss_data
1518            .set_item_field(RssItemField::Link, "https://example.com");
1519        rss_data.set_item_field(RssItemField::Description, "Desc");
1520        rss_data.set_item_field(RssItemField::Guid, "guid-1");
1521        rss_data.set_item_field(
1522            RssItemField::PubDate,
1523            "Mon, 01 Jan 2024 00:00:00 GMT",
1524        );
1525        rss_data
1526            .set_item_field(RssItemField::Author, "author@example.com");
1527        rss_data.set_item_field(RssItemField::Category, "tech");
1528        rss_data.set_item_field(
1529            RssItemField::Comments,
1530            "https://example.com/comments",
1531        );
1532        rss_data.set_item_field(
1533            RssItemField::Enclosure,
1534            "https://example.com/file.mp3",
1535        );
1536        rss_data.set_item_field(
1537            RssItemField::Source,
1538            "https://example.com/source",
1539        );
1540
1541        let item = &rss_data.items[0];
1542        assert_eq!(item.title, "Title");
1543        assert_eq!(item.link, "https://example.com");
1544        assert_eq!(item.description, "Desc");
1545        assert_eq!(item.guid, "guid-1");
1546        assert_eq!(item.author, "author@example.com");
1547        assert_eq!(item.category, Some("tech".to_string()));
1548        assert_eq!(
1549            item.comments,
1550            Some("https://example.com/comments".to_string())
1551        );
1552        assert_eq!(
1553            item.enclosure,
1554            Some("https://example.com/file.mp3".to_string())
1555        );
1556        assert_eq!(
1557            item.source,
1558            Some("https://example.com/source".to_string())
1559        );
1560    }
1561
1562    #[test]
1563    fn test_rss_item_builder_all_fields() {
1564        let item = RssItem::new()
1565            .title("Title")
1566            .link("https://example.com")
1567            .description("Desc")
1568            .guid("guid-1")
1569            .pub_date("Mon, 01 Jan 2024 00:00:00 GMT")
1570            .author("author@example.com")
1571            .category("tech")
1572            .comments("https://example.com/comments")
1573            .enclosure("https://example.com/file.mp3")
1574            .source("https://example.com/source");
1575
1576        assert_eq!(item.category, Some("tech".to_string()));
1577        assert_eq!(
1578            item.comments,
1579            Some("https://example.com/comments".to_string())
1580        );
1581        assert_eq!(
1582            item.enclosure,
1583            Some("https://example.com/file.mp3".to_string())
1584        );
1585        assert_eq!(
1586            item.source,
1587            Some("https://example.com/source".to_string())
1588        );
1589    }
1590
1591    #[test]
1592    fn test_rss_item_pub_date_parsed_valid() {
1593        let item =
1594            RssItem::new().pub_date("Sat, 07 Sep 2002 09:42:31 GMT");
1595        let result = item.pub_date_parsed();
1596        assert!(result.is_ok());
1597    }
1598
1599    #[test]
1600    fn test_rss_item_pub_date_parsed_invalid() {
1601        let item = RssItem::new().pub_date("not-a-date");
1602        let result = item.pub_date_parsed();
1603        assert!(result.is_err());
1604    }
1605
1606    #[test]
1607    fn test_parse_date_iso8601() {
1608        let result = parse_date("2024-01-15T10:30:00Z");
1609        assert!(result.is_ok());
1610    }
1611
1612    #[test]
1613    fn test_parse_date_invalid() {
1614        let result = parse_date("completely invalid date");
1615        assert!(result.is_err());
1616    }
1617
1618    #[test]
1619    fn test_rss_item_accepts_relative_link() {
1620        // Regression for sebastienrousseau/rssgen#34.
1621        // RSS 2.0 §5.7 allows item-level relative URLs. Root-relative
1622        // and bare-path forms must both pass.
1623        for link in ["/tags/", "articles/foo.html", "post.html"] {
1624            let item = RssItem::new()
1625                .title("Item")
1626                .link(link)
1627                .description("Desc");
1628            assert!(
1629                item.validate().is_ok(),
1630                "expected relative link {link:?} to be accepted"
1631            );
1632        }
1633    }
1634
1635    #[test]
1636    fn test_rss_data_still_rejects_relative_link() {
1637        // Channel-level link (RSS 2.0 §5.1) must be absolute.
1638        // Regression for sebastienrousseau/rssgen#34: don't accidentally
1639        // relax this when fixing the item-level rule.
1640        let data = RssData::new(None)
1641            .title("Channel")
1642            .link("/tags/")
1643            .description("Desc");
1644        let result = data.validate();
1645        assert!(
1646            result.is_err(),
1647            "channel.link `/tags/` must be rejected"
1648        );
1649        if let Err(RssError::ValidationErrors(errors)) = result {
1650            assert!(
1651                errors.iter().any(|e| e.field == "channel.link"
1652                    && e.message.contains("Invalid channel.link")),
1653                "expected channel-prefixed structured error, got: {errors:?}"
1654            );
1655        } else {
1656            panic!("expected ValidationErrors");
1657        }
1658    }
1659
1660    #[test]
1661    fn test_validate_link_field_rejects_whitespace_and_control() {
1662        // Whitespace / control characters break XML well-formedness
1663        // even though they pass URL parsing.
1664        assert!(validate_link_field("/path with space").is_err());
1665        assert!(validate_link_field("foo\tbar").is_err());
1666        assert!(validate_link_field("\u{0007}beep").is_err());
1667        assert!(validate_link_field("").is_err());
1668    }
1669
1670    #[test]
1671    fn test_validate_link_field_accepts_absolute_and_relative() {
1672        assert!(validate_link_field("https://example.com/post").is_ok());
1673        assert!(validate_link_field("http://example.com/").is_ok());
1674        assert!(validate_link_field("/tags/").is_ok());
1675        assert!(validate_link_field("articles/foo.html").is_ok());
1676        // Non-http schemes are fine for items (atom, mailto, etc.).
1677        assert!(validate_link_field("mailto:hi@example.com").is_ok());
1678    }
1679}