1use 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#[derive(
27 Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
28)]
29#[non_exhaustive]
30#[derive(Default)]
31pub enum RssVersion {
32 RSS0_90,
34 RSS0_91,
36 RSS0_92,
38 RSS1_0,
40 #[default]
42 RSS2_0,
43}
44
45impl RssVersion {
46 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
86#[non_exhaustive]
87pub struct RssData {
88 pub atom_link: String,
90 pub author: String,
92 pub category: String,
94 pub copyright: String,
96 pub description: String,
98 pub docs: String,
100 pub generator: String,
102 pub guid: String,
104 pub image_title: String,
106 pub image_url: String,
108 pub image_link: String,
110 pub language: String,
112 pub last_build_date: String,
114 pub link: String,
116 pub managing_editor: String,
118 pub pub_date: String,
120 pub title: String,
122 pub ttl: String,
124 pub webmaster: String,
126 pub items: Vec<RssItem>,
128 pub version: RssVersion,
130 pub creator: String,
132 pub date: String,
134}
135
136impl RssData {
137 #[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 #[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 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 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 for item in &self.items {
253 total_size += item.title.len();
254 total_size += item.link.len();
255 total_size += item.description.len();
256 }
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 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 pub fn add_item(&mut self, item: RssItem) {
289 self.items.push(item);
290 }
291
292 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 #[must_use]
309 pub fn item_count(&self) -> usize {
310 self.items.len()
311 }
312
313 pub fn clear_items(&mut self) {
315 self.items.clear();
316 }
317
318 pub fn validate(&self) -> Result<()> {
334 let mut errors: Vec<ValidationError> = Vec::new();
335
336 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 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 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 #[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 #[must_use]
431 pub fn version(mut self, version: RssVersion) -> Self {
432 self.version = version;
433 self
434 }
435
436 #[must_use]
438 pub fn atom_link<T: Into<String>>(self, value: T) -> Self {
439 self.set(RssDataField::AtomLink, value)
440 }
441
442 #[must_use]
444 pub fn author<T: Into<String>>(self, value: T) -> Self {
445 self.set(RssDataField::Author, value)
446 }
447
448 #[must_use]
450 pub fn category<T: Into<String>>(self, value: T) -> Self {
451 self.set(RssDataField::Category, value)
452 }
453
454 #[must_use]
456 pub fn copyright<T: Into<String>>(self, value: T) -> Self {
457 self.set(RssDataField::Copyright, value)
458 }
459
460 #[must_use]
462 pub fn description<T: Into<String>>(self, value: T) -> Self {
463 self.set(RssDataField::Description, value)
464 }
465
466 #[must_use]
468 pub fn docs<T: Into<String>>(self, value: T) -> Self {
469 self.set(RssDataField::Docs, value)
470 }
471
472 #[must_use]
474 pub fn generator<T: Into<String>>(self, value: T) -> Self {
475 self.set(RssDataField::Generator, value)
476 }
477
478 #[must_use]
480 pub fn guid<T: Into<String>>(self, value: T) -> Self {
481 self.set(RssDataField::Guid, value)
482 }
483
484 #[must_use]
486 pub fn image_title<T: Into<String>>(self, value: T) -> Self {
487 self.set(RssDataField::ImageTitle, value)
488 }
489
490 #[must_use]
492 pub fn image_url<T: Into<String>>(self, value: T) -> Self {
493 self.set(RssDataField::ImageUrl, value)
494 }
495
496 #[must_use]
498 pub fn image_link<T: Into<String>>(self, value: T) -> Self {
499 self.set(RssDataField::ImageLink, value)
500 }
501
502 #[must_use]
504 pub fn language<T: Into<String>>(self, value: T) -> Self {
505 self.set(RssDataField::Language, value)
506 }
507
508 #[must_use]
510 pub fn last_build_date<T: Into<String>>(self, value: T) -> Self {
511 self.set(RssDataField::LastBuildDate, value)
512 }
513
514 #[must_use]
516 pub fn link<T: Into<String>>(self, value: T) -> Self {
517 self.set(RssDataField::Link, value)
518 }
519
520 #[must_use]
522 pub fn managing_editor<T: Into<String>>(self, value: T) -> Self {
523 self.set(RssDataField::ManagingEditor, value)
524 }
525
526 #[must_use]
528 pub fn pub_date<T: Into<String>>(self, value: T) -> Self {
529 self.set(RssDataField::PubDate, value)
530 }
531
532 #[must_use]
534 pub fn title<T: Into<String>>(self, value: T) -> Self {
535 self.set(RssDataField::Title, value)
536 }
537
538 #[must_use]
540 pub fn ttl<T: Into<String>>(self, value: T) -> Self {
541 self.set(RssDataField::Ttl, value)
542 }
543
544 #[must_use]
546 pub fn webmaster<T: Into<String>>(self, value: T) -> Self {
547 self.set(RssDataField::Webmaster, value)
548 }
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
553pub enum RssDataField {
554 AtomLink,
556 Author,
558 Category,
560 Copyright,
562 Description,
564 Docs,
566 Generator,
568 Guid,
570 ImageTitle,
572 ImageUrl,
574 ImageLink,
576 Language,
578 LastBuildDate,
580 Link,
582 ManagingEditor,
584 PubDate,
586 Title,
588 Ttl,
590 Webmaster,
592}
593
594#[derive(
596 Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize,
597)]
598#[non_exhaustive]
599pub struct RssItem {
600 pub guid: String,
602 pub category: Option<String>,
604 pub description: String,
606 pub link: String,
608 pub pub_date: String,
610 pub title: String,
612 pub author: String,
614 pub comments: Option<String>,
616 pub enclosure: Option<String>,
618 pub source: Option<String>,
620 pub creator: Option<String>,
622 pub date: Option<String>,
624}
625
626impl RssItem {
627 #[must_use]
629 pub fn new() -> Self {
630 Self::default()
631 }
632
633 #[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 pub fn validate(&self) -> Result<()> {
682 let mut errors: Vec<ValidationError> = Vec::new();
683
684 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 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 pub fn pub_date_parsed(&self) -> Result<OffsetDateTime> {
732 parse_date(&self.pub_date)
733 }
734
735 #[must_use]
739 pub fn guid<T: Into<String>>(self, value: T) -> Self {
740 self.set(RssItemField::Guid, value)
741 }
742
743 #[must_use]
745 pub fn category<T: Into<String>>(self, value: T) -> Self {
746 self.set(RssItemField::Category, value)
747 }
748
749 #[must_use]
751 pub fn description<T: Into<String>>(self, value: T) -> Self {
752 self.set(RssItemField::Description, value)
753 }
754
755 #[must_use]
757 pub fn link<T: Into<String>>(self, value: T) -> Self {
758 self.set(RssItemField::Link, value)
759 }
760
761 #[must_use]
763 pub fn pub_date<T: Into<String>>(self, value: T) -> Self {
764 self.set(RssItemField::PubDate, value)
765 }
766
767 #[must_use]
769 pub fn title<T: Into<String>>(self, value: T) -> Self {
770 self.set(RssItemField::Title, value)
771 }
772
773 #[must_use]
775 pub fn author<T: Into<String>>(self, value: T) -> Self {
776 self.set(RssItemField::Author, value)
777 }
778
779 #[must_use]
781 pub fn comments<T: Into<String>>(self, value: T) -> Self {
782 self.set(RssItemField::Comments, value)
783 }
784
785 #[must_use]
787 pub fn enclosure<T: Into<String>>(self, value: T) -> Self {
788 self.set(RssItemField::Enclosure, value)
789 }
790
791 #[must_use]
793 pub fn source<T: Into<String>>(self, value: T) -> Self {
794 self.set(RssItemField::Source, value)
795 }
796}
797
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
800pub enum RssItemField {
801 Guid,
803 Category,
805 Description,
807 Link,
809 PubDate,
811 Title,
813 Author,
815 Comments,
817 Enclosure,
819 Source,
821}
822
823pub 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
851pub 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 Ok(())
889}
890
891pub 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
922fn sanitize_input(input: &str) -> String {
932 input
933 .replace('&', "&")
934 .replace('<', "<")
935 .replace('>', ">")
936 .replace('"', """)
937 .replace('\'', "'")
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 <script>alert('XSS')</script>"
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 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 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 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 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 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 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 assert!(validate_link_field("mailto:hi@example.com").is_ok());
1678 }
1679}