Skip to main content

ssg/plugins/postprocess/
atom.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Atom 1.0 feed plugin.
5
6use super::helpers::{read_meta_sidecars, xml_escape};
7use crate::dates::parse_flexible_date;
8use crate::error::{PathErrorExt, SsgError};
9use crate::plugin::{Plugin, PluginContext};
10use crate::util::head_dom::inject_before_head_close;
11use std::fs;
12use std::path::Path;
13
14/// Generates an Atom 1.0 `atom.xml` feed from `.meta.json` sidecars.
15///
16/// Runs after `RssAggregatePlugin` in `after_compile`. Reads the same
17/// sidecar files, sorts entries by date descending, and limits to 50.
18#[derive(Debug, Clone, Copy)]
19pub struct AtomFeedPlugin;
20
21impl Plugin for AtomFeedPlugin {
22    fn name(&self) -> &'static str {
23        "atom-feed"
24    }
25
26    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
27        let mut meta_entries =
28            read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
29
30        // Fall back to build_dir/.meta for sidecars emitted by
31        // TemplatePlugin::before_compile (not present in site_dir
32        // when staticdatagen doesn't copy them).
33        if meta_entries.is_empty() {
34            let meta_dir = ctx.build_dir.join(".meta");
35            if meta_dir.exists() {
36                meta_entries =
37                    read_meta_sidecars(&meta_dir).unwrap_or_default();
38            }
39        }
40
41        // Last resort: extract entries from an existing rss.xml
42        // (staticdatagen generates rss.xml natively even without sidecars).
43        if meta_entries.is_empty() {
44            meta_entries = extract_entries_from_rss(&ctx.site_dir);
45        }
46
47        let base_url = ctx
48            .config
49            .as_ref()
50            .map(|c| c.base_url.trim_end_matches('/').to_string())
51            .unwrap_or_default();
52
53        let site_name = ctx
54            .config
55            .as_ref()
56            .map(|c| c.site_name.clone())
57            .unwrap_or_default();
58
59        let feed_title = if site_name.is_empty() {
60            "Untitled".to_string()
61        } else {
62            site_name
63        };
64
65        let mut articles = collect_atom_entries(&meta_entries, &base_url);
66        // Sort by date descending, then by `id` ascending as a
67        // deterministic tiebreaker. `read_meta_sidecars` walks the
68        // filesystem tree, whose entry order is OS-dependent (ext4 vs
69        // APFS) — without a tiebreaker, pages sharing a date (common
70        // in synthetic fixtures) retain that non-deterministic order
71        // through the stable sort, failing the cross-OS determinism
72        // gate.
73        articles
74            .sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)));
75        articles.truncate(50);
76
77        if articles.is_empty() {
78            return Ok(());
79        }
80
81        let feed_xml = build_atom_feed(&feed_title, &base_url, &articles);
82
83        let atom_path = ctx.site_dir.join("atom.xml");
84        fs::write(&atom_path, &feed_xml).with_path(&atom_path)?;
85
86        let atom_self_link = if base_url.is_empty() {
87            "atom.xml".to_string()
88        } else {
89            format!("{base_url}/atom.xml")
90        };
91        inject_atom_link(&ctx.site_dir, &atom_self_link)?;
92
93        log::info!(
94            "[atom-feed] Generated atom.xml with {} entries",
95            articles.len()
96        );
97        Ok(())
98    }
99}
100
101/// Collects Atom entries from metadata sidecars.
102fn collect_atom_entries(
103    meta_entries: &[(String, std::collections::HashMap<String, String>)],
104    base_url: &str,
105) -> Vec<(String, AtomEntry)> {
106    let mut articles = Vec::new();
107    for (rel_path, meta) in meta_entries {
108        if let Some(entry) = build_atom_entry(rel_path, meta, base_url) {
109            articles.push(entry);
110        }
111    }
112    articles
113}
114
115/// Builds a single Atom entry from metadata, or `None` if data is insufficient.
116fn build_atom_entry(
117    rel_path: &str,
118    meta: &std::collections::HashMap<String, String>,
119    base_url: &str,
120) -> Option<(String, AtomEntry)> {
121    if rel_path.is_empty() {
122        return None;
123    }
124
125    let title = meta.get("title").cloned().unwrap_or_default();
126    if title.is_empty() {
127        return None;
128    }
129
130    let description = meta.get("description").cloned().unwrap_or_default();
131    let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
132    let author = meta.get("author").cloned().unwrap_or_default();
133
134    let link = if base_url.is_empty() {
135        format!("{rel_path}/")
136    } else {
137        format!("{base_url}/{rel_path}/")
138    };
139
140    // Issue #586 / plan §2 item 1.4 (spec A4): shared flexible date
141    // chain — RFC 2822, long-form, and ISO 8601 inputs all normalise
142    // to the RFC 3339 shape Atom requires; unparseable values pass
143    // through verbatim (previous behaviour) with a warning naming the
144    // failing field.
145    let rfc3339 = match parse_flexible_date(&pub_date) {
146        Ok(dt) => dt.to_rfc3339(),
147        Err(err) => {
148            if !pub_date.is_empty() {
149                log::warn!(
150                    "[atom-feed] 'item_pub_date' for '{rel_path}': {err}"
151                );
152            }
153            pub_date.clone()
154        }
155    };
156
157    Some((
158        rfc3339.clone(),
159        AtomEntry {
160            title,
161            link: link.clone(),
162            id: link,
163            updated: rfc3339.clone(),
164            published: rfc3339,
165            summary: description,
166            author,
167        },
168    ))
169}
170
171/// Builds the complete Atom XML feed from entries.
172fn build_atom_feed(
173    feed_title: &str,
174    base_url: &str,
175    articles: &[(String, AtomEntry)],
176) -> String {
177    let feed_updated = &articles[0].0;
178    let entries_xml: String = articles
179        .iter()
180        .map(|(_, entry)| entry.to_xml())
181        .collect::<Vec<_>>()
182        .join("\n");
183
184    let atom_self_link = if base_url.is_empty() {
185        "atom.xml".to_string()
186    } else {
187        format!("{base_url}/atom.xml")
188    };
189
190    let feed_id = if base_url.is_empty() {
191        "/".to_string()
192    } else {
193        base_url.to_string()
194    };
195
196    format!(
197        r#"<?xml version="1.0" encoding="UTF-8"?>
198<feed xmlns="http://www.w3.org/2005/Atom">
199  <title>{feed_title}</title>
200  <link href="{atom_self_link}" rel="self" type="application/atom+xml"/>
201  <link href="{base_url}"/>
202  <id>{feed_id}</id>
203  <updated>{feed_updated}</updated>
204{entries_xml}
205</feed>
206"#,
207        feed_title = xml_escape(feed_title),
208    )
209}
210
211/// A single Atom entry's data.
212pub(super) struct AtomEntry {
213    pub title: String,
214    pub link: String,
215    pub id: String,
216    pub updated: String,
217    pub published: String,
218    pub summary: String,
219    pub author: String,
220}
221
222impl AtomEntry {
223    pub(super) fn to_xml(&self) -> String {
224        let author_name = if self.author.is_empty() {
225            "Unknown".to_string()
226        } else {
227            xml_escape(&self.author)
228        };
229        format!(
230            r#"  <entry>
231    <title>{title}</title>
232    <link href="{link}"/>
233    <id>{id}</id>
234    <updated>{updated}</updated>
235    <published>{published}</published>
236    <summary>{summary}</summary>
237    <author><name>{author}</name></author>
238  </entry>"#,
239            title = xml_escape(&self.title),
240            link = xml_escape(&self.link),
241            id = xml_escape(&self.id),
242            updated = xml_escape(&self.updated),
243            published = xml_escape(&self.published),
244            summary = xml_escape(&self.summary),
245            author = author_name,
246        )
247    }
248}
249
250/// Extracts entry metadata from an existing `rss.xml` when no sidecars
251/// are available. Returns entries in the same format as `read_meta_sidecars`.
252pub(super) fn extract_entries_from_rss(
253    site_dir: &Path,
254) -> Vec<(String, std::collections::HashMap<String, String>)> {
255    let rss_path = site_dir.join("rss.xml");
256    let Ok(rss_content) = fs::read_to_string(&rss_path) else {
257        return Vec::new();
258    };
259
260    if let Ok(doc) = oxml::parse(&rss_content) {
261        return extract_entries_from_rss_doc(&doc);
262    }
263
264    let mut entries = Vec::new();
265
266    // Simple XML parsing fallback: extract <item>…</item> blocks
267    let mut search_from = 0;
268    while let Some(item_start) = rss_content[search_from..].find("<item>") {
269        let abs_start = search_from + item_start;
270        let Some(item_end) = rss_content[abs_start..].find("</item>") else {
271            break;
272        };
273        let item = &rss_content[abs_start..abs_start + item_end + 7];
274
275        let mut meta = std::collections::HashMap::new();
276        if let Some(title) = extract_xml_tag(item, "title") {
277            let _ = meta.insert("title".to_string(), title);
278        }
279        if let Some(desc) = extract_xml_tag(item, "description") {
280            let _ = meta.insert("description".to_string(), desc);
281        }
282        if let Some(date) = extract_xml_tag(item, "pubDate") {
283            let _ = meta.insert("item_pub_date".to_string(), date);
284        }
285        if let Some(author) = extract_xml_tag(item, "author") {
286            let _ = meta.insert("author".to_string(), author);
287        }
288
289        // Derive relative path from <link>
290        let rel_path = extract_xml_tag(item, "link")
291            .map(|link| {
292                link.trim_end_matches('/')
293                    .rsplit('/')
294                    .next()
295                    .unwrap_or("")
296                    .to_string()
297            })
298            .unwrap_or_default();
299
300        if !rel_path.is_empty() && meta.contains_key("title") {
301            entries.push((rel_path, meta));
302        }
303
304        search_from = abs_start + item_end + 7;
305    }
306
307    entries
308}
309
310/// Extracts RSS `<item>` entries from a parsed `oxml::Document`.
311fn extract_entries_from_rss_doc(
312    doc: &oxml::Document,
313) -> Vec<(String, std::collections::HashMap<String, String>)> {
314    let mut entries = Vec::new();
315    let Some(root) = doc.root_element() else {
316        return entries;
317    };
318    let Some(&channel) = doc.children(root).iter().find(|&&child| {
319        doc.element_name(child)
320            .is_some_and(|n| n.local.eq_ignore_ascii_case("channel"))
321    }) else {
322        return entries;
323    };
324
325    for &item in doc.children(channel) {
326        if !doc
327            .element_name(item)
328            .is_some_and(|n| n.local.eq_ignore_ascii_case("item"))
329        {
330            continue;
331        }
332        let mut meta = std::collections::HashMap::new();
333        let mut link_val = None;
334
335        for &field in doc.children(item) {
336            let Some(name) = doc.element_name(field) else {
337                continue;
338            };
339            let text = element_text(doc, field);
340            let trimmed = text.trim();
341            if trimmed.is_empty() {
342                continue;
343            }
344            match name.local.as_str() {
345                "title" => {
346                    let _ =
347                        meta.insert("title".to_string(), xml_escape(trimmed));
348                }
349                "description" => {
350                    let _ = meta
351                        .insert("description".to_string(), xml_escape(trimmed));
352                }
353                "pubDate" => {
354                    let _ = meta.insert(
355                        "item_pub_date".to_string(),
356                        xml_escape(trimmed),
357                    );
358                }
359                "author" => {
360                    let _ =
361                        meta.insert("author".to_string(), xml_escape(trimmed));
362                }
363                "link" => {
364                    link_val = Some(trimmed.to_string());
365                }
366                _ => {}
367            }
368        }
369
370        let rel_path = link_val
371            .map(|link| {
372                link.trim_end_matches('/')
373                    .rsplit('/')
374                    .next()
375                    .unwrap_or("")
376                    .to_string()
377            })
378            .unwrap_or_default();
379
380        if !rel_path.is_empty() && meta.contains_key("title") {
381            entries.push((rel_path, meta));
382        }
383    }
384
385    entries
386}
387
388/// Concatenates direct text/CDATA children of an element in `doc`.
389fn element_text(doc: &oxml::Document, elem: oxml::NodeId) -> String {
390    let mut out = String::new();
391    for &child in doc.children(elem) {
392        if let Some(t) = doc.text_content(child) {
393            out.push_str(t);
394        }
395    }
396    out
397}
398
399/// Extracts text content from a simple XML tag.
400///
401/// Handles both `<tag>content</tag>` and `<tag attr="...">content</tag>`.
402/// Strips CDATA wrappers and decodes common XML entities.
403fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
404    // Match both <tag> and <tag attr="...">
405    let open_plain = format!("<{tag}>");
406    let open_attr = format!("<{tag} ");
407    let close = format!("</{tag}>");
408
409    let (start, content_start) = if let Some(pos) = xml.find(&open_plain) {
410        (pos, pos + open_plain.len())
411    } else {
412        let pos = xml.find(&open_attr)?;
413        let gt = xml[pos..].find('>')?;
414        (pos, pos + gt + 1)
415    };
416
417    let _ = start; // used for finding the tag
418    let end = xml[content_start..].find(&close)? + content_start;
419    let content = xml[content_start..end].trim();
420
421    // Strip CDATA wrapper
422    let content = content
423        .strip_prefix("<![CDATA[")
424        .and_then(|s| s.strip_suffix("]]>"))
425        .unwrap_or(content);
426
427    // Decode common XML entities
428    let decoded = content
429        .replace("&amp;", "&")
430        .replace("&lt;", "<")
431        .replace("&gt;", ">")
432        .replace("&quot;", "\"")
433        .replace("&apos;", "'");
434
435    let decoded = decoded.trim();
436    if decoded.is_empty() {
437        None
438    } else {
439        Some(xml_escape(decoded))
440    }
441}
442
443/// Inject `<link rel="alternate" type="application/atom+xml">` into
444/// HTML files that don't already have one.
445pub(super) fn inject_atom_link(
446    site_dir: &Path,
447    atom_url: &str,
448) -> Result<(), SsgError> {
449    let html_files = crate::walk::walk_files(site_dir, "html")
450        .map_err(|e| SsgError::io(e, site_dir))?;
451    for path in &html_files {
452        let html = fs::read_to_string(path).with_path(path)?;
453
454        if html.contains("application/atom+xml") {
455            continue;
456        }
457
458        let link_tag = format!(
459            "  <link rel=\"alternate\" type=\"application/atom+xml\" title=\"Atom Feed\" href=\"{atom_url}\"/>\n"
460        );
461        let modified = inject_before_head_close(&html, &link_tag);
462        if modified != html {
463            fs::write(path, &modified).with_path(path)?;
464        }
465    }
466    Ok(())
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::plugin::PluginContext;
473    use anyhow::Result;
474    use std::collections::HashMap;
475    use std::path::Path;
476    use tempfile::tempdir;
477
478    fn write_meta_sidecar(
479        dir: &Path,
480        slug: &str,
481        meta: &HashMap<String, String>,
482    ) {
483        let page_dir = dir.join(slug);
484        fs::create_dir_all(&page_dir).expect("create page dir");
485        let meta_path = page_dir.join("index.meta.json");
486        let json = serde_json::to_string(meta).expect("serialize meta");
487        fs::write(&meta_path, json).expect("write meta");
488    }
489
490    fn make_atom_ctx(site_dir: &Path) -> PluginContext {
491        crate::test_support::init_logger();
492        let config = crate::cmd::SsgConfig {
493            listings: Vec::new(),
494            base_url: "https://example.com".to_string(),
495            site_name: "Test Site".to_string(),
496            site_title: "Test Site".to_string(),
497            site_description: "A test site".to_string(),
498            language: "en".to_string(),
499            content_dir: std::path::PathBuf::from("content"),
500            output_dir: std::path::PathBuf::from("build"),
501            template_dir: std::path::PathBuf::from("templates"),
502            theme: None,
503            serve_dir: None,
504            #[cfg(feature = "i18n")]
505            i18n: None,
506            cdn_prefix: None,
507            og_image: None,
508            image: crate::cmd::ImageConfig::default(),
509            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
510            agents: None,
511            transitions: false,
512            security: crate::cmd::SecurityConfig::default(),
513            no_taxonomy_pages: false,
514        };
515        PluginContext::with_config(
516            Path::new("content"),
517            Path::new("build"),
518            site_dir,
519            Path::new("templates"),
520            config,
521        )
522    }
523
524    #[test]
525    fn test_atom_feed_valid_namespace_and_elements() -> Result<()> {
526        let tmp = tempdir().unwrap();
527
528        let mut meta = HashMap::new();
529        let _ = meta.insert("title".to_string(), "Hello World".to_string());
530        let _ =
531            meta.insert("description".to_string(), "A test post".to_string());
532        let _ = meta.insert(
533            "item_pub_date".to_string(),
534            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
535        );
536        let _ = meta.insert("author".to_string(), "Alice".to_string());
537        write_meta_sidecar(tmp.path(), "hello", &meta);
538
539        let ctx = make_atom_ctx(tmp.path());
540        AtomFeedPlugin.after_compile(&ctx).unwrap();
541
542        let atom_path = tmp.path().join("atom.xml");
543        assert!(atom_path.exists(), "atom.xml should be created");
544
545        let content = fs::read_to_string(&atom_path).unwrap();
546        assert!(
547            content.contains("xmlns=\"http://www.w3.org/2005/Atom\""),
548            "Missing Atom namespace"
549        );
550        assert!(content.contains("<feed"), "Missing <feed> element");
551        assert!(content.contains("<title>"), "Missing <title>");
552        assert!(content.contains("rel=\"self\""), "Missing self link");
553        assert!(content.contains("<id>"), "Missing <id>");
554        assert!(content.contains("<updated>"), "Missing <updated>");
555        assert!(content.contains("<entry>"), "Missing <entry>");
556        assert!(content.contains("<author>"), "Missing <author>");
557        assert!(
558            content.contains("<name>Alice</name>"),
559            "Missing author name"
560        );
561        assert!(content.contains("<summary>"), "Missing <summary>");
562        assert!(content.contains("<published>"), "Missing <published>");
563        Ok(())
564    }
565
566    #[test]
567    fn test_atom_feed_entry_count_matches() -> Result<()> {
568        let tmp = tempdir().unwrap();
569
570        for i in 0..5 {
571            let mut meta = HashMap::new();
572            let _ = meta.insert("title".to_string(), format!("Post {i}"));
573            let _ = meta.insert("description".to_string(), format!("Desc {i}"));
574            let _ = meta.insert(
575                "item_pub_date".to_string(),
576                format!("Thu, {:02} Apr 2026 06:06:06 +0000", 10 + i),
577            );
578            let _ = meta.insert("author".to_string(), "Bob".to_string());
579            write_meta_sidecar(tmp.path(), &format!("post-{i}"), &meta);
580        }
581
582        let ctx = make_atom_ctx(tmp.path());
583        AtomFeedPlugin.after_compile(&ctx).unwrap();
584
585        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
586        let entry_count = content.matches("<entry>").count();
587        assert_eq!(entry_count, 5, "Expected 5 entries, got {entry_count}");
588        Ok(())
589    }
590
591    #[test]
592    fn test_atom_feed_dates_are_rfc3339() -> Result<()> {
593        let tmp = tempdir().unwrap();
594
595        let mut meta = HashMap::new();
596        let _ = meta.insert("title".to_string(), "Date Test".to_string());
597        let _ =
598            meta.insert("description".to_string(), "Testing dates".to_string());
599        let _ = meta.insert(
600            "item_pub_date".to_string(),
601            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
602        );
603        let _ = meta.insert("author".to_string(), "Charlie".to_string());
604        write_meta_sidecar(tmp.path(), "datepost", &meta);
605
606        let ctx = make_atom_ctx(tmp.path());
607        AtomFeedPlugin.after_compile(&ctx).unwrap();
608
609        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
610        assert!(
611            content.contains("2026-04-11T06:06:06+00:00"),
612            "Expected RFC 3339 date in atom.xml, got:\n{content}"
613        );
614        Ok(())
615    }
616
617    #[test]
618    fn test_atom_feed_idempotent() -> Result<()> {
619        let tmp = tempdir().unwrap();
620
621        let mut meta = HashMap::new();
622        let _ = meta.insert("title".to_string(), "Idempotent".to_string());
623        let _ = meta.insert("description".to_string(), "Test".to_string());
624        let _ = meta.insert(
625            "item_pub_date".to_string(),
626            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
627        );
628        let _ = meta.insert("author".to_string(), "Dave".to_string());
629        write_meta_sidecar(tmp.path(), "idem", &meta);
630
631        let ctx = make_atom_ctx(tmp.path());
632        AtomFeedPlugin.after_compile(&ctx).unwrap();
633        let first = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
634
635        AtomFeedPlugin.after_compile(&ctx).unwrap();
636        let second = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
637
638        assert_eq!(first, second, "Atom feed should be idempotent");
639        Ok(())
640    }
641
642    #[test]
643    fn test_atom_feed_injects_link_into_html() -> Result<()> {
644        let tmp = tempdir().unwrap();
645
646        let mut meta = HashMap::new();
647        let _ = meta.insert("title".to_string(), "Link Test".to_string());
648        let _ = meta.insert("description".to_string(), "Test".to_string());
649        let _ = meta.insert(
650            "item_pub_date".to_string(),
651            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
652        );
653        let _ = meta.insert("author".to_string(), "Eve".to_string());
654        write_meta_sidecar(tmp.path(), "linktest", &meta);
655
656        let html_path = tmp.path().join("index.html");
657        fs::write(
658            &html_path,
659            "<html><head><title>Test</title></head><body></body></html>",
660        )
661        .unwrap();
662
663        let ctx = make_atom_ctx(tmp.path());
664        AtomFeedPlugin.after_compile(&ctx).unwrap();
665
666        let html = fs::read_to_string(&html_path).unwrap();
667        assert!(
668            html.contains("application/atom+xml"),
669            "HTML should have atom link tag"
670        );
671        Ok(())
672    }
673
674    #[test]
675    fn test_atom_plugin_registers() {
676        use crate::plugin::PluginManager;
677        let mut pm = PluginManager::new();
678        pm.register(AtomFeedPlugin);
679        assert_eq!(pm.len(), 1);
680        assert_eq!(pm.names(), vec!["atom-feed"]);
681    }
682
683    #[test]
684    fn test_atom_feed_sorts_descending() -> Result<()> {
685        let tmp = tempdir().unwrap();
686
687        let mut meta_old = HashMap::new();
688        let _ = meta_old.insert("title".to_string(), "Old Post".to_string());
689        let _ = meta_old.insert("description".to_string(), "old".to_string());
690        let _ = meta_old.insert(
691            "item_pub_date".to_string(),
692            "Mon, 01 Jan 2025 00:00:00 +0000".to_string(),
693        );
694        let _ = meta_old.insert("author".to_string(), "Alice".to_string());
695        write_meta_sidecar(tmp.path(), "old-post", &meta_old);
696
697        let mut meta_new = HashMap::new();
698        let _ = meta_new.insert("title".to_string(), "New Post".to_string());
699        let _ = meta_new.insert("description".to_string(), "new".to_string());
700        let _ = meta_new.insert(
701            "item_pub_date".to_string(),
702            "Fri, 11 Apr 2026 12:00:00 +0000".to_string(),
703        );
704        let _ = meta_new.insert("author".to_string(), "Bob".to_string());
705        write_meta_sidecar(tmp.path(), "new-post", &meta_new);
706
707        let ctx = make_atom_ctx(tmp.path());
708        AtomFeedPlugin.after_compile(&ctx).unwrap();
709
710        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
711        let first_entry_pos = content.find("<entry>").unwrap();
712        let new_title_pos = content.find("New Post").unwrap();
713        let old_title_pos = content.find("Old Post").unwrap();
714        assert!(
715            new_title_pos < old_title_pos,
716            "Newer post should come first"
717        );
718        assert!(
719            new_title_pos > first_entry_pos,
720            "Title should be inside an entry"
721        );
722        Ok(())
723    }
724
725    #[test]
726    fn test_atom_feed_empty_author_shows_unknown() -> Result<()> {
727        let tmp = tempdir().unwrap();
728
729        let mut meta = HashMap::new();
730        let _ = meta.insert("title".to_string(), "No Author".to_string());
731        let _ = meta.insert("description".to_string(), "test".to_string());
732        let _ = meta.insert(
733            "item_pub_date".to_string(),
734            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
735        );
736        write_meta_sidecar(tmp.path(), "no-author", &meta);
737
738        let ctx = make_atom_ctx(tmp.path());
739        AtomFeedPlugin.after_compile(&ctx).unwrap();
740
741        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
742        assert!(
743            content.contains("<name>Unknown</name>"),
744            "Empty author should show 'Unknown': {content}"
745        );
746        Ok(())
747    }
748
749    #[test]
750    fn test_atom_feed_skips_empty_title() -> Result<()> {
751        let tmp = tempdir().unwrap();
752
753        let mut meta = HashMap::new();
754        let _ = meta.insert("title".to_string(), String::new());
755        let _ = meta.insert("description".to_string(), "test".to_string());
756        let _ = meta.insert(
757            "item_pub_date".to_string(),
758            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
759        );
760        write_meta_sidecar(tmp.path(), "no-title", &meta);
761
762        let ctx = make_atom_ctx(tmp.path());
763        AtomFeedPlugin.after_compile(&ctx).unwrap();
764
765        let atom_path = tmp.path().join("atom.xml");
766        assert!(
767            !atom_path.exists(),
768            "Should not create atom.xml when all entries have empty titles"
769        );
770        Ok(())
771    }
772
773    #[test]
774    fn test_atom_feed_xml_escapes_content() -> Result<()> {
775        let tmp = tempdir().unwrap();
776
777        let mut meta = HashMap::new();
778        let _ = meta
779            .insert("title".to_string(), "Tom & Jerry <friends>".to_string());
780        let _ = meta
781            .insert("description".to_string(), "A \"great\" show".to_string());
782        let _ = meta.insert(
783            "item_pub_date".to_string(),
784            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
785        );
786        let _ = meta.insert("author".to_string(), "O'Brien".to_string());
787        write_meta_sidecar(tmp.path(), "escape-test", &meta);
788
789        let ctx = make_atom_ctx(tmp.path());
790        AtomFeedPlugin.after_compile(&ctx).unwrap();
791
792        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
793        assert!(content.contains("Tom &amp; Jerry"), "& should be escaped");
794        assert!(
795            content.contains("&lt;friends&gt;"),
796            "< and > should be escaped"
797        );
798        assert!(
799            content.contains("&quot;great&quot;"),
800            "quotes should be escaped"
801        );
802        assert!(
803            content.contains("O&apos;Brien"),
804            "apostrophe should be escaped"
805        );
806        Ok(())
807    }
808
809    // -----------------------------------------------------------------
810    // AtomEntry::to_xml direct test
811    // -----------------------------------------------------------------
812
813    #[test]
814    fn test_atom_entry_to_xml() {
815        let entry = AtomEntry {
816            title: "Test Post".to_string(),
817            link: "https://example.com/test/".to_string(),
818            id: "https://example.com/test/".to_string(),
819            updated: "2026-04-11T06:06:06+00:00".to_string(),
820            published: "2026-04-11T06:06:06+00:00".to_string(),
821            summary: "A test summary".to_string(),
822            author: "Alice".to_string(),
823        };
824        let xml = entry.to_xml();
825        assert!(xml.contains("<entry>"));
826        assert!(xml.contains("</entry>"));
827        assert!(xml.contains("<title>Test Post</title>"));
828        assert!(xml.contains("href=\"https://example.com/test/\""));
829        assert!(xml.contains("<name>Alice</name>"));
830        assert!(xml.contains("<summary>A test summary</summary>"));
831    }
832
833    #[test]
834    fn test_atom_entry_empty_author() {
835        let entry = AtomEntry {
836            title: "No Author".to_string(),
837            link: "https://example.com/".to_string(),
838            id: "https://example.com/".to_string(),
839            updated: "2026-01-01T00:00:00+00:00".to_string(),
840            published: "2026-01-01T00:00:00+00:00".to_string(),
841            summary: String::new(),
842            author: String::new(),
843        };
844        let xml = entry.to_xml();
845        assert!(
846            xml.contains("<name>Unknown</name>"),
847            "Empty author should show 'Unknown'"
848        );
849    }
850
851    // -----------------------------------------------------------------
852    // inject_atom_link
853    // -----------------------------------------------------------------
854
855    #[test]
856    fn test_inject_atom_link_adds_tag() -> Result<()> {
857        let tmp = tempdir().unwrap();
858        let html_path = tmp.path().join("page.html");
859        fs::write(
860            &html_path,
861            "<html><head><title>Test</title></head><body></body></html>",
862        )
863        .unwrap();
864
865        inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
866
867        let result = fs::read_to_string(&html_path).unwrap();
868        assert!(
869            result.contains("application/atom+xml"),
870            "Should inject atom link: {result}"
871        );
872        assert!(
873            result.contains("href=\"https://example.com/atom.xml\""),
874            "Should have correct href: {result}"
875        );
876        Ok(())
877    }
878
879    #[test]
880    fn test_inject_atom_link_idempotent() -> Result<()> {
881        let tmp = tempdir().unwrap();
882        let html_path = tmp.path().join("page.html");
883        fs::write(
884            &html_path,
885            "<html><head><title>Test</title></head><body></body></html>",
886        )
887        .unwrap();
888
889        inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
890        let first = fs::read_to_string(&html_path).unwrap();
891
892        inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
893        let second = fs::read_to_string(&html_path).unwrap();
894
895        assert_eq!(first, second, "inject_atom_link should be idempotent");
896        assert_eq!(
897            second.matches("application/atom+xml").count(),
898            1,
899            "Should have exactly one atom link"
900        );
901        Ok(())
902    }
903
904    #[test]
905    fn test_inject_atom_link_no_head() -> Result<()> {
906        let tmp = tempdir().unwrap();
907        let html_path = tmp.path().join("nohead.html");
908        fs::write(&html_path, "<html><body>No head</body></html>").unwrap();
909
910        inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
911
912        let result = fs::read_to_string(&html_path).unwrap();
913        assert!(
914            !result.contains("application/atom+xml"),
915            "Should not inject when there is no </head>"
916        );
917        Ok(())
918    }
919
920    // -----------------------------------------------------------------
921    // Plugin trait coverage
922    // -----------------------------------------------------------------
923
924    #[test]
925    fn test_atom_feed_plugin_name() {
926        let plugin = AtomFeedPlugin;
927        assert_eq!(plugin.name(), "atom-feed");
928    }
929
930    #[test]
931    fn test_atom_feed_plugin_debug() {
932        let plugin = AtomFeedPlugin;
933        let debug = format!("{plugin:?}");
934        assert!(debug.contains("AtomFeedPlugin"));
935    }
936
937    #[test]
938    fn test_atom_feed_plugin_clone_copy() {
939        let plugin = AtomFeedPlugin;
940        let cloned = plugin;
941        assert_eq!(cloned.name(), "atom-feed");
942    }
943
944    // -----------------------------------------------------------------
945    // Empty site directory
946    // -----------------------------------------------------------------
947
948    #[test]
949    fn test_atom_feed_empty_site_dir() -> Result<()> {
950        let tmp = tempdir().unwrap();
951        // No sidecars, no rss.xml, nothing
952        let ctx = make_atom_ctx(tmp.path());
953        AtomFeedPlugin.after_compile(&ctx).unwrap();
954
955        let atom_path = tmp.path().join("atom.xml");
956        assert!(
957            !atom_path.exists(),
958            "Should not create atom.xml with no entries"
959        );
960        Ok(())
961    }
962
963    // -----------------------------------------------------------------
964    // Missing sidecar files / fallback paths
965    // -----------------------------------------------------------------
966
967    #[test]
968    fn test_atom_feed_falls_back_to_rss_xml() -> Result<()> {
969        let tmp = tempdir().unwrap();
970        // No sidecars, but an rss.xml exists
971        let rss_content = r#"<?xml version="1.0"?>
972<rss version="2.0">
973<channel>
974<title>Test</title>
975<item>
976<title>From RSS</title>
977<description>Extracted from RSS</description>
978<link>https://example.com/rss-post/</link>
979<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
980<author>Alice</author>
981</item>
982</channel>
983</rss>"#;
984        fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
985
986        let ctx = make_atom_ctx(tmp.path());
987        AtomFeedPlugin.after_compile(&ctx).unwrap();
988
989        let atom_path = tmp.path().join("atom.xml");
990        assert!(atom_path.exists(), "Should create atom.xml from rss.xml");
991        let content = fs::read_to_string(&atom_path).unwrap();
992        assert!(
993            content.contains("From RSS"),
994            "Should contain entry from rss.xml"
995        );
996        Ok(())
997    }
998
999    #[test]
1000    fn test_atom_feed_rss_multiple_items() -> Result<()> {
1001        let tmp = tempdir().unwrap();
1002        let rss_content = r#"<?xml version="1.0"?>
1003<rss version="2.0">
1004<channel>
1005<title>Test</title>
1006<item>
1007<title>Post A</title>
1008<description>Desc A</description>
1009<link>https://example.com/post-a/</link>
1010<pubDate>Thu, 10 Apr 2026 00:00:00 +0000</pubDate>
1011</item>
1012<item>
1013<title>Post B</title>
1014<description>Desc B</description>
1015<link>https://example.com/post-b/</link>
1016<pubDate>Fri, 11 Apr 2026 00:00:00 +0000</pubDate>
1017</item>
1018</channel>
1019</rss>"#;
1020        fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
1021
1022        let ctx = make_atom_ctx(tmp.path());
1023        AtomFeedPlugin.after_compile(&ctx).unwrap();
1024
1025        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1026        assert!(content.contains("Post A"));
1027        assert!(content.contains("Post B"));
1028        let entry_count = content.matches("<entry>").count();
1029        assert_eq!(entry_count, 2);
1030        Ok(())
1031    }
1032
1033    #[test]
1034    fn test_atom_feed_rss_with_cdata() -> Result<()> {
1035        let tmp = tempdir().unwrap();
1036        let rss_content = r#"<?xml version="1.0"?>
1037<rss version="2.0">
1038<channel>
1039<title>Test</title>
1040<item>
1041<title><![CDATA[CDATA Title]]></title>
1042<description><![CDATA[CDATA Description]]></description>
1043<link>https://example.com/cdata-post/</link>
1044<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>
1045</item>
1046</channel>
1047</rss>"#;
1048        fs::write(tmp.path().join("rss.xml"), rss_content).unwrap();
1049
1050        let ctx = make_atom_ctx(tmp.path());
1051        AtomFeedPlugin.after_compile(&ctx).unwrap();
1052
1053        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1054        assert!(content.contains("CDATA Title"), "Should unwrap CDATA");
1055        Ok(())
1056    }
1057
1058    // -----------------------------------------------------------------
1059    // extract_xml_tag
1060    // -----------------------------------------------------------------
1061
1062    #[test]
1063    fn test_extract_xml_tag_simple() {
1064        let xml = "<item><title>Hello</title></item>";
1065        assert_eq!(extract_xml_tag(xml, "title"), Some("Hello".to_string()));
1066    }
1067
1068    #[test]
1069    fn test_extract_xml_tag_with_attributes() {
1070        let xml = r#"<item><link href="http://example.com">text</link></item>"#;
1071        assert_eq!(extract_xml_tag(xml, "link"), Some("text".to_string()));
1072    }
1073
1074    #[test]
1075    fn test_extract_xml_tag_missing() {
1076        let xml = "<item><title>Hello</title></item>";
1077        assert_eq!(extract_xml_tag(xml, "author"), None);
1078    }
1079
1080    #[test]
1081    fn test_extract_xml_tag_empty_content() {
1082        let xml = "<item><title></title></item>";
1083        assert_eq!(extract_xml_tag(xml, "title"), None);
1084    }
1085
1086    #[test]
1087    fn test_extract_xml_tag_cdata() {
1088        let xml = "<item><title><![CDATA[My Title]]></title></item>";
1089        assert_eq!(extract_xml_tag(xml, "title"), Some("My Title".to_string()));
1090    }
1091
1092    #[test]
1093    fn test_extract_xml_tag_decodes_entities() {
1094        let xml = "<item><title>Tom &amp; Jerry</title></item>";
1095        // The function decodes entities then re-escapes via xml_escape
1096        let result = extract_xml_tag(xml, "title").unwrap();
1097        assert!(
1098            result.contains("Tom") && result.contains("Jerry"),
1099            "Should contain decoded text: {result}"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_extract_xml_tag_whitespace() {
1105        let xml = "<item><title>  Hello World  </title></item>";
1106        assert_eq!(
1107            extract_xml_tag(xml, "title"),
1108            Some("Hello World".to_string())
1109        );
1110    }
1111
1112    // -----------------------------------------------------------------
1113    // extract_entries_from_rss
1114    // -----------------------------------------------------------------
1115
1116    #[test]
1117    fn test_extract_entries_from_rss_no_file() {
1118        let tmp = tempdir().unwrap();
1119        let entries = extract_entries_from_rss(tmp.path());
1120        assert!(entries.is_empty(), "{:?}", entries);
1121    }
1122
1123    #[test]
1124    fn test_extract_entries_from_rss_empty_rss() {
1125        let tmp = tempdir().unwrap();
1126        fs::write(
1127            tmp.path().join("rss.xml"),
1128            r#"<?xml version="1.0"?><rss><channel></channel></rss>"#,
1129        )
1130        .unwrap();
1131        let entries = extract_entries_from_rss(tmp.path());
1132        assert!(entries.is_empty(), "{:?}", entries);
1133    }
1134
1135    #[test]
1136    fn test_extract_entries_from_rss_item_without_title() {
1137        let tmp = tempdir().unwrap();
1138        let rss = r#"<?xml version="1.0"?>
1139<rss><channel>
1140<item>
1141<description>No title item</description>
1142<link>https://example.com/no-title/</link>
1143</item>
1144</channel></rss>"#;
1145        fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1146        let entries = extract_entries_from_rss(tmp.path());
1147        // Has a link-derived rel_path and a description but no title,
1148        // so it should still be included (meta has no "title" key but
1149        // the filter checks contains_key("title") — so it's excluded)
1150        assert!(entries.is_empty(), "{:?}", entries);
1151    }
1152
1153    #[test]
1154    fn test_extract_entries_from_rss_item_without_link() {
1155        let tmp = tempdir().unwrap();
1156        let rss = r#"<?xml version="1.0"?>
1157<rss><channel>
1158<item>
1159<title>No Link</title>
1160<description>No link item</description>
1161</item>
1162</channel></rss>"#;
1163        fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1164        let entries = extract_entries_from_rss(tmp.path());
1165        // rel_path is empty without link => skipped
1166        assert!(entries.is_empty(), "{:?}", entries);
1167    }
1168
1169    // -----------------------------------------------------------------
1170    // build_atom_entry
1171    // -----------------------------------------------------------------
1172
1173    #[test]
1174    fn test_build_atom_entry_empty_rel_path() {
1175        let meta = HashMap::new();
1176        assert!(build_atom_entry("", &meta, "https://example.com").is_none());
1177    }
1178
1179    #[test]
1180    fn test_build_atom_entry_empty_title() {
1181        let mut meta = HashMap::new();
1182        let _ = meta.insert("title".to_string(), String::new());
1183        assert!(
1184            build_atom_entry("page", &meta, "https://example.com").is_none()
1185        );
1186    }
1187
1188    #[test]
1189    fn test_build_atom_entry_minimal() {
1190        let mut meta = HashMap::new();
1191        let _ = meta.insert("title".to_string(), "Test".to_string());
1192        let result = build_atom_entry("page", &meta, "https://example.com");
1193        assert!(result.is_some());
1194        let (date_key, entry) = result.unwrap();
1195        assert_eq!(entry.title, "Test");
1196        assert!(entry.link.contains("example.com/page/"));
1197        assert!(entry.author.is_empty(), "{:?}", entry.author);
1198        // No pub_date in meta => empty string date key
1199        assert!(date_key.is_empty(), "{:?}", date_key);
1200    }
1201
1202    #[test]
1203    fn test_build_atom_entry_empty_base_url() {
1204        let mut meta = HashMap::new();
1205        let _ = meta.insert("title".to_string(), "Test".to_string());
1206        let result = build_atom_entry("page", &meta, "");
1207        assert!(result.is_some());
1208        let (_, entry) = result.unwrap();
1209        assert_eq!(entry.link, "page/");
1210    }
1211
1212    #[test]
1213    fn test_build_atom_entry_with_all_fields() {
1214        let mut meta = HashMap::new();
1215        let _ = meta.insert("title".to_string(), "Full Entry".to_string());
1216        let _ =
1217            meta.insert("description".to_string(), "A description".to_string());
1218        let _ = meta.insert(
1219            "item_pub_date".to_string(),
1220            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1221        );
1222        let _ = meta.insert("author".to_string(), "Alice".to_string());
1223
1224        let result = build_atom_entry("full", &meta, "https://example.com");
1225        let (date_key, entry) = result.unwrap();
1226        assert_eq!(entry.title, "Full Entry");
1227        assert_eq!(entry.summary, "A description");
1228        assert_eq!(entry.author, "Alice");
1229        assert!(date_key.contains("2026"));
1230    }
1231
1232    #[test]
1233    fn test_build_atom_entry_unparseable_date() {
1234        let mut meta = HashMap::new();
1235        let _ = meta.insert("title".to_string(), "Bad Date".to_string());
1236        let _ =
1237            meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
1238
1239        let result = build_atom_entry("baddate", &meta, "https://example.com");
1240        let (date_key, _) = result.unwrap();
1241        // Falls back to raw string
1242        assert_eq!(date_key, "not-a-date");
1243    }
1244
1245    // -----------------------------------------------------------------
1246    // Flexible date chain (issue #586 / plan §2 item 1.4, spec A4)
1247    // -----------------------------------------------------------------
1248
1249    #[test]
1250    fn test_build_atom_entry_iso_date() {
1251        let mut meta = HashMap::new();
1252        let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1253        let _ =
1254            meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
1255
1256        let result = build_atom_entry("isodate", &meta, "https://example.com");
1257        let (date_key, entry) = result.unwrap();
1258        assert_eq!(date_key, "2026-07-01T00:00:00+00:00");
1259        assert_eq!(entry.updated, "2026-07-01T00:00:00+00:00");
1260        assert_eq!(entry.published, "2026-07-01T00:00:00+00:00");
1261    }
1262
1263    #[test]
1264    fn test_build_atom_entry_long_form_date() {
1265        let mut meta = HashMap::new();
1266        let _ = meta.insert("title".to_string(), "Long Form".to_string());
1267        let _ = meta
1268            .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
1269
1270        let result = build_atom_entry("longform", &meta, "https://example.com");
1271        let (date_key, entry) = result.unwrap();
1272        assert_eq!(date_key, "2026-07-01T00:00:00+00:00");
1273        assert_eq!(entry.updated, "2026-07-01T00:00:00+00:00");
1274    }
1275
1276    #[test]
1277    fn test_build_atom_entry_iso_datetime_normalised() {
1278        let mut meta = HashMap::new();
1279        let _ = meta.insert("title".to_string(), "ISO DT".to_string());
1280        let _ = meta.insert(
1281            "item_pub_date".to_string(),
1282            "2026-07-01T07:07:07Z".to_string(),
1283        );
1284
1285        let result = build_atom_entry("isodt", &meta, "https://example.com");
1286        let (date_key, _) = result.unwrap();
1287        assert_eq!(date_key, "2026-07-01T07:07:07+00:00");
1288    }
1289
1290    // -----------------------------------------------------------------
1291    // collect_atom_entries
1292    // -----------------------------------------------------------------
1293
1294    #[test]
1295    fn test_collect_atom_entries_empty() {
1296        let entries: Vec<(String, HashMap<String, String>)> = vec![];
1297        let result = collect_atom_entries(&entries, "https://example.com");
1298        assert!(result.is_empty());
1299    }
1300
1301    #[test]
1302    fn test_collect_atom_entries_filters_invalid() {
1303        let mut meta1 = HashMap::new();
1304        let _ = meta1.insert("title".to_string(), "Valid".to_string());
1305        let mut meta2 = HashMap::new();
1306        let _ = meta2.insert("title".to_string(), String::new()); // empty title
1307
1308        let entries =
1309            vec![("valid".to_string(), meta1), ("invalid".to_string(), meta2)];
1310        let result = collect_atom_entries(&entries, "https://example.com");
1311        assert_eq!(result.len(), 1);
1312        assert_eq!(result[0].1.title, "Valid");
1313    }
1314
1315    // -----------------------------------------------------------------
1316    // build_atom_feed
1317    // -----------------------------------------------------------------
1318
1319    #[test]
1320    fn test_build_atom_feed_structure() {
1321        let entry = AtomEntry {
1322            title: "Feed Test".to_string(),
1323            link: "https://example.com/test/".to_string(),
1324            id: "https://example.com/test/".to_string(),
1325            updated: "2026-04-11T00:00:00+00:00".to_string(),
1326            published: "2026-04-11T00:00:00+00:00".to_string(),
1327            summary: "Summary".to_string(),
1328            author: "Bob".to_string(),
1329        };
1330        let articles = vec![("2026-04-11T00:00:00+00:00".to_string(), entry)];
1331        let xml = build_atom_feed("My Feed", "https://example.com", &articles);
1332        assert!(xml.starts_with("<?xml"));
1333        assert!(xml.contains("xmlns=\"http://www.w3.org/2005/Atom\""));
1334        assert!(xml.contains("<title>My Feed</title>"));
1335        assert!(xml.contains("rel=\"self\""));
1336        assert!(xml.contains("https://example.com/atom.xml"));
1337        assert!(xml.contains("<id>https://example.com</id>"));
1338        assert!(xml.contains("Feed Test"));
1339    }
1340
1341    #[test]
1342    fn test_build_atom_feed_empty_base_url() {
1343        let entry = AtomEntry {
1344            title: "Test".to_string(),
1345            link: "test/".to_string(),
1346            id: "test/".to_string(),
1347            updated: "2026-01-01T00:00:00+00:00".to_string(),
1348            published: "2026-01-01T00:00:00+00:00".to_string(),
1349            summary: String::new(),
1350            author: String::new(),
1351        };
1352        let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1353        let xml = build_atom_feed("Untitled", "", &articles);
1354        assert!(xml.contains("<id>/</id>"));
1355        assert!(xml.contains("href=\"atom.xml\""));
1356    }
1357
1358    #[test]
1359    fn test_build_atom_feed_xml_escapes_title() {
1360        let entry = AtomEntry {
1361            title: "A".to_string(),
1362            link: "a/".to_string(),
1363            id: "a/".to_string(),
1364            updated: "2026-01-01T00:00:00+00:00".to_string(),
1365            published: "2026-01-01T00:00:00+00:00".to_string(),
1366            summary: String::new(),
1367            author: String::new(),
1368        };
1369        let articles = vec![("2026-01-01T00:00:00+00:00".to_string(), entry)];
1370        let xml = build_atom_feed(
1371            "Tom & Jerry's <Feed>",
1372            "https://example.com",
1373            &articles,
1374        );
1375        assert!(xml.contains("Tom &amp; Jerry"));
1376        assert!(xml.contains("&lt;Feed&gt;"));
1377    }
1378
1379    // -----------------------------------------------------------------
1380    // AtomEntry::to_xml additional coverage
1381    // -----------------------------------------------------------------
1382
1383    #[test]
1384    fn test_atom_entry_to_xml_escapes_all_fields() {
1385        let entry = AtomEntry {
1386            title: "A & B".to_string(),
1387            link: "https://example.com/a&b/".to_string(),
1388            id: "https://example.com/a&b/".to_string(),
1389            updated: "2026-01-01".to_string(),
1390            published: "2026-01-01".to_string(),
1391            summary: "\"quoted\" <summary>".to_string(),
1392            author: "O'Brien".to_string(),
1393        };
1394        let xml = entry.to_xml();
1395        assert!(xml.contains("A &amp; B"), "Title not escaped");
1396        assert!(
1397            xml.contains("&quot;quoted&quot;"),
1398            "Summary quotes not escaped"
1399        );
1400        assert!(
1401            xml.contains("&lt;summary&gt;"),
1402            "Summary angles not escaped"
1403        );
1404        assert!(
1405            xml.contains("O&apos;Brien"),
1406            "Author apostrophe not escaped"
1407        );
1408    }
1409
1410    #[test]
1411    fn test_atom_entry_to_xml_empty_summary() {
1412        let entry = AtomEntry {
1413            title: "No Summary".to_string(),
1414            link: "https://example.com/".to_string(),
1415            id: "https://example.com/".to_string(),
1416            updated: "2026-01-01".to_string(),
1417            published: "2026-01-01".to_string(),
1418            summary: String::new(),
1419            author: "Alice".to_string(),
1420        };
1421        let xml = entry.to_xml();
1422        assert!(xml.contains("<summary></summary>"));
1423    }
1424
1425    // -----------------------------------------------------------------
1426    // Atom feed with config variations
1427    // -----------------------------------------------------------------
1428
1429    #[test]
1430    fn test_atom_feed_untitled_site() -> Result<()> {
1431        let tmp = tempdir().unwrap();
1432
1433        let mut meta = HashMap::new();
1434        let _ = meta.insert("title".to_string(), "Post".to_string());
1435        let _ = meta.insert("description".to_string(), "desc".to_string());
1436        let _ = meta.insert(
1437            "item_pub_date".to_string(),
1438            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1439        );
1440        write_meta_sidecar(tmp.path(), "post", &meta);
1441
1442        // Use a config with empty site_name
1443        let config = crate::cmd::SsgConfig {
1444            listings: Vec::new(),
1445            base_url: "https://example.com".to_string(),
1446            site_name: String::new(),
1447            site_title: String::new(),
1448            site_description: String::new(),
1449            language: "en".to_string(),
1450            content_dir: std::path::PathBuf::from("content"),
1451            output_dir: std::path::PathBuf::from("build"),
1452            template_dir: std::path::PathBuf::from("templates"),
1453            theme: None,
1454            serve_dir: None,
1455            #[cfg(feature = "i18n")]
1456            i18n: None,
1457            cdn_prefix: None,
1458            og_image: None,
1459            image: crate::cmd::ImageConfig::default(),
1460            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1461            agents: None,
1462            transitions: false,
1463            security: crate::cmd::SecurityConfig::default(),
1464            no_taxonomy_pages: false,
1465        };
1466        let ctx = PluginContext::with_config(
1467            Path::new("content"),
1468            Path::new("build"),
1469            tmp.path(),
1470            Path::new("templates"),
1471            config,
1472        );
1473
1474        AtomFeedPlugin.after_compile(&ctx).unwrap();
1475
1476        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1477        assert!(
1478            content.contains("<title>Untitled</title>"),
1479            "Empty site_name should produce 'Untitled'"
1480        );
1481        Ok(())
1482    }
1483
1484    #[test]
1485    fn test_atom_feed_no_config() -> Result<()> {
1486        let tmp = tempdir().unwrap();
1487
1488        let mut meta = HashMap::new();
1489        let _ = meta.insert("title".to_string(), "Post".to_string());
1490        let _ = meta.insert("description".to_string(), "desc".to_string());
1491        let _ = meta.insert(
1492            "item_pub_date".to_string(),
1493            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1494        );
1495        write_meta_sidecar(tmp.path(), "post", &meta);
1496
1497        // PluginContext without config
1498        let ctx = PluginContext::new(
1499            Path::new("content"),
1500            Path::new("build"),
1501            tmp.path(),
1502            Path::new("templates"),
1503        );
1504
1505        AtomFeedPlugin.after_compile(&ctx).unwrap();
1506
1507        let atom_path = tmp.path().join("atom.xml");
1508        assert!(atom_path.exists(), "atom.xml must be generated");
1509        let content = fs::read_to_string(&atom_path).unwrap();
1510        assert!(
1511            content.contains("<title>Untitled</title>"),
1512            "No config should produce 'Untitled'"
1513        );
1514        Ok(())
1515    }
1516
1517    // -----------------------------------------------------------------
1518    // Date format parsing edge cases
1519    // -----------------------------------------------------------------
1520
1521    #[test]
1522    fn test_atom_entry_iso8601_date_passthrough() {
1523        let mut meta = HashMap::new();
1524        let _ = meta.insert("title".to_string(), "ISO Date".to_string());
1525        let _ = meta.insert(
1526            "item_pub_date".to_string(),
1527            "2026-04-11T12:00:00+00:00".to_string(),
1528        );
1529        let result = build_atom_entry("iso", &meta, "https://example.com");
1530        let (date_key, _) = result.unwrap();
1531        // ISO 8601 may not parse as RFC 2822, so raw string is used
1532        assert!(date_key.contains("2026"));
1533    }
1534
1535    #[test]
1536    fn test_atom_entry_empty_date() {
1537        let mut meta = HashMap::new();
1538        let _ = meta.insert("title".to_string(), "No Date".to_string());
1539        let result = build_atom_entry("nodate", &meta, "https://example.com");
1540        let (date_key, entry) = result.unwrap();
1541        assert!(date_key.is_empty(), "{:?}", date_key);
1542        assert!(entry.published.is_empty(), "{:?}", entry.published);
1543    }
1544
1545    // -----------------------------------------------------------------
1546    // Truncation to 50 entries
1547    // -----------------------------------------------------------------
1548
1549    #[test]
1550    fn test_atom_feed_truncates_at_50() -> Result<()> {
1551        let tmp = tempdir().unwrap();
1552
1553        for i in 0..60 {
1554            let mut meta = HashMap::new();
1555            let _ = meta.insert("title".to_string(), format!("Post {i}"));
1556            let _ = meta.insert("description".to_string(), format!("Desc {i}"));
1557            let _ = meta.insert(
1558                "item_pub_date".to_string(),
1559                format!(
1560                    "Thu, {:02} Apr 2026 {:02}:00:00 +0000",
1561                    (i % 28) + 1,
1562                    i % 24
1563                ),
1564            );
1565            let _ = meta.insert("author".to_string(), "Bot".to_string());
1566            write_meta_sidecar(tmp.path(), &format!("post-{i:03}"), &meta);
1567        }
1568
1569        let ctx = make_atom_ctx(tmp.path());
1570        AtomFeedPlugin.after_compile(&ctx).unwrap();
1571
1572        let content = fs::read_to_string(tmp.path().join("atom.xml")).unwrap();
1573        let entry_count = content.matches("<entry>").count();
1574        assert_eq!(
1575            entry_count, 50,
1576            "Should truncate to 50 entries, got {entry_count}"
1577        );
1578        Ok(())
1579    }
1580
1581    // -----------------------------------------------------------------
1582    // inject_atom_link: multiple HTML files
1583    // -----------------------------------------------------------------
1584
1585    #[test]
1586    fn test_inject_atom_link_multiple_files() -> Result<()> {
1587        let tmp = tempdir().unwrap();
1588        for name in ["index.html", "about.html", "contact.html"] {
1589            fs::write(
1590                tmp.path().join(name),
1591                "<html><head><title>T</title></head><body></body></html>",
1592            )
1593            .unwrap();
1594        }
1595
1596        inject_atom_link(tmp.path(), "https://example.com/atom.xml").unwrap();
1597
1598        for name in ["index.html", "about.html", "contact.html"] {
1599            let content = fs::read_to_string(tmp.path().join(name)).unwrap();
1600            assert!(
1601                content.contains("application/atom+xml"),
1602                "{name} should have atom link"
1603            );
1604        }
1605        Ok(())
1606    }
1607
1608    // -----------------------------------------------------------------
1609    // RSS extraction edge cases
1610    // -----------------------------------------------------------------
1611
1612    #[test]
1613    fn test_extract_entries_from_rss_malformed_item() {
1614        let tmp = tempdir().unwrap();
1615        // Item that opens but never closes
1616        let rss = r#"<?xml version="1.0"?>
1617<rss><channel>
1618<item>
1619<title>Unclosed
1620</channel></rss>"#;
1621        fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1622        let entries = extract_entries_from_rss(tmp.path());
1623        assert!(entries.is_empty(), "{:?}", entries);
1624    }
1625
1626    #[test]
1627    fn test_extract_entries_from_rss_link_trailing_slash() {
1628        let tmp = tempdir().unwrap();
1629        let rss = r#"<?xml version="1.0"?>
1630<rss><channel>
1631<item>
1632<title>Slash Test</title>
1633<link>https://example.com/my-post/</link>
1634</item>
1635</channel></rss>"#;
1636        fs::write(tmp.path().join("rss.xml"), rss).unwrap();
1637        let entries = extract_entries_from_rss(tmp.path());
1638        assert_eq!(entries.len(), 1);
1639        assert_eq!(entries[0].0, "my-post");
1640    }
1641
1642    // -----------------------------------------------------------------
1643    // Build dir .meta fallback
1644    // -----------------------------------------------------------------
1645
1646    #[test]
1647    fn test_atom_feed_falls_back_to_build_meta_dir() -> Result<()> {
1648        let tmp = tempdir().unwrap();
1649        let site_dir = tmp.path().join("site");
1650        let build_dir = tmp.path().join("build");
1651        let meta_dir = build_dir.join(".meta");
1652        fs::create_dir_all(&site_dir).unwrap();
1653        fs::create_dir_all(&meta_dir).unwrap();
1654
1655        // Put sidecar in build/.meta instead of site_dir
1656        let page_dir = meta_dir.join("fallback-post");
1657        fs::create_dir_all(&page_dir).unwrap();
1658        let mut meta = HashMap::new();
1659        let _ = meta.insert("title".to_string(), "Fallback".to_string());
1660        let _ = meta
1661            .insert("description".to_string(), "From build dir".to_string());
1662        let _ = meta.insert(
1663            "item_pub_date".to_string(),
1664            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1665        );
1666        let json = serde_json::to_string(&meta).unwrap();
1667        fs::write(page_dir.join("index.meta.json"), json).unwrap();
1668
1669        let config = crate::cmd::SsgConfig {
1670            listings: Vec::new(),
1671            base_url: "https://example.com".to_string(),
1672            site_name: "Test".to_string(),
1673            site_title: "Test".to_string(),
1674            site_description: "Test".to_string(),
1675            language: "en".to_string(),
1676            content_dir: std::path::PathBuf::from("content"),
1677            output_dir: build_dir.clone(),
1678            template_dir: std::path::PathBuf::from("templates"),
1679            theme: None,
1680            serve_dir: None,
1681            #[cfg(feature = "i18n")]
1682            i18n: None,
1683            cdn_prefix: None,
1684            og_image: None,
1685            image: crate::cmd::ImageConfig::default(),
1686            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1687            agents: None,
1688            transitions: false,
1689            security: crate::cmd::SecurityConfig::default(),
1690            no_taxonomy_pages: false,
1691        };
1692        let ctx = PluginContext::with_config(
1693            Path::new("content"),
1694            &build_dir,
1695            &site_dir,
1696            Path::new("templates"),
1697            config,
1698        );
1699
1700        AtomFeedPlugin.after_compile(&ctx).unwrap();
1701
1702        let atom_path = site_dir.join("atom.xml");
1703        assert!(
1704            atom_path.exists(),
1705            "Should create atom.xml from build/.meta"
1706        );
1707        let content = fs::read_to_string(&atom_path).unwrap();
1708        assert!(content.contains("Fallback"));
1709        Ok(())
1710    }
1711
1712    // -----------------------------------------------------------------
1713    // Error paths: write/read failures propagate as SsgError
1714    // -----------------------------------------------------------------
1715
1716    fn sidecar_meta(title: &str) -> HashMap<String, String> {
1717        let mut meta = HashMap::new();
1718        let _ = meta.insert("title".to_string(), title.to_string());
1719        let _ = meta.insert(
1720            "item_pub_date".to_string(),
1721            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
1722        );
1723        meta
1724    }
1725
1726    #[test]
1727    fn test_after_compile_errors_when_atom_xml_is_a_directory() {
1728        let tmp = tempdir().unwrap();
1729        write_meta_sidecar(tmp.path(), "post", &sidecar_meta("Post"));
1730        // A directory named atom.xml makes fs::write fail (EISDIR).
1731        fs::create_dir_all(tmp.path().join("atom.xml")).unwrap();
1732
1733        let ctx = make_atom_ctx(tmp.path());
1734        let err = AtomFeedPlugin.after_compile(&ctx).unwrap_err();
1735        assert!(
1736            format!("{err}").contains("atom.xml"),
1737            "error names the path"
1738        );
1739    }
1740
1741    #[test]
1742    fn test_after_compile_propagates_unreadable_html_error() {
1743        let tmp = tempdir().unwrap();
1744        write_meta_sidecar(tmp.path(), "post", &sidecar_meta("Post"));
1745        // Invalid UTF-8 makes read_to_string fail inside inject_atom_link.
1746        fs::write(tmp.path().join("bad.html"), [0xFF, 0xFE, 0xFD]).unwrap();
1747
1748        let ctx = make_atom_ctx(tmp.path());
1749        let err = AtomFeedPlugin.after_compile(&ctx).unwrap_err();
1750        assert!(
1751            format!("{err}").contains("bad.html"),
1752            "error names the file"
1753        );
1754    }
1755
1756    #[test]
1757    #[cfg(unix)]
1758    fn test_inject_atom_link_write_failure_on_readonly_html() {
1759        use std::os::unix::fs::PermissionsExt;
1760        let tmp = tempdir().unwrap();
1761        let html_path = tmp.path().join("index.html");
1762        fs::write(
1763            &html_path,
1764            "<html><head><title>T</title></head><body></body></html>",
1765        )
1766        .unwrap();
1767        fs::set_permissions(&html_path, fs::Permissions::from_mode(0o444))
1768            .unwrap();
1769
1770        let result = inject_atom_link(tmp.path(), "https://x.example/atom.xml");
1771        let _ =
1772            fs::set_permissions(&html_path, fs::Permissions::from_mode(0o644));
1773        let err = result.unwrap_err();
1774        assert!(format!("{err}").contains("index.html"));
1775    }
1776
1777    #[test]
1778    #[cfg(unix)]
1779    fn test_inject_atom_link_walk_failure_on_unreadable_subdir() {
1780        use std::os::unix::fs::PermissionsExt;
1781        let tmp = tempdir().unwrap();
1782        let locked = tmp.path().join("locked");
1783        fs::create_dir_all(&locked).unwrap();
1784        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1785            .unwrap();
1786
1787        let result = inject_atom_link(tmp.path(), "https://x.example/atom.xml");
1788        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1789        assert!(result.is_err(), "expected an error from the locked path");
1790    }
1791
1792    // -----------------------------------------------------------------
1793    // extract_xml_tag: malformed attribute-form tags
1794    // -----------------------------------------------------------------
1795
1796    #[test]
1797    fn test_extract_xml_tag_attr_form_without_closing_bracket() {
1798        // `<title ` matches the attribute form, but the tag never
1799        // closes with `>`, so extraction bails out.
1800        assert_eq!(extract_xml_tag("<title attr=x no-gt", "title"), None);
1801    }
1802
1803    #[test]
1804    fn test_extract_xml_tag_missing_end_tag() {
1805        assert_eq!(extract_xml_tag("<title>abc", "title"), None);
1806    }
1807}