Skip to main content

rss_gen/
atom.rs

1// Copyright © 2026 RSS Gen. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4// src/atom.rs
5
6//! Atom 1.0 feed generation, validation, and format detection.
7//!
8//! This module implements the subset of [RFC 4287](https://www.rfc-editor.org/rfc/rfc4287)
9//! needed to author syndication feeds in the Atom 1.0 format alongside the
10//! existing RSS support. It is fully independent of the RSS code path: callers
11//! that want Atom output construct an [`AtomFeed`], add [`AtomEntry`] values
12//! via the builder API, and serialize through [`generate_atom`].
13//!
14//! It also exposes [`FeedFormat`] and [`detect_feed_format`] for callers that
15//! need to dispatch between RSS and Atom inputs without parsing the whole
16//! document.
17//!
18//! # Required elements (RFC 4287 §4.1.1 & §4.1.2)
19//!
20//! * `<feed>`: `id`, `title`, `updated` are required.
21//! * `<entry>`: `id`, `title`, `updated` are required.
22//!
23//! Validation in this module enforces those required elements and prefixes
24//! reported errors with `feed.` / `entry.<idx>.` to stay consistent with the
25//! contextual validation errors introduced for the RSS path in issue #34.
26//!
27//! # Example
28//!
29//! ```rust
30//! use rss_gen::atom::{generate_atom, AtomEntry, AtomFeed};
31//!
32//! let feed = AtomFeed::new()
33//!     .id("https://example.com/feed")
34//!     .title("Example Feed")
35//!     .updated("2026-06-27T00:00:00Z")
36//!     .author_name("Jane Doe")
37//!     .self_link("https://example.com/atom.xml")
38//!     .add_entry(
39//!         AtomEntry::new()
40//!             .id("https://example.com/post-1")
41//!             .title("First Post")
42//!             .updated("2026-06-27T00:00:00Z")
43//!             .summary("Hello, Atom"),
44//!     );
45//!
46//! let xml = generate_atom(&feed).unwrap();
47//! assert!(xml.contains(r#"<feed xmlns="http://www.w3.org/2005/Atom">"#));
48//! ```
49
50use 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
59/// Atom 1.0 namespace literal used on the root `<feed>` element.
60pub 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/// Discriminates between supported feed formats.
66///
67/// Returned by [`detect_feed_format`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69#[non_exhaustive]
70pub enum FeedFormat {
71    /// RSS 0.9x / 2.0 (`<rss>` root element).
72    Rss,
73    /// RSS 1.0 (`<rdf:RDF>` root element).
74    RssRdf,
75    /// Atom 1.0 (`<feed xmlns="http://www.w3.org/2005/Atom">` root).
76    Atom,
77    /// The document could not be classified.
78    Unknown,
79}
80
81/// Peeks the root element of an XML document and returns the detected
82/// [`FeedFormat`].
83///
84/// This is a lightweight heuristic intended for dispatching between
85/// [`crate::generator::generate_rss`] / [`crate::parser::parse_rss`] and the
86/// Atom code path. It reads only as far as the first start element and does
87/// not validate the document.
88///
89/// # Examples
90///
91/// ```rust
92/// use rss_gen::atom::{detect_feed_format, FeedFormat};
93///
94/// let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel/></rss>"#;
95/// assert_eq!(detect_feed_format(rss), FeedFormat::Rss);
96///
97/// let atom = r#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"/>"#;
98/// assert_eq!(detect_feed_format(atom), FeedFormat::Atom);
99/// ```
100#[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                    // Only classify as Atom if the namespace matches —
121                    // some hand-rolled feeds use <feed> with no xmlns.
122                    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/// A person reference (`<author>` / `<contributor>`) per RFC 4287 §3.2.
143#[derive(
144    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
145)]
146#[non_exhaustive]
147pub struct AtomPerson {
148    /// Human-readable name (required by the spec).
149    pub name: String,
150    /// Optional email address.
151    pub email: String,
152    /// Optional homepage URI.
153    pub uri: String,
154}
155
156impl AtomPerson {
157    /// Creates a new `AtomPerson` with the given name and empty contact
158    /// fields.
159    #[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    /// Sets the email address.
168    #[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    /// Sets the homepage URI.
175    #[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/// An Atom `<link>` element per RFC 4287 §4.2.7.
183///
184/// Atom links are typed pointers; the canonical alternate link to the
185/// resource is `rel="alternate"` and self-references use `rel="self"`. Media
186/// enclosures (e.g. podcasts) use `rel="enclosure"` together with the `type`
187/// and `length` attributes.
188#[derive(
189    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
190)]
191#[non_exhaustive]
192pub struct AtomLink {
193    /// Target URI of the link.
194    pub href: String,
195    /// Link relation (`alternate`, `self`, `enclosure`, …). Empty means
196    /// `alternate`, per RFC 4287 §4.2.7.2.
197    pub rel: String,
198    /// MIME type of the linked resource.
199    pub mime_type: String,
200    /// Length in bytes (relevant for `rel="enclosure"`).
201    pub length: String,
202    /// Optional human-readable title.
203    pub title: String,
204}
205
206impl AtomLink {
207    /// Constructs an `AtomLink` with the given href, defaulting to
208    /// `rel="alternate"`.
209    #[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    /// Constructs a `rel="self"` link pointing at the canonical location of
219    /// the feed.
220    #[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    /// Constructs a `rel="enclosure"` link for a media attachment.
230    #[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    /// Sets the optional title attribute.
246    #[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/// Text content for `<title>`, `<summary>`, `<content>` per RFC 4287 §3.1.
254#[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    /// Plain text. Special characters are XML-escaped.
268    #[default]
269    Text,
270    /// HTML payload. Treated as opaque text by this module and emitted
271    /// verbatim after XML-escaping; consumers are responsible for ensuring
272    /// the payload is safe.
273    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/// An Atom 1.0 feed (`<feed>`) document.
286#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
287#[non_exhaustive]
288pub struct AtomFeed {
289    /// Permanent, universally unique identifier of the feed (RFC 4287 §4.2.6).
290    pub id: String,
291    /// Human-readable title of the feed.
292    pub title: String,
293    /// Optional human-readable subtitle.
294    pub subtitle: String,
295    /// RFC 3339 timestamp of the most recent significant modification.
296    pub updated: String,
297    /// Optional rights / copyright string.
298    pub rights: String,
299    /// Generator identification (e.g. `"rss-gen"`).
300    pub generator: String,
301    /// Optional URI of a small icon representing the feed.
302    pub icon: String,
303    /// Optional URI of a larger logo representing the feed.
304    pub logo: String,
305    /// Language tag (e.g. `"en-US"`). Emitted as `xml:lang` on `<feed>`.
306    pub language: String,
307    /// Authors of the feed.
308    pub authors: Vec<AtomPerson>,
309    /// Contributors to the feed.
310    pub contributors: Vec<AtomPerson>,
311    /// Links advertised at feed level (`self`, `alternate`, …).
312    pub links: Vec<AtomLink>,
313    /// Category tags applied to the feed (term values only).
314    pub categories: Vec<String>,
315    /// Entries contained in the feed.
316    pub entries: Vec<AtomEntry>,
317}
318
319/// An Atom 1.0 entry (`<entry>`).
320#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
321#[non_exhaustive]
322pub struct AtomEntry {
323    /// Permanent, universally unique identifier of the entry.
324    pub id: String,
325    /// Human-readable title.
326    pub title: String,
327    /// RFC 3339 timestamp of the most recent significant modification.
328    pub updated: String,
329    /// Optional RFC 3339 publication timestamp.
330    pub published: String,
331    /// Short human-readable description.
332    pub summary: String,
333    /// Type of the `summary` payload.
334    pub summary_type: AtomTextType,
335    /// Full content payload (may be empty).
336    pub content: String,
337    /// Type of the `content` payload.
338    pub content_type: AtomTextType,
339    /// Optional rights string.
340    pub rights: String,
341    /// Authors of the entry.
342    pub authors: Vec<AtomPerson>,
343    /// Contributors to the entry.
344    pub contributors: Vec<AtomPerson>,
345    /// Links attached to the entry, including media enclosures.
346    pub links: Vec<AtomLink>,
347    /// Category tags applied to the entry (term values only).
348    pub categories: Vec<String>,
349}
350
351impl AtomFeed {
352    /// Creates an empty `AtomFeed`.
353    #[must_use]
354    pub fn new() -> Self {
355        Self::default()
356    }
357
358    /// Sets the feed's permanent identifier.
359    #[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    /// Sets the feed title.
366    #[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    /// Sets the feed subtitle.
373    #[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    /// Sets the feed-level `updated` timestamp (RFC 3339).
380    #[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    /// Sets the rights string.
387    #[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    /// Sets the generator string.
394    #[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    /// Sets the icon URI.
401    #[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    /// Sets the logo URI.
408    #[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    /// Sets the `xml:lang` value.
415    #[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    /// Adds an author with the given name.
422    #[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    /// Adds an [`AtomPerson`] to the author list.
429    #[must_use]
430    pub fn add_author(mut self, author: AtomPerson) -> Self {
431        self.authors.push(author);
432        self
433    }
434
435    /// Adds an [`AtomPerson`] to the contributor list.
436    #[must_use]
437    pub fn add_contributor(mut self, contributor: AtomPerson) -> Self {
438        self.contributors.push(contributor);
439        self
440    }
441
442    /// Adds an [`AtomLink`] to the feed.
443    #[must_use]
444    pub fn add_link(mut self, link: AtomLink) -> Self {
445        self.links.push(link);
446        self
447    }
448
449    /// Convenience: adds a `rel="self"` link to the canonical feed URL.
450    #[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    /// Convenience: adds a `rel="alternate"` link to the human page.
456    #[must_use]
457    pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
458        self.add_link(AtomLink::alternate(href))
459    }
460
461    /// Adds a category tag.
462    #[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    /// Appends an entry to the feed.
469    #[must_use]
470    pub fn add_entry(mut self, entry: AtomEntry) -> Self {
471        self.entries.push(entry);
472        self
473    }
474
475    /// Number of entries in the feed.
476    #[must_use]
477    pub fn entry_count(&self) -> usize {
478        self.entries.len()
479    }
480
481    /// Validates the feed against the required RFC 4287 elements.
482    ///
483    /// Error messages are prefixed with `feed.` for top-level violations
484    /// and `entry.<idx>.` for per-entry violations, matching the contextual
485    /// validation introduced in issue #34 for the RSS code path.
486    ///
487    /// # Errors
488    ///
489    /// Returns [`RssError::ValidationErrors`] containing every missing or
490    /// invalid required element discovered.
491    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        // RFC 4287 §4.1.1: a feed without a feed-level author MUST have
522        // an author on every entry. Surface that explicitly so callers
523        // don't silently emit a non-conformant document.
524        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    /// Creates an empty `AtomEntry`.
552    #[must_use]
553    pub fn new() -> Self {
554        Self::default()
555    }
556
557    /// Sets the entry's permanent identifier.
558    #[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    /// Sets the entry title.
565    #[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    /// Sets the entry-level `updated` timestamp (RFC 3339).
572    #[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    /// Sets the entry-level `published` timestamp (RFC 3339).
579    #[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    /// Sets the plain-text summary.
586    #[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    /// Sets the HTML summary payload.
594    #[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    /// Sets the plain-text content payload.
602    #[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    /// Sets the HTML content payload.
610    #[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    /// Sets the rights string.
618    #[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    /// Adds an author with the given name.
625    #[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    /// Adds an [`AtomPerson`] to the entry's author list.
632    #[must_use]
633    pub fn add_author(mut self, author: AtomPerson) -> Self {
634        self.authors.push(author);
635        self
636    }
637
638    /// Adds an [`AtomLink`] to the entry.
639    #[must_use]
640    pub fn add_link(mut self, link: AtomLink) -> Self {
641        self.links.push(link);
642        self
643    }
644
645    /// Convenience: adds a `rel="alternate"` link to the entry's resource.
646    #[must_use]
647    pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
648        self.add_link(AtomLink::alternate(href))
649    }
650
651    /// Convenience: attaches a media enclosure (RFC 4287 §4.2.7.2).
652    #[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    /// Adds a category tag.
667    #[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    /// Validates the entry against RFC 4287 §4.1.2 required elements.
674    ///
675    /// Error messages are prefixed with `entry.` (no index).
676    ///
677    /// # Errors
678    ///
679    /// Returns [`RssError::ValidationErrors`] containing every missing or
680    /// invalid required element discovered.
681    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
760/// Serializes an [`AtomFeed`] into an Atom 1.0 XML string.
761///
762/// Validation runs first via [`AtomFeed::validate`]; invalid feeds return
763/// the validation errors unchanged. On success the produced string is a
764/// stand-alone Atom 1.0 document with `xmlns="http://www.w3.org/2005/Atom"`
765/// on the root element.
766///
767/// # Errors
768///
769/// Returns [`RssError::ValidationErrors`] when required elements are missing
770/// or malformed, or [`RssError::XmlWriteError`] when the underlying XML
771/// writer fails.
772pub 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        // The HTML payload must be escaped — the angle brackets in <p>
1098        // become entities so the Atom document stays well-formed.
1099        assert!(xml.contains("&lt;p&gt;Hello&lt;/p&gt;"));
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 &amp; B &lt; C &gt; D</title>"));
1140    }
1141}