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::clean_xml_text;
52use oxml::{
53    tree::NodeId, Document, EmptyElement, Newline, SerialiseOptions,
54};
55use serde::{Deserialize, Serialize};
56
57/// Atom 1.0 namespace literal used on the root `<feed>` element.
58pub 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/// Discriminates between supported feed formats.
64///
65/// Returned by [`detect_feed_format`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[non_exhaustive]
68pub enum FeedFormat {
69    /// RSS 0.9x / 2.0 (`<rss>` root element).
70    Rss,
71    /// RSS 1.0 (`<rdf:RDF>` root element).
72    RssRdf,
73    /// Atom 1.0 (`<feed xmlns="http://www.w3.org/2005/Atom">` root).
74    Atom,
75    /// The document could not be classified.
76    Unknown,
77}
78
79/// Peeks the root element of an XML document and returns the detected
80/// [`FeedFormat`].
81///
82/// This is a lightweight heuristic intended for dispatching between
83/// [`crate::generator::generate_rss`] / [`crate::parser::parse_rss`] and the
84/// Atom code path. It reads only as far as the first start element and does
85/// not validate the document.
86///
87/// # Examples
88///
89/// ```rust
90/// use rss_gen::atom::{detect_feed_format, FeedFormat};
91///
92/// let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel/></rss>"#;
93/// assert_eq!(detect_feed_format(rss), FeedFormat::Rss);
94///
95/// let atom = r#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"/>"#;
96/// assert_eq!(detect_feed_format(atom), FeedFormat::Atom);
97/// ```
98#[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                        // Only classify as Atom if the namespace matches —
113                        // some hand-rolled feeds use <feed> with no xmlns.
114                        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/// A person reference (`<author>` / `<contributor>`) per RFC 4287 §3.2.
132#[derive(
133    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
134)]
135#[non_exhaustive]
136pub struct AtomPerson {
137    /// Human-readable name (required by the spec).
138    pub name: String,
139    /// Optional email address.
140    pub email: String,
141    /// Optional homepage URI.
142    pub uri: String,
143}
144
145impl AtomPerson {
146    /// Creates a new `AtomPerson` with the given name and empty contact
147    /// fields.
148    #[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    /// Sets the email address.
157    #[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    /// Sets the homepage URI.
164    #[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/// An Atom `<link>` element per RFC 4287 §4.2.7.
172///
173/// Atom links are typed pointers; the canonical alternate link to the
174/// resource is `rel="alternate"` and self-references use `rel="self"`. Media
175/// enclosures (e.g. podcasts) use `rel="enclosure"` together with the `type`
176/// and `length` attributes.
177#[derive(
178    Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
179)]
180#[non_exhaustive]
181pub struct AtomLink {
182    /// Target URI of the link.
183    pub href: String,
184    /// Link relation (`alternate`, `self`, `enclosure`, …). Empty means
185    /// `alternate`, per RFC 4287 §4.2.7.2.
186    pub rel: String,
187    /// MIME type of the linked resource.
188    pub mime_type: String,
189    /// Length in bytes (relevant for `rel="enclosure"`).
190    pub length: String,
191    /// Optional human-readable title.
192    pub title: String,
193}
194
195impl AtomLink {
196    /// Constructs an `AtomLink` with the given href, defaulting to
197    /// `rel="alternate"`.
198    #[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    /// Constructs a `rel="self"` link pointing at the canonical location of
208    /// the feed.
209    #[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    /// Constructs a `rel="enclosure"` link for a media attachment.
219    #[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    /// Sets the optional title attribute.
235    #[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/// Text content for `<title>`, `<summary>`, `<content>` per RFC 4287 §3.1.
243#[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    /// Plain text. Special characters are XML-escaped.
257    #[default]
258    Text,
259    /// HTML payload. Treated as opaque text by this module and emitted
260    /// verbatim after XML-escaping; consumers are responsible for ensuring
261    /// the payload is safe.
262    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/// An Atom 1.0 feed (`<feed>`) document.
275#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[non_exhaustive]
277pub struct AtomFeed {
278    /// Permanent, universally unique identifier of the feed (RFC 4287 §4.2.6).
279    pub id: String,
280    /// Human-readable title of the feed.
281    pub title: String,
282    /// Optional human-readable subtitle.
283    pub subtitle: String,
284    /// RFC 3339 timestamp of the most recent significant modification.
285    pub updated: String,
286    /// Optional rights / copyright string.
287    pub rights: String,
288    /// Generator identification (e.g. `"rss-gen"`).
289    pub generator: String,
290    /// Optional URI of a small icon representing the feed.
291    pub icon: String,
292    /// Optional URI of a larger logo representing the feed.
293    pub logo: String,
294    /// Language tag (e.g. `"en-US"`). Emitted as `xml:lang` on `<feed>`.
295    pub language: String,
296    /// Authors of the feed.
297    pub authors: Vec<AtomPerson>,
298    /// Contributors to the feed.
299    pub contributors: Vec<AtomPerson>,
300    /// Links advertised at feed level (`self`, `alternate`, …).
301    pub links: Vec<AtomLink>,
302    /// Category tags applied to the feed (term values only).
303    pub categories: Vec<String>,
304    /// Entries contained in the feed.
305    pub entries: Vec<AtomEntry>,
306}
307
308/// An Atom 1.0 entry (`<entry>`).
309#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
310#[non_exhaustive]
311pub struct AtomEntry {
312    /// Permanent, universally unique identifier of the entry.
313    pub id: String,
314    /// Human-readable title.
315    pub title: String,
316    /// RFC 3339 timestamp of the most recent significant modification.
317    pub updated: String,
318    /// Optional RFC 3339 publication timestamp.
319    pub published: String,
320    /// Short human-readable description.
321    pub summary: String,
322    /// Type of the `summary` payload.
323    pub summary_type: AtomTextType,
324    /// Full content payload (may be empty).
325    pub content: String,
326    /// Type of the `content` payload.
327    pub content_type: AtomTextType,
328    /// Optional rights string.
329    pub rights: String,
330    /// Authors of the entry.
331    pub authors: Vec<AtomPerson>,
332    /// Contributors to the entry.
333    pub contributors: Vec<AtomPerson>,
334    /// Links attached to the entry, including media enclosures.
335    pub links: Vec<AtomLink>,
336    /// Category tags applied to the entry (term values only).
337    pub categories: Vec<String>,
338}
339
340impl AtomFeed {
341    /// Creates an empty `AtomFeed`.
342    #[must_use]
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Sets the feed's permanent identifier.
348    #[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    /// Sets the feed title.
355    #[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    /// Sets the feed subtitle.
362    #[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    /// Sets the feed-level `updated` timestamp (RFC 3339).
369    #[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    /// Sets the rights string.
376    #[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    /// Sets the generator string.
383    #[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    /// Sets the icon URI.
390    #[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    /// Sets the logo URI.
397    #[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    /// Sets the `xml:lang` value.
404    #[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    /// Adds an author with the given name.
411    #[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    /// Adds an [`AtomPerson`] to the author list.
418    #[must_use]
419    pub fn add_author(mut self, author: AtomPerson) -> Self {
420        self.authors.push(author);
421        self
422    }
423
424    /// Adds an [`AtomPerson`] to the contributor list.
425    #[must_use]
426    pub fn add_contributor(mut self, contributor: AtomPerson) -> Self {
427        self.contributors.push(contributor);
428        self
429    }
430
431    /// Adds an [`AtomLink`] to the feed.
432    #[must_use]
433    pub fn add_link(mut self, link: AtomLink) -> Self {
434        self.links.push(link);
435        self
436    }
437
438    /// Convenience: adds a `rel="self"` link to the canonical feed URL.
439    #[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    /// Convenience: adds a `rel="alternate"` link to the human page.
445    #[must_use]
446    pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
447        self.add_link(AtomLink::alternate(href))
448    }
449
450    /// Adds a category tag.
451    #[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    /// Appends an entry to the feed.
458    #[must_use]
459    pub fn add_entry(mut self, entry: AtomEntry) -> Self {
460        self.entries.push(entry);
461        self
462    }
463
464    /// Number of entries in the feed.
465    #[must_use]
466    pub fn entry_count(&self) -> usize {
467        self.entries.len()
468    }
469
470    /// Validates the feed against the required RFC 4287 elements.
471    ///
472    /// Error messages are prefixed with `feed.` for top-level violations
473    /// and `entry.<idx>.` for per-entry violations, matching the contextual
474    /// validation introduced in issue #34 for the RSS code path.
475    ///
476    /// # Errors
477    ///
478    /// Returns [`RssError::ValidationErrors`] containing every missing or
479    /// invalid required element discovered.
480    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        // RFC 4287 §4.1.1: a feed without a feed-level author MUST have
511        // an author on every entry. Surface that explicitly so callers
512        // don't silently emit a non-conformant document.
513        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    /// Creates an empty `AtomEntry`.
541    #[must_use]
542    pub fn new() -> Self {
543        Self::default()
544    }
545
546    /// Sets the entry's permanent identifier.
547    #[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    /// Sets the entry title.
554    #[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    /// Sets the entry-level `updated` timestamp (RFC 3339).
561    #[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    /// Sets the entry-level `published` timestamp (RFC 3339).
568    #[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    /// Sets the plain-text summary.
575    #[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    /// Sets the HTML summary payload.
583    #[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    /// Sets the plain-text content payload.
591    #[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    /// Sets the HTML content payload.
599    #[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    /// Sets the rights string.
607    #[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    /// Adds an author with the given name.
614    #[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    /// Adds an [`AtomPerson`] to the entry's author list.
621    #[must_use]
622    pub fn add_author(mut self, author: AtomPerson) -> Self {
623        self.authors.push(author);
624        self
625    }
626
627    /// Adds an [`AtomLink`] to the entry.
628    #[must_use]
629    pub fn add_link(mut self, link: AtomLink) -> Self {
630        self.links.push(link);
631        self
632    }
633
634    /// Convenience: adds a `rel="alternate"` link to the entry's resource.
635    #[must_use]
636    pub fn alternate_link<S: Into<String>>(self, href: S) -> Self {
637        self.add_link(AtomLink::alternate(href))
638    }
639
640    /// Convenience: attaches a media enclosure (RFC 4287 §4.2.7.2).
641    #[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    /// Adds a category tag.
656    #[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    /// Validates the entry against RFC 4287 §4.1.2 required elements.
663    ///
664    /// Error messages are prefixed with `entry.` (no index).
665    ///
666    /// # Errors
667    ///
668    /// Returns [`RssError::ValidationErrors`] containing every missing or
669    /// invalid required element discovered.
670    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
749/// Serializes an [`AtomFeed`] into an Atom 1.0 XML string.
750///
751/// Validation runs first via [`AtomFeed::validate`]; invalid feeds return
752/// the validation errors unchanged. On success the produced string is a
753/// stand-alone Atom 1.0 document with `xmlns="http://www.w3.org/2005/Atom"`
754/// on the root element.
755///
756/// # Errors
757///
758/// Returns [`RssError::ValidationErrors`] when required elements are missing
759/// or malformed, or [`RssError::XmlWriteError`] when the underlying XML
760/// writer fails.
761pub 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        // The HTML payload must be escaped — the angle brackets in <p>
1137        // become entities so the Atom document stays well-formed.
1138        assert!(xml.contains("&lt;p&gt;Hello&lt;/p&gt;"));
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 &amp; B &lt; C &gt; D</title>"));
1179    }
1180}