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