use std::fmt::Write as _;
use crate::core::escape::escape_xml;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SitemapUrl {
pub loc: String,
pub lastmod: Option<String>,
pub changefreq: Option<String>,
pub priority: Option<f64>,
}
impl SitemapUrl {
pub fn new(loc: impl Into<String>) -> Self {
Self {
loc: loc.into(),
..Self::default()
}
}
#[must_use]
pub fn lastmod(mut self, lastmod: impl Into<String>) -> Self {
self.lastmod = Some(lastmod.into());
self
}
#[must_use]
pub fn changefreq(mut self, changefreq: impl Into<String>) -> Self {
self.changefreq = Some(changefreq.into());
self
}
#[must_use]
pub fn priority(mut self, priority: f64) -> Self {
self.priority = Some(priority);
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct SitemapGenerator;
impl SitemapGenerator {
#[must_use]
pub fn generate(urls: &[SitemapUrl]) -> String {
let mut xml = String::with_capacity(256 + urls.len() * 160);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for url in urls {
xml.push_str(" <url>\n");
let _ = writeln!(xml, " <loc>{}</loc>", escape_xml(&url.loc));
if let Some(lastmod) = &url.lastmod {
let _ = writeln!(xml, " <lastmod>{}</lastmod>", escape_xml(lastmod));
}
if let Some(changefreq) = &url.changefreq {
let _ = writeln!(
xml,
" <changefreq>{}</changefreq>",
escape_xml(changefreq)
);
}
if let Some(priority) = url.priority {
let _ = writeln!(
xml,
" <priority>{}</priority>",
format_priority(priority)
);
}
xml.push_str(" </url>\n");
}
xml.push_str("</urlset>");
xml
}
#[must_use]
pub fn generate_index(sitemaps: &[(String, Option<String>)]) -> String {
let mut xml = String::with_capacity(256 + sitemaps.len() * 96);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for (loc, lastmod) in sitemaps {
xml.push_str(" <sitemap>\n");
let _ = writeln!(xml, " <loc>{}</loc>", escape_xml(loc));
if let Some(lastmod) = lastmod {
let _ = writeln!(xml, " <lastmod>{}</lastmod>", escape_xml(lastmod));
}
xml.push_str(" </sitemap>\n");
}
xml.push_str("</sitemapindex>");
xml
}
}
fn format_priority(priority: f64) -> String {
if priority.fract() == 0.0 && priority.is_finite() {
format!("{priority:.1}")
} else {
format!("{priority}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sitemap_has_an_xml_declaration_and_a_urlset() {
let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://example.com/")]);
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset "));
assert!(xml.ends_with("</urlset>"));
}
#[test]
fn a_loc_containing_an_ampersand_is_escaped() {
let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/a?x=1&y=2")]);
assert!(xml.contains("<loc>https://e.com/a?x=1&y=2</loc>"));
}
#[test]
fn whole_priorities_keep_one_decimal_place() {
assert_eq!(format_priority(1.0), "1.0");
assert_eq!(format_priority(0.0), "0.0");
assert_eq!(format_priority(0.7), "0.7");
assert_eq!(format_priority(0.25), "0.25");
}
#[test]
fn optional_fields_are_omitted_entirely() {
let xml = SitemapGenerator::generate(&[SitemapUrl::new("https://e.com/")]);
assert!(!xml.contains("<lastmod>"));
assert!(!xml.contains("<changefreq>"));
assert!(!xml.contains("<priority>"));
}
#[test]
fn an_empty_sitemap_is_still_well_formed() {
let xml = SitemapGenerator::generate(&[]);
assert!(xml.contains("<urlset"));
assert!(xml.ends_with("</urlset>"));
}
#[test]
fn an_index_lists_each_sitemap() {
let xml = SitemapGenerator::generate_index(&[
(
"https://e.com/sitemap-posts.xml".into(),
Some("2026-01-15".into()),
),
("https://e.com/sitemap-pages.xml".into(), None),
]);
assert!(xml.contains("<sitemapindex"));
assert!(xml.contains("<loc>https://e.com/sitemap-posts.xml</loc>"));
assert!(xml.contains("<lastmod>2026-01-15</lastmod>"));
assert_eq!(xml.matches("<sitemap>").count(), 2);
assert_eq!(xml.matches("<lastmod>").count(), 1);
let escaped = SitemapGenerator::generate_index(&[(
"https://e.com/sitemap-pages.xml?v=2&full=1".into(),
None,
)]);
assert!(escaped.contains("v=2&full=1"));
}
#[test]
fn an_index_entry_without_a_lastmod_emits_no_element() {
let xml = SitemapGenerator::generate_index(&[("https://e.com/sitemap.xml".into(), None)]);
assert!(!xml.contains("<lastmod>"));
}
}