jev-seo 0.1.1

FOSS zero-cost, agent-first SEO & GEO search radar CLI powered by TypeSafe Jev
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SitemapReport {
    pub target: String,
    pub is_valid: bool,
    pub total_urls: usize,
    pub https_urls: usize,
    pub insecure_http_urls: usize,
    pub urls_with_params: usize,
    pub urls_with_lastmod: usize,
    pub hreflang_count: usize,
    pub invalid_hreflang_codes: Vec<String>,
    pub warnings: Vec<String>,
    pub errors: Vec<String>,
    pub sample_urls: Vec<String>,
}

pub const MAX_SITEMAP_URLS: usize = 50_000;

pub fn audit_sitemap(target: &str) -> Result<SitemapReport> {
    let xml_content = if target.starts_with("http://") || target.starts_with("https://") {
        crate::paths::reject_private_url(target)?;
        let resp = ureq::get(target)
            .timeout(Duration::from_secs(10))
            .set("User-Agent", concat!("jev-seo/", env!("CARGO_PKG_VERSION"), " (TypeSafe Jev XML Sitemap Inspector)"))
            .call()
            .with_context(|| format!("Failed to fetch remote sitemap: {}", target))?;
        crate::paths::reject_redirect_target(resp.get_url())?;
        resp.into_string()
            .with_context(|| format!("Failed to read sitemap body from {}", target))?
    } else {
        crate::paths::read_user_file(target, &["xml"])
            .with_context(|| format!("Failed to read local sitemap file: {}", target))?
    };

    parse_sitemap_xml(target, &xml_content)
}

pub fn parse_sitemap_xml(target: &str, xml: &str) -> Result<SitemapReport> {
    let loc_re = Regex::new(r#"(?is)<loc>(.*?)</loc>"#)?;
    let lastmod_re = Regex::new(r#"(?is)<lastmod>(.*?)</lastmod>"#)?;
    let hreflang_re = Regex::new(r#"(?is)<xhtml:link\b[^>]*hreflang=["']([^"']+)["'][^>]*>"#)?;

    let mut total_urls = 0;
    let mut https_urls = 0;
    let mut insecure_http_urls = 0;
    let mut urls_with_params = 0;
    let mut sample_urls = Vec::new();

    for cap in loc_re.captures_iter(xml) {
        total_urls += 1;
        let url = cap[1].trim();

        if total_urls <= 5 {
            sample_urls.push(url.to_string());
        }

        if url.starts_with("https://") {
            https_urls += 1;
        } else if url.starts_with("http://") {
            insecure_http_urls += 1;
        }

        if url.contains('?') {
            urls_with_params += 1;
        }
    }

    let urls_with_lastmod = lastmod_re.find_iter(xml).count();

    let mut hreflang_count = 0;
    let mut invalid_hreflang_codes = Vec::new();
    let mut has_x_default = false;

    for cap in hreflang_re.captures_iter(xml) {
        hreflang_count += 1;
        let code = cap[1].trim();
        if code.eq_ignore_ascii_case("x-default") {
            has_x_default = true;
        } else if code.eq_ignore_ascii_case("en-uk") {
            invalid_hreflang_codes.push("en-UK (invalid: use en-GB instead)".into());
        } else if !is_valid_hreflang(code) {
            invalid_hreflang_codes.push(format!("{} (malformed language/region code)", code));
        }
    }

    let mut errors = Vec::new();
    let mut warnings = Vec::new();

    if total_urls == 0 {
        errors.push("No <loc> URLs found in sitemap XML.".into());
    }

    if total_urls > MAX_SITEMAP_URLS {
        errors.push(format!(
            "Sitemap contains {} URLs, exceeding Google's maximum of 50,000 URLs per file.",
            total_urls
        ));
    }

    if insecure_http_urls > 0 {
        warnings.push(format!(
            "Found {} Insecure HTTP URLs. Google requires all sitemap URLs to be canonical HTTPS.",
            insecure_http_urls
        ));
    }

    if urls_with_params > 0 {
        warnings.push(format!(
            "Found {} URLs with Query Parameters. Sitemaps should only reference clean canonical URLs.",
            urls_with_params
        ));
    }

    if hreflang_count > 0 && !has_x_default {
        warnings.push("Multilingual hreflang annotations found but missing required 'x-default' fallback entry.".into());
    }

    if !invalid_hreflang_codes.is_empty() {
        warnings.push(format!(
            "Invalid hreflang codes detected: [{}]",
            invalid_hreflang_codes.join(", ")
        ));
    }

    let is_valid = errors.is_empty() && total_urls > 0;

    Ok(SitemapReport {
        target: target.to_string(),
        is_valid,
        total_urls,
        https_urls,
        insecure_http_urls,
        urls_with_params,
        urls_with_lastmod,
        hreflang_count,
        invalid_hreflang_codes,
        warnings,
        errors,
        sample_urls,
    })
}

fn is_valid_hreflang(code: &str) -> bool {
    let parts: Vec<&str> = code.split('-').collect();
    if parts.is_empty() || parts.len() > 2 {
        return false;
    }

    let lang = parts[0];
    if lang.len() != 2 && lang.len() != 3 {
        return false;
    }

    if parts.len() == 2 {
        let region = parts[1];
        if region.len() != 2 && region.len() != 3 {
            return false;
        }
    }

    true
}