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#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum SitemapValueError {
30 EmptyUrl,
32 InvalidUrl,
34 InvalidPriority(f32),
36 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub struct SitemapUrl(String);
58
59impl SitemapUrl {
60 pub fn new(value: impl AsRef<str>) -> Result<Self, SitemapValueError> {
66 validate_url(value).map(Self)
67 }
68
69 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
98pub enum ChangeFrequency {
99 Always,
101 Hourly,
103 Daily,
105 Weekly,
107 Monthly,
109 Yearly,
111 Never,
113}
114
115impl ChangeFrequency {
116 #[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#[derive(Clone, Copy, Debug, PartialEq)]
133pub struct Priority(f32);
134
135impl Priority {
136 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
164pub struct LastModified(String);
165
166impl LastModified {
167 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 #[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#[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 #[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 #[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 #[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 #[must_use]
231 pub const fn with_priority(mut self, priority: Priority) -> Self {
232 self.priority = Some(priority);
233 self
234 }
235
236 #[must_use]
238 pub const fn url(&self) -> &SitemapUrl {
239 &self.url
240 }
241
242 #[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#[derive(Clone, Debug, Eq, PartialEq)]
268pub struct SitemapIndexEntry {
269 sitemap: SitemapUrl,
270 last_modified: Option<LastModified>,
271}
272
273impl SitemapIndexEntry {
274 #[must_use]
276 pub const fn new(sitemap: SitemapUrl) -> Self {
277 Self {
278 sitemap,
279 last_modified: None,
280 }
281 }
282
283 #[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 #[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#[must_use]
308pub fn escape_xml(input: &str) -> String {
309 input
310 .replace('&', "&")
311 .replace('<', "<")
312 .replace('>', ">")
313 .replace('"', """)
314 .replace('\'', "'")
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&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&B");
349 }
350}