1use crate::error::{Result, RssError, ValidationError};
51use crate::generator::sanitize_content;
52use quick_xml::events::{
53 BytesDecl, BytesEnd, BytesStart, BytesText, Event,
54};
55use quick_xml::Writer;
56use serde::{Deserialize, Serialize};
57use std::io::Cursor;
58
59pub const ATOM_NAMESPACE: &str = "http://www.w3.org/2005/Atom";
61
62const XML_VERSION: &str = "1.0";
63const XML_ENCODING: &str = "utf-8";
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69#[non_exhaustive]
70pub enum FeedFormat {
71 Rss,
73 RssRdf,
75 Atom,
77 Unknown,
79}
80
81#[must_use]
101pub fn detect_feed_format(xml: &str) -> FeedFormat {
102 use quick_xml::Reader;
103
104 let mut reader = Reader::from_str(xml);
105 reader.config_mut().trim_text(true);
106 let mut buf = Vec::new();
107
108 loop {
109 match reader.read_event_into(&mut buf) {
110 Ok(Event::Start(start) | Event::Empty(start)) => {
111 let name = start.name();
112 let local = name.as_ref();
113 if local == b"rss" {
114 return FeedFormat::Rss;
115 }
116 if local == b"rdf:RDF" || local == b"RDF" {
117 return FeedFormat::RssRdf;
118 }
119 if local == b"feed" {
120 let has_atom_ns =
123 start.attributes().flatten().any(|a| {
124 a.key.as_ref() == b"xmlns"
125 && a.value.as_ref()
126 == ATOM_NAMESPACE.as_bytes()
127 });
128 return if has_atom_ns {
129 FeedFormat::Atom
130 } else {
131 FeedFormat::Unknown
132 };
133 }
134 return FeedFormat::Unknown;
135 }
136 Ok(Event::Eof) | Err(_) => return FeedFormat::Unknown,
137 Ok(_) => buf.clear(),
138 }
139 }
140}
141
142#[derive(
144 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
145)]
146#[non_exhaustive]
147pub struct AtomPerson {
148 pub name: String,
150 pub email: String,
152 pub uri: String,
154}
155
156impl AtomPerson {
157 #[must_use]
160 pub fn new<S: Into<String>>(name: S) -> Self {
161 Self {
162 name: sanitize_input(&name.into()),
163 ..Self::default()
164 }
165 }
166
167 #[must_use]
169 pub fn email<S: Into<String>>(mut self, value: S) -> Self {
170 self.email = sanitize_input(&value.into());
171 self
172 }
173
174 #[must_use]
176 pub fn uri<S: Into<String>>(mut self, value: S) -> Self {
177 self.uri = sanitize_input(&value.into());
178 self
179 }
180}
181
182#[derive(
189 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
190)]
191#[non_exhaustive]
192pub struct AtomLink {
193 pub href: String,
195 pub rel: String,
198 pub mime_type: String,
200 pub length: String,
202 pub title: String,
204}
205
206impl AtomLink {
207 #[must_use]
210 pub fn alternate<S: Into<String>>(href: S) -> Self {
211 Self {
212 href: sanitize_input(&href.into()),
213 rel: "alternate".to_string(),
214 ..Self::default()
215 }
216 }
217
218 #[must_use]
221 pub fn self_ref<S: Into<String>>(href: S) -> Self {
222 Self {
223 href: sanitize_input(&href.into()),
224 rel: "self".to_string(),
225 ..Self::default()
226 }
227 }
228
229 #[must_use]
231 pub fn enclosure<S, T>(href: S, mime_type: T, length: u64) -> Self
232 where
233 S: Into<String>,
234 T: Into<String>,
235 {
236 Self {
237 href: sanitize_input(&href.into()),
238 rel: "enclosure".to_string(),
239 mime_type: sanitize_input(&mime_type.into()),
240 length: length.to_string(),
241 ..Self::default()
242 }
243 }
244
245 #[must_use]
247 pub fn title<S: Into<String>>(mut self, value: S) -> Self {
248 self.title = sanitize_input(&value.into());
249 self
250 }
251}
252
253#[derive(
255 Debug,
256 Clone,
257 Copy,
258 Default,
259 PartialEq,
260 Eq,
261 Hash,
262 Serialize,
263 Deserialize,
264)]
265#[non_exhaustive]
266pub enum AtomTextType {
267 #[default]
269 Text,
270 Html,
274}
275
276impl AtomTextType {
277 fn as_attr(self) -> &'static str {
278 match self {
279 Self::Text => "text",
280 Self::Html => "html",
281 }
282 }
283}
284
285#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
287#[non_exhaustive]
288pub struct AtomFeed {
289 pub id: String,
291 pub title: String,
293 pub subtitle: String,
295 pub updated: String,
297 pub rights: String,
299 pub generator: String,
301 pub icon: String,
303 pub logo: String,
305 pub language: String,
307 pub authors: Vec<AtomPerson>,
309 pub contributors: Vec<AtomPerson>,
311 pub links: Vec<AtomLink>,
313 pub categories: Vec<String>,
315 pub entries: Vec<AtomEntry>,
317}
318
319#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
321#[non_exhaustive]
322pub struct AtomEntry {
323 pub id: String,
325 pub title: String,
327 pub updated: String,
329 pub published: String,
331 pub summary: String,
333 pub summary_type: AtomTextType,
335 pub content: String,
337 pub content_type: AtomTextType,
339 pub rights: String,
341 pub authors: Vec<AtomPerson>,
343 pub contributors: Vec<AtomPerson>,
345 pub links: Vec<AtomLink>,
347 pub categories: Vec<String>,
349}
350
351impl AtomFeed {
352 #[must_use]
354 pub fn new() -> Self {
355 Self::default()
356 }
357
358 #[must_use]
360 pub fn id<S: Into<String>>(mut self, value: S) -> Self {
361 self.id = sanitize_input(&value.into());
362 self
363 }
364
365 #[must_use]
367 pub fn title<S: Into<String>>(mut self, value: S) -> Self {
368 self.title = sanitize_input(&value.into());
369 self
370 }
371
372 #[must_use]
374 pub fn subtitle<S: Into<String>>(mut self, value: S) -> Self {
375 self.subtitle = sanitize_input(&value.into());
376 self
377 }
378
379 #[must_use]
381 pub fn updated<S: Into<String>>(mut self, value: S) -> Self {
382 self.updated = sanitize_input(&value.into());
383 self
384 }
385
386 #[must_use]
388 pub fn rights<S: Into<String>>(mut self, value: S) -> Self {
389 self.rights = sanitize_input(&value.into());
390 self
391 }
392
393 #[must_use]
395 pub fn generator<S: Into<String>>(mut self, value: S) -> Self {
396 self.generator = sanitize_input(&value.into());
397 self
398 }
399
400 #[must_use]
402 pub fn icon<S: Into<String>>(mut self, value: S) -> Self {
403 self.icon = sanitize_input(&value.into());
404 self
405 }
406
407 #[must_use]
409 pub fn logo<S: Into<String>>(mut self, value: S) -> Self {
410 self.logo = sanitize_input(&value.into());
411 self
412 }
413
414 #[must_use]
416 pub fn language<S: Into<String>>(mut self, value: S) -> Self {
417 self.language = sanitize_input(&value.into());
418 self
419 }
420
421 #[must_use]
423 pub fn author_name<S: Into<String>>(mut self, name: S) -> Self {
424 self.authors.push(AtomPerson::new(name));
425 self
426 }
427
428 #[must_use]
430 pub fn add_author(mut self, author: AtomPerson) -> Self {
431 self.authors.push(author);
432 self
433 }
434
435 #[must_use]
437 pub fn add_contributor(mut self, contributor: AtomPerson) -> Self {
438 self.contributors.push(contributor);
439 self
440 }
441
442 #[must_use]
444 pub fn add_link(mut self, link: AtomLink) -> Self {
445 self.links.push(link);
446 self
447 }
448
449 #[must_use]
451 pub fn self_link<S: Into<String>>(self, href: S) -> Self {
452 self.add_link(AtomLink::self_ref(href))
453 }
454
455 #[must_use]
457 pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
458 self.add_link(AtomLink::alternate(href))
459 }
460
461 #[must_use]
463 pub fn add_category<S: Into<String>>(mut self, term: S) -> Self {
464 self.categories.push(sanitize_input(&term.into()));
465 self
466 }
467
468 #[must_use]
470 pub fn add_entry(mut self, entry: AtomEntry) -> Self {
471 self.entries.push(entry);
472 self
473 }
474
475 #[must_use]
477 pub fn entry_count(&self) -> usize {
478 self.entries.len()
479 }
480
481 pub fn validate(&self) -> Result<()> {
492 let mut errors: Vec<ValidationError> = Vec::new();
493
494 if self.id.is_empty() {
495 errors.push(ValidationError::new(
496 "feed.id",
497 "feed.id is missing",
498 ));
499 }
500 if self.title.is_empty() {
501 errors.push(ValidationError::new(
502 "feed.title",
503 "feed.title is missing",
504 ));
505 }
506 if self.updated.is_empty() {
507 errors.push(ValidationError::new(
508 "feed.updated",
509 "feed.updated is missing",
510 ));
511 } else if !is_rfc3339(&self.updated) {
512 errors.push(ValidationError::new(
513 "feed.updated",
514 format!(
515 "feed.updated is not a valid RFC 3339 timestamp: {}",
516 self.updated
517 ),
518 ));
519 }
520
521 let feed_has_author = !self.authors.is_empty();
525 for (idx, entry) in self.entries.iter().enumerate() {
526 if let Err(RssError::ValidationErrors(mut entry_errors)) =
527 entry.validate_with_index(idx)
528 {
529 errors.append(&mut entry_errors);
530 }
531 if !feed_has_author && entry.authors.is_empty() {
532 errors.push(ValidationError::new(
533 format!("entry.{idx}.author"),
534 format!(
535 "entry.{idx}.author is missing (and feed has \
536 no feed-level author)"
537 ),
538 ));
539 }
540 }
541
542 if errors.is_empty() {
543 Ok(())
544 } else {
545 Err(RssError::ValidationErrors(errors))
546 }
547 }
548}
549
550impl AtomEntry {
551 #[must_use]
553 pub fn new() -> Self {
554 Self::default()
555 }
556
557 #[must_use]
559 pub fn id<S: Into<String>>(mut self, value: S) -> Self {
560 self.id = sanitize_input(&value.into());
561 self
562 }
563
564 #[must_use]
566 pub fn title<S: Into<String>>(mut self, value: S) -> Self {
567 self.title = sanitize_input(&value.into());
568 self
569 }
570
571 #[must_use]
573 pub fn updated<S: Into<String>>(mut self, value: S) -> Self {
574 self.updated = sanitize_input(&value.into());
575 self
576 }
577
578 #[must_use]
580 pub fn published<S: Into<String>>(mut self, value: S) -> Self {
581 self.published = sanitize_input(&value.into());
582 self
583 }
584
585 #[must_use]
587 pub fn summary<S: Into<String>>(mut self, value: S) -> Self {
588 self.summary = sanitize_input(&value.into());
589 self.summary_type = AtomTextType::Text;
590 self
591 }
592
593 #[must_use]
595 pub fn summary_html<S: Into<String>>(mut self, value: S) -> Self {
596 self.summary = sanitize_input(&value.into());
597 self.summary_type = AtomTextType::Html;
598 self
599 }
600
601 #[must_use]
603 pub fn content<S: Into<String>>(mut self, value: S) -> Self {
604 self.content = sanitize_input(&value.into());
605 self.content_type = AtomTextType::Text;
606 self
607 }
608
609 #[must_use]
611 pub fn content_html<S: Into<String>>(mut self, value: S) -> Self {
612 self.content = sanitize_input(&value.into());
613 self.content_type = AtomTextType::Html;
614 self
615 }
616
617 #[must_use]
619 pub fn rights<S: Into<String>>(mut self, value: S) -> Self {
620 self.rights = sanitize_input(&value.into());
621 self
622 }
623
624 #[must_use]
626 pub fn author_name<S: Into<String>>(mut self, name: S) -> Self {
627 self.authors.push(AtomPerson::new(name));
628 self
629 }
630
631 #[must_use]
633 pub fn add_author(mut self, author: AtomPerson) -> Self {
634 self.authors.push(author);
635 self
636 }
637
638 #[must_use]
640 pub fn add_link(mut self, link: AtomLink) -> Self {
641 self.links.push(link);
642 self
643 }
644
645 #[must_use]
647 pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
648 self.add_link(AtomLink::alternate(href))
649 }
650
651 #[must_use]
653 pub fn add_enclosure<S, T>(
654 self,
655 href: S,
656 mime_type: T,
657 length: u64,
658 ) -> Self
659 where
660 S: Into<String>,
661 T: Into<String>,
662 {
663 self.add_link(AtomLink::enclosure(href, mime_type, length))
664 }
665
666 #[must_use]
668 pub fn add_category<S: Into<String>>(mut self, term: S) -> Self {
669 self.categories.push(sanitize_input(&term.into()));
670 self
671 }
672
673 pub fn validate(&self) -> Result<()> {
682 let mut errors: Vec<ValidationError> = Vec::new();
683 push_entry_errors(self, "entry.", &mut errors);
684 if errors.is_empty() {
685 Ok(())
686 } else {
687 Err(RssError::ValidationErrors(errors))
688 }
689 }
690
691 fn validate_with_index(&self, idx: usize) -> Result<()> {
692 let prefix = format!("entry.{idx}.");
693 let mut errors: Vec<ValidationError> = Vec::new();
694 push_entry_errors(self, &prefix, &mut errors);
695 if errors.is_empty() {
696 Ok(())
697 } else {
698 Err(RssError::ValidationErrors(errors))
699 }
700 }
701}
702
703fn push_entry_errors(
704 entry: &AtomEntry,
705 prefix: &str,
706 errors: &mut Vec<ValidationError>,
707) {
708 let field_path = |suffix: &str| format!("{prefix}{suffix}");
709
710 if entry.id.is_empty() {
711 errors.push(ValidationError::new(
712 field_path("id"),
713 format!("{prefix}id is missing"),
714 ));
715 }
716 if entry.title.is_empty() {
717 errors.push(ValidationError::new(
718 field_path("title"),
719 format!("{prefix}title is missing"),
720 ));
721 }
722 if entry.updated.is_empty() {
723 errors.push(ValidationError::new(
724 field_path("updated"),
725 format!("{prefix}updated is missing"),
726 ));
727 } else if !is_rfc3339(&entry.updated) {
728 errors.push(ValidationError::new(
729 field_path("updated"),
730 format!(
731 "{prefix}updated is not a valid RFC 3339 timestamp: {}",
732 entry.updated
733 ),
734 ));
735 }
736 if !entry.published.is_empty() && !is_rfc3339(&entry.published) {
737 errors.push(ValidationError::new(
738 field_path("published"),
739 format!(
740 "{prefix}published is not a valid RFC 3339 timestamp: {}",
741 entry.published
742 ),
743 ));
744 }
745}
746
747fn is_rfc3339(value: &str) -> bool {
748 use time::format_description::well_known::Rfc3339;
749 use time::OffsetDateTime;
750 OffsetDateTime::parse(value, &Rfc3339).is_ok()
751}
752
753fn sanitize_input(value: &str) -> String {
754 value
755 .chars()
756 .filter(|c| !c.is_control() || matches!(*c, '\n' | '\r' | '\t'))
757 .collect()
758}
759
760pub fn generate_atom(feed: &AtomFeed) -> Result<String> {
773 feed.validate()?;
774
775 let mut writer = Writer::new(Cursor::new(Vec::new()));
776
777 writer.write_event(Event::Decl(BytesDecl::new(
778 XML_VERSION,
779 Some(XML_ENCODING),
780 None,
781 )))?;
782
783 let mut feed_start = BytesStart::new("feed");
784 feed_start.push_attribute(("xmlns", ATOM_NAMESPACE));
785 if !feed.language.is_empty() {
786 feed_start.push_attribute(("xml:lang", feed.language.as_str()));
787 }
788 writer.write_event(Event::Start(feed_start))?;
789
790 write_text_element(&mut writer, "id", &feed.id)?;
791 write_text_element(&mut writer, "title", &feed.title)?;
792 write_text_element(&mut writer, "updated", &feed.updated)?;
793 if !feed.subtitle.is_empty() {
794 write_text_element(&mut writer, "subtitle", &feed.subtitle)?;
795 }
796 if !feed.rights.is_empty() {
797 write_text_element(&mut writer, "rights", &feed.rights)?;
798 }
799 if !feed.icon.is_empty() {
800 write_text_element(&mut writer, "icon", &feed.icon)?;
801 }
802 if !feed.logo.is_empty() {
803 write_text_element(&mut writer, "logo", &feed.logo)?;
804 }
805 if !feed.generator.is_empty() {
806 write_text_element(&mut writer, "generator", &feed.generator)?;
807 }
808
809 for person in &feed.authors {
810 write_person(&mut writer, "author", person)?;
811 }
812 for person in &feed.contributors {
813 write_person(&mut writer, "contributor", person)?;
814 }
815 for link in &feed.links {
816 write_link(&mut writer, link)?;
817 }
818 for category in &feed.categories {
819 write_category(&mut writer, category)?;
820 }
821
822 for entry in &feed.entries {
823 write_entry(&mut writer, entry)?;
824 }
825
826 writer.write_event(Event::End(BytesEnd::new("feed")))?;
827
828 let xml = writer.into_inner().into_inner();
829 String::from_utf8(xml).map_err(RssError::from)
830}
831
832fn write_text_element<W: std::io::Write>(
833 writer: &mut Writer<W>,
834 name: &str,
835 content: &str,
836) -> Result<()> {
837 let escaped = sanitize_content(content);
838 writer.write_event(Event::Start(BytesStart::new(name)))?;
839 writer
840 .write_event(Event::Text(BytesText::from_escaped(escaped)))?;
841 writer.write_event(Event::End(BytesEnd::new(name)))?;
842 Ok(())
843}
844
845fn write_typed_text<W: std::io::Write>(
846 writer: &mut Writer<W>,
847 name: &str,
848 content: &str,
849 text_type: AtomTextType,
850) -> Result<()> {
851 let escaped = sanitize_content(content);
852 let mut start = BytesStart::new(name);
853 start.push_attribute(("type", text_type.as_attr()));
854 writer.write_event(Event::Start(start))?;
855 writer
856 .write_event(Event::Text(BytesText::from_escaped(escaped)))?;
857 writer.write_event(Event::End(BytesEnd::new(name)))?;
858 Ok(())
859}
860
861fn write_person<W: std::io::Write>(
862 writer: &mut Writer<W>,
863 element: &str,
864 person: &AtomPerson,
865) -> Result<()> {
866 writer.write_event(Event::Start(BytesStart::new(element)))?;
867 write_text_element(writer, "name", &person.name)?;
868 if !person.email.is_empty() {
869 write_text_element(writer, "email", &person.email)?;
870 }
871 if !person.uri.is_empty() {
872 write_text_element(writer, "uri", &person.uri)?;
873 }
874 writer.write_event(Event::End(BytesEnd::new(element)))?;
875 Ok(())
876}
877
878fn write_link<W: std::io::Write>(
879 writer: &mut Writer<W>,
880 link: &AtomLink,
881) -> Result<()> {
882 let mut start = BytesStart::new("link");
883 start.push_attribute(("href", link.href.as_str()));
884 if !link.rel.is_empty() {
885 start.push_attribute(("rel", link.rel.as_str()));
886 }
887 if !link.mime_type.is_empty() {
888 start.push_attribute(("type", link.mime_type.as_str()));
889 }
890 if !link.length.is_empty() {
891 start.push_attribute(("length", link.length.as_str()));
892 }
893 if !link.title.is_empty() {
894 start.push_attribute(("title", link.title.as_str()));
895 }
896 writer.write_event(Event::Empty(start))?;
897 Ok(())
898}
899
900fn write_category<W: std::io::Write>(
901 writer: &mut Writer<W>,
902 term: &str,
903) -> Result<()> {
904 let mut start = BytesStart::new("category");
905 start.push_attribute(("term", term));
906 writer.write_event(Event::Empty(start))?;
907 Ok(())
908}
909
910fn write_entry<W: std::io::Write>(
911 writer: &mut Writer<W>,
912 entry: &AtomEntry,
913) -> Result<()> {
914 writer.write_event(Event::Start(BytesStart::new("entry")))?;
915
916 write_text_element(writer, "id", &entry.id)?;
917 write_text_element(writer, "title", &entry.title)?;
918 write_text_element(writer, "updated", &entry.updated)?;
919 if !entry.published.is_empty() {
920 write_text_element(writer, "published", &entry.published)?;
921 }
922 if !entry.summary.is_empty() {
923 write_typed_text(
924 writer,
925 "summary",
926 &entry.summary,
927 entry.summary_type,
928 )?;
929 }
930 if !entry.content.is_empty() {
931 write_typed_text(
932 writer,
933 "content",
934 &entry.content,
935 entry.content_type,
936 )?;
937 }
938 if !entry.rights.is_empty() {
939 write_text_element(writer, "rights", &entry.rights)?;
940 }
941
942 for person in &entry.authors {
943 write_person(writer, "author", person)?;
944 }
945 for person in &entry.contributors {
946 write_person(writer, "contributor", person)?;
947 }
948 for link in &entry.links {
949 write_link(writer, link)?;
950 }
951 for category in &entry.categories {
952 write_category(writer, category)?;
953 }
954
955 writer.write_event(Event::End(BytesEnd::new("entry")))?;
956 Ok(())
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962
963 fn minimal_feed() -> AtomFeed {
964 AtomFeed::new()
965 .id("urn:example:feed")
966 .title("Example")
967 .updated("2026-06-27T00:00:00Z")
968 .author_name("Tester")
969 }
970
971 #[test]
972 fn validate_rejects_missing_required_fields() {
973 let feed = AtomFeed::new();
974 let err = feed.validate().unwrap_err();
975 let RssError::ValidationErrors(errs) = err else {
976 panic!("expected ValidationErrors");
977 };
978 assert!(errs.iter().any(|e| e.field == "feed.id"
979 && e.message == "feed.id is missing"));
980 assert!(errs.iter().any(|e| e.field == "feed.title"
981 && e.message == "feed.title is missing"));
982 assert!(errs.iter().any(|e| e.field == "feed.updated"
983 && e.message == "feed.updated is missing"));
984 }
985
986 #[test]
987 fn validate_rejects_non_rfc3339_updated() {
988 let feed = AtomFeed::new()
989 .id("urn:example:feed")
990 .title("Example")
991 .updated("yesterday afternoon")
992 .author_name("Tester");
993 let err = feed.validate().unwrap_err();
994 let RssError::ValidationErrors(errs) = err else {
995 panic!("expected ValidationErrors");
996 };
997 assert!(errs.iter().any(|e| e.field == "feed.updated"
998 && e.message.starts_with(
999 "feed.updated is not a valid RFC 3339 timestamp"
1000 )));
1001 }
1002
1003 #[test]
1004 fn entry_inherits_feed_author_requirement() {
1005 let feed = AtomFeed::new()
1006 .id("urn:example:feed")
1007 .title("Example")
1008 .updated("2026-06-27T00:00:00Z")
1009 .add_entry(
1010 AtomEntry::new()
1011 .id("urn:example:entry-1")
1012 .title("Entry 1")
1013 .updated("2026-06-27T00:00:00Z"),
1014 );
1015 let err = feed.validate().unwrap_err();
1016 let RssError::ValidationErrors(errs) = err else {
1017 panic!("expected ValidationErrors");
1018 };
1019 assert!(errs.iter().any(|e| e.field == "entry.0.author"));
1020 }
1021
1022 #[test]
1023 fn entry_validate_uses_unindexed_prefix() {
1024 let entry = AtomEntry::new();
1025 let err = entry.validate().unwrap_err();
1026 let RssError::ValidationErrors(errs) = err else {
1027 panic!("expected ValidationErrors");
1028 };
1029 assert!(errs.iter().any(|e| e.field == "entry.id"
1030 && e.message == "entry.id is missing"));
1031 assert!(errs.iter().any(|e| e.field == "entry.title"
1032 && e.message == "entry.title is missing"));
1033 assert!(errs.iter().any(|e| e.field == "entry.updated"
1034 && e.message == "entry.updated is missing"));
1035 }
1036
1037 #[test]
1038 fn generate_minimal_feed_emits_required_elements() {
1039 let xml = generate_atom(&minimal_feed()).unwrap();
1040 assert!(xml
1041 .contains(r#"<feed xmlns="http://www.w3.org/2005/Atom">"#));
1042 assert!(xml.contains("<id>urn:example:feed</id>"));
1043 assert!(xml.contains("<title>Example</title>"));
1044 assert!(xml.contains("<updated>2026-06-27T00:00:00Z</updated>"));
1045 assert!(xml.contains("<author>"));
1046 assert!(xml.contains("<name>Tester</name>"));
1047 }
1048
1049 #[test]
1050 fn generate_feed_with_language_sets_xml_lang() {
1051 let feed = minimal_feed().language("en-US");
1052 let xml = generate_atom(&feed).unwrap();
1053 assert!(xml.contains(r#"xml:lang="en-US""#));
1054 }
1055
1056 #[test]
1057 fn generate_feed_with_self_link_emits_rel_self() {
1058 let feed =
1059 minimal_feed().self_link("https://example.com/atom.xml");
1060 let xml = generate_atom(&feed).unwrap();
1061 assert!(xml.contains(
1062 r#"<link href="https://example.com/atom.xml" rel="self"/>"#
1063 ));
1064 }
1065
1066 #[test]
1067 fn generate_entry_with_enclosure_emits_rel_enclosure() {
1068 let feed = minimal_feed().add_entry(
1069 AtomEntry::new()
1070 .id("urn:example:ep-1")
1071 .title("Episode 1")
1072 .updated("2026-06-27T00:00:00Z")
1073 .summary("Pilot episode")
1074 .add_enclosure(
1075 "https://example.com/ep-1.mp3",
1076 "audio/mpeg",
1077 12_345_678,
1078 ),
1079 );
1080 let xml = generate_atom(&feed).unwrap();
1081 assert!(xml.contains(r#"rel="enclosure""#));
1082 assert!(xml.contains(r#"type="audio/mpeg""#));
1083 assert!(xml.contains(r#"length="12345678""#));
1084 }
1085
1086 #[test]
1087 fn generate_entry_with_html_content_sets_type_html() {
1088 let feed = minimal_feed().add_entry(
1089 AtomEntry::new()
1090 .id("urn:example:post-1")
1091 .title("Post 1")
1092 .updated("2026-06-27T00:00:00Z")
1093 .content_html("<p>Hello</p>"),
1094 );
1095 let xml = generate_atom(&feed).unwrap();
1096 assert!(xml.contains(r#"<content type="html">"#));
1097 assert!(xml.contains("<p>Hello</p>"));
1100 }
1101
1102 #[test]
1103 fn detect_feed_format_classifies_correctly() {
1104 let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel/></rss>"#;
1105 let atom = r#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><id/></feed>"#;
1106 let rdf = r#"<?xml version="1.0"?><rdf:RDF xmlns:rdf="..."><channel/></rdf:RDF>"#;
1107 let other = r#"<?xml version="1.0"?><html><body/></html>"#;
1108 let unparseable = "not xml at all";
1109 assert_eq!(detect_feed_format(rss), FeedFormat::Rss);
1110 assert_eq!(detect_feed_format(atom), FeedFormat::Atom);
1111 assert_eq!(detect_feed_format(rdf), FeedFormat::RssRdf);
1112 assert_eq!(detect_feed_format(other), FeedFormat::Unknown);
1113 assert_eq!(
1114 detect_feed_format(unparseable),
1115 FeedFormat::Unknown
1116 );
1117 }
1118
1119 #[test]
1120 fn detect_treats_feed_without_atom_namespace_as_unknown() {
1121 let no_ns = r#"<?xml version="1.0"?><feed><id/></feed>"#;
1122 assert_eq!(detect_feed_format(no_ns), FeedFormat::Unknown);
1123 }
1124
1125 #[test]
1126 fn round_trip_detect_after_generate() {
1127 let xml = generate_atom(&minimal_feed()).unwrap();
1128 assert_eq!(detect_feed_format(&xml), FeedFormat::Atom);
1129 }
1130
1131 #[test]
1132 fn special_characters_are_escaped_in_text_payloads() {
1133 let feed = AtomFeed::new()
1134 .id("urn:example:feed")
1135 .title("A & B < C > D")
1136 .updated("2026-06-27T00:00:00Z")
1137 .author_name("Tester");
1138 let xml = generate_atom(&feed).unwrap();
1139 assert!(xml.contains("<title>A & B < C > D</title>"));
1140 }
1141}