Skip to main content

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