Skip to main content

use_sitemap/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{
5    fmt::{self, Write},
6    str::FromStr,
7};
8use std::error::Error;
9
10fn is_http_url(value: &str) -> bool {
11    let lower = value.to_ascii_lowercase();
12    (lower.starts_with("https://") || lower.starts_with("http://")) && value.contains('.')
13}
14
15fn validate_url(value: impl AsRef<str>) -> Result<String, SitemapValueError> {
16    let trimmed = value.as_ref().trim();
17    if trimmed.is_empty() {
18        return Err(SitemapValueError::EmptyUrl);
19    }
20    if is_http_url(trimmed) {
21        Ok(trimmed.to_string())
22    } else {
23        Err(SitemapValueError::InvalidUrl)
24    }
25}
26
27/// Error returned by sitemap primitive constructors.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum SitemapValueError {
30    /// The URL was empty after trimming whitespace.
31    EmptyUrl,
32    /// The URL did not look like an HTTP or HTTPS URL.
33    InvalidUrl,
34    /// Priority was outside `0.0..=1.0` or not finite.
35    InvalidPriority(f32),
36    /// The last-modified label was empty.
37    EmptyLastModified,
38}
39
40impl fmt::Display for SitemapValueError {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::EmptyUrl => formatter.write_str("sitemap URL cannot be empty"),
44            Self::InvalidUrl => {
45                formatter.write_str("sitemap URL must start with http:// or https://")
46            },
47            Self::InvalidPriority(value) => write!(formatter, "invalid sitemap priority {value}"),
48            Self::EmptyLastModified => formatter.write_str("last-modified label cannot be empty"),
49        }
50    }
51}
52
53impl Error for SitemapValueError {}
54
55/// A validated sitemap URL.
56#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub struct SitemapUrl(String);
58
59impl SitemapUrl {
60    /// Creates a sitemap URL from an HTTP or HTTPS URL-like string.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`SitemapValueError`] when the URL is empty or unsupported.
65    pub fn new(value: impl AsRef<str>) -> Result<Self, SitemapValueError> {
66        validate_url(value).map(Self)
67    }
68
69    /// Returns the URL string.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76impl AsRef<str> for SitemapUrl {
77    fn as_ref(&self) -> &str {
78        self.as_str()
79    }
80}
81
82impl fmt::Display for SitemapUrl {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        formatter.write_str(self.as_str())
85    }
86}
87
88impl FromStr for SitemapUrl {
89    type Err = SitemapValueError;
90
91    fn from_str(value: &str) -> Result<Self, Self::Err> {
92        Self::new(value)
93    }
94}
95
96/// Sitemap change frequency label.
97#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
98pub enum ChangeFrequency {
99    /// Always changes.
100    Always,
101    /// Hourly changes.
102    Hourly,
103    /// Daily changes.
104    Daily,
105    /// Weekly changes.
106    Weekly,
107    /// Monthly changes.
108    Monthly,
109    /// Yearly changes.
110    Yearly,
111    /// Never changes.
112    Never,
113}
114
115impl ChangeFrequency {
116    /// Returns the sitemap label.
117    #[must_use]
118    pub const fn as_str(self) -> &'static str {
119        match self {
120            Self::Always => "always",
121            Self::Hourly => "hourly",
122            Self::Daily => "daily",
123            Self::Weekly => "weekly",
124            Self::Monthly => "monthly",
125            Self::Yearly => "yearly",
126            Self::Never => "never",
127        }
128    }
129}
130
131/// Sitemap priority value in the inclusive `0.0..=1.0` range.
132#[derive(Clone, Copy, Debug, PartialEq)]
133pub struct Priority(f32);
134
135impl Priority {
136    /// Creates a sitemap priority.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`SitemapValueError::InvalidPriority`] when the value is outside `0.0..=1.0`.
141    pub fn new(value: f32) -> Result<Self, SitemapValueError> {
142        if value.is_finite() && (0.0..=1.0).contains(&value) {
143            Ok(Self(value))
144        } else {
145            Err(SitemapValueError::InvalidPriority(value))
146        }
147    }
148
149    /// Returns the raw priority value.
150    #[must_use]
151    pub const fn value(self) -> f32 {
152        self.0
153    }
154}
155
156impl fmt::Display for Priority {
157    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write!(formatter, "{:.1}", self.0)
159    }
160}
161
162/// Last-modified label for sitemap entries.
163#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
164pub struct LastModified(String);
165
166impl LastModified {
167    /// Creates a last-modified label.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`SitemapValueError::EmptyLastModified`] when the value is empty.
172    pub fn new(value: impl AsRef<str>) -> Result<Self, SitemapValueError> {
173        let trimmed = value.as_ref().trim();
174        if trimmed.is_empty() {
175            Err(SitemapValueError::EmptyLastModified)
176        } else {
177            Ok(Self(trimmed.to_string()))
178        }
179    }
180
181    /// Returns the last-modified label.
182    #[must_use]
183    pub fn as_str(&self) -> &str {
184        &self.0
185    }
186}
187
188impl AsRef<str> for LastModified {
189    fn as_ref(&self) -> &str {
190        self.as_str()
191    }
192}
193
194/// A sitemap URL entry.
195#[derive(Clone, Debug, PartialEq)]
196pub struct SitemapEntry {
197    url: SitemapUrl,
198    last_modified: Option<LastModified>,
199    change_frequency: Option<ChangeFrequency>,
200    priority: Option<Priority>,
201}
202
203impl SitemapEntry {
204    /// Creates a sitemap entry from a URL.
205    #[must_use]
206    pub const fn new(url: SitemapUrl) -> Self {
207        Self {
208            url,
209            last_modified: None,
210            change_frequency: None,
211            priority: None,
212        }
213    }
214
215    /// Sets the last-modified label.
216    #[must_use]
217    pub fn with_last_modified(mut self, last_modified: LastModified) -> Self {
218        self.last_modified = Some(last_modified);
219        self
220    }
221
222    /// Sets the change-frequency label.
223    #[must_use]
224    pub const fn with_change_frequency(mut self, frequency: ChangeFrequency) -> Self {
225        self.change_frequency = Some(frequency);
226        self
227    }
228
229    /// Sets the priority value.
230    #[must_use]
231    pub const fn with_priority(mut self, priority: Priority) -> Self {
232        self.priority = Some(priority);
233        self
234    }
235
236    /// Returns the entry URL.
237    #[must_use]
238    pub const fn url(&self) -> &SitemapUrl {
239        &self.url
240    }
241
242    /// Formats this entry as a compact XML `<url>` block.
243    #[must_use]
244    pub fn to_url_xml(&self) -> String {
245        let mut xml = format!("<url><loc>{}</loc>", escape_xml(self.url.as_str()));
246
247        if let Some(last_modified) = &self.last_modified {
248            let _ = write!(
249                xml,
250                "<lastmod>{}</lastmod>",
251                escape_xml(last_modified.as_str())
252            );
253        }
254        if let Some(frequency) = self.change_frequency {
255            let _ = write!(xml, "<changefreq>{}</changefreq>", frequency.as_str());
256        }
257        if let Some(priority) = self.priority {
258            let _ = write!(xml, "<priority>{priority}</priority>");
259        }
260
261        xml.push_str("</url>");
262        xml
263    }
264}
265
266/// A sitemap index entry.
267#[derive(Clone, Debug, Eq, PartialEq)]
268pub struct SitemapIndexEntry {
269    sitemap: SitemapUrl,
270    last_modified: Option<LastModified>,
271}
272
273impl SitemapIndexEntry {
274    /// Creates a sitemap index entry.
275    #[must_use]
276    pub const fn new(sitemap: SitemapUrl) -> Self {
277        Self {
278            sitemap,
279            last_modified: None,
280        }
281    }
282
283    /// Sets the last-modified label.
284    #[must_use]
285    pub fn with_last_modified(mut self, last_modified: LastModified) -> Self {
286        self.last_modified = Some(last_modified);
287        self
288    }
289
290    /// Formats this entry as a compact XML `<sitemap>` block.
291    #[must_use]
292    pub fn to_index_xml(&self) -> String {
293        let mut xml = format!("<sitemap><loc>{}</loc>", escape_xml(self.sitemap.as_str()));
294        if let Some(last_modified) = &self.last_modified {
295            let _ = write!(
296                xml,
297                "<lastmod>{}</lastmod>",
298                escape_xml(last_modified.as_str())
299            );
300        }
301        xml.push_str("</sitemap>");
302        xml
303    }
304}
305
306/// Escapes the XML characters required by sitemap formatting helpers.
307#[must_use]
308pub fn escape_xml(input: &str) -> String {
309    input
310        .replace('&', "&amp;")
311        .replace('<', "&lt;")
312        .replace('>', "&gt;")
313        .replace('"', "&quot;")
314        .replace('\'', "&apos;")
315}
316
317#[cfg(test)]
318mod tests {
319    use super::{
320        ChangeFrequency, LastModified, Priority, SitemapEntry, SitemapIndexEntry, SitemapUrl,
321        escape_xml,
322    };
323
324    #[test]
325    fn validates_urls_and_priorities() {
326        assert!(SitemapUrl::new("https://example.com/").is_ok());
327        assert!(SitemapUrl::new("ftp://example.com/").is_err());
328        assert!(Priority::new(1.2).is_err());
329    }
330
331    #[test]
332    fn formats_url_xml() {
333        let entry = SitemapEntry::new(SitemapUrl::new("https://example.com/?a=1&b=2").unwrap())
334            .with_last_modified(LastModified::new("2026-05-19").unwrap())
335            .with_change_frequency(ChangeFrequency::Weekly)
336            .with_priority(Priority::new(0.8).unwrap());
337
338        assert!(entry.to_url_xml().contains("a=1&amp;b=2"));
339        assert!(entry.to_url_xml().contains("<priority>0.8</priority>"));
340    }
341
342    #[test]
343    fn formats_index_xml() {
344        let entry =
345            SitemapIndexEntry::new(SitemapUrl::new("https://example.com/sitemap.xml").unwrap());
346
347        assert!(entry.to_index_xml().contains("<sitemap>"));
348        assert_eq!(escape_xml("A&B"), "A&amp;B");
349    }
350}