Skip to main content

indexflow_sitemap/
lib.rs

1//! # indexflow-sitemap
2//!
3//! A high-performance, fault-tolerant, Google-compliant Sitemap parser and crawler in Rust.
4//!
5//! ## Features
6//! - Standard XML (`<urlset>`, `<sitemapindex>`)
7//! - Google extensions (Images, Videos, News, and XHTML Hreflang)
8//! - Plain text (`.txt`) sitemap support
9//! - Transparent Gzip decompression (`.xml.gz`) with decompression-bomb caps
10//! - Circular reference detection and depth protection
11
12pub mod compression;
13pub mod error;
14pub mod models;
15pub mod parser;
16
17#[cfg(feature = "fetch")]
18pub mod fetcher;
19
20pub use compression::{decode_if_gzipped, decode_if_gzipped_with_limit};
21pub use error::SitemapError;
22pub use models::*;
23pub use parser::{parse_datetime, parse_priority, parse_sitemap};
24
25#[cfg(feature = "fetch")]
26pub use fetcher::SitemapFetcher;
27
28#[cfg(test)]
29mod tests {
30    use super::*;
31
32    #[test]
33    fn test_parse_standard_urlset() {
34        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
35        <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
36          <url>
37            <loc>https://example.com/page-1</loc>
38            <lastmod>2026-03-30T10:00:00Z</lastmod>
39            <changefreq>weekly</changefreq>
40            <priority>0.8</priority>
41          </url>
42          <url>
43            <loc>https://example.com/page-2</loc>
44            <lastmod>2026-03-29</lastmod>
45            <changefreq>monthly</changefreq>
46            <priority>0.5</priority>
47          </url>
48        </urlset>"#;
49
50        match parse_sitemap(xml) {
51            ParsedSitemap::UrlSet { entries } => {
52                assert_eq!(entries.len(), 2);
53
54                let first = &entries[0];
55                assert_eq!(first.loc, "https://example.com/page-1");
56                assert_eq!(first.changefreq, Some(ChangeFreq::Weekly));
57                assert_eq!(first.priority, Some(0.8));
58                assert!(first.lastmod.is_some());
59
60                let second = &entries[1];
61                assert_eq!(second.loc, "https://example.com/page-2");
62                assert_eq!(second.changefreq, Some(ChangeFreq::Monthly));
63                assert_eq!(second.priority, Some(0.5));
64            }
65            _ => panic!("Expected ParsedSitemap::UrlSet"),
66        }
67    }
68
69    #[test]
70    fn test_parse_sitemap_index() {
71        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
72        <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
73          <sitemap>
74            <loc>https://example.com/sitemap-posts.xml</loc>
75            <lastmod>2026-03-30</lastmod>
76          </sitemap>
77          <sitemap>
78            <loc>https://example.com/sitemap-tags.xml</loc>
79            <lastmod>2026-03-29</lastmod>
80          </sitemap>
81        </sitemapindex>"#;
82
83        match parse_sitemap(xml) {
84            ParsedSitemap::Index { child_urls } => {
85                assert_eq!(child_urls.len(), 2);
86                assert_eq!(child_urls[0], "https://example.com/sitemap-posts.xml");
87                assert_eq!(child_urls[1], "https://example.com/sitemap-tags.xml");
88            }
89            _ => panic!("Expected ParsedSitemap::Index"),
90        }
91    }
92
93    #[test]
94    fn test_parse_google_extensions() {
95        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
96        <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
97                xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
98                xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
99                xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
100                xmlns:xhtml="http://www.w3.org/1999/xhtml">
101          <url>
102            <loc>https://example.com/post-1</loc>
103            <lastmod>2026-03-30T10:00:00Z</lastmod>
104            <changefreq>daily</changefreq>
105            <priority>0.8</priority>
106            <xhtml:link rel="alternate" hreflang="zh" href="https://example.com/zh/post-1"/>
107            <image:image>
108              <image:loc>https://example.com/cover.jpg</image:loc>
109              <image:title>Post Cover</image:title>
110              <image:caption>A descriptive caption</image:caption>
111            </image:image>
112            <video:video>
113              <video:thumbnail_loc>https://example.com/thumb.jpg</video:thumbnail_loc>
114              <video:title>Tutorial Video</video:title>
115              <video:description>How to build with Rust</video:description>
116              <video:duration>600</video:duration>
117              <video:family_friendly>yes</video:family_friendly>
118            </video:video>
119            <news:news>
120              <news:publication>
121                <news:name>Tech Daily</news:name>
122                <news:language>en</news:language>
123              </news:publication>
124              <news:publication_date>2026-03-30</news:publication_date>
125              <news:title>Rust Monolith in 2026</news:title>
126            </news:news>
127          </url>
128        </urlset>"#;
129
130        match parse_sitemap(xml) {
131            ParsedSitemap::UrlSet { entries } => {
132                assert_eq!(entries.len(), 1);
133                let entry = &entries[0];
134                assert_eq!(entry.loc, "https://example.com/post-1");
135
136                assert_eq!(entry.hreflangs.len(), 1);
137                assert_eq!(entry.hreflangs[0].lang, "zh");
138                assert_eq!(entry.hreflangs[0].href, "https://example.com/zh/post-1");
139
140                assert_eq!(entry.images.len(), 1);
141                assert_eq!(entry.images[0].loc, "https://example.com/cover.jpg");
142                assert_eq!(entry.images[0].title.as_deref(), Some("Post Cover"));
143
144                assert_eq!(entry.videos.len(), 1);
145                assert_eq!(entry.videos[0].title, "Tutorial Video");
146                assert_eq!(entry.videos[0].duration_seconds, Some(600));
147                assert_eq!(entry.videos[0].family_friendly, Some(true));
148
149                assert!(entry.news.is_some());
150                let news = entry.news.as_ref().unwrap();
151                assert_eq!(news.publication_name, "Tech Daily");
152                assert_eq!(news.title, "Rust Monolith in 2026");
153            }
154            _ => panic!("Expected ParsedSitemap::UrlSet"),
155        }
156    }
157
158    #[test]
159    fn test_parse_cdata_and_bom_tolerance() {
160        let xml_cdata = "\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>
161        <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
162          <url>
163            <loc><![CDATA[https://example.com/article?id=100&type=tech]]></loc>
164            <lastmod><![CDATA[2026-03-30T12:00:00Z]]></lastmod>
165          </url>
166        </urlset>";
167
168        match parse_sitemap(xml_cdata) {
169            ParsedSitemap::UrlSet { entries } => {
170                assert_eq!(entries.len(), 1);
171                assert_eq!(
172                    entries[0].loc,
173                    "https://example.com/article?id=100&type=tech"
174                );
175                assert!(entries[0].lastmod.is_some());
176            }
177            _ => panic!("Expected ParsedSitemap::UrlSet from CDATA"),
178        }
179    }
180
181    #[test]
182    fn test_parse_plain_text_sitemap() {
183        let text_content = r#"
184        # Main Sitemap URLs
185        https://example.com/home
186        https://example.com/about
187        
188        # Invalid Line Ignored
189        not-a-valid-url
190        https://example.com/contact
191        "#;
192
193        match parse_sitemap(text_content) {
194            ParsedSitemap::PlainText { urls } => {
195                assert_eq!(urls.len(), 3);
196                assert_eq!(urls[0], "https://example.com/home");
197                assert_eq!(urls[1], "https://example.com/about");
198                assert_eq!(urls[2], "https://example.com/contact");
199            }
200            _ => panic!("Expected ParsedSitemap::PlainText"),
201        }
202    }
203
204    #[test]
205    fn test_empty_sitemap_detection() {
206        let empty_urlset = r#"<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>"#;
207        let parsed = parse_sitemap(empty_urlset);
208        assert!(parsed.is_empty());
209
210        let empty_index =
211            r#"<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></sitemapindex>"#;
212        let parsed_idx = parse_sitemap(empty_index);
213        assert!(parsed_idx.is_empty());
214    }
215}