use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::stream::{FuturesUnordered, StreamExt};
use reqwest::{redirect::Policy, Client};
use tracing::{info, warn};
use url::Url;
use super::pipeline::{audit_page, PipelineConfig};
use super::report::{
AuditReport, BatchError, BatchReport, CrawlDepthDiagnostics, CrawlDepthEntry,
RobotsSitemapConflict, SitemapDiagnostics, SitemapHttpIssue,
};
use crate::browser::{BrowserOptions, BrowserPool, PoolConfig};
use crate::cli::{Args, RequestMode};
use crate::error::{AuditError, Result};
use crate::seo::{BotClass, RobotsAudit};
use crate::util::build_browser_client;
#[derive(Debug, Clone)]
pub struct BatchConfig {
pub pipeline: PipelineConfig,
pub concurrency: usize,
pub max_urls: usize,
pub pool_config: PoolConfig,
}
impl From<&Args> for BatchConfig {
fn from(args: &Args) -> Self {
let pool_config = PoolConfig {
max_pages: args.effective_concurrency(),
browser_options: BrowserOptions {
chrome_path: args.chrome_path.clone(),
no_sandbox: args.no_sandbox,
disable_images: args.disable_images,
timeout_secs: args.effective_timeout(),
verbose: args.verbose,
user_agent_override: (args.request_mode == RequestMode::Bot).then(|| {
concat!(
"auditmysite/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/casoon/auditmysite)"
)
.to_string()
}),
..BrowserOptions::default()
},
..PoolConfig::default()
};
Self {
pipeline: PipelineConfig::from(args),
concurrency: args.effective_concurrency(),
max_urls: args.max_pages,
pool_config,
}
}
}
#[derive(Debug)]
pub enum BatchAuditError {
Audit(AuditError),
Other(String),
}
impl std::fmt::Display for BatchAuditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BatchAuditError::Audit(e) => write!(f, "{e}"),
BatchAuditError::Other(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug)]
pub struct BatchResult {
pub url: String,
pub outcome: std::result::Result<AuditReport, BatchAuditError>,
}
pub type ProgressCallback = Arc<dyn Fn(usize, usize, &str, Option<&str>) + Send + Sync>;
pub async fn run_concurrent_batch(
urls: Vec<String>,
config: &BatchConfig,
progress: Option<ProgressCallback>,
) -> Result<BatchReport> {
let start_time = Instant::now();
let total_urls = if config.max_urls > 0 {
config.max_urls.min(urls.len())
} else {
urls.len()
};
info!(
"Starting batch audit of {} URLs with {} concurrent workers",
total_urls, config.concurrency
);
let pool = Arc::new(BrowserPool::new(config.pool_config.clone()).await?);
let pipeline_config = Arc::new(config.pipeline.clone());
let completed = Arc::new(AtomicUsize::new(0));
let mut in_flight: FuturesUnordered<_> = FuturesUnordered::new();
let mut url_iter = urls.into_iter().take(total_urls);
let make_task = |url: String,
pool: Arc<BrowserPool>,
config: Arc<PipelineConfig>,
completed: Arc<AtomicUsize>,
progress: Option<ProgressCallback>,
total: usize| {
async move {
let result = audit_url_with_pool(&pool, &url, &config).await;
let current = completed.fetch_add(1, Ordering::SeqCst) + 1;
match &result.outcome {
Ok(report) => {
info!(
"[{}/{}] Completed: {} (score: {})",
current, total, url, report.accessibility.score
);
if let Some(ref cb) = progress {
cb(current, total, &url, None);
}
}
Err(e) => {
let msg = e.to_string();
warn!("[{}/{}] Failed: {} - {}", current, total, url, msg);
if let Some(ref cb) = progress {
cb(current, total, &url, Some(&msg));
}
}
}
result
}
};
for url in url_iter.by_ref().take(config.concurrency) {
in_flight.push(make_task(
url,
Arc::clone(&pool),
Arc::clone(&pipeline_config),
Arc::clone(&completed),
progress.clone(),
total_urls,
));
}
let mut reports = Vec::with_capacity(total_urls);
let mut errors = Vec::new();
while let Some(batch_result) = in_flight.next().await {
match batch_result.outcome {
Ok(report) => reports.push(report),
Err(e) => errors.push((batch_result.url, e)),
}
if let Some(url) = url_iter.next() {
in_flight.push(make_task(
url,
Arc::clone(&pool),
Arc::clone(&pipeline_config),
Arc::clone(&completed),
progress.clone(),
total_urls,
));
}
}
match Arc::try_unwrap(pool) {
Ok(pool) => {
if let Err(e) = pool.close().await {
warn!("Failed to close browser pool: {}", e);
}
}
Err(_) => {
warn!(
"Browser pool still had outstanding references at batch end; \
the browser process may not shut down cleanly"
);
}
}
let total_duration_ms = start_time.elapsed().as_millis() as u64;
info!(
"Batch audit completed: {}/{} successful, {} failed in {}ms",
reports.len(),
total_urls,
errors.len(),
total_duration_ms
);
let batch_errors = errors
.into_iter()
.map(|(url, error)| BatchError {
url,
error: error.to_string(),
})
.collect();
Ok(BatchReport::from_reports(
reports,
batch_errors,
total_duration_ms,
))
}
pub async fn analyze_sitemap_diagnostics(
sitemap_urls: &[String],
reports: &[AuditReport],
) -> SitemapDiagnostics {
let client = Client::builder()
.redirect(Policy::none())
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new());
let http_issues: Vec<SitemapHttpIssue> = futures::stream::iter(sitemap_urls.iter())
.map(|url| check_sitemap_url(&client, url))
.buffer_unordered(8)
.filter_map(|issue| async move { issue })
.collect()
.await;
let sitemap_set: HashSet<String> = sitemap_urls
.iter()
.filter_map(|u| normalize_url(u))
.collect();
let audited_set: HashSet<String> = reports
.iter()
.filter_map(|r| normalize_url(&r.url))
.collect();
let linked_set = collect_internal_link_targets(reports);
let mut orphan_sitemap_urls: Vec<String> = sitemap_set
.iter()
.filter(|url| audited_set.contains(*url) && !linked_set.contains(*url))
.cloned()
.collect();
orphan_sitemap_urls.sort();
let mut linked_not_in_sitemap: Vec<String> =
linked_set.difference(&sitemap_set).cloned().collect();
linked_not_in_sitemap.sort();
let robots_conflicts = reports
.iter()
.find_map(|r| {
r.discoverability
.seo
.as_ref()
.and_then(|s| s.robots.as_ref())
})
.filter(|robots| robots.fetched)
.map(|robots| find_robots_sitemap_conflicts(sitemap_urls, robots))
.unwrap_or_default();
let crawl_depths = build_crawl_depth_diagnostics(sitemap_urls, reports);
SitemapDiagnostics {
checked_urls: sitemap_urls.len(),
http_issues,
orphan_sitemap_urls,
linked_not_in_sitemap,
robots_conflicts,
crawl_depths,
}
}
const MAX_ROBOTS_SITEMAP_CONFLICTS: usize = 50;
fn find_robots_sitemap_conflicts(
sitemap_urls: &[String],
robots: &RobotsAudit,
) -> Vec<RobotsSitemapConflict> {
let disallows: Vec<&str> = robots
.groups
.iter()
.filter(|g| g.bot_class == BotClass::Wildcard)
.flat_map(|g| g.disallows.iter().map(String::as_str))
.filter(|rule| !rule.is_empty())
.collect();
if disallows.is_empty() {
return Vec::new();
}
let allows: Vec<&str> = robots
.groups
.iter()
.filter(|g| g.bot_class == BotClass::Wildcard)
.flat_map(|g| g.allows.iter().map(String::as_str))
.filter(|rule| !rule.is_empty())
.collect();
let mut conflicts = Vec::new();
for url in sitemap_urls {
let Ok(parsed) = Url::parse(url) else {
continue;
};
let path = parsed.path();
let Some(blocking_rule) = disallows
.iter()
.filter(|rule| path.starts_with(**rule))
.max_by_key(|rule| rule.len())
else {
continue;
};
let allowed_override = allows
.iter()
.any(|rule| path.starts_with(*rule) && rule.len() >= blocking_rule.len());
if allowed_override {
continue;
}
conflicts.push(RobotsSitemapConflict {
url: url.clone(),
rule: (*blocking_rule).to_string(),
});
if conflicts.len() >= MAX_ROBOTS_SITEMAP_CONFLICTS {
break;
}
}
conflicts
}
async fn check_sitemap_url(client: &Client, url: &str) -> Option<SitemapHttpIssue> {
let response = match client
.get(url)
.header("User-Agent", "auditmysite-sitemap-validator/1.0")
.send()
.await
{
Ok(response) => response,
Err(err) => {
return Some(SitemapHttpIssue {
kind: "fetch_error".to_string(),
url: url.to_string(),
status_code: None,
final_url: None,
detail: err.to_string(),
});
}
};
let status = response.status();
if status.is_redirection() {
let final_url = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.and_then(|location| resolve_url(url, location))
.unwrap_or_else(|| url.to_string());
return Some(SitemapHttpIssue {
kind: "redirect".to_string(),
url: url.to_string(),
status_code: Some(status.as_u16()),
final_url: Some(final_url.clone()),
detail: final_url,
});
}
if status.as_u16() != 200 {
return Some(SitemapHttpIssue {
kind: "status".to_string(),
url: url.to_string(),
status_code: Some(status.as_u16()),
final_url: None,
detail: status.to_string(),
});
}
let headers_noindex = response
.headers()
.get("x-robots-tag")
.and_then(|value| value.to_str().ok())
.is_some_and(contains_noindex);
let body = response.text().await.unwrap_or_default();
let meta_noindex = contains_noindex_meta(&body);
if headers_noindex || meta_noindex {
return Some(SitemapHttpIssue {
kind: "noindex".to_string(),
url: url.to_string(),
status_code: Some(200),
final_url: None,
detail: if headers_noindex {
"x-robots-tag".to_string()
} else {
"meta robots".to_string()
},
});
}
None
}
fn contains_noindex(value: &str) -> bool {
value
.split(|c: char| c == ',' || c == ';' || c.is_whitespace())
.any(|part| part.eq_ignore_ascii_case("noindex"))
}
fn contains_noindex_meta(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
lower.contains("<meta")
&& lower.contains("name=\"robots\"")
&& lower.contains("content=")
&& lower.contains("noindex")
}
fn collect_internal_link_targets(reports: &[AuditReport]) -> HashSet<String> {
let mut targets = HashSet::new();
for report in reports {
let Some(base) = Url::parse(&report.url).ok() else {
continue;
};
let Some(seo) = &report.discoverability.seo else {
continue;
};
for target in &seo.technical.internal_link_targets {
if let Some(normalized) = resolve_link_target(&base, target) {
targets.insert(normalized);
}
}
}
targets
}
fn resolve_link_target(base: &Url, target: &str) -> Option<String> {
let resolved = if target.starts_with("http://") || target.starts_with("https://") {
target.to_string()
} else {
base.join(target)
.map(|url| url.to_string())
.unwrap_or_else(|_| target.to_string())
};
normalize_url(&resolved)
}
const MAX_CRAWL_DEPTH_LIST: usize = 20;
fn build_crawl_depth_diagnostics(
sitemap_urls: &[String],
reports: &[AuditReport],
) -> Option<CrawlDepthDiagnostics> {
let start_url = choose_crawl_start_url(sitemap_urls, reports)?;
let depths = compute_crawl_depths(&start_url, reports);
let mut depth_histogram: BTreeMap<usize, usize> = BTreeMap::new();
for depth in depths.values() {
*depth_histogram.entry(*depth).or_insert(0) += 1;
}
let mut deepest_pages: Vec<CrawlDepthEntry> = depths
.iter()
.map(|(url, depth)| CrawlDepthEntry {
url: url.clone(),
depth: *depth,
})
.collect();
deepest_pages.sort_by(|a, b| b.depth.cmp(&a.depth).then_with(|| a.url.cmp(&b.url)));
deepest_pages.truncate(MAX_CRAWL_DEPTH_LIST);
let unreachable_set: HashSet<String> = reports
.iter()
.filter_map(|r| normalize_url(&r.url))
.filter(|url| !depths.contains_key(url))
.collect();
let mut unreachable_pages: Vec<String> = unreachable_set.into_iter().collect();
unreachable_pages.sort();
unreachable_pages.truncate(MAX_CRAWL_DEPTH_LIST);
let partial_batch = reports.len() < sitemap_urls.len();
Some(CrawlDepthDiagnostics {
start_url,
depth_histogram,
deepest_pages,
unreachable_pages,
partial_batch,
})
}
fn choose_crawl_start_url(sitemap_urls: &[String], reports: &[AuditReport]) -> Option<String> {
let audited_urls: HashSet<String> = reports
.iter()
.filter_map(|r| normalize_url(&r.url))
.collect();
sitemap_urls
.iter()
.filter(|url| {
normalize_url(url).is_some_and(|normalized| audited_urls.contains(&normalized))
})
.min_by_key(|url| {
let path_len = Url::parse(url)
.map(|u| u.path().len())
.unwrap_or(usize::MAX);
(path_len, (*url).clone())
})
.cloned()
}
fn compute_crawl_depths(start_url: &str, reports: &[AuditReport]) -> HashMap<String, usize> {
let Some(start) = normalize_url(start_url) else {
return HashMap::new();
};
let audited_urls: HashSet<String> = reports
.iter()
.filter_map(|r| normalize_url(&r.url))
.collect();
if !audited_urls.contains(&start) {
return HashMap::new();
}
let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
for report in reports {
let Some(source) = normalize_url(&report.url) else {
continue;
};
let Some(base) = Url::parse(&report.url).ok() else {
continue;
};
let Some(seo) = &report.discoverability.seo else {
continue;
};
let neighbors = adjacency.entry(source).or_default();
for target in &seo.technical.internal_link_targets {
if let Some(normalized) = resolve_link_target(&base, target) {
if audited_urls.contains(&normalized) {
neighbors.push(normalized);
}
}
}
}
let mut depths: HashMap<String, usize> = HashMap::new();
depths.insert(start.clone(), 0);
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(start);
while let Some(current) = queue.pop_front() {
let current_depth = depths[¤t];
let Some(neighbors) = adjacency.get(¤t) else {
continue;
};
for neighbor in neighbors {
if !depths.contains_key(neighbor) {
depths.insert(neighbor.clone(), current_depth + 1);
queue.push_back(neighbor.clone());
}
}
}
depths
}
fn resolve_url(base: &str, location: &str) -> Option<String> {
Url::parse(base).ok()?.join(location).ok().map(|mut url| {
url.set_fragment(None);
url.to_string()
})
}
fn normalize_url(url: &str) -> Option<String> {
let mut parsed = Url::parse(url).ok()?;
parsed.set_fragment(None);
let path = parsed.path().to_string();
if path != "/" && path.ends_with('/') {
parsed.set_path(path.trim_end_matches('/'));
}
Some(parsed.to_string())
}
async fn audit_url_with_pool(
pool: &BrowserPool,
url: &str,
config: &PipelineConfig,
) -> BatchResult {
let mut last_error: Option<AuditError> = None;
let per_attempt_timeout = Duration::from_secs(config.timeout_secs.max(30) * 4);
for attempt in 0..2 {
if attempt > 0 {
warn!("Retrying audit for {} (attempt {})", url, attempt + 1);
tokio::time::sleep(Duration::from_secs(2)).await;
}
let result = tokio::time::timeout(per_attempt_timeout, async {
let pooled_page = pool.acquire().await?;
let page = pooled_page.page()?;
let (report, snapshot) = audit_page(page, url, config, pool.browser()).await?;
if config.persist_artifacts {
crate::audit::pipeline::persist_artifacts(url, config, &snapshot, &report);
}
Ok::<AuditReport, AuditError>(report)
})
.await
.unwrap_or_else(|_| {
Err(AuditError::AuditTimeout {
url: url.to_string(),
timeout_secs: per_attempt_timeout.as_secs(),
})
});
match result {
Ok(report) => {
return BatchResult {
url: url.to_string(),
outcome: Ok(report),
};
}
Err(e @ (AuditError::AuditTimeout { .. } | AuditError::PageLoadTimeout { .. })) => {
return BatchResult {
url: url.to_string(),
outcome: Err(BatchAuditError::Audit(e)),
};
}
Err(e) => {
last_error = Some(e);
}
}
}
BatchResult {
url: url.to_string(),
outcome: Err(match last_error {
Some(e) => BatchAuditError::Audit(e),
None => BatchAuditError::Other("Unknown error".to_string()),
}),
}
}
pub async fn parse_sitemap(sitemap_url: &str) -> Result<Vec<String>> {
let client = build_browser_client(15).unwrap_or_default();
const MAX_SUB_SITEMAPS: usize = 1_000;
const MAX_SITEMAP_URLS: usize = 100_000;
let mut queue = VecDeque::from([sitemap_url.to_string()]);
let mut visited = HashSet::new();
let mut all_urls = Vec::new();
let mut seen_urls = HashSet::new();
while let Some(current) = queue.pop_front() {
if !visited.insert(current.clone()) {
warn!(
"Skipping cyclic or duplicate sitemap reference: {}",
current
);
continue;
}
if visited.len() > MAX_SUB_SITEMAPS {
return Err(AuditError::SitemapParseFailed {
url: sitemap_url.to_string(),
reason: format!(
"safety limit exceeded: more than {MAX_SUB_SITEMAPS} sitemap documents"
),
});
}
info!("Fetching sitemap from: {}", current);
let content = client
.get(¤t)
.send()
.await
.map_err(|error| AuditError::SitemapParseFailed {
url: current.clone(),
reason: error.to_string(),
})?
.text()
.await
.map_err(|error| AuditError::SitemapParseFailed {
url: current.clone(),
reason: error.to_string(),
})?;
if content.contains("<sitemapindex") {
for nested in
extract_sitemap_urls(&content).map_err(|reason| AuditError::SitemapParseFailed {
url: current.clone(),
reason,
})?
{
if visited.contains(&nested) {
warn!(
"Detected cyclic sitemap reference: {} -> {}",
current, nested
);
} else {
queue.push_back(nested);
}
}
if visited.len() + queue.len() > MAX_SUB_SITEMAPS {
return Err(AuditError::SitemapParseFailed {
url: sitemap_url.to_string(),
reason: format!(
"safety limit exceeded: more than {MAX_SUB_SITEMAPS} sitemap documents queued"
),
});
}
continue;
}
for url in extract_loc_urls(&content).map_err(|reason| AuditError::SitemapParseFailed {
url: current.clone(),
reason,
})? {
if seen_urls.insert(url.clone()) {
all_urls.push(url);
if all_urls.len() > MAX_SITEMAP_URLS {
return Err(AuditError::SitemapParseFailed {
url: sitemap_url.to_string(),
reason: format!(
"safety limit exceeded: more than {MAX_SITEMAP_URLS} unique URLs"
),
});
}
}
}
}
info!(
"Found {} unique URLs across {} sitemap documents",
all_urls.len(),
visited.len()
);
Ok(all_urls)
}
pub async fn count_sitemap_entries_shallow(sitemap_url: &str) -> Option<usize> {
let client = build_browser_client(10).ok()?;
let content = client
.get(sitemap_url)
.send()
.await
.ok()?
.text()
.await
.ok()?;
let count = extract_all_loc_values(&content).len();
if count > 0 {
Some(count)
} else {
None
}
}
fn extract_sitemap_urls(content: &str) -> std::result::Result<Vec<String>, String> {
let urls = extract_all_loc_values(content);
if urls.is_empty() {
return Err("sitemap index contains no <loc> entries for nested sitemaps".to_string());
}
Ok(urls)
}
fn extract_loc_urls(content: &str) -> std::result::Result<Vec<String>, String> {
let urls = extract_all_loc_values(content);
if urls.is_empty() && !looks_like_sitemap_xml(content) {
return Err(
"response does not look like sitemap XML (no <urlset>/<loc> markers found) — \
the sitemap URL may be returning an error page or the wrong content type"
.to_string(),
);
}
Ok(urls)
}
fn looks_like_sitemap_xml(content: &str) -> bool {
let head = content.get(..content.len().min(500)).unwrap_or(content);
head.contains("<?xml") || head.contains("<urlset") || head.contains("<sitemapindex")
}
fn extract_all_loc_values(content: &str) -> Vec<String> {
let mut urls = Vec::new();
let mut search_from = 0;
while let Some(start_tag) = content[search_from..].find("<loc>") {
let abs_start = search_from + start_tag + 5;
if let Some(end_tag) = content[abs_start..].find("</loc>") {
let abs_end = abs_start + end_tag;
let mut url = content[abs_start..abs_end].trim().to_string();
if url.starts_with("<![CDATA[") && url.ends_with("]]>") {
url = url[9..url.len() - 3].to_string();
}
if !url.is_empty() {
urls.push(url);
}
search_from = abs_end + 6; } else {
break;
}
}
urls
}
pub fn read_url_file(path: &str) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path).map_err(|e| AuditError::FileError {
path: path.into(),
reason: e.to_string(),
})?;
let urls: Vec<String> = content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter(|l| l.starts_with("http://") || l.starts_with("https://"))
.map(String::from)
.collect();
info!("Read {} URLs from file: {}", urls.len(), path);
Ok(urls)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_all_loc_values() {
let urls = extract_all_loc_values(" <loc>https://example.com/page</loc> ");
assert_eq!(urls, vec!["https://example.com/page"]);
let urls = extract_all_loc_values("<loc>https://test.com</loc>");
assert_eq!(urls, vec!["https://test.com"]);
let urls = extract_all_loc_values("no loc here");
assert!(urls.is_empty());
}
#[test]
fn test_extract_loc_urls() {
let sitemap = r#"<?xml version="1.0"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/page1</loc>
</url>
<url>
<loc>https://example.com/page2</loc>
</url>
</urlset>"#;
let urls = extract_loc_urls(sitemap).unwrap();
assert_eq!(urls.len(), 2);
assert!(urls.contains(&"https://example.com/page1".to_string()));
assert!(urls.contains(&"https://example.com/page2".to_string()));
}
#[test]
fn test_extract_sitemap_urls() {
let index = r#"<?xml version="1.0"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap1.xml</loc>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap2.xml</loc>
</sitemap>
</sitemapindex>"#;
let urls = extract_sitemap_urls(index).unwrap();
assert_eq!(urls.len(), 2);
}
#[test]
fn test_inline_url_loc() {
let sitemap = r#"<urlset><url><loc>https://example.com/a</loc></url><url><loc>https://example.com/b</loc></url></urlset>"#;
let urls = extract_loc_urls(sitemap).unwrap();
assert_eq!(urls.len(), 2);
}
#[test]
fn test_cdata_loc() {
let sitemap = r#"<urlset>
<url>
<loc><![CDATA[https://example.com/cdata]]></loc>
</url>
</urlset>"#;
let urls = extract_loc_urls(sitemap).unwrap();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0], "https://example.com/cdata");
}
#[test]
fn test_genuinely_empty_sitemap_is_not_an_error() {
let sitemap = r#"<?xml version="1.0"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>"#;
let urls = extract_loc_urls(sitemap).unwrap();
assert!(urls.is_empty());
}
#[test]
fn test_html_error_page_is_a_distinct_parse_failure() {
let html = "<!DOCTYPE html><html><body><h1>404 Not Found</h1></body></html>";
let result = extract_loc_urls(html);
assert!(result.is_err());
}
#[test]
fn test_malformed_sitemap_index_is_a_distinct_parse_failure() {
let index = r#"<?xml version="1.0"?><sitemapindex></sitemapindex>"#;
let result = extract_sitemap_urls(index);
assert!(result.is_err());
}
#[test]
fn sitemap_link_graph_uses_audited_internal_targets() {
let mut report = AuditReport::new(
"https://example.com/a".to_string(),
crate::cli::WcagLevel::AA,
crate::wcag::WcagResults::new(),
10,
);
let mut seo = crate::seo::SeoAnalysis::default();
seo.technical.internal_link_targets = vec!["/b".to_string(), "/linked-only".to_string()];
report.discoverability.seo = Some(seo);
let targets = collect_internal_link_targets(&[report.clone()]);
assert!(targets.contains("https://example.com/b"));
assert!(targets.contains("https://example.com/linked-only"));
let sitemap_set: HashSet<String> = ["https://example.com/a", "https://example.com/b"]
.into_iter()
.filter_map(normalize_url)
.collect();
let audited_set: HashSet<String> = [report.url.as_str()]
.into_iter()
.filter_map(normalize_url)
.collect();
let mut orphan_sitemap_urls: Vec<String> = sitemap_set
.iter()
.filter(|url| audited_set.contains(*url) && !targets.contains(*url))
.cloned()
.collect();
orphan_sitemap_urls.sort();
let mut linked_not_in_sitemap: Vec<String> =
targets.difference(&sitemap_set).cloned().collect();
linked_not_in_sitemap.sort();
assert_eq!(orphan_sitemap_urls, vec!["https://example.com/a"]);
assert_eq!(
linked_not_in_sitemap,
vec!["https://example.com/linked-only"]
);
}
#[test]
fn sitemap_diagnostics_use_full_sitemap_not_just_the_audited_sample() {
let mut report = AuditReport::new(
"https://example.com/audited-page".to_string(),
crate::cli::WcagLevel::AA,
crate::wcag::WcagResults::new(),
10,
);
let mut seo = crate::seo::SeoAnalysis::default();
seo.technical.internal_link_targets = vec!["/nav-target/".to_string()];
report.discoverability.seo = Some(seo);
let targets = collect_internal_link_targets(&[report.clone()]);
let sample_urls = ["https://example.com/audited-page"];
let full_sitemap_urls = [
"https://example.com/audited-page",
"https://example.com/nav-target",
];
for (label, sitemap_urls) in [
("sample", &sample_urls[..]),
("full", &full_sitemap_urls[..]),
] {
let sitemap_set: HashSet<String> = sitemap_urls
.iter()
.filter_map(|u| normalize_url(u))
.collect();
let linked_not_in_sitemap: HashSet<String> =
targets.difference(&sitemap_set).cloned().collect();
if label == "sample" {
assert!(
linked_not_in_sitemap.contains("https://example.com/nav-target"),
"sanity check: comparing against the sample alone should reproduce \
the #514 false positive"
);
} else {
assert!(
!linked_not_in_sitemap.contains("https://example.com/nav-target"),
"comparing against the full sitemap must not flag a page that's \
genuinely listed there, just outside the audited sample"
);
}
}
}
fn wildcard_robots(disallows: &[&str], allows: &[&str]) -> RobotsAudit {
RobotsAudit {
fetched: true,
groups: vec![crate::seo::RobotsGroup {
user_agent: "*".to_string(),
bot_class: BotClass::Wildcard,
allows: allows.iter().map(|s| s.to_string()).collect(),
disallows: disallows.iter().map(|s| s.to_string()).collect(),
}],
..Default::default()
}
}
#[test]
fn robots_sitemap_conflict_found_for_wildcard_disallow() {
let robots = wildcard_robots(&["/private/"], &[]);
let sitemap_urls = vec![
"https://example.com/private/page".to_string(),
"https://example.com/public/page".to_string(),
];
let conflicts = find_robots_sitemap_conflicts(&sitemap_urls, &robots);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].url, "https://example.com/private/page");
assert_eq!(conflicts[0].rule, "/private/");
}
#[test]
fn robots_sitemap_conflict_suppressed_by_more_specific_allow() {
let robots = wildcard_robots(&["/private/"], &["/private/public-page"]);
let sitemap_urls = vec!["https://example.com/private/public-page".to_string()];
let conflicts = find_robots_sitemap_conflicts(&sitemap_urls, &robots);
assert!(conflicts.is_empty());
}
#[test]
fn robots_sitemap_conflict_empty_when_no_overlap() {
let robots = wildcard_robots(&["/admin/"], &[]);
let sitemap_urls = vec!["https://example.com/products/page".to_string()];
let conflicts = find_robots_sitemap_conflicts(&sitemap_urls, &robots);
assert!(conflicts.is_empty());
}
fn report_with_links(url: &str, targets: &[&str]) -> AuditReport {
let mut report = AuditReport::new(
url.to_string(),
crate::cli::WcagLevel::AA,
crate::wcag::WcagResults::new(),
10,
);
let mut seo = crate::seo::SeoAnalysis::default();
seo.technical.internal_link_targets = targets.iter().map(|t| t.to_string()).collect();
report.discoverability.seo = Some(seo);
report
}
#[test]
fn compute_crawl_depths_simple_chain() {
let reports = vec![
report_with_links("https://example.com/", &["/a"]),
report_with_links("https://example.com/a", &["/b"]),
report_with_links("https://example.com/b", &[]),
];
let depths = compute_crawl_depths("https://example.com/", &reports);
assert_eq!(depths.get("https://example.com/"), Some(&0));
assert_eq!(depths.get("https://example.com/a"), Some(&1));
assert_eq!(depths.get("https://example.com/b"), Some(&2));
}
#[test]
fn compute_crawl_depths_shorter_path_wins() {
let reports = vec![
report_with_links("https://example.com/", &["/a", "/c"]),
report_with_links("https://example.com/a", &["/c"]),
report_with_links("https://example.com/c", &[]),
];
let depths = compute_crawl_depths("https://example.com/", &reports);
assert_eq!(depths.get("https://example.com/c"), Some(&1));
}
#[test]
fn compute_crawl_depths_unreached_page_is_absent_not_defaulted() {
let reports = vec![
report_with_links("https://example.com/", &["/a"]),
report_with_links("https://example.com/a", &[]),
report_with_links("https://example.com/d", &[]),
];
let depths = compute_crawl_depths("https://example.com/", &reports);
assert_eq!(depths.get("https://example.com/a"), Some(&1));
assert_eq!(depths.get("https://example.com/d"), None);
assert_eq!(depths.len(), 2);
}
#[test]
fn compute_crawl_depths_single_page_batch() {
let reports = vec![report_with_links("https://example.com/", &[])];
let depths = compute_crawl_depths("https://example.com/", &reports);
assert_eq!(depths.len(), 1);
assert_eq!(depths.get("https://example.com/"), Some(&0));
}
#[test]
fn compute_crawl_depths_empty_input() {
let depths = compute_crawl_depths("https://example.com/", &[]);
assert!(depths.is_empty());
}
#[test]
fn choose_crawl_start_url_prefers_shortest_path_among_audited() {
let reports = vec![
report_with_links("https://example.com/", &[]),
report_with_links("https://example.com/about", &[]),
];
let sitemap_urls = vec![
"https://example.com/about".to_string(),
"https://example.com/".to_string(),
];
let start = choose_crawl_start_url(&sitemap_urls, &reports);
assert_eq!(start, Some("https://example.com/".to_string()));
}
#[test]
fn choose_crawl_start_url_none_when_no_sitemap_url_was_audited() {
let reports = vec![report_with_links("https://example.com/only-audited", &[])];
let sitemap_urls = vec!["https://example.com/".to_string()];
assert_eq!(choose_crawl_start_url(&sitemap_urls, &reports), None);
}
#[test]
fn build_crawl_depth_diagnostics_flags_partial_batch_on_sample() {
let reports = vec![
report_with_links("https://example.com/", &["/a"]),
report_with_links("https://example.com/a", &[]),
];
let sitemap_urls = vec![
"https://example.com/".to_string(),
"https://example.com/a".to_string(),
"https://example.com/b".to_string(),
];
let diag = build_crawl_depth_diagnostics(&sitemap_urls, &reports).unwrap();
assert_eq!(diag.start_url, "https://example.com/");
assert!(diag.partial_batch);
assert_eq!(diag.depth_histogram.get(&0), Some(&1));
assert_eq!(diag.depth_histogram.get(&1), Some(&1));
}
#[test]
fn build_crawl_depth_diagnostics_no_partial_flag_on_full_coverage() {
let reports = vec![
report_with_links("https://example.com/", &["/a"]),
report_with_links("https://example.com/a", &[]),
];
let sitemap_urls = vec![
"https://example.com/".to_string(),
"https://example.com/a".to_string(),
];
let diag = build_crawl_depth_diagnostics(&sitemap_urls, &reports).unwrap();
assert!(!diag.partial_batch);
}
}