1use crate::error::{Result, RssError, ValidationError};
51use crate::generator::clean_xml_text;
52use oxml::{
53 tree::NodeId, Document, EmptyElement, Newline, SerialiseOptions,
54};
55use serde::{Deserialize, Serialize};
56
57pub const ATOM_NAMESPACE: &str = "http://www.w3.org/2005/Atom";
59
60const XML_DECLARATION: &str =
61 r#"<?xml version="1.0" encoding="utf-8"?>"#;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[non_exhaustive]
68pub enum FeedFormat {
69 Rss,
71 RssRdf,
73 Atom,
75 Unknown,
77}
78
79#[must_use]
99pub fn detect_feed_format(xml: &str) -> FeedFormat {
100 use oxml::stream::{Event, Reader};
101
102 let Ok(mut reader) = Reader::new(xml.trim_start()) else {
103 return FeedFormat::Unknown;
104 };
105 loop {
106 match reader.next_event() {
107 Ok(Some(Event::StartElement { name, .. })) => {
108 return match name.local.as_str() {
109 "rss" => FeedFormat::Rss,
110 "RDF" => FeedFormat::RssRdf,
111 "feed" => {
112 if name.namespace.as_deref()
115 == Some(ATOM_NAMESPACE)
116 {
117 FeedFormat::Atom
118 } else {
119 FeedFormat::Unknown
120 }
121 }
122 _ => FeedFormat::Unknown,
123 };
124 }
125 Ok(None) | Err(_) => return FeedFormat::Unknown,
126 Ok(Some(_)) => {}
127 }
128 }
129}
130
131#[derive(
133 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
134)]
135#[non_exhaustive]
136pub struct AtomPerson {
137 pub name: String,
139 pub email: String,
141 pub uri: String,
143}
144
145impl AtomPerson {
146 #[must_use]
149 pub fn new<S: Into<String>>(name: S) -> Self {
150 Self {
151 name: sanitize_input(&name.into()),
152 ..Self::default()
153 }
154 }
155
156 #[must_use]
158 pub fn email<S: Into<String>>(mut self, value: S) -> Self {
159 self.email = sanitize_input(&value.into());
160 self
161 }
162
163 #[must_use]
165 pub fn uri<S: Into<String>>(mut self, value: S) -> Self {
166 self.uri = sanitize_input(&value.into());
167 self
168 }
169}
170
171#[derive(
178 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
179)]
180#[non_exhaustive]
181pub struct AtomLink {
182 pub href: String,
184 pub rel: String,
187 pub mime_type: String,
189 pub length: String,
191 pub title: String,
193}
194
195impl AtomLink {
196 #[must_use]
199 pub fn alternate<S: Into<String>>(href: S) -> Self {
200 Self {
201 href: sanitize_input(&href.into()),
202 rel: "alternate".to_string(),
203 ..Self::default()
204 }
205 }
206
207 #[must_use]
210 pub fn self_ref<S: Into<String>>(href: S) -> Self {
211 Self {
212 href: sanitize_input(&href.into()),
213 rel: "self".to_string(),
214 ..Self::default()
215 }
216 }
217
218 #[must_use]
220 pub fn enclosure<S, T>(href: S, mime_type: T, length: u64) -> Self
221 where
222 S: Into<String>,
223 T: Into<String>,
224 {
225 Self {
226 href: sanitize_input(&href.into()),
227 rel: "enclosure".to_string(),
228 mime_type: sanitize_input(&mime_type.into()),
229 length: length.to_string(),
230 ..Self::default()
231 }
232 }
233
234 #[must_use]
236 pub fn title<S: Into<String>>(mut self, value: S) -> Self {
237 self.title = sanitize_input(&value.into());
238 self
239 }
240}
241
242#[derive(
244 Debug,
245 Clone,
246 Copy,
247 Default,
248 PartialEq,
249 Eq,
250 Hash,
251 Serialize,
252 Deserialize,
253)]
254#[non_exhaustive]
255pub enum AtomTextType {
256 #[default]
258 Text,
259 Html,
263}
264
265impl AtomTextType {
266 fn as_attr(self) -> &'static str {
267 match self {
268 Self::Text => "text",
269 Self::Html => "html",
270 }
271 }
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[non_exhaustive]
277pub struct AtomFeed {
278 pub id: String,
280 pub title: String,
282 pub subtitle: String,
284 pub updated: String,
286 pub rights: String,
288 pub generator: String,
290 pub icon: String,
292 pub logo: String,
294 pub language: String,
296 pub authors: Vec<AtomPerson>,
298 pub contributors: Vec<AtomPerson>,
300 pub links: Vec<AtomLink>,
302 pub categories: Vec<String>,
304 pub entries: Vec<AtomEntry>,
306}
307
308#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
310#[non_exhaustive]
311pub struct AtomEntry {
312 pub id: String,
314 pub title: String,
316 pub updated: String,
318 pub published: String,
320 pub summary: String,
322 pub summary_type: AtomTextType,
324 pub content: String,
326 pub content_type: AtomTextType,
328 pub rights: String,
330 pub authors: Vec<AtomPerson>,
332 pub contributors: Vec<AtomPerson>,
334 pub links: Vec<AtomLink>,
336 pub categories: Vec<String>,
338}
339
340impl AtomFeed {
341 #[must_use]
343 pub fn new() -> Self {
344 Self::default()
345 }
346
347 #[must_use]
349 pub fn id<S: Into<String>>(mut self, value: S) -> Self {
350 self.id = sanitize_input(&value.into());
351 self
352 }
353
354 #[must_use]
356 pub fn title<S: Into<String>>(mut self, value: S) -> Self {
357 self.title = sanitize_input(&value.into());
358 self
359 }
360
361 #[must_use]
363 pub fn subtitle<S: Into<String>>(mut self, value: S) -> Self {
364 self.subtitle = sanitize_input(&value.into());
365 self
366 }
367
368 #[must_use]
370 pub fn updated<S: Into<String>>(mut self, value: S) -> Self {
371 self.updated = sanitize_input(&value.into());
372 self
373 }
374
375 #[must_use]
377 pub fn rights<S: Into<String>>(mut self, value: S) -> Self {
378 self.rights = sanitize_input(&value.into());
379 self
380 }
381
382 #[must_use]
384 pub fn generator<S: Into<String>>(mut self, value: S) -> Self {
385 self.generator = sanitize_input(&value.into());
386 self
387 }
388
389 #[must_use]
391 pub fn icon<S: Into<String>>(mut self, value: S) -> Self {
392 self.icon = sanitize_input(&value.into());
393 self
394 }
395
396 #[must_use]
398 pub fn logo<S: Into<String>>(mut self, value: S) -> Self {
399 self.logo = sanitize_input(&value.into());
400 self
401 }
402
403 #[must_use]
405 pub fn language<S: Into<String>>(mut self, value: S) -> Self {
406 self.language = sanitize_input(&value.into());
407 self
408 }
409
410 #[must_use]
412 pub fn author_name<S: Into<String>>(mut self, name: S) -> Self {
413 self.authors.push(AtomPerson::new(name));
414 self
415 }
416
417 #[must_use]
419 pub fn add_author(mut self, author: AtomPerson) -> Self {
420 self.authors.push(author);
421 self
422 }
423
424 #[must_use]
426 pub fn add_contributor(mut self, contributor: AtomPerson) -> Self {
427 self.contributors.push(contributor);
428 self
429 }
430
431 #[must_use]
433 pub fn add_link(mut self, link: AtomLink) -> Self {
434 self.links.push(link);
435 self
436 }
437
438 #[must_use]
440 pub fn self_link<S: Into<String>>(self, href: S) -> Self {
441 self.add_link(AtomLink::self_ref(href))
442 }
443
444 #[must_use]
446 pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
447 self.add_link(AtomLink::alternate(href))
448 }
449
450 #[must_use]
452 pub fn add_category<S: Into<String>>(mut self, term: S) -> Self {
453 self.categories.push(sanitize_input(&term.into()));
454 self
455 }
456
457 #[must_use]
459 pub fn add_entry(mut self, entry: AtomEntry) -> Self {
460 self.entries.push(entry);
461 self
462 }
463
464 #[must_use]
466 pub fn entry_count(&self) -> usize {
467 self.entries.len()
468 }
469
470 pub fn validate(&self) -> Result<()> {
481 let mut errors: Vec<ValidationError> = Vec::new();
482
483 if self.id.is_empty() {
484 errors.push(ValidationError::new(
485 "feed.id",
486 "feed.id is missing",
487 ));
488 }
489 if self.title.is_empty() {
490 errors.push(ValidationError::new(
491 "feed.title",
492 "feed.title is missing",
493 ));
494 }
495 if self.updated.is_empty() {
496 errors.push(ValidationError::new(
497 "feed.updated",
498 "feed.updated is missing",
499 ));
500 } else if !is_rfc3339(&self.updated) {
501 errors.push(ValidationError::new(
502 "feed.updated",
503 format!(
504 "feed.updated is not a valid RFC 3339 timestamp: {}",
505 self.updated
506 ),
507 ));
508 }
509
510 let feed_has_author = !self.authors.is_empty();
514 for (idx, entry) in self.entries.iter().enumerate() {
515 if let Err(RssError::ValidationErrors(mut entry_errors)) =
516 entry.validate_with_index(idx)
517 {
518 errors.append(&mut entry_errors);
519 }
520 if !feed_has_author && entry.authors.is_empty() {
521 errors.push(ValidationError::new(
522 format!("entry.{idx}.author"),
523 format!(
524 "entry.{idx}.author is missing (and feed has \
525 no feed-level author)"
526 ),
527 ));
528 }
529 }
530
531 if errors.is_empty() {
532 Ok(())
533 } else {
534 Err(RssError::ValidationErrors(errors))
535 }
536 }
537}
538
539impl AtomEntry {
540 #[must_use]
542 pub fn new() -> Self {
543 Self::default()
544 }
545
546 #[must_use]
548 pub fn id<S: Into<String>>(mut self, value: S) -> Self {
549 self.id = sanitize_input(&value.into());
550 self
551 }
552
553 #[must_use]
555 pub fn title<S: Into<String>>(mut self, value: S) -> Self {
556 self.title = sanitize_input(&value.into());
557 self
558 }
559
560 #[must_use]
562 pub fn updated<S: Into<String>>(mut self, value: S) -> Self {
563 self.updated = sanitize_input(&value.into());
564 self
565 }
566
567 #[must_use]
569 pub fn published<S: Into<String>>(mut self, value: S) -> Self {
570 self.published = sanitize_input(&value.into());
571 self
572 }
573
574 #[must_use]
576 pub fn summary<S: Into<String>>(mut self, value: S) -> Self {
577 self.summary = sanitize_input(&value.into());
578 self.summary_type = AtomTextType::Text;
579 self
580 }
581
582 #[must_use]
584 pub fn summary_html<S: Into<String>>(mut self, value: S) -> Self {
585 self.summary = sanitize_input(&value.into());
586 self.summary_type = AtomTextType::Html;
587 self
588 }
589
590 #[must_use]
592 pub fn content<S: Into<String>>(mut self, value: S) -> Self {
593 self.content = sanitize_input(&value.into());
594 self.content_type = AtomTextType::Text;
595 self
596 }
597
598 #[must_use]
600 pub fn content_html<S: Into<String>>(mut self, value: S) -> Self {
601 self.content = sanitize_input(&value.into());
602 self.content_type = AtomTextType::Html;
603 self
604 }
605
606 #[must_use]
608 pub fn rights<S: Into<String>>(mut self, value: S) -> Self {
609 self.rights = sanitize_input(&value.into());
610 self
611 }
612
613 #[must_use]
615 pub fn author_name<S: Into<String>>(mut self, name: S) -> Self {
616 self.authors.push(AtomPerson::new(name));
617 self
618 }
619
620 #[must_use]
622 pub fn add_author(mut self, author: AtomPerson) -> Self {
623 self.authors.push(author);
624 self
625 }
626
627 #[must_use]
629 pub fn add_link(mut self, link: AtomLink) -> Self {
630 self.links.push(link);
631 self
632 }
633
634 #[must_use]
636 pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
637 self.add_link(AtomLink::alternate(href))
638 }
639
640 #[must_use]
642 pub fn add_enclosure<S, T>(
643 self,
644 href: S,
645 mime_type: T,
646 length: u64,
647 ) -> Self
648 where
649 S: Into<String>,
650 T: Into<String>,
651 {
652 self.add_link(AtomLink::enclosure(href, mime_type, length))
653 }
654
655 #[must_use]
657 pub fn add_category<S: Into<String>>(mut self, term: S) -> Self {
658 self.categories.push(sanitize_input(&term.into()));
659 self
660 }
661
662 pub fn validate(&self) -> Result<()> {
671 let mut errors: Vec<ValidationError> = Vec::new();
672 push_entry_errors(self, "entry.", &mut errors);
673 if errors.is_empty() {
674 Ok(())
675 } else {
676 Err(RssError::ValidationErrors(errors))
677 }
678 }
679
680 fn validate_with_index(&self, idx: usize) -> Result<()> {
681 let prefix = format!("entry.{idx}.");
682 let mut errors: Vec<ValidationError> = Vec::new();
683 push_entry_errors(self, &prefix, &mut errors);
684 if errors.is_empty() {
685 Ok(())
686 } else {
687 Err(RssError::ValidationErrors(errors))
688 }
689 }
690}
691
692fn push_entry_errors(
693 entry: &AtomEntry,
694 prefix: &str,
695 errors: &mut Vec<ValidationError>,
696) {
697 let field_path = |suffix: &str| format!("{prefix}{suffix}");
698
699 if entry.id.is_empty() {
700 errors.push(ValidationError::new(
701 field_path("id"),
702 format!("{prefix}id is missing"),
703 ));
704 }
705 if entry.title.is_empty() {
706 errors.push(ValidationError::new(
707 field_path("title"),
708 format!("{prefix}title is missing"),
709 ));
710 }
711 if entry.updated.is_empty() {
712 errors.push(ValidationError::new(
713 field_path("updated"),
714 format!("{prefix}updated is missing"),
715 ));
716 } else if !is_rfc3339(&entry.updated) {
717 errors.push(ValidationError::new(
718 field_path("updated"),
719 format!(
720 "{prefix}updated is not a valid RFC 3339 timestamp: {}",
721 entry.updated
722 ),
723 ));
724 }
725 if !entry.published.is_empty() && !is_rfc3339(&entry.published) {
726 errors.push(ValidationError::new(
727 field_path("published"),
728 format!(
729 "{prefix}published is not a valid RFC 3339 timestamp: {}",
730 entry.published
731 ),
732 ));
733 }
734}
735
736fn is_rfc3339(value: &str) -> bool {
737 use time::format_description::well_known::Rfc3339;
738 use time::OffsetDateTime;
739 OffsetDateTime::parse(value, &Rfc3339).is_ok()
740}
741
742fn sanitize_input(value: &str) -> String {
743 value
744 .chars()
745 .filter(|c| !c.is_control() || matches!(*c, '\n' | '\r' | '\t'))
746 .collect()
747}
748
749pub fn generate_atom(feed: &AtomFeed) -> Result<String> {
762 feed.validate()?;
763
764 let mut doc = Document::empty();
765 let feed_elem =
766 doc.append_element(doc.root(), Some(ATOM_NAMESPACE), "feed")?;
767 doc.declare_namespace(feed_elem, None, ATOM_NAMESPACE)?;
768 if !feed.language.is_empty() {
769 doc.set_attribute(
770 feed_elem,
771 Some("http://www.w3.org/XML/1998/namespace"),
772 "lang",
773 &clean_xml_text(&feed.language),
774 )?;
775 }
776
777 write_text_element(&mut doc, feed_elem, "id", &feed.id)?;
778 write_text_element(&mut doc, feed_elem, "title", &feed.title)?;
779 write_text_element(&mut doc, feed_elem, "updated", &feed.updated)?;
780 if !feed.subtitle.is_empty() {
781 write_text_element(
782 &mut doc,
783 feed_elem,
784 "subtitle",
785 &feed.subtitle,
786 )?;
787 }
788 if !feed.rights.is_empty() {
789 write_text_element(
790 &mut doc,
791 feed_elem,
792 "rights",
793 &feed.rights,
794 )?;
795 }
796 if !feed.icon.is_empty() {
797 write_text_element(&mut doc, feed_elem, "icon", &feed.icon)?;
798 }
799 if !feed.logo.is_empty() {
800 write_text_element(&mut doc, feed_elem, "logo", &feed.logo)?;
801 }
802 if !feed.generator.is_empty() {
803 write_text_element(
804 &mut doc,
805 feed_elem,
806 "generator",
807 &feed.generator,
808 )?;
809 }
810
811 for person in &feed.authors {
812 write_person(&mut doc, feed_elem, "author", person)?;
813 }
814 for person in &feed.contributors {
815 write_person(&mut doc, feed_elem, "contributor", person)?;
816 }
817 for link in &feed.links {
818 write_link(&mut doc, feed_elem, link)?;
819 }
820 for category in &feed.categories {
821 write_category(&mut doc, feed_elem, category)?;
822 }
823
824 for entry in &feed.entries {
825 write_entry(&mut doc, feed_elem, entry)?;
826 }
827
828 let serialise_opts = SerialiseOptions {
829 indent: None,
830 empty_elements: EmptyElement::SelfClosing,
831 newline: Newline::Lf,
832 };
833 let mut xml = String::from(XML_DECLARATION);
834 let _ = doc.write_xml_with(&mut xml, serialise_opts);
835 Ok(xml)
836}
837
838fn write_text_element(
839 doc: &mut Document,
840 parent: NodeId,
841 name: &str,
842 content: &str,
843) -> Result<()> {
844 let elem =
845 doc.append_element(parent, Some(ATOM_NAMESPACE), name)?;
846 doc.append_text(elem, &clean_xml_text(content))?;
847 Ok(())
848}
849
850fn write_typed_text(
851 doc: &mut Document,
852 parent: NodeId,
853 name: &str,
854 content: &str,
855 text_type: AtomTextType,
856) -> Result<()> {
857 let elem =
858 doc.append_element(parent, Some(ATOM_NAMESPACE), name)?;
859 doc.set_attribute(elem, None, "type", text_type.as_attr())?;
860 doc.append_text(elem, &clean_xml_text(content))?;
861 Ok(())
862}
863
864fn write_person(
865 doc: &mut Document,
866 parent: NodeId,
867 element: &str,
868 person: &AtomPerson,
869) -> Result<()> {
870 let person_elem =
871 doc.append_element(parent, Some(ATOM_NAMESPACE), element)?;
872 write_text_element(doc, person_elem, "name", &person.name)?;
873 if !person.email.is_empty() {
874 write_text_element(doc, person_elem, "email", &person.email)?;
875 }
876 if !person.uri.is_empty() {
877 write_text_element(doc, person_elem, "uri", &person.uri)?;
878 }
879 Ok(())
880}
881
882fn write_link(
883 doc: &mut Document,
884 parent: NodeId,
885 link: &AtomLink,
886) -> Result<()> {
887 let link_elem =
888 doc.append_element(parent, Some(ATOM_NAMESPACE), "link")?;
889 doc.set_attribute(
890 link_elem,
891 None,
892 "href",
893 &clean_xml_text(&link.href),
894 )?;
895 if !link.rel.is_empty() {
896 doc.set_attribute(
897 link_elem,
898 None,
899 "rel",
900 &clean_xml_text(&link.rel),
901 )?;
902 }
903 if !link.mime_type.is_empty() {
904 doc.set_attribute(
905 link_elem,
906 None,
907 "type",
908 &clean_xml_text(&link.mime_type),
909 )?;
910 }
911 if !link.length.is_empty() {
912 doc.set_attribute(
913 link_elem,
914 None,
915 "length",
916 &clean_xml_text(&link.length),
917 )?;
918 }
919 if !link.title.is_empty() {
920 doc.set_attribute(
921 link_elem,
922 None,
923 "title",
924 &clean_xml_text(&link.title),
925 )?;
926 }
927 Ok(())
928}
929
930fn write_category(
931 doc: &mut Document,
932 parent: NodeId,
933 term: &str,
934) -> Result<()> {
935 let cat =
936 doc.append_element(parent, Some(ATOM_NAMESPACE), "category")?;
937 doc.set_attribute(cat, None, "term", &clean_xml_text(term))?;
938 Ok(())
939}
940
941fn write_entry(
942 doc: &mut Document,
943 parent: NodeId,
944 entry: &AtomEntry,
945) -> Result<()> {
946 let entry_elem =
947 doc.append_element(parent, Some(ATOM_NAMESPACE), "entry")?;
948
949 write_text_element(doc, entry_elem, "id", &entry.id)?;
950 write_text_element(doc, entry_elem, "title", &entry.title)?;
951 write_text_element(doc, entry_elem, "updated", &entry.updated)?;
952 if !entry.published.is_empty() {
953 write_text_element(
954 doc,
955 entry_elem,
956 "published",
957 &entry.published,
958 )?;
959 }
960 if !entry.summary.is_empty() {
961 write_typed_text(
962 doc,
963 entry_elem,
964 "summary",
965 &entry.summary,
966 entry.summary_type,
967 )?;
968 }
969 if !entry.content.is_empty() {
970 write_typed_text(
971 doc,
972 entry_elem,
973 "content",
974 &entry.content,
975 entry.content_type,
976 )?;
977 }
978 if !entry.rights.is_empty() {
979 write_text_element(doc, entry_elem, "rights", &entry.rights)?;
980 }
981
982 for person in &entry.authors {
983 write_person(doc, entry_elem, "author", person)?;
984 }
985 for person in &entry.contributors {
986 write_person(doc, entry_elem, "contributor", person)?;
987 }
988 for link in &entry.links {
989 write_link(doc, entry_elem, link)?;
990 }
991 for category in &entry.categories {
992 write_category(doc, entry_elem, category)?;
993 }
994
995 Ok(())
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn minimal_feed() -> AtomFeed {
1003 AtomFeed::new()
1004 .id("urn:example:feed")
1005 .title("Example")
1006 .updated("2026-06-27T00:00:00Z")
1007 .author_name("Tester")
1008 }
1009
1010 #[test]
1011 fn validate_rejects_missing_required_fields() {
1012 let feed = AtomFeed::new();
1013 let err = feed.validate().unwrap_err();
1014 let RssError::ValidationErrors(errs) = err else {
1015 panic!("expected ValidationErrors");
1016 };
1017 assert!(errs.iter().any(|e| e.field == "feed.id"
1018 && e.message == "feed.id is missing"));
1019 assert!(errs.iter().any(|e| e.field == "feed.title"
1020 && e.message == "feed.title is missing"));
1021 assert!(errs.iter().any(|e| e.field == "feed.updated"
1022 && e.message == "feed.updated is missing"));
1023 }
1024
1025 #[test]
1026 fn validate_rejects_non_rfc3339_updated() {
1027 let feed = AtomFeed::new()
1028 .id("urn:example:feed")
1029 .title("Example")
1030 .updated("yesterday afternoon")
1031 .author_name("Tester");
1032 let err = feed.validate().unwrap_err();
1033 let RssError::ValidationErrors(errs) = err else {
1034 panic!("expected ValidationErrors");
1035 };
1036 assert!(errs.iter().any(|e| e.field == "feed.updated"
1037 && e.message.starts_with(
1038 "feed.updated is not a valid RFC 3339 timestamp"
1039 )));
1040 }
1041
1042 #[test]
1043 fn entry_inherits_feed_author_requirement() {
1044 let feed = AtomFeed::new()
1045 .id("urn:example:feed")
1046 .title("Example")
1047 .updated("2026-06-27T00:00:00Z")
1048 .add_entry(
1049 AtomEntry::new()
1050 .id("urn:example:entry-1")
1051 .title("Entry 1")
1052 .updated("2026-06-27T00:00:00Z"),
1053 );
1054 let err = feed.validate().unwrap_err();
1055 let RssError::ValidationErrors(errs) = err else {
1056 panic!("expected ValidationErrors");
1057 };
1058 assert!(errs.iter().any(|e| e.field == "entry.0.author"));
1059 }
1060
1061 #[test]
1062 fn entry_validate_uses_unindexed_prefix() {
1063 let entry = AtomEntry::new();
1064 let err = entry.validate().unwrap_err();
1065 let RssError::ValidationErrors(errs) = err else {
1066 panic!("expected ValidationErrors");
1067 };
1068 assert!(errs.iter().any(|e| e.field == "entry.id"
1069 && e.message == "entry.id is missing"));
1070 assert!(errs.iter().any(|e| e.field == "entry.title"
1071 && e.message == "entry.title is missing"));
1072 assert!(errs.iter().any(|e| e.field == "entry.updated"
1073 && e.message == "entry.updated is missing"));
1074 }
1075
1076 #[test]
1077 fn generate_minimal_feed_emits_required_elements() {
1078 let xml = generate_atom(&minimal_feed()).unwrap();
1079 assert!(xml
1080 .contains(r#"<feed xmlns="http://www.w3.org/2005/Atom">"#));
1081 assert!(xml.contains("<id>urn:example:feed</id>"));
1082 assert!(xml.contains("<title>Example</title>"));
1083 assert!(xml.contains("<updated>2026-06-27T00:00:00Z</updated>"));
1084 assert!(xml.contains("<author>"));
1085 assert!(xml.contains("<name>Tester</name>"));
1086 }
1087
1088 #[test]
1089 fn generate_feed_with_language_sets_xml_lang() {
1090 let feed = minimal_feed().language("en-US");
1091 let xml = generate_atom(&feed).unwrap();
1092 assert!(xml.contains(r#"xml:lang="en-US""#));
1093 }
1094
1095 #[test]
1096 fn generate_feed_with_self_link_emits_rel_self() {
1097 let feed =
1098 minimal_feed().self_link("https://example.com/atom.xml");
1099 let xml = generate_atom(&feed).unwrap();
1100 assert!(xml.contains(
1101 r#"<link href="https://example.com/atom.xml" rel="self"/>"#
1102 ));
1103 }
1104
1105 #[test]
1106 fn generate_entry_with_enclosure_emits_rel_enclosure() {
1107 let feed = minimal_feed().add_entry(
1108 AtomEntry::new()
1109 .id("urn:example:ep-1")
1110 .title("Episode 1")
1111 .updated("2026-06-27T00:00:00Z")
1112 .summary("Pilot episode")
1113 .add_enclosure(
1114 "https://example.com/ep-1.mp3",
1115 "audio/mpeg",
1116 12_345_678,
1117 ),
1118 );
1119 let xml = generate_atom(&feed).unwrap();
1120 assert!(xml.contains(r#"rel="enclosure""#));
1121 assert!(xml.contains(r#"type="audio/mpeg""#));
1122 assert!(xml.contains(r#"length="12345678""#));
1123 }
1124
1125 #[test]
1126 fn generate_entry_with_html_content_sets_type_html() {
1127 let feed = minimal_feed().add_entry(
1128 AtomEntry::new()
1129 .id("urn:example:post-1")
1130 .title("Post 1")
1131 .updated("2026-06-27T00:00:00Z")
1132 .content_html("<p>Hello</p>"),
1133 );
1134 let xml = generate_atom(&feed).unwrap();
1135 assert!(xml.contains(r#"<content type="html">"#));
1136 assert!(xml.contains("<p>Hello</p>"));
1139 }
1140
1141 #[test]
1142 fn detect_feed_format_classifies_correctly() {
1143 let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel/></rss>"#;
1144 let atom = r#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><id/></feed>"#;
1145 let rdf = r#"<?xml version="1.0"?><rdf:RDF xmlns:rdf="..."><channel/></rdf:RDF>"#;
1146 let other = r#"<?xml version="1.0"?><html><body/></html>"#;
1147 let unparseable = "not xml at all";
1148 assert_eq!(detect_feed_format(rss), FeedFormat::Rss);
1149 assert_eq!(detect_feed_format(atom), FeedFormat::Atom);
1150 assert_eq!(detect_feed_format(rdf), FeedFormat::RssRdf);
1151 assert_eq!(detect_feed_format(other), FeedFormat::Unknown);
1152 assert_eq!(
1153 detect_feed_format(unparseable),
1154 FeedFormat::Unknown
1155 );
1156 }
1157
1158 #[test]
1159 fn detect_treats_feed_without_atom_namespace_as_unknown() {
1160 let no_ns = r#"<?xml version="1.0"?><feed><id/></feed>"#;
1161 assert_eq!(detect_feed_format(no_ns), FeedFormat::Unknown);
1162 }
1163
1164 #[test]
1165 fn round_trip_detect_after_generate() {
1166 let xml = generate_atom(&minimal_feed()).unwrap();
1167 assert_eq!(detect_feed_format(&xml), FeedFormat::Atom);
1168 }
1169
1170 #[test]
1171 fn special_characters_are_escaped_in_text_payloads() {
1172 let feed = AtomFeed::new()
1173 .id("urn:example:feed")
1174 .title("A & B < C > D")
1175 .updated("2026-06-27T00:00:00Z")
1176 .author_name("Tester");
1177 let xml = generate_atom(&feed).unwrap();
1178 assert!(xml.contains("<title>A & B < C > D</title>"));
1179 }
1180}