#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{
fmt::{self, Write},
str::FromStr,
};
use std::error::Error;
fn is_http_url(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
(lower.starts_with("https://") || lower.starts_with("http://")) && value.contains('.')
}
fn validate_url(value: impl AsRef<str>) -> Result<String, SitemapValueError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Err(SitemapValueError::EmptyUrl);
}
if is_http_url(trimmed) {
Ok(trimmed.to_string())
} else {
Err(SitemapValueError::InvalidUrl)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum SitemapValueError {
EmptyUrl,
InvalidUrl,
InvalidPriority(f32),
EmptyLastModified,
}
impl fmt::Display for SitemapValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyUrl => formatter.write_str("sitemap URL cannot be empty"),
Self::InvalidUrl => {
formatter.write_str("sitemap URL must start with http:// or https://")
},
Self::InvalidPriority(value) => write!(formatter, "invalid sitemap priority {value}"),
Self::EmptyLastModified => formatter.write_str("last-modified label cannot be empty"),
}
}
}
impl Error for SitemapValueError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SitemapUrl(String);
impl SitemapUrl {
pub fn new(value: impl AsRef<str>) -> Result<Self, SitemapValueError> {
validate_url(value).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for SitemapUrl {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for SitemapUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for SitemapUrl {
type Err = SitemapValueError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ChangeFrequency {
Always,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
Never,
}
impl ChangeFrequency {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Always => "always",
Self::Hourly => "hourly",
Self::Daily => "daily",
Self::Weekly => "weekly",
Self::Monthly => "monthly",
Self::Yearly => "yearly",
Self::Never => "never",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Priority(f32);
impl Priority {
pub fn new(value: f32) -> Result<Self, SitemapValueError> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(Self(value))
} else {
Err(SitemapValueError::InvalidPriority(value))
}
}
#[must_use]
pub const fn value(self) -> f32 {
self.0
}
}
impl fmt::Display for Priority {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{:.1}", self.0)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LastModified(String);
impl LastModified {
pub fn new(value: impl AsRef<str>) -> Result<Self, SitemapValueError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(SitemapValueError::EmptyLastModified)
} else {
Ok(Self(trimmed.to_string()))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for LastModified {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SitemapEntry {
url: SitemapUrl,
last_modified: Option<LastModified>,
change_frequency: Option<ChangeFrequency>,
priority: Option<Priority>,
}
impl SitemapEntry {
#[must_use]
pub const fn new(url: SitemapUrl) -> Self {
Self {
url,
last_modified: None,
change_frequency: None,
priority: None,
}
}
#[must_use]
pub fn with_last_modified(mut self, last_modified: LastModified) -> Self {
self.last_modified = Some(last_modified);
self
}
#[must_use]
pub const fn with_change_frequency(mut self, frequency: ChangeFrequency) -> Self {
self.change_frequency = Some(frequency);
self
}
#[must_use]
pub const fn with_priority(mut self, priority: Priority) -> Self {
self.priority = Some(priority);
self
}
#[must_use]
pub const fn url(&self) -> &SitemapUrl {
&self.url
}
#[must_use]
pub fn to_url_xml(&self) -> String {
let mut xml = format!("<url><loc>{}</loc>", escape_xml(self.url.as_str()));
if let Some(last_modified) = &self.last_modified {
let _ = write!(
xml,
"<lastmod>{}</lastmod>",
escape_xml(last_modified.as_str())
);
}
if let Some(frequency) = self.change_frequency {
let _ = write!(xml, "<changefreq>{}</changefreq>", frequency.as_str());
}
if let Some(priority) = self.priority {
let _ = write!(xml, "<priority>{priority}</priority>");
}
xml.push_str("</url>");
xml
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SitemapIndexEntry {
sitemap: SitemapUrl,
last_modified: Option<LastModified>,
}
impl SitemapIndexEntry {
#[must_use]
pub const fn new(sitemap: SitemapUrl) -> Self {
Self {
sitemap,
last_modified: None,
}
}
#[must_use]
pub fn with_last_modified(mut self, last_modified: LastModified) -> Self {
self.last_modified = Some(last_modified);
self
}
#[must_use]
pub fn to_index_xml(&self) -> String {
let mut xml = format!("<sitemap><loc>{}</loc>", escape_xml(self.sitemap.as_str()));
if let Some(last_modified) = &self.last_modified {
let _ = write!(
xml,
"<lastmod>{}</lastmod>",
escape_xml(last_modified.as_str())
);
}
xml.push_str("</sitemap>");
xml
}
}
#[must_use]
pub fn escape_xml(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::{
ChangeFrequency, LastModified, Priority, SitemapEntry, SitemapIndexEntry, SitemapUrl,
escape_xml,
};
#[test]
fn validates_urls_and_priorities() {
assert!(SitemapUrl::new("https://example.com/").is_ok());
assert!(SitemapUrl::new("ftp://example.com/").is_err());
assert!(Priority::new(1.2).is_err());
}
#[test]
fn formats_url_xml() {
let entry = SitemapEntry::new(SitemapUrl::new("https://example.com/?a=1&b=2").unwrap())
.with_last_modified(LastModified::new("2026-05-19").unwrap())
.with_change_frequency(ChangeFrequency::Weekly)
.with_priority(Priority::new(0.8).unwrap());
assert!(entry.to_url_xml().contains("a=1&b=2"));
assert!(entry.to_url_xml().contains("<priority>0.8</priority>"));
}
#[test]
fn formats_index_xml() {
let entry =
SitemapIndexEntry::new(SitemapUrl::new("https://example.com/sitemap.xml").unwrap());
assert!(entry.to_index_xml().contains("<sitemap>"));
assert_eq!(escape_xml("A&B"), "A&B");
}
}