Skip to main content

ssg/plugins/postprocess/
rss.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! RSS aggregate plugin.
5
6use super::helpers::{extract_xml_value, read_meta_sidecars, xml_escape};
7use crate::dates::{parse_flexible_date, DateFormat};
8use crate::error::{PathErrorExt, SsgError};
9use crate::plugin::{Plugin, PluginContext};
10use std::fs;
11
12/// Aggregates per-page RSS items into the root `rss.xml` feed.
13#[derive(Debug, Clone, Copy)]
14pub struct RssAggregatePlugin;
15
16/// Builds a list of `(sort_key, xml_item)` pairs from metadata entries.
17fn collect_articles(
18    meta_entries: &[(String, std::collections::HashMap<String, String>)],
19    base_url: &str,
20) -> Vec<(String, String)> {
21    let mut articles: Vec<(String, String)> = Vec::new();
22    for (rel_path, meta) in meta_entries {
23        if rel_path.is_empty() {
24            continue;
25        }
26
27        let title = meta.get("title").cloned().unwrap_or_default();
28        let description = meta.get("description").cloned().unwrap_or_default();
29        let pub_date = meta.get("item_pub_date").cloned().unwrap_or_default();
30        let author = meta.get("author").cloned().unwrap_or_default();
31        let banner = meta.get("banner").or_else(|| meta.get("image")).cloned();
32        let category = meta.get("category").cloned();
33        let tags = meta.get("tags").cloned();
34
35        if title.is_empty() {
36            continue;
37        }
38
39        let link = if base_url.is_empty() {
40            format!("{rel_path}/")
41        } else {
42            format!("{base_url}/{rel_path}/")
43        };
44
45        // Issue #586 / plan §2 item 1.4 (spec A4): shared flexible
46        // date chain (RFC 2822 → long form → ISO 8601). RFC 2822
47        // inputs pass through verbatim so existing feed output stays
48        // byte-identical; long-form/ISO inputs are normalised into a
49        // valid RFC 2822 <pubDate> instead of leaking raw strings.
50        let (sort_key, pub_date) = match parse_flexible_date(&pub_date) {
51            Ok(dt) => {
52                let rfc2822 = if dt.format == DateFormat::Rfc2822 {
53                    pub_date.clone()
54                } else {
55                    dt.to_rfc2822()
56                };
57                (dt.to_rfc3339(), rfc2822)
58            }
59            Err(err) => {
60                if !pub_date.is_empty() {
61                    log::warn!(
62                        "[rss-aggregate] 'item_pub_date' for '{rel_path}': {err}"
63                    );
64                }
65                (pub_date.clone(), pub_date)
66            }
67        };
68
69        let escaped_desc = xml_escape(&description);
70
71        // Build optional elements
72        let mut extras = String::new();
73
74        // Enclosure for banner/image (P2 fix)
75        if let Some(ref img) = banner {
76            let img_url = if img.starts_with("http") {
77                img.clone()
78            } else if !base_url.is_empty() {
79                format!("{base_url}/{}", img.trim_start_matches('/'))
80            } else {
81                img.clone()
82            };
83            let mime = if img_url.ends_with(".webp") {
84                "image/webp"
85            } else if img_url.ends_with(".png") {
86                "image/png"
87            } else {
88                "image/jpeg"
89            };
90            extras.push_str(&format!(
91                "\n      <enclosure url=\"{img_url}\" type=\"{mime}\" length=\"0\"/>"
92            ));
93        }
94
95        // Category elements (P2 fix)
96        if let Some(ref cat) = category {
97            extras.push_str(&format!(
98                "\n      <category>{}</category>",
99                xml_escape(cat)
100            ));
101        }
102        if let Some(ref t) = tags {
103            for tag in t.split(',') {
104                let tag = tag.trim();
105                if !tag.is_empty() {
106                    extras.push_str(&format!(
107                        "\n      <category>{}</category>",
108                        xml_escape(tag)
109                    ));
110                }
111            }
112        }
113
114        let item = format!(
115            r#"    <item>
116      <title>{title}</title>
117      <link>{link}</link>
118      <description>{escaped_desc}</description>
119      <guid isPermaLink="true">{link}</guid>
120      <pubDate>{pub_date}</pubDate>
121      <author>{author}</author>{extras}
122    </item>"#
123        );
124
125        articles.push((sort_key, item));
126    }
127    articles
128}
129
130/// Formats the final RSS XML channel document.
131fn build_rss_channel(
132    channel_title: &str,
133    channel_link: &str,
134    channel_desc: &str,
135    base_url: &str,
136    language: &str,
137    last_build_date: &str,
138    copyright: &str,
139    items_xml: &str,
140) -> String {
141    let mut channel_extras = String::new();
142    if !language.is_empty() {
143        channel_extras
144            .push_str(&format!("\n    <language>{language}</language>"));
145    }
146    if !last_build_date.is_empty() {
147        channel_extras.push_str(&format!(
148            "\n    <lastBuildDate>{last_build_date}</lastBuildDate>"
149        ));
150    }
151    if !copyright.is_empty() {
152        channel_extras.push_str(&format!(
153            "\n    <copyright>{}</copyright>",
154            xml_escape(copyright)
155        ));
156    }
157
158    format!(
159        r#"<?xml version="1.0" encoding="UTF-8"?>
160<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
161  <channel>
162    <title>{channel_title}</title>
163    <link>{channel_link}</link>
164    <description>{channel_desc}</description>
165    <atom:link href="{base_url}/rss.xml" rel="self" type="application/rss+xml"/>{channel_extras}
166{items_xml}
167  </channel>
168</rss>
169"#
170    )
171}
172
173impl Plugin for RssAggregatePlugin {
174    fn name(&self) -> &'static str {
175        "rss-aggregate"
176    }
177
178    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
179        let rss_path = ctx.site_dir.join("rss.xml");
180        if !rss_path.exists() {
181            return Ok(());
182        }
183
184        let content = fs::read_to_string(&rss_path).with_path(&rss_path)?;
185
186        if content.matches("<item>").count() > 1 {
187            return Ok(());
188        }
189
190        let meta_entries =
191            read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
192
193        let base_url = ctx
194            .config
195            .as_ref()
196            .map(|c| c.base_url.trim_end_matches('/').to_string())
197            .unwrap_or_default();
198
199        let language = extract_language(ctx);
200        let copyright = extract_copyright(&meta_entries);
201
202        let mut articles = collect_articles(&meta_entries, &base_url);
203        // Sort by date descending, then by the rendered item itself
204        // (unique per article — it embeds the item's own URL) as a
205        // deterministic tiebreaker. `read_meta_sidecars` walks the
206        // filesystem tree, whose entry order is OS-dependent (ext4 vs
207        // APFS) — without a tiebreaker, articles sharing a date
208        // (common in synthetic fixtures) retain that non-deterministic
209        // order through the stable sort, failing the cross-OS
210        // determinism gate.
211        articles.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
212        articles.truncate(50);
213
214        if articles.is_empty() {
215            return Ok(());
216        }
217
218        let last_build_date = extract_last_build_date(&articles);
219
220        let items_xml: String = articles
221            .iter()
222            .map(|(_, xml)| xml.as_str())
223            .collect::<Vec<_>>()
224            .join("\n");
225
226        let channel_title = extract_xml_value(&content, "title")
227            .unwrap_or_else(|| "Untitled".to_string());
228        let channel_link = extract_xml_value(&content, "link")
229            .unwrap_or_else(|| base_url.clone());
230        let channel_desc =
231            extract_xml_value(&content, "description").unwrap_or_default();
232
233        let rebuilt = build_rss_channel(
234            &channel_title,
235            &channel_link,
236            &channel_desc,
237            &base_url,
238            &language,
239            &last_build_date,
240            &copyright,
241            &items_xml,
242        );
243
244        fs::write(&rss_path, rebuilt).with_path(&rss_path)?;
245
246        log::info!(
247            "[rss-aggregate] Rebuilt rss.xml with {} article items",
248            articles.len()
249        );
250        Ok(())
251    }
252}
253
254/// Extracts the language setting from the plugin context.
255fn extract_language(ctx: &PluginContext) -> String {
256    ctx.config
257        .as_ref()
258        .and_then(|c| {
259            if c.site_name.is_empty() {
260                None
261            } else {
262                Some("en".to_string())
263            }
264        })
265        .unwrap_or_else(|| "en".to_string())
266}
267
268/// Extracts the copyright string from meta entries.
269fn extract_copyright(
270    meta_entries: &[(String, std::collections::HashMap<String, String>)],
271) -> String {
272    meta_entries
273        .iter()
274        .find_map(|(_, m)| m.get("copyright").cloned())
275        .unwrap_or_default()
276}
277
278/// Extracts the last build date from the most recent article.
279fn extract_last_build_date(articles: &[(String, String)]) -> String {
280    articles
281        .first()
282        .and_then(|(_, xml)| {
283            xml.find("<pubDate>").and_then(|s| {
284                let after = &xml[s + 9..];
285                after.find("</pubDate>").map(|e| after[..e].to_string())
286            })
287        })
288        .unwrap_or_default()
289}
290
291#[cfg(test)]
292mod tests {
293
294    use super::*;
295    use crate::plugin::PluginContext;
296    use anyhow::Result;
297    use std::collections::HashMap;
298    use std::path::Path;
299    use tempfile::tempdir;
300
301    fn write_meta_sidecar(
302        dir: &Path,
303        slug: &str,
304        meta: &HashMap<String, String>,
305    ) {
306        let page_dir = dir.join(slug);
307        fs::create_dir_all(&page_dir).expect("create page dir");
308        let meta_path = page_dir.join("index.meta.json");
309        let json = serde_json::to_string(meta).expect("serialize meta");
310        fs::write(&meta_path, json).expect("write meta");
311    }
312
313    fn make_atom_ctx(site_dir: &Path) -> PluginContext {
314        crate::test_support::init_logger();
315        let config = crate::cmd::SsgConfig {
316            listings: Vec::new(),
317            base_url: "https://example.com".to_string(),
318            site_name: "Test Site".to_string(),
319            site_title: "Test Site".to_string(),
320            site_description: "A test site".to_string(),
321            language: "en".to_string(),
322            content_dir: std::path::PathBuf::from("content"),
323            output_dir: std::path::PathBuf::from("build"),
324            template_dir: std::path::PathBuf::from("templates"),
325            theme: None,
326            serve_dir: None,
327            #[cfg(feature = "i18n")]
328            i18n: None,
329            cdn_prefix: None,
330            og_image: None,
331            image: crate::cmd::ImageConfig::default(),
332            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
333            agents: None,
334            transitions: false,
335            security: crate::cmd::SecurityConfig::default(),
336            no_taxonomy_pages: false,
337        };
338        PluginContext::with_config(
339            Path::new("content"),
340            Path::new("build"),
341            site_dir,
342            Path::new("templates"),
343            config,
344        )
345    }
346
347    fn test_ctx(site_dir: &Path) -> PluginContext {
348        crate::test_support::init_logger();
349        PluginContext::new(
350            Path::new("content"),
351            Path::new("build"),
352            site_dir,
353            Path::new("templates"),
354        )
355    }
356
357    #[test]
358    fn test_rss_aggregate_single_item_trigger() -> Result<()> {
359        let tmp = tempdir().unwrap();
360        let rss_path = tmp.path().join("rss.xml");
361        fs::write(
362            &rss_path,
363            r#"<?xml version="1.0" encoding="UTF-8"?>
364<rss version="2.0">
365  <channel>
366    <title>My Site</title>
367    <link>https://example.com</link>
368    <description>A test site</description>
369    <item>
370      <title>Feed itself</title>
371      <link>https://example.com/rss.xml</link>
372    </item>
373  </channel>
374</rss>"#,
375        )
376        .unwrap();
377
378        let ctx = test_ctx(tmp.path());
379        RssAggregatePlugin.after_compile(&ctx).unwrap();
380        Ok(())
381    }
382
383    #[test]
384    fn test_rss_aggregate_with_full_metadata() -> Result<()> {
385        let tmp = tempdir().unwrap();
386
387        let rss_path = tmp.path().join("rss.xml");
388        fs::write(
389            &rss_path,
390            r#"<?xml version="1.0" encoding="UTF-8"?>
391<rss version="2.0">
392  <channel>
393    <title>Test Blog</title>
394    <link>https://example.com</link>
395    <description>A test blog</description>
396    <item>
397      <title>Placeholder</title>
398    </item>
399  </channel>
400</rss>"#,
401        )
402        .unwrap();
403
404        let mut meta = HashMap::new();
405        let _ = meta.insert("title".to_string(), "Article One".to_string());
406        let _ = meta.insert(
407            "description".to_string(),
408            "First article desc".to_string(),
409        );
410        let _ = meta.insert(
411            "item_pub_date".to_string(),
412            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
413        );
414        let _ = meta.insert("author".to_string(), "Alice".to_string());
415        let _ = meta
416            .insert("banner".to_string(), "/images/banner.webp".to_string());
417        let _ = meta.insert("category".to_string(), "Technology".to_string());
418        let _ = meta.insert("tags".to_string(), "rust, web".to_string());
419        let _ = meta.insert(
420            "copyright".to_string(),
421            "Copyright 2026 Alice".to_string(),
422        );
423        write_meta_sidecar(tmp.path(), "article-one", &meta);
424
425        let ctx = make_atom_ctx(tmp.path());
426        RssAggregatePlugin.after_compile(&ctx).unwrap();
427
428        let result = fs::read_to_string(&rss_path).unwrap();
429
430        assert!(
431            result.contains(
432                "<enclosure url=\"https://example.com/images/banner.webp\""
433            ),
434            "Should have enclosure with base_url prefix: {result}"
435        );
436        assert!(
437            result.contains("type=\"image/webp\""),
438            "Should detect webp MIME type: {result}"
439        );
440        assert!(
441            result.contains("<category>Technology</category>"),
442            "Should have category element: {result}"
443        );
444        assert!(
445            result.contains("<category>rust</category>"),
446            "Should have tag category 'rust': {result}"
447        );
448        assert!(
449            result.contains("<category>web</category>"),
450            "Should have tag category 'web': {result}"
451        );
452        assert!(
453            result.contains("<language>en</language>"),
454            "Should have language element: {result}"
455        );
456        assert!(
457            result.contains("<lastBuildDate>"),
458            "Should have lastBuildDate: {result}"
459        );
460        assert!(
461            result.contains("<copyright>Copyright 2026 Alice</copyright>"),
462            "Should have copyright: {result}"
463        );
464
465        Ok(())
466    }
467
468    #[test]
469    fn test_rss_aggregate_banner_with_image_field() -> Result<()> {
470        let tmp = tempdir().unwrap();
471
472        let rss_path = tmp.path().join("rss.xml");
473        fs::write(
474            &rss_path,
475            r#"<?xml version="1.0" encoding="UTF-8"?>
476<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
477        ).unwrap();
478
479        let mut meta = HashMap::new();
480        let _ = meta.insert("title".to_string(), "Image Test".to_string());
481        let _ =
482            meta.insert("description".to_string(), "Testing image".to_string());
483        let _ = meta.insert(
484            "item_pub_date".to_string(),
485            "Mon, 01 Sep 2025 12:00:00 +0000".to_string(),
486        );
487        let _ = meta.insert("author".to_string(), "Bob".to_string());
488        let _ = meta.insert(
489            "image".to_string(),
490            "https://cdn.example.com/photo.png".to_string(),
491        );
492        write_meta_sidecar(tmp.path(), "img-test", &meta);
493
494        let ctx = make_atom_ctx(tmp.path());
495        RssAggregatePlugin.after_compile(&ctx).unwrap();
496
497        let result = fs::read_to_string(&rss_path).unwrap();
498        assert!(
499            result.contains("url=\"https://cdn.example.com/photo.png\""),
500            "Should use absolute image URL as-is: {result}"
501        );
502        assert!(
503            result.contains("type=\"image/png\""),
504            "Should detect png MIME type: {result}"
505        );
506        Ok(())
507    }
508
509    #[test]
510    fn test_rss_aggregate_jpeg_mime() -> Result<()> {
511        let tmp = tempdir().unwrap();
512
513        let rss_path = tmp.path().join("rss.xml");
514        fs::write(
515            &rss_path,
516            r#"<?xml version="1.0" encoding="UTF-8"?>
517<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
518        ).unwrap();
519
520        let mut meta = HashMap::new();
521        let _ = meta.insert("title".to_string(), "JPEG Test".to_string());
522        let _ = meta.insert("description".to_string(), "desc".to_string());
523        let _ = meta.insert(
524            "item_pub_date".to_string(),
525            "Mon, 01 Sep 2025 12:00:00 +0000".to_string(),
526        );
527        let _ = meta.insert("author".to_string(), "Carol".to_string());
528        let _ = meta.insert("banner".to_string(), "/img/photo.jpg".to_string());
529        write_meta_sidecar(tmp.path(), "jpeg-test", &meta);
530
531        let ctx = make_atom_ctx(tmp.path());
532        RssAggregatePlugin.after_compile(&ctx).unwrap();
533
534        let result = fs::read_to_string(&rss_path).unwrap();
535        assert!(
536            result.contains("type=\"image/jpeg\""),
537            "Should default to image/jpeg for .jpg: {result}"
538        );
539        Ok(())
540    }
541
542    #[test]
543    fn test_rss_aggregate_skips_multi_item() -> Result<()> {
544        let tmp = tempdir().unwrap();
545
546        let rss_path = tmp.path().join("rss.xml");
547        let original = r#"<?xml version="1.0" encoding="UTF-8"?>
548<rss version="2.0"><channel><title>T</title><link>x</link><description>D</description>
549<item><title>A</title></item>
550<item><title>B</title></item>
551</channel></rss>"#;
552        fs::write(&rss_path, original).unwrap();
553
554        let ctx = test_ctx(tmp.path());
555        RssAggregatePlugin.after_compile(&ctx).unwrap();
556
557        let result = fs::read_to_string(&rss_path).unwrap();
558        assert_eq!(result, original, "Should not modify feed with >1 items");
559        Ok(())
560    }
561
562    #[test]
563    fn test_collect_articles_empty_entries() {
564        let articles = collect_articles(&[], "https://example.com");
565        assert!(
566            articles.is_empty(),
567            "no meta entries should produce no articles"
568        );
569    }
570
571    #[test]
572    fn test_collect_articles_skips_empty_title() {
573        let mut meta = HashMap::new();
574        let _ =
575            meta.insert("description".to_string(), "no title here".to_string());
576        let entries = vec![("page".to_string(), meta)];
577        let articles = collect_articles(&entries, "https://example.com");
578        assert!(
579            articles.is_empty(),
580            "entries without title should be skipped"
581        );
582    }
583
584    #[test]
585    fn test_collect_articles_skips_empty_path() {
586        let mut meta = HashMap::new();
587        let _ = meta.insert("title".to_string(), "Has Title".to_string());
588        let entries = vec![(String::new(), meta)];
589        let articles = collect_articles(&entries, "https://example.com");
590        assert!(
591            articles.is_empty(),
592            "entries with empty path should be skipped"
593        );
594    }
595
596    #[test]
597    fn test_collect_articles_multiple_entries_sorted() {
598        let mut meta1 = HashMap::new();
599        let _ = meta1.insert("title".to_string(), "Older".to_string());
600        let _ = meta1.insert("description".to_string(), "old".to_string());
601        let _ = meta1.insert(
602            "item_pub_date".to_string(),
603            "Mon, 01 Jan 2024 00:00:00 +0000".to_string(),
604        );
605        let _ = meta1.insert("author".to_string(), "A".to_string());
606
607        let mut meta2 = HashMap::new();
608        let _ = meta2.insert("title".to_string(), "Newer".to_string());
609        let _ = meta2.insert("description".to_string(), "new".to_string());
610        let _ = meta2.insert(
611            "item_pub_date".to_string(),
612            "Wed, 01 Jan 2025 00:00:00 +0000".to_string(),
613        );
614        let _ = meta2.insert("author".to_string(), "B".to_string());
615
616        let entries = vec![
617            ("old-post".to_string(), meta1),
618            ("new-post".to_string(), meta2),
619        ];
620        let mut articles = collect_articles(&entries, "https://example.com");
621        assert_eq!(articles.len(), 2);
622
623        // Sort descending like the plugin does
624        articles.sort_by(|a, b| b.0.cmp(&a.0));
625        assert!(
626            articles[0].1.contains("<title>Newer</title>"),
627            "newest article should sort first"
628        );
629    }
630
631    #[test]
632    fn test_collect_articles_xml_escapes_description() {
633        let mut meta = HashMap::new();
634        let _ = meta.insert("title".to_string(), "Escape Test".to_string());
635        let _ = meta.insert(
636            "description".to_string(),
637            "Use <b>bold</b> & \"quotes\"".to_string(),
638        );
639        let _ = meta.insert("author".to_string(), "X".to_string());
640        let entries = vec![("esc".to_string(), meta)];
641        let articles = collect_articles(&entries, "");
642        assert_eq!(articles.len(), 1);
643        let xml = &articles[0].1;
644        assert!(
645            xml.contains("&lt;b&gt;bold&lt;/b&gt;"),
646            "angle brackets should be escaped: {xml}"
647        );
648        assert!(xml.contains("&amp;"), "ampersands should be escaped: {xml}");
649    }
650
651    // -----------------------------------------------------------------
652    // Flexible date chain (issue #586 / plan §2 item 1.4, spec A4)
653    // -----------------------------------------------------------------
654
655    #[test]
656    fn test_collect_articles_rfc2822_date_passes_through_verbatim() {
657        let mut meta = HashMap::new();
658        let _ = meta.insert("title".to_string(), "RFC".to_string());
659        // Deliberately wrong weekday (2026-04-11 is a Saturday):
660        // verbatim passthrough keeps the feed byte-identical.
661        let _ = meta.insert(
662            "item_pub_date".to_string(),
663            "Thu, 11 Apr 2026 06:06:06 +0000".to_string(),
664        );
665        let entries = vec![("rfc".to_string(), meta)];
666        let articles = collect_articles(&entries, "https://example.com");
667        let item = &articles[0].1;
668        assert!(
669            item.contains("<pubDate>Thu, 11 Apr 2026 06:06:06 +0000</pubDate>"),
670            "RFC 2822 input must pass through unchanged: {item}"
671        );
672        assert_eq!(articles[0].0, "2026-04-11T06:06:06+00:00");
673    }
674
675    #[test]
676    fn test_collect_articles_iso_date_becomes_rfc2822_pubdate() {
677        let mut meta = HashMap::new();
678        let _ = meta.insert("title".to_string(), "ISO".to_string());
679        let _ =
680            meta.insert("item_pub_date".to_string(), "2026-07-01".to_string());
681        let entries = vec![("iso".to_string(), meta)];
682        let articles = collect_articles(&entries, "https://example.com");
683        let item = &articles[0].1;
684        assert!(
685            item.contains("<pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>"),
686            "ISO input should be normalised to RFC 2822: {item}"
687        );
688        assert_eq!(articles[0].0, "2026-07-01T00:00:00+00:00");
689    }
690
691    #[test]
692    fn test_collect_articles_long_form_date_becomes_rfc2822_pubdate() {
693        let mut meta = HashMap::new();
694        let _ = meta.insert("title".to_string(), "Long".to_string());
695        let _ = meta
696            .insert("item_pub_date".to_string(), "July 1, 2026".to_string());
697        let entries = vec![("long".to_string(), meta)];
698        let articles = collect_articles(&entries, "");
699        let item = &articles[0].1;
700        assert!(
701            item.contains("<pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate>"),
702            "long-form input should be normalised to RFC 2822: {item}"
703        );
704    }
705
706    #[test]
707    fn test_collect_articles_unparseable_date_passes_through() {
708        crate::test_support::init_logger();
709        let mut meta = HashMap::new();
710        let _ = meta.insert("title".to_string(), "Bad".to_string());
711        let _ =
712            meta.insert("item_pub_date".to_string(), "not-a-date".to_string());
713        let entries = vec![("bad".to_string(), meta)];
714        let articles = collect_articles(&entries, "");
715        let item = &articles[0].1;
716        assert!(
717            item.contains("<pubDate>not-a-date</pubDate>"),
718            "unparseable input keeps previous passthrough behaviour: {item}"
719        );
720        assert_eq!(articles[0].0, "not-a-date");
721    }
722
723    #[test]
724    fn test_build_rss_channel_minimal() {
725        let result = build_rss_channel(
726            "Title",
727            "https://x.example",
728            "Desc",
729            "https://x.example",
730            "",
731            "",
732            "",
733            "",
734        );
735        assert!(result.contains("<title>Title</title>"));
736        assert!(result.contains("<link>https://x.example</link>"));
737        assert!(result.contains("<description>Desc</description>"));
738        assert!(
739            !result.contains("<language>"),
740            "no language when empty string supplied"
741        );
742        assert!(
743            !result.contains("<lastBuildDate>"),
744            "no lastBuildDate when empty string supplied"
745        );
746    }
747
748    #[test]
749    fn test_build_rss_channel_with_all_extras() {
750        let result = build_rss_channel(
751            "T",
752            "L",
753            "D",
754            "https://x.example",
755            "en",
756            "Mon, 01 Jan 2024 00:00:00 +0000",
757            "Copyright 2024 X",
758            "<item><title>A</title></item>",
759        );
760        assert!(result.contains("<language>en</language>"));
761        assert!(result.contains(
762            "<lastBuildDate>Mon, 01 Jan 2024 00:00:00 +0000</lastBuildDate>"
763        ));
764        assert!(result.contains("<copyright>Copyright 2024 X</copyright>"));
765        assert!(result.contains("<item><title>A</title></item>"));
766    }
767
768    #[test]
769    fn test_extract_last_build_date_from_articles() {
770        let articles = vec![
771            ("2025".to_string(), "<item><pubDate>Mon, 01 Sep 2025 12:00:00 +0000</pubDate></item>".to_string()),
772            ("2024".to_string(), "<item><pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate></item>".to_string()),
773        ];
774        let date = extract_last_build_date(&articles);
775        assert_eq!(date, "Mon, 01 Sep 2025 12:00:00 +0000");
776    }
777
778    #[test]
779    fn test_extract_last_build_date_empty() {
780        let articles: Vec<(String, String)> = vec![];
781        let date = extract_last_build_date(&articles);
782        assert!(date.is_empty());
783    }
784
785    #[test]
786    fn test_rss_no_file_is_noop() -> Result<()> {
787        let tmp = tempdir().unwrap();
788        // No rss.xml exists
789        let ctx = test_ctx(tmp.path());
790        RssAggregatePlugin.after_compile(&ctx).unwrap();
791        assert!(!tmp.path().join("rss.xml").exists());
792        Ok(())
793    }
794
795    // -----------------------------------------------------------------
796    // Regression: sidecars with non-string fields (numeric word_count)
797    // must not be dropped from the aggregate feed
798    // -----------------------------------------------------------------
799
800    #[test]
801    fn test_rss_aggregate_keeps_page_with_numeric_sidecar_field() {
802        let tmp = tempdir().unwrap();
803        let rss_path = tmp.path().join("rss.xml");
804        fs::write(
805            &rss_path,
806            r#"<?xml version="1.0" encoding="UTF-8"?>
807<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
808        )
809        .unwrap();
810
811        // Hand-written sidecar with a NUMBER-valued field — the
812        // pipeline emits numeric word_count, which previously failed
813        // HashMap<String, String> deserialisation and silently dropped
814        // the page from the feed.
815        let page_dir = tmp.path().join("counted");
816        fs::create_dir_all(&page_dir).unwrap();
817        fs::write(
818            page_dir.join("index.meta.json"),
819            r#"{"title":"Counted Post","description":"Has word_count","item_pub_date":"Thu, 11 Apr 2026 06:06:06 +0000","word_count":342}"#,
820        )
821        .unwrap();
822
823        let ctx = make_atom_ctx(tmp.path());
824        RssAggregatePlugin.after_compile(&ctx).unwrap();
825
826        let result = fs::read_to_string(&rss_path).unwrap();
827        assert!(
828            result.contains("<title>Counted Post</title>"),
829            "page with numeric sidecar field must appear in feed: {result}"
830        );
831    }
832
833    // -----------------------------------------------------------------
834    // collect_articles: relative banner with no base_url
835    // -----------------------------------------------------------------
836
837    #[test]
838    fn test_collect_articles_relative_banner_without_base_url() {
839        let mut meta = HashMap::new();
840        let _ = meta.insert("title".to_string(), "Img".to_string());
841        let _ = meta.insert("banner".to_string(), "/img/pic.png".to_string());
842        let entries = vec![("img".to_string(), meta)];
843        let articles = collect_articles(&entries, "");
844        let item = &articles[0].1;
845        assert!(
846            item.contains("url=\"/img/pic.png\""),
847            "relative banner is kept verbatim when base_url empty: {item}"
848        );
849    }
850
851    #[test]
852    fn test_collect_articles_skips_blank_tag_segments() {
853        let mut meta = HashMap::new();
854        let _ = meta.insert("title".to_string(), "Tags".to_string());
855        let _ = meta.insert("tags".to_string(), "rust,, web".to_string());
856        let entries = vec![("tags".to_string(), meta)];
857        let articles = collect_articles(&entries, "");
858        let item = &articles[0].1;
859        assert!(item.contains("<category>rust</category>"));
860        assert!(item.contains("<category>web</category>"));
861        assert_eq!(
862            item.matches("<category>").count(),
863            2,
864            "blank tag segment must not emit an empty category: {item}"
865        );
866    }
867
868    // -----------------------------------------------------------------
869    // after_compile: sort path with multiple sidecar articles
870    // -----------------------------------------------------------------
871
872    #[test]
873    fn test_rss_aggregate_sorts_multiple_articles_newest_first() {
874        let tmp = tempdir().unwrap();
875        let rss_path = tmp.path().join("rss.xml");
876        fs::write(
877            &rss_path,
878            r#"<?xml version="1.0" encoding="UTF-8"?>
879<rss version="2.0"><channel><title>T</title><link>https://example.com</link><description>D</description><item><title>X</title></item></channel></rss>"#,
880        )
881        .unwrap();
882
883        let mut older = HashMap::new();
884        let _ = older.insert("title".to_string(), "Older".to_string());
885        let _ = older.insert(
886            "item_pub_date".to_string(),
887            "Mon, 01 Jan 2024 00:00:00 +0000".to_string(),
888        );
889        write_meta_sidecar(tmp.path(), "older", &older);
890
891        let mut newer = HashMap::new();
892        let _ = newer.insert("title".to_string(), "Newer".to_string());
893        let _ = newer.insert(
894            "item_pub_date".to_string(),
895            "Wed, 01 Jan 2025 00:00:00 +0000".to_string(),
896        );
897        write_meta_sidecar(tmp.path(), "newer", &newer);
898
899        let ctx = make_atom_ctx(tmp.path());
900        RssAggregatePlugin.after_compile(&ctx).unwrap();
901
902        let result = fs::read_to_string(&rss_path).unwrap();
903        let newer_pos = result.find("<title>Newer</title>").unwrap();
904        let older_pos = result.find("<title>Older</title>").unwrap();
905        assert!(newer_pos < older_pos, "newest article must sort first");
906    }
907
908    // -----------------------------------------------------------------
909    // Channel title / link fallbacks
910    // -----------------------------------------------------------------
911
912    #[test]
913    fn test_rss_aggregate_falls_back_to_untitled_channel() {
914        let tmp = tempdir().unwrap();
915        let rss_path = tmp.path().join("rss.xml");
916        // Single item, no <title>/<link>/<description> anywhere.
917        fs::write(
918            &rss_path,
919            "<rss version=\"2.0\"><channel><item><guid>g</guid></item></channel></rss>",
920        )
921        .unwrap();
922
923        let mut meta = HashMap::new();
924        let _ = meta.insert("title".to_string(), "Post".to_string());
925        write_meta_sidecar(tmp.path(), "post", &meta);
926
927        let ctx = make_atom_ctx(tmp.path());
928        RssAggregatePlugin.after_compile(&ctx).unwrap();
929
930        let result = fs::read_to_string(&rss_path).unwrap();
931        assert!(
932            result.contains("<title>Untitled</title>"),
933            "missing channel title falls back to Untitled: {result}"
934        );
935        assert!(
936            result.contains("<link>https://example.com</link>"),
937            "missing channel link falls back to base_url: {result}"
938        );
939    }
940
941    // -----------------------------------------------------------------
942    // Error paths
943    // -----------------------------------------------------------------
944
945    #[test]
946    fn test_after_compile_errors_on_invalid_utf8_rss() {
947        let tmp = tempdir().unwrap();
948        let rss_path = tmp.path().join("rss.xml");
949        fs::write(&rss_path, [0xFF, 0xFE, 0xFD]).unwrap();
950        let ctx = test_ctx(tmp.path());
951        let err = RssAggregatePlugin.after_compile(&ctx).unwrap_err();
952        assert!(format!("{err}").contains("rss.xml"));
953    }
954
955    #[test]
956    #[cfg(unix)]
957    fn test_after_compile_write_failure_on_readonly_rss() {
958        use std::os::unix::fs::PermissionsExt;
959        let tmp = tempdir().unwrap();
960        let rss_path = tmp.path().join("rss.xml");
961        fs::write(
962            &rss_path,
963            r#"<rss version="2.0"><channel><title>T</title><link>x</link><description>D</description><item><title>X</title></item></channel></rss>"#,
964        )
965        .unwrap();
966        let mut meta = HashMap::new();
967        let _ = meta.insert("title".to_string(), "Post".to_string());
968        write_meta_sidecar(tmp.path(), "post", &meta);
969        fs::set_permissions(&rss_path, fs::Permissions::from_mode(0o444))
970            .unwrap();
971
972        let ctx = make_atom_ctx(tmp.path());
973        let result = RssAggregatePlugin.after_compile(&ctx);
974        let _ =
975            fs::set_permissions(&rss_path, fs::Permissions::from_mode(0o644));
976        let err = result.unwrap_err();
977        assert!(format!("{err}").contains("rss.xml"));
978    }
979
980    // -----------------------------------------------------------------
981    // extract_language: config with empty site_name
982    // -----------------------------------------------------------------
983
984    #[test]
985    fn test_extract_language_with_empty_site_name() {
986        crate::test_support::init_logger();
987        let config = crate::cmd::SsgConfig {
988            listings: Vec::new(),
989            base_url: String::new(),
990            site_name: String::new(),
991            site_title: String::new(),
992            site_description: String::new(),
993            language: String::new(),
994            content_dir: std::path::PathBuf::from("c"),
995            output_dir: std::path::PathBuf::from("b"),
996            template_dir: std::path::PathBuf::from("t"),
997            theme: None,
998            serve_dir: None,
999            #[cfg(feature = "i18n")]
1000            i18n: None,
1001            cdn_prefix: None,
1002            og_image: None,
1003            image: crate::cmd::ImageConfig::default(),
1004            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
1005            agents: None,
1006            transitions: false,
1007            security: crate::cmd::SecurityConfig::default(),
1008            no_taxonomy_pages: false,
1009        };
1010        let ctx = PluginContext::with_config(
1011            Path::new("c"),
1012            Path::new("b"),
1013            Path::new("s"),
1014            Path::new("t"),
1015            config,
1016        );
1017        assert_eq!(extract_language(&ctx), "en");
1018    }
1019}