Skip to main content

crawlkit_engine/
analyzers.rs

1use std::collections::{HashMap, HashSet};
2use std::time::Duration;
3use url::Url;
4
5use crate::parser::ParsedPage;
6use crate::storage::{IssueCategory, Severity};
7use crate::{CrawlConfig, RedirectHop};
8
9// ---------------------------------------------------------------------------
10// Security Header Analyzer types
11// ---------------------------------------------------------------------------
12
13/// Pre-fetched TLS certificate information for offline validation.
14#[derive(Debug, Clone)]
15pub struct SslCertificateInfo {
16    /// The subject Common Name (CN) or first SAN entry.
17    pub subject: Option<String>,
18    /// The certificate issuer.
19    pub issuer: Option<String>,
20    /// Subject Alternative Names (DNS names, IP addresses).
21    pub san_entries: Vec<String>,
22    /// Certificate valid-from timestamp (ISO 8601).
23    pub not_before: Option<String>,
24    /// Certificate expiry timestamp (ISO 8601).
25    pub not_after: Option<String>,
26    /// Whether the certificate chain validated successfully.
27    pub is_valid_chain: bool,
28    /// Whether the certificate is self-signed.
29    pub is_self_signed: bool,
30    /// The signature algorithm (e.g. "SHA256withRSA").
31    pub signature_algorithm: Option<String>,
32}
33
34// ---------------------------------------------------------------------------
35// Core types
36// ---------------------------------------------------------------------------
37
38/// Context for analyzing a page, bundling parsed HTML with HTTP metadata.
39///
40/// Passed to each [`Analyzer`] to provide all the data needed for analysis.
41/// The context borrows the parsed page and response metadata to avoid
42/// unnecessary cloning.
43pub struct AnalysisContext<'a> {
44    /// The parsed page content.
45    pub page: &'a ParsedPage,
46    /// HTTP status code (if fetched).
47    pub status_code: Option<u16>,
48    /// Response headers.
49    pub headers: &'a [(String, String)],
50    /// Response time (if measured).
51    pub response_time: Option<Duration>,
52    /// Redirect chain hops (if any).
53    pub redirect_chain: &'a [RedirectHop],
54    /// Pre-fetched robots.txt content for this page's domain (if available).
55    pub robots_txt: Option<&'a str>,
56}
57
58/// A finding/issue detected by an analyzer.
59///
60/// Represents a single SEO or technical issue found during page analysis.
61/// Each finding has a severity, category, machine-readable code, and
62/// a human-readable recommendation for fixing the issue.
63#[derive(Debug, Clone)]
64pub struct Finding {
65    /// Issue severity (Critical, Error, Warning, Info).
66    pub severity: Severity,
67    /// Issue category (SEO, HTTP, Links, etc.).
68    pub category: IssueCategory,
69    /// Machine-readable issue code (e.g., "META001", "HTTP005").
70    pub code: String,
71    /// Short human-readable title.
72    pub title: String,
73    /// Detailed description of the issue.
74    pub description: String,
75    /// URL of the page where the issue was found.
76    pub url: String,
77    /// Recommendation for fixing the issue.
78    pub recommendation: String,
79}
80
81/// Trait for page analyzers.
82///
83/// Implement this trait to create custom SEO analyzers. Each analyzer
84/// receives an [`AnalysisContext`] and returns a list of [`Finding`]s.
85///
86/// # Examples
87///
88/// ```rust
89/// use crawlkit_engine::analyzers::{Analyzer, AnalysisContext, Finding};
90/// use crawlkit_engine::CrawlConfig;
91///
92/// struct MyAnalyzer;
93///
94/// impl Analyzer for MyAnalyzer {
95///     fn name(&self) -> &str { "my-analyzer" }
96///     fn analyze(&self, _ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
97///         vec![]
98///     }
99/// }
100/// ```
101pub trait Analyzer: Send + Sync {
102    /// Returns the human-readable name of this analyzer.
103    fn name(&self) -> &str;
104
105    /// Analyze a page and return any findings/issues.
106    fn analyze(&self, ctx: &AnalysisContext, config: &CrawlConfig) -> Vec<Finding>;
107}
108
109// ---------------------------------------------------------------------------
110// 1. HTTP Status Analyzer
111// ---------------------------------------------------------------------------
112
113pub struct HttpStatusAnalyzer;
114
115impl HttpStatusAnalyzer {
116    pub fn new() -> Self {
117        Self
118    }
119
120    /// Check if a 200 response looks like an error page (soft 404).
121    #[allow(dead_code)]
122    pub(crate) fn is_soft_404(body: &str) -> bool {
123        let lower = body.to_lowercase();
124        let indicators = [
125            "page not found",
126            "404 not found",
127            "the page you requested",
128            "does not exist",
129            "has been removed",
130            "no longer available",
131            "error 404",
132            "not found",
133            "sorry, we couldn't find",
134            "this page is no longer available",
135            "the requested url was not found",
136        ];
137        indicators.iter().any(|ind| lower.contains(ind))
138    }
139
140    fn status_category(code: u16) -> &'static str {
141        match code {
142            200..=299 => "success",
143            300..=399 => "redirection",
144            400..=499 => "client_error",
145            500..=599 => "server_error",
146            _ => "unknown",
147        }
148    }
149}
150
151impl Default for HttpStatusAnalyzer {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl Analyzer for HttpStatusAnalyzer {
158    fn name(&self) -> &str {
159        "http-status"
160    }
161
162    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
163        let mut findings = Vec::new();
164        let url = &ctx.page.url;
165
166        let status = match ctx.status_code {
167            Some(s) => s,
168            None => {
169                findings.push(Finding {
170                    severity: Severity::Error,
171                    category: IssueCategory::Http,
172                    code: "HTTP001".to_string(),
173                    title: "Missing status code".to_string(),
174                    description: "No HTTP status code was recorded for this page.".to_string(),
175                    url: url.clone(),
176                    recommendation: "Ensure the page is fetched and the status code is recorded."
177                        .to_string(),
178                });
179                return findings;
180            }
181        };
182
183        // Record response time if present
184        if let Some(rt) = ctx.response_time {
185            if rt > Duration::from_secs(5) {
186                findings.push(Finding {
187                    severity: Severity::Warning,
188                    category: IssueCategory::Performance,
189                    code: "HTTP002".to_string(),
190                    title: "Slow response time".to_string(),
191                    description: format!("Response took {rt:?}, which exceeds 5 seconds."),
192                    url: url.clone(),
193                    recommendation: "Optimize server response time. Consider caching, CDN, or \
194                                     reducing server-side processing."
195                        .to_string(),
196                });
197            }
198        }
199
200        match status {
201            200 => {
202                // Check for soft 404
203                let body_lower = ctx.page.word_count;
204                // Soft 404 heuristic: very low word count on a 200 page can indicate error page
205                if body_lower == 0 {
206                    findings.push(Finding {
207                        severity: Severity::Warning,
208                        category: IssueCategory::Http,
209                        code: "HTTP003".to_string(),
210                        title: "Possible soft 404 — empty body".to_string(),
211                        description: "Page returned 200 but has no content. This may indicate \
212                                     a soft 404."
213                            .to_string(),
214                        url: url.clone(),
215                        recommendation: "Verify the page renders correctly. Fix server-side \
216                                         rendering issues."
217                            .to_string(),
218                    });
219                }
220            }
221            301 | 302 | 307 | 308 => {
222                // Redirects are handled by RedirectChainAnalyzer
223            }
224            404 => {
225                findings.push(Finding {
226                    severity: Severity::Error,
227                    category: IssueCategory::Http,
228                    code: "HTTP004".to_string(),
229                    title: "Page not found (404)".to_string(),
230                    description: "The page returned a 404 status code.".to_string(),
231                    url: url.clone(),
232                    recommendation: "Remove links to this page or redirect to a valid URL."
233                        .to_string(),
234                });
235            }
236            500..=599 => {
237                findings.push(Finding {
238                    severity: Severity::Critical,
239                    category: IssueCategory::Http,
240                    code: "HTTP005".to_string(),
241                    title: format!("Server error ({status})"),
242                    description: format!(
243                        "The page returned a {status} status code, indicating a server error."
244                    ),
245                    url: url.clone(),
246                    recommendation: "Fix server-side errors. Check application logs for details."
247                        .to_string(),
248                });
249            }
250            _ => {
251                // Info-level for other codes
252            }
253        }
254
255        // Check for status category
256        findings.push(Finding {
257            severity: Severity::Info,
258            category: IssueCategory::Http,
259            code: "HTTP006".to_string(),
260            title: format!("Status category: {}", Self::status_category(status)),
261            description: format!(
262                "HTTP {status} — categorized as {}.",
263                Self::status_category(status)
264            ),
265            url: url.clone(),
266            recommendation: String::new(),
267        });
268
269        findings
270    }
271}
272
273// ---------------------------------------------------------------------------
274// 2. Redirect Chain Tracker
275// ---------------------------------------------------------------------------
276
277pub struct RedirectChainAnalyzer;
278
279impl RedirectChainAnalyzer {
280    pub fn new() -> Self {
281        Self
282    }
283
284    fn is_mixed_protocol(from: &Url, to: &Url) -> bool {
285        from.scheme() != to.scheme()
286    }
287}
288
289impl Default for RedirectChainAnalyzer {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295impl Analyzer for RedirectChainAnalyzer {
296    fn name(&self) -> &str {
297        "redirect-chain"
298    }
299
300    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
301        let mut findings = Vec::new();
302        let url = &ctx.page.url;
303        let hops = ctx.redirect_chain;
304
305        if hops.is_empty() {
306            return findings;
307        }
308
309        // Flag chains longer than 5 hops
310        if hops.len() > 5 {
311            findings.push(Finding {
312                severity: Severity::Warning,
313                category: IssueCategory::Http,
314                code: "REDIR001".to_string(),
315                title: "Long redirect chain".to_string(),
316                description: format!(
317                    "Redirect chain has {} hops, exceeding the recommended maximum of 5.",
318                    hops.len()
319                ),
320                url: url.clone(),
321                recommendation: "Reduce redirect hops. Update links to point directly to the \
322                                 final URL."
323                    .to_string(),
324            });
325        }
326
327        // Detect loops
328        let mut seen_urls: HashSet<String> = HashSet::new();
329        seen_urls.insert(hops[0].from.to_string());
330        let mut has_loop = false;
331        for hop in hops {
332            if !seen_urls.insert(hop.to.to_string()) {
333                has_loop = true;
334                break;
335            }
336        }
337        if has_loop {
338            findings.push(Finding {
339                severity: Severity::Critical,
340                category: IssueCategory::Http,
341                code: "REDIR002".to_string(),
342                title: "Redirect loop detected".to_string(),
343                description: "The redirect chain contains a loop, which will prevent the page \
344                             from loading."
345                    .to_string(),
346                url: url.clone(),
347                recommendation: "Fix the redirect chain to eliminate the loop. Ensure each \
348                                 redirect points to a unique destination."
349                    .to_string(),
350            });
351        }
352
353        // Detect mixed-protocol redirects
354        for hop in hops {
355            if Self::is_mixed_protocol(&hop.from, &hop.to) {
356                findings.push(Finding {
357                    severity: Severity::Warning,
358                    category: IssueCategory::Security,
359                    code: "REDIR003".to_string(),
360                    title: "Mixed-protocol redirect".to_string(),
361                    description: format!(
362                        "Redirect from {} ({}) to {} ({}) changes protocol.",
363                        hop.from,
364                        hop.from.scheme(),
365                        hop.to,
366                        hop.to.scheme()
367                    ),
368                    url: url.clone(),
369                    recommendation: "Use consistent protocol (prefer HTTPS) for all redirects."
370                        .to_string(),
371                });
372                break;
373            }
374        }
375
376        // Check for unnecessary redirect (direct URL works)
377        if hops.len() == 1 {
378            findings.push(Finding {
379                severity: Severity::Info,
380                category: IssueCategory::Http,
381                code: "REDIR004".to_string(),
382                title: "Single redirect detected".to_string(),
383                description: format!("URL redirects from {} to {}.", hops[0].from, hops[0].to),
384                url: url.clone(),
385                recommendation: "Consider updating inbound links to point directly to the \
386                                 final URL to eliminate the redirect."
387                    .to_string(),
388            });
389        }
390
391        findings
392    }
393}
394
395// ---------------------------------------------------------------------------
396// 3. Canonical URL Validator
397// ---------------------------------------------------------------------------
398
399pub struct CanonicalUrlValidator;
400
401impl CanonicalUrlValidator {
402    pub fn new() -> Self {
403        Self
404    }
405
406    /// Normalize a URL for comparison (strip trailing slash, lowercase scheme/host).
407    fn normalize_url(url: &Url) -> String {
408        let mut s = url.to_string();
409        // Strip fragment (anchor) — search engines ignore fragments for canonical comparison
410        if let Some(pos) = s.find('#') {
411            s.truncate(pos);
412        }
413        // Remove trailing slash from path-only URLs
414        if s.ends_with('/') && url.path() != "/" {
415            s.pop();
416        }
417        s
418    }
419}
420
421impl Default for CanonicalUrlValidator {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl Analyzer for CanonicalUrlValidator {
428    fn name(&self) -> &str {
429        "canonical-url"
430    }
431
432    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
433        let mut findings = Vec::new();
434        let url = &ctx.page.url;
435        let page_url = match Url::parse(url) {
436            Ok(u) => u,
437            Err(_) => return findings,
438        };
439
440        match &ctx.page.meta.canonical {
441            None => {
442                findings.push(Finding {
443                    severity: Severity::Warning,
444                    category: IssueCategory::Seo,
445                    code: "CANON001".to_string(),
446                    title: "Missing canonical URL".to_string(),
447                    description: "No <link rel=\"canonical\"> tag was found on this page."
448                        .to_string(),
449                    url: url.clone(),
450                    recommendation: "Add a canonical URL tag pointing to the preferred version \
451                                     of this page."
452                        .to_string(),
453                });
454            }
455            Some(canonical) => {
456                let canonical_str = Self::normalize_url(canonical);
457                let page_str = Self::normalize_url(&page_url);
458
459                if canonical_str == page_str {
460                    // Self-referencing canonical — this is correct
461                } else {
462                    // Canonical points elsewhere — check if it's intentional
463                    let same_host = canonical.host_str() == page_url.host_str();
464                    let same_path = canonical.path() == page_url.path();
465
466                    if same_host && same_path {
467                        // Likely a parameter difference — info
468                        findings.push(Finding {
469                            severity: Severity::Info,
470                            category: IssueCategory::Seo,
471                            code: "CANON002".to_string(),
472                            title: "Canonical URL differs".to_string(),
473                            description: format!(
474                                "Canonical points to {canonical}, which differs from the \
475                                 current URL."
476                            ),
477                            url: url.clone(),
478                            recommendation: "Verify this is intentional. The canonical should \
479                                             point to the preferred URL."
480                                .to_string(),
481                        });
482                    } else {
483                        findings.push(Finding {
484                            severity: Severity::Warning,
485                            category: IssueCategory::Seo,
486                            code: "CANON003".to_string(),
487                            title: "Canonical URL mismatch".to_string(),
488                            description: format!(
489                                "Canonical URL ({canonical}) does not match the current page \
490                                 URL ({url})."
491                            ),
492                            url: url.clone(),
493                            recommendation: "Ensure the canonical URL points to the correct \
494                                             preferred version of this page."
495                                .to_string(),
496                        });
497                    }
498                }
499            }
500        }
501
502        findings
503    }
504}
505
506// ---------------------------------------------------------------------------
507// 4. Hreflang Validator
508// ---------------------------------------------------------------------------
509
510pub struct HreflangValidator;
511
512impl HreflangValidator {
513    pub fn new() -> Self {
514        Self
515    }
516
517    /// Check if a locale code looks valid (language[-region] format).
518    fn is_valid_locale(code: &str) -> bool {
519        if code == "x-default" {
520            return true;
521        }
522        let parts: Vec<&str> = code.split('-').collect();
523        match parts.len() {
524            1 => {
525                // Language-only: 2-3 letter code
526                let lang = parts[0];
527                lang.len() >= 2 && lang.len() <= 3 && lang.chars().all(|c| c.is_ascii_alphabetic())
528            }
529            2 => {
530                // Language-Region
531                let lang = parts[0];
532                let region = parts[1];
533                lang.len() >= 2
534                    && lang.len() <= 3
535                    && lang.chars().all(|c| c.is_ascii_alphabetic())
536                    && (region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic())
537                        || region.len() == 4 && region.chars().all(|c| c.is_ascii_digit()))
538            }
539            _ => false,
540        }
541    }
542}
543
544impl Default for HreflangValidator {
545    fn default() -> Self {
546        Self::new()
547    }
548}
549
550impl Analyzer for HreflangValidator {
551    fn name(&self) -> &str {
552        "hreflang"
553    }
554
555    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
556        let mut findings = Vec::new();
557        let url = &ctx.page.url;
558        let hreflang_tags = &ctx.page.meta.hreflang;
559
560        if hreflang_tags.is_empty() {
561            return findings; // No hreflang — not an error (just not implemented)
562        }
563
564        // Check for x-default
565        let has_x_default = hreflang_tags.iter().any(|t| t.lang == "x-default");
566        if !has_x_default {
567            findings.push(Finding {
568                severity: Severity::Warning,
569                category: IssueCategory::Seo,
570                code: "HREF001".to_string(),
571                title: "Missing x-default hreflang".to_string(),
572                description: "No hreflang tag with lang=\"x-default\" was found.".to_string(),
573                url: url.clone(),
574                recommendation: "Add an x-default hreflang tag to specify the fallback URL for \
575                                 users whose language doesn't match any other hreflang."
576                    .to_string(),
577            });
578        }
579
580        // Validate locale codes
581        for tag in hreflang_tags {
582            if !Self::is_valid_locale(&tag.lang) {
583                findings.push(Finding {
584                    severity: Severity::Error,
585                    category: IssueCategory::Seo,
586                    code: "HREF002".to_string(),
587                    title: "Invalid hreflang locale code".to_string(),
588                    description: format!(
589                        "The hreflang code \"{}\" does not follow the ISO 639-1/BCP 47 format.",
590                        tag.lang
591                    ),
592                    url: url.clone(),
593                    recommendation: "Use valid BCP 47 language tags (e.g., \"en\", \"en-US\", \
594                                     \"fr-CA\")."
595                        .to_string(),
596                });
597            }
598        }
599
600        // Collect all languages and their URLs
601        let mut lang_to_urls: HashMap<String, Vec<String>> = HashMap::new();
602        for tag in hreflang_tags {
603            lang_to_urls
604                .entry(tag.lang.clone())
605                .or_default()
606                .push(tag.url.to_string());
607        }
608
609        // Check for duplicate language codes
610        for (lang, urls) in &lang_to_urls {
611            if urls.len() > 1 && lang != "x-default" {
612                findings.push(Finding {
613                    severity: Severity::Error,
614                    category: IssueCategory::Seo,
615                    code: "HREF003".to_string(),
616                    title: "Duplicate hreflang language".to_string(),
617                    description: format!(
618                        "Language \"{}\" appears {} times in hreflang tags.",
619                        lang,
620                        urls.len()
621                    ),
622                    url: url.clone(),
623                    recommendation: "Each language code should appear only once in hreflang tags."
624                        .to_string(),
625                });
626            }
627        }
628
629        findings
630    }
631}
632
633// ---------------------------------------------------------------------------
634// 5. Sitemap Analyzer
635// ---------------------------------------------------------------------------
636
637/// Known sitemap entries for validation.
638#[derive(Debug, Clone)]
639pub struct SitemapEntry {
640    pub url: String,
641    pub lastmod: Option<String>,
642    pub changefreq: Option<String>,
643    pub priority: Option<f64>,
644}
645
646pub struct SitemapAnalyzer {
647    /// Pre-loaded sitemap entries (URLs found in sitemaps).
648    known_urls: HashSet<String>,
649    /// Entries with metadata.
650    entries: Vec<SitemapEntry>,
651}
652
653impl SitemapAnalyzer {
654    pub fn new(known_urls: HashSet<String>, entries: Vec<SitemapEntry>) -> Self {
655        Self {
656            known_urls,
657            entries,
658        }
659    }
660
661    pub fn empty() -> Self {
662        Self {
663            known_urls: HashSet::new(),
664            entries: Vec::new(),
665        }
666    }
667
668    /// Validate a lastmod date format (ISO 8601).
669    fn is_valid_lastmod(lastmod: &str) -> bool {
670        // Simple check: must contain YYYY-MM (at minimum) with valid separators
671        let bytes = lastmod.as_bytes();
672        if bytes.len() < 7 {
673            return false;
674        }
675        // Look for a 4-digit year followed by -MM
676        for i in 0..=bytes.len().saturating_sub(7) {
677            if bytes[i].is_ascii_digit()
678                && bytes[i + 1].is_ascii_digit()
679                && bytes[i + 2].is_ascii_digit()
680                && bytes[i + 3].is_ascii_digit()
681                && bytes[i + 4] == b'-'
682                && bytes[i + 5].is_ascii_digit()
683                && bytes[i + 6].is_ascii_digit()
684            {
685                return true;
686            }
687        }
688        false
689    }
690
691    /// Validate changefreq value.
692    fn is_valid_changefreq(freq: &str) -> bool {
693        matches!(
694            freq,
695            "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"
696        )
697    }
698
699    /// Validate priority value (0.0 - 1.0).
700    fn is_valid_priority(p: f64) -> bool {
701        (0.0..=1.0).contains(&p)
702    }
703}
704
705impl Default for SitemapAnalyzer {
706    fn default() -> Self {
707        Self::empty()
708    }
709}
710
711impl Analyzer for SitemapAnalyzer {
712    fn name(&self) -> &str {
713        "sitemap"
714    }
715
716    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
717        let mut findings = Vec::new();
718        let url = &ctx.page.url;
719
720        if self.known_urls.is_empty() {
721            findings.push(Finding {
722                severity: Severity::Info,
723                category: IssueCategory::Seo,
724                code: "SITEMAP001".to_string(),
725                title: "No sitemap data available".to_string(),
726                description: "No sitemap URLs were loaded for validation.".to_string(),
727                url: url.clone(),
728                recommendation: "Provide sitemap data to enable sitemap validation.".to_string(),
729            });
730            return findings;
731        }
732
733        // Check if this page URL is in the sitemap
734        if !self.known_urls.contains(url) {
735            findings.push(Finding {
736                severity: Severity::Warning,
737                category: IssueCategory::Seo,
738                code: "SITEMAP002".to_string(),
739                title: "Page not found in sitemap".to_string(),
740                description: "This page URL was not found in any loaded sitemap.".to_string(),
741                url: url.clone(),
742                recommendation: "Add this page to your sitemap.xml to ensure it is crawled and \
743                                 indexed."
744                    .to_string(),
745            });
746        }
747
748        // Validate sitemap entry metadata
749        if let Some(entry) = self.entries.iter().find(|e| e.url == *url) {
750            if let Some(ref lastmod) = entry.lastmod {
751                if !Self::is_valid_lastmod(lastmod) {
752                    findings.push(Finding {
753                        severity: Severity::Warning,
754                        category: IssueCategory::Seo,
755                        code: "SITEMAP003".to_string(),
756                        title: "Invalid lastmod format".to_string(),
757                        description: format!(
758                            "The lastmod value \"{lastmod}\" does not appear to be a valid \
759                             ISO 8601 date."
760                        ),
761                        url: url.clone(),
762                        recommendation: "Use ISO 8601 format for lastmod (e.g., \
763                                         2024-01-15T10:30:00Z)."
764                            .to_string(),
765                    });
766                }
767            }
768
769            if let Some(ref freq) = entry.changefreq {
770                if !Self::is_valid_changefreq(freq) {
771                    findings.push(Finding {
772                        severity: Severity::Warning,
773                        category: IssueCategory::Seo,
774                        code: "SITEMAP004".to_string(),
775                        title: "Invalid changefreq value".to_string(),
776                        description: format!(
777                            "The changefreq value \"{freq}\" is not a recognized value."
778                        ),
779                        url: url.clone(),
780                        recommendation: "Use one of: always, hourly, daily, weekly, monthly, \
781                                         yearly, never."
782                            .to_string(),
783                    });
784                }
785            }
786
787            if let Some(priority) = entry.priority {
788                if !Self::is_valid_priority(priority) {
789                    findings.push(Finding {
790                        severity: Severity::Warning,
791                        category: IssueCategory::Seo,
792                        code: "SITEMAP005".to_string(),
793                        title: "Invalid priority value".to_string(),
794                        description: format!(
795                            "The priority value {priority} is outside the valid range (0.0-1.0)."
796                        ),
797                        url: url.clone(),
798                        recommendation: "Set priority to a value between 0.0 and 1.0.".to_string(),
799                    });
800                }
801            }
802        }
803
804        findings
805    }
806}
807
808// ---------------------------------------------------------------------------
809// 6. Robots.txt Analyzer
810// ---------------------------------------------------------------------------
811
812/// A parsed robots.txt rule.
813#[derive(Debug, Clone)]
814pub struct RobotsRule {
815    pub user_agent: String,
816    pub disallowed_paths: Vec<String>,
817    pub allowed_paths: Vec<String>,
818    pub crawl_delay: Option<f64>,
819    pub sitemaps: Vec<String>,
820}
821
822pub struct RobotsTxtAnalyzer {
823    rules: Vec<RobotsRule>,
824    sitemap_urls: Vec<String>,
825}
826
827impl RobotsTxtAnalyzer {
828    pub fn new(rules: Vec<RobotsRule>, sitemap_urls: Vec<String>) -> Self {
829        Self {
830            rules,
831            sitemap_urls,
832        }
833    }
834
835    pub fn empty() -> Self {
836        Self {
837            rules: Vec::new(),
838            sitemap_urls: Vec::new(),
839        }
840    }
841
842    /// Check if a path is disallowed for a user-agent.
843    fn is_disallowed(path: &str, disallowed: &[String]) -> bool {
844        for pattern in disallowed {
845            if path.starts_with(pattern.as_str()) {
846                return true;
847            }
848        }
849        false
850    }
851
852    /// Check if a path is explicitly allowed for a user-agent.
853    fn is_allowed(path: &str, allowed: &[String]) -> bool {
854        for pattern in allowed {
855            if path.starts_with(pattern.as_str()) {
856                return true;
857            }
858        }
859        false
860    }
861}
862
863impl Default for RobotsTxtAnalyzer {
864    fn default() -> Self {
865        Self::empty()
866    }
867}
868
869impl Analyzer for RobotsTxtAnalyzer {
870    fn name(&self) -> &str {
871        "robots-txt"
872    }
873
874    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
875        let mut findings = Vec::new();
876        let url = &ctx.page.url;
877
878        if self.rules.is_empty() {
879            return findings;
880        }
881
882        let page_url = match Url::parse(url) {
883            Ok(u) => u,
884            Err(_) => return findings,
885        };
886
887        let path = page_url.path();
888
889        // Check rules for the default user-agent and the configured user-agent
890        for rule in &self.rules {
891            let user_agent = &rule.user_agent;
892            let is_match = user_agent == "*" || user_agent.contains("crawlkit");
893
894            if !is_match {
895                continue;
896            }
897
898            // Check disallow
899            if Self::is_disallowed(path, &rule.disallowed_paths) {
900                // Check if an allow rule overrides it
901                if Self::is_allowed(path, &rule.allowed_paths) {
902                    findings.push(Finding {
903                        severity: Severity::Info,
904                        category: IssueCategory::Seo,
905                        code: "ROBOT001".to_string(),
906                        title: "Path allowed by robots.txt override".to_string(),
907                        description: format!(
908                            "Path \"{path}\" was disallowed but is explicitly allowed by a \
909                             more specific rule for user-agent \"{user_agent}\"."
910                        ),
911                        url: url.clone(),
912                        recommendation: "This is informational. The path is crawlable due to an \
913                                         allow rule override."
914                            .to_string(),
915                    });
916                } else {
917                    findings.push(Finding {
918                        severity: Severity::Error,
919                        category: IssueCategory::Seo,
920                        code: "ROBOT002".to_string(),
921                        title: "Path disallowed by robots.txt".to_string(),
922                        description: format!(
923                            "Path \"{path}\" is disallowed for user-agent \"{user_agent}\"."
924                        ),
925                        url: url.clone(),
926                        recommendation: "Remove the disallow rule if this page should be \
927                                         crawled, or update internal links to avoid disallowed \
928                                         paths."
929                            .to_string(),
930                    });
931                }
932            }
933
934            // Check crawl-delay
935            if let Some(delay) = rule.crawl_delay {
936                if delay > 10.0 {
937                    findings.push(Finding {
938                        severity: Severity::Warning,
939                        category: IssueCategory::Performance,
940                        code: "ROBOT003".to_string(),
941                        title: "High crawl-delay value".to_string(),
942                        description: format!(
943                            "Crawl-delay of {delay}s for user-agent \"{user_agent}\" may \
944                             significantly slow crawling."
945                        ),
946                        url: url.clone(),
947                        recommendation: "Consider reducing crawl-delay if faster crawling is \
948                                         needed."
949                            .to_string(),
950                    });
951                }
952            }
953        }
954
955        // Validate sitemap references in robots.txt
956        for sitemap_url in &self.sitemap_urls {
957            if Url::parse(sitemap_url).is_err() {
958                findings.push(Finding {
959                    severity: Severity::Warning,
960                    category: IssueCategory::Seo,
961                    code: "ROBOT004".to_string(),
962                    title: "Invalid sitemap URL in robots.txt".to_string(),
963                    description: format!(
964                        "The sitemap URL \"{sitemap_url}\" in robots.txt is not a valid URL."
965                    ),
966                    url: url.clone(),
967                    recommendation: "Fix the sitemap URL in robots.txt to be a valid absolute URL."
968                        .to_string(),
969                });
970            }
971        }
972
973        findings
974    }
975}
976
977// ---------------------------------------------------------------------------
978// 7. Meta Tag Analyzer
979// ---------------------------------------------------------------------------
980
981pub struct MetaTagAnalyzer;
982
983impl MetaTagAnalyzer {
984    pub fn new() -> Self {
985        Self
986    }
987}
988
989impl Default for MetaTagAnalyzer {
990    fn default() -> Self {
991        Self::new()
992    }
993}
994
995impl Analyzer for MetaTagAnalyzer {
996    fn name(&self) -> &str {
997        "meta-tags"
998    }
999
1000    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1001        let mut findings = Vec::new();
1002        let url = &ctx.page.url;
1003        let meta = &ctx.page.meta;
1004
1005        // --- Title ---
1006        match &meta.title {
1007            None => {
1008                findings.push(Finding {
1009                    severity: Severity::Error,
1010                    category: IssueCategory::Seo,
1011                    code: "META001".to_string(),
1012                    title: "Missing page title".to_string(),
1013                    description: "No <title> tag was found on this page.".to_string(),
1014                    url: url.clone(),
1015                    recommendation: "Add a descriptive title tag (30-60 characters).".to_string(),
1016                });
1017            }
1018            Some(title) => {
1019                let len = title.len();
1020                if len < 30 {
1021                    findings.push(Finding {
1022                        severity: Severity::Warning,
1023                        category: IssueCategory::Seo,
1024                        code: "META002".to_string(),
1025                        title: "Title too short".to_string(),
1026                        description: format!(
1027                            "Title is {len} characters, below the recommended minimum of 30."
1028                        ),
1029                        url: url.clone(),
1030                        recommendation: "Expand the title to 30-60 characters to improve \
1031                                         search snippet quality."
1032                            .to_string(),
1033                    });
1034                } else if len > 60 {
1035                    findings.push(Finding {
1036                        severity: Severity::Warning,
1037                        category: IssueCategory::Seo,
1038                        code: "META003".to_string(),
1039                        title: "Title too long".to_string(),
1040                        description: format!(
1041                            "Title is {len} characters, exceeding the recommended maximum of 60."
1042                        ),
1043                        url: url.clone(),
1044                        recommendation: "Shorten the title to 60 characters or less to prevent \
1045                                         truncation in search results."
1046                            .to_string(),
1047                    });
1048                }
1049            }
1050        }
1051
1052        // --- Description ---
1053        match &meta.description {
1054            None => {
1055                findings.push(Finding {
1056                    severity: Severity::Warning,
1057                    category: IssueCategory::Seo,
1058                    code: "META004".to_string(),
1059                    title: "Missing meta description".to_string(),
1060                    description: "No meta description was found on this page.".to_string(),
1061                    url: url.clone(),
1062                    recommendation: "Add a meta description (120-160 characters) to control \
1063                                     how your page appears in search results."
1064                        .to_string(),
1065                });
1066            }
1067            Some(desc) => {
1068                let len = desc.len();
1069                if len < 120 {
1070                    // Skip short description warning for utility pages
1071                    let is_utility_page = url.contains("/account")
1072                        || url.contains("/compare")
1073                        || url.contains("/wishlist")
1074                        || url.contains("/cart")
1075                        || url.contains("/checkout")
1076                        || url.contains("/login")
1077                        || url.contains("/register")
1078                        || url.contains("/forgot")
1079                        || url.contains("/contact")
1080                        || url.contains("/about")
1081                        || url.contains("/certifications")
1082                        || url.contains("/research-use");
1083                    if !is_utility_page {
1084                        findings.push(Finding {
1085                            severity: Severity::Warning,
1086                            category: IssueCategory::Seo,
1087                            code: "META005".to_string(),
1088                            title: "Meta description too short".to_string(),
1089                            description: format!(
1090                                "Description is {len} characters, below the recommended minimum \
1091                                 of 120."
1092                            ),
1093                            url: url.clone(),
1094                            recommendation: "Expand the description to 120-160 characters."
1095                                .to_string(),
1096                        });
1097                    }
1098                } else if len > 165 {
1099                    // Allow 5-char tolerance for truncation differences
1100                    findings.push(Finding {
1101                        severity: Severity::Warning,
1102                        category: IssueCategory::Seo,
1103                        code: "META006".to_string(),
1104                        title: "Meta description too long".to_string(),
1105                        description: format!(
1106                            "Description is {len} characters, exceeding the recommended \
1107                             maximum of 160."
1108                        ),
1109                        url: url.clone(),
1110                        recommendation: "Shorten the description to 160 characters or less."
1111                            .to_string(),
1112                    });
1113                }
1114            }
1115        }
1116
1117        // --- Open Graph completeness ---
1118        let og_required = [
1119            ("og:title", &meta.og.title),
1120            ("og:image", &meta.og.image),
1121            ("og:url", &meta.og.url),
1122            ("og:type", &meta.og.r#type),
1123        ];
1124
1125        for (tag_name, value) in &og_required {
1126            if value.is_none() {
1127                findings.push(Finding {
1128                    severity: Severity::Warning,
1129                    category: IssueCategory::Social,
1130                    code: "META007".to_string(),
1131                    title: format!("Missing {tag_name} tag"),
1132                    description: format!(
1133                        "The Open Graph tag {tag_name} is missing. Social media previews \
1134                         may be incomplete."
1135                    ),
1136                    url: url.clone(),
1137                    recommendation: format!(
1138                        "Add <meta property=\"{tag_name}\" content=\"...\"> to improve social \
1139                         sharing."
1140                    ),
1141                });
1142            }
1143        }
1144
1145        // --- Twitter Card completeness ---
1146        let twitter_required = [
1147            ("twitter:card", &meta.twitter.card),
1148            ("twitter:title", &meta.twitter.title),
1149            ("twitter:image", &meta.twitter.image),
1150        ];
1151
1152        for (tag_name, value) in &twitter_required {
1153            if value.is_none() {
1154                findings.push(Finding {
1155                    severity: Severity::Info,
1156                    category: IssueCategory::Social,
1157                    code: "META008".to_string(),
1158                    title: format!("Missing {tag_name} tag"),
1159                    description: format!(
1160                        "The Twitter Card tag {tag_name} is missing. Twitter/X previews \
1161                         may be incomplete."
1162                    ),
1163                    url: url.clone(),
1164                    recommendation: format!(
1165                        "Add <meta name=\"{tag_name}\" content=\"...\"> to improve Twitter/X \
1166                         sharing."
1167                    ),
1168                });
1169            }
1170        }
1171
1172        // --- Viewport ---
1173        if meta.viewport.is_none() {
1174            findings.push(Finding {
1175                severity: Severity::Warning,
1176                category: IssueCategory::Mobile,
1177                code: "META009".to_string(),
1178                title: "Missing viewport meta tag".to_string(),
1179                description: "No viewport meta tag was found. This may affect mobile rendering."
1180                    .to_string(),
1181                url: url.clone(),
1182                recommendation: "Add <meta name=\"viewport\" content=\"width=device-width, \
1183                                 initial-scale=1\"> for proper mobile rendering."
1184                    .to_string(),
1185            });
1186        }
1187
1188        findings
1189    }
1190}
1191
1192// ---------------------------------------------------------------------------
1193// 8. Heading Hierarchy Analyzer
1194// ---------------------------------------------------------------------------
1195
1196pub struct HeadingHierarchyAnalyzer;
1197
1198impl HeadingHierarchyAnalyzer {
1199    pub fn new() -> Self {
1200        Self
1201    }
1202}
1203
1204impl Default for HeadingHierarchyAnalyzer {
1205    fn default() -> Self {
1206        Self::new()
1207    }
1208}
1209
1210impl Analyzer for HeadingHierarchyAnalyzer {
1211    fn name(&self) -> &str {
1212        "heading-hierarchy"
1213    }
1214
1215    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1216        let mut findings = Vec::new();
1217        let url = &ctx.page.url;
1218        let headings = &ctx.page.headings;
1219
1220        if headings.is_empty() {
1221            findings.push(Finding {
1222                severity: Severity::Warning,
1223                category: IssueCategory::Content,
1224                code: "HEAD001".to_string(),
1225                title: "No headings found".to_string(),
1226                description: "The page contains no heading elements (H1-H6).".to_string(),
1227                url: url.clone(),
1228                recommendation: "Add at least one H1 heading to define the page topic.".to_string(),
1229            });
1230            return findings;
1231        }
1232
1233        // Check H1 count
1234        let h1_count = headings.iter().filter(|h| h.level == 1).count();
1235        if h1_count == 0 {
1236            findings.push(Finding {
1237                severity: Severity::Error,
1238                category: IssueCategory::Seo,
1239                code: "HEAD002".to_string(),
1240                title: "Missing H1 heading".to_string(),
1241                description: "The page has headings but no H1 tag.".to_string(),
1242                url: url.clone(),
1243                recommendation: "Add exactly one H1 heading that describes the main topic of \
1244                                 the page."
1245                    .to_string(),
1246            });
1247        } else if h1_count > 1 {
1248            findings.push(Finding {
1249                severity: Severity::Warning,
1250                category: IssueCategory::Seo,
1251                code: "HEAD003".to_string(),
1252                title: "Multiple H1 headings".to_string(),
1253                description: format!(
1254                    "The page has {h1_count} H1 headings. Best practice is to have exactly one."
1255                ),
1256                url: url.clone(),
1257                recommendation: "Use a single H1 for the main topic. Use H2-H6 for subsections."
1258                    .to_string(),
1259            });
1260        }
1261
1262        // Check for skipped heading levels
1263        let mut prev_level: Option<u8> = None;
1264        for heading in headings {
1265            if let Some(prev) = prev_level {
1266                if heading.level > prev + 1 {
1267                    findings.push(Finding {
1268                        severity: Severity::Warning,
1269                        category: IssueCategory::Content,
1270                        code: "HEAD004".to_string(),
1271                        title: "Skipped heading level".to_string(),
1272                        description: format!(
1273                            "Heading level jumps from H{prev} to H{}, skipping intermediate \
1274                             levels.",
1275                            heading.level
1276                        ),
1277                        url: url.clone(),
1278                        recommendation: format!(
1279                            "Use H{} after H{prev} to maintain proper document hierarchy.",
1280                            prev + 1
1281                        ),
1282                    });
1283                }
1284            }
1285            prev_level = Some(heading.level);
1286        }
1287
1288        // Calculate max depth
1289        let max_depth = headings.iter().map(|h| h.level).max().unwrap_or(0);
1290        if max_depth >= 5 {
1291            findings.push(Finding {
1292                severity: Severity::Info,
1293                category: IssueCategory::Content,
1294                code: "HEAD005".to_string(),
1295                title: "Deep heading hierarchy".to_string(),
1296                description: format!(
1297                    "Heading hierarchy reaches H{max_depth}. Deep nesting may indicate \
1298                     overly complex content structure."
1299                ),
1300                url: url.clone(),
1301                recommendation: "Consider flattening the heading hierarchy for better \
1302                                 readability."
1303                    .to_string(),
1304            });
1305        }
1306
1307        findings
1308    }
1309}
1310
1311// ---------------------------------------------------------------------------
1312// 9. Link Analyzer
1313// ---------------------------------------------------------------------------
1314
1315/// Per-page link analysis summary.
1316#[derive(Debug, Clone, Default)]
1317pub struct LinkInfo {
1318    pub total: usize,
1319    pub internal: usize,
1320    pub external: usize,
1321    pub nofollow: usize,
1322    pub anchor_text_empty: usize,
1323}
1324
1325pub struct LinkAnalyzer {
1326    /// All URLs observed across the crawl, mapped to the pages that link to them.
1327    /// Used for orphan page detection. Callers can inject this after a full crawl.
1328    inbound_links: HashMap<String, usize>,
1329}
1330
1331impl LinkAnalyzer {
1332    pub fn new() -> Self {
1333        Self {
1334            inbound_links: HashMap::new(),
1335        }
1336    }
1337
1338    /// Create a LinkAnalyzer with pre-computed inbound link counts for orphan
1339    /// detection. The map should contain target URL -> number of inbound links.
1340    pub fn with_inbound_links(inbound_links: HashMap<String, usize>) -> Self {
1341        Self { inbound_links }
1342    }
1343}
1344
1345impl Default for LinkAnalyzer {
1346    fn default() -> Self {
1347        Self::new()
1348    }
1349}
1350
1351impl Analyzer for LinkAnalyzer {
1352    fn name(&self) -> &str {
1353        "link-analyzer"
1354    }
1355
1356    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1357        let mut findings = Vec::new();
1358        let url = &ctx.page.url;
1359
1360        let internal_count = ctx.page.links.iter().filter(|l| !l.is_external).count();
1361        let external_count = ctx.page.links.iter().filter(|l| l.is_external).count();
1362        let nofollow_count = ctx
1363            .page
1364            .links
1365            .iter()
1366            .filter(|l| l.rel.contains(&"nofollow".to_string()))
1367            .count();
1368
1369        // 2.1 — Internal vs external link counts
1370        findings.push(Finding {
1371            severity: Severity::Info,
1372            category: IssueCategory::Links,
1373            code: "LINK001".to_string(),
1374            title: "Link counts".to_string(),
1375            description: format!(
1376                "Internal: {}, External: {}, Nofollow: {}",
1377                internal_count, external_count, nofollow_count
1378            ),
1379            url: url.clone(),
1380            recommendation: String::new(),
1381        });
1382
1383        // 2.2 — Flag broken links (4xx/5xx) when status code is available
1384        for link in &ctx.page.links {
1385            let resolved = Url::parse(url)
1386                .ok()
1387                .and_then(|base| base.join(&link.href).ok());
1388            let target = resolved.as_ref().map(|u| u.as_str()).unwrap_or(&link.href);
1389            if let Some(status) = ctx.status_code {
1390                if (400..=599).contains(&status) {
1391                    // The page itself is broken — all outbound links are suspect
1392                    findings.push(Finding {
1393                        severity: Severity::Error,
1394                        category: IssueCategory::Links,
1395                        code: "LINK002".to_string(),
1396                        title: "Link on broken page".to_string(),
1397                        description: format!(
1398                            "Link \"{}\" points to \"{}\" but the current page itself returned \
1399                             HTTP {status}.",
1400                            link.text, target,
1401                        ),
1402                        url: url.clone(),
1403                        recommendation: "Fix the broken page or remove links from it.".to_string(),
1404                    });
1405                }
1406            }
1407        }
1408
1409        // 2.2 — Nofollow detection
1410        if nofollow_count > 0 {
1411            findings.push(Finding {
1412                severity: Severity::Info,
1413                category: IssueCategory::Links,
1414                code: "LINK003".to_string(),
1415                title: "Nofollow links present".to_string(),
1416                description: format!(
1417                    "{} link(s) have rel=\"nofollow\". This tells search engines not to pass \
1418                     PageRank.",
1419                    nofollow_count
1420                ),
1421                url: url.clone(),
1422                recommendation: "Ensure nofollow is used intentionally (e.g., paid links, \
1423                                 untrusted user content)."
1424                    .to_string(),
1425            });
1426        }
1427
1428        // 2.3 — Anchor text quality
1429        for link in &ctx.page.links {
1430            // Check for accessible name: text content, aria-label, or img alt
1431            let has_accessible_name = !link.text.trim().is_empty()
1432                || link
1433                    .aria_label
1434                    .as_ref()
1435                    .is_some_and(|l| !l.trim().is_empty())
1436                || link.img_alt.as_ref().is_some_and(|a| !a.trim().is_empty());
1437            if !has_accessible_name {
1438                findings.push(Finding {
1439                    severity: Severity::Warning,
1440                    category: IssueCategory::Links,
1441                    code: "LINK004".to_string(),
1442                    title: "Empty anchor text".to_string(),
1443                    description: format!("Link to \"{}\" has no visible anchor text.", link.href),
1444                    url: url.clone(),
1445                    recommendation: "Add descriptive anchor text to help users and search engines \
1446                                     understand the link destination."
1447                        .to_string(),
1448                });
1449            } else if link.text.trim().len() < 3 && !link.text.trim().is_empty() {
1450                findings.push(Finding {
1451                    severity: Severity::Info,
1452                    category: IssueCategory::Links,
1453                    code: "LINK005".to_string(),
1454                    title: "Very short anchor text".to_string(),
1455                    description: format!(
1456                        "Link \"{}\" has very short anchor text ({} chars).",
1457                        link.text,
1458                        link.text.len()
1459                    ),
1460                    url: url.clone(),
1461                    recommendation: "Use more descriptive anchor text for better usability."
1462                        .to_string(),
1463                });
1464            }
1465        }
1466
1467        // 2.3 — Orphan page detection (0 inbound links)
1468        if let Some(&count) = self.inbound_links.get(url) {
1469            if count == 0 {
1470                findings.push(Finding {
1471                    severity: Severity::Warning,
1472                    category: IssueCategory::Links,
1473                    code: "LINK006".to_string(),
1474                    title: "Orphan page".to_string(),
1475                    description: "This page has no inbound links from other pages on the site."
1476                        .to_string(),
1477                    url: url.clone(),
1478                    recommendation:
1479                        "Add internal links from other pages to improve discoverability."
1480                            .to_string(),
1481                });
1482            }
1483        }
1484
1485        findings
1486    }
1487}
1488
1489// ---------------------------------------------------------------------------
1490// 10. Image Analyzer
1491// ---------------------------------------------------------------------------
1492
1493/// Detailed image information for analysis.
1494#[derive(Debug, Clone)]
1495pub struct ImageInfo {
1496    pub src: String,
1497    pub alt: String,
1498    pub width: Option<u32>,
1499    pub height: Option<u32>,
1500    pub format: Option<String>,
1501    pub file_size: Option<u64>,
1502    pub has_alt: bool,
1503    pub is_lazy_loaded: bool,
1504}
1505
1506pub struct ImageAnalyzer;
1507
1508impl ImageAnalyzer {
1509    pub fn new() -> Self {
1510        Self
1511    }
1512
1513    #[allow(dead_code)]
1514    pub(crate) fn detect_format(src: &str) -> Option<String> {
1515        let path = src.split('?').next()?;
1516        let ext = path.rsplit('.').next()?;
1517        match ext.to_lowercase().as_str() {
1518            "jpg" | "jpeg" => Some("jpeg".to_string()),
1519            "png" => Some("png".to_string()),
1520            "gif" => Some("gif".to_string()),
1521            "webp" => Some("webp".to_string()),
1522            "avif" => Some("avif".to_string()),
1523            "svg" => Some("svg".to_string()),
1524            "bmp" => Some("bmp".to_string()),
1525            "ico" => Some("ico".to_string()),
1526            "tiff" | "tif" => Some("tiff".to_string()),
1527            _ => None,
1528        }
1529    }
1530}
1531
1532impl Default for ImageAnalyzer {
1533    fn default() -> Self {
1534        Self::new()
1535    }
1536}
1537
1538impl Analyzer for ImageAnalyzer {
1539    fn name(&self) -> &str {
1540        "image-analyzer"
1541    }
1542
1543    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1544        let mut findings = Vec::new();
1545        let url = &ctx.page.url;
1546
1547        if ctx.page.images.is_empty() {
1548            return findings;
1549        }
1550
1551        let mut total_lazy = 0u32;
1552        let mut total_with_dimensions = 0u32;
1553
1554        for img in &ctx.page.images {
1555            // 2.4 — Missing alt text
1556            if !img.has_alt {
1557                findings.push(Finding {
1558                    severity: Severity::Warning,
1559                    category: IssueCategory::Accessibility,
1560                    code: "IMG001".to_string(),
1561                    title: "Image missing alt text".to_string(),
1562                    description: format!("Image \"{}\" has no alt attribute.", img.src),
1563                    url: url.clone(),
1564                    recommendation: "Add descriptive alt text to improve accessibility and SEO."
1565                        .to_string(),
1566                });
1567            }
1568
1569            // Image file size check: file_size is not available from HTML parsing alone.
1570            // This requires HTTP response headers or HEAD request to determine actual file size.
1571            // Documented limitation: oversized image detection is not implemented.
1572
1573            if img.is_lazy_loaded {
1574                total_lazy += 1;
1575            }
1576
1577            if img.width.is_some() && img.height.is_some() {
1578                total_with_dimensions += 1;
1579            }
1580        }
1581
1582        // Lazy loading summary
1583        if total_lazy > 0 {
1584            findings.push(Finding {
1585                severity: Severity::Info,
1586                category: IssueCategory::Performance,
1587                code: "IMG003".to_string(),
1588                title: "Lazy-loaded images".to_string(),
1589                description: format!(
1590                    "{} of {} images use lazy loading.",
1591                    total_lazy,
1592                    ctx.page.images.len()
1593                ),
1594                url: url.clone(),
1595                recommendation: "Verify lazy loading is only applied to below-the-fold images."
1596                    .to_string(),
1597            });
1598        }
1599
1600        // Dimension summary
1601        let missing_dimensions = ctx.page.images.len() as u32 - total_with_dimensions;
1602        if missing_dimensions > 0 {
1603            findings.push(Finding {
1604                severity: Severity::Info,
1605                category: IssueCategory::Performance,
1606                code: "IMG004".to_string(),
1607                title: "Images missing dimensions".to_string(),
1608                description: format!(
1609                    "{} of {} images are missing width/height attributes.",
1610                    missing_dimensions,
1611                    ctx.page.images.len()
1612                ),
1613                url: url.clone(),
1614                recommendation: "Specify width and height to prevent layout shifts (CLS)."
1615                    .to_string(),
1616            });
1617        }
1618
1619        findings
1620    }
1621}
1622
1623// ---------------------------------------------------------------------------
1624// 11. Structured Data Validator
1625// ---------------------------------------------------------------------------
1626
1627/// Required properties per Schema.org type (subset).
1628const REQUIRED_PROPERTIES: &[(&str, &[&str])] = &[
1629    ("Article", &["headline", "author"]),
1630    ("NewsArticle", &["headline", "author"]),
1631    ("BlogPosting", &["headline", "author"]),
1632    ("ScholarlyArticle", &["headline", "author"]),
1633    ("Product", &["name"]),
1634    ("Organization", &["name"]),
1635    ("LocalBusiness", &["name", "address"]),
1636    ("Store", &["name", "address"]),
1637    ("WebPage", &["name"]),
1638    ("BreadcrumbList", &["itemListElement"]),
1639    ("FAQPage", &["mainEntity"]),
1640    ("HowTo", &["name"]),
1641    ("HowToStep", &["text"]),
1642    ("Event", &["name", "startDate"]),
1643    ("Recipe", &["name"]),
1644    ("VideoObject", &["name", "embedUrl"]),
1645    ("SoftwareApplication", &["name"]),
1646    ("Book", &["name"]),
1647    ("MusicAlbum", &["name"]),
1648    ("Movie", &["name"]),
1649    ("Quiz", &["name"]),
1650    ("Question", &["text"]),
1651    ("Answer", &["text"]),
1652    ("Drug", &["name"]),
1653    ("DefinedTerm", &["name"]),
1654    ("DefinedTermSet", &["name"]),
1655    ("Dataset", &["name"]),
1656];
1657
1658/// Recognized Schema.org types for validation.
1659const RECOGNIZED_TYPES: &[&str] = &[
1660    // Core content types
1661    "Article",
1662    "NewsArticle",
1663    "BlogPosting",
1664    "ScholarlyArticle",
1665    // Product & commerce
1666    "Product",
1667    "Offer",
1668    "Brand",
1669    "AggregateRating",
1670    "Review",
1671    // Organization & people
1672    "Organization",
1673    "LocalBusiness",
1674    "Store",
1675    "Person",
1676    "Place",
1677    // Web
1678    "WebSite",
1679    "WebPage",
1680    "WebPageElement",
1681    // Navigation
1682    "BreadcrumbList",
1683    "ItemList",
1684    "ListItem",
1685    // Interactive content
1686    "FAQPage",
1687    "HowTo",
1688    "HowToStep",
1689    "HowToDirection",
1690    "HowToSupply",
1691    "HowToTool",
1692    "Quiz",
1693    "Question",
1694    "Answer",
1695    // Media
1696    "Event",
1697    "Recipe",
1698    "VideoObject",
1699    "ImageObject",
1700    "AudioObject",
1701    // Software
1702    "SoftwareApplication",
1703    "SoftwareSourceCode",
1704    // Books & media
1705    "Book",
1706    "MusicAlbum",
1707    "MusicRecording",
1708    "Movie",
1709    "TVSeries",
1710    // Knowledge & data
1711    "Dataset",
1712    "DataDownload",
1713    "DefinedTerm",
1714    "DefinedTermSet",
1715    // Medical
1716    "Drug",
1717    "MedicalWebPage",
1718    "MedicalSubstance",
1719    "MedicalAudience",
1720    "MedicalCondition",
1721    "MedicalProcedure",
1722    // Creative works
1723    "CreativeWork",
1724    "Course",
1725    "LearningResource",
1726    // Research & Data
1727    "ResearchProject",
1728    "CollectionPage",
1729];
1730
1731pub struct StructuredDataValidator;
1732
1733impl StructuredDataValidator {
1734    pub fn new() -> Self {
1735        Self
1736    }
1737
1738    fn required_properties(schema_type: &str) -> &'static [&'static str] {
1739        REQUIRED_PROPERTIES
1740            .iter()
1741            .find(|(t, _)| *t == schema_type)
1742            .map(|(_, props)| *props)
1743            .unwrap_or(&[])
1744    }
1745}
1746
1747impl Default for StructuredDataValidator {
1748    fn default() -> Self {
1749        Self::new()
1750    }
1751}
1752
1753impl Analyzer for StructuredDataValidator {
1754    fn name(&self) -> &str {
1755        "structured-data"
1756    }
1757
1758    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1759        let mut findings = Vec::new();
1760        let url = &ctx.page.url;
1761
1762        if ctx.page.structured_data.is_empty() {
1763            findings.push(Finding {
1764                severity: Severity::Info,
1765                category: IssueCategory::Schema,
1766                code: "SD001".to_string(),
1767                title: "No structured data found".to_string(),
1768                description: "No JSON-LD structured data was found on this page.".to_string(),
1769                url: url.clone(),
1770                recommendation: "Add relevant Schema.org JSON-LD markup to enhance search \
1771                                 results."
1772                    .to_string(),
1773            });
1774            return findings;
1775        }
1776
1777        for sd in &ctx.page.structured_data {
1778            // 2.5 — Validate @context
1779            match &sd.context {
1780                None => {
1781                    findings.push(Finding {
1782                        severity: Severity::Error,
1783                        category: IssueCategory::Schema,
1784                        code: "SD002".to_string(),
1785                        title: "Missing @context".to_string(),
1786                        description: "JSON-LD block is missing the @context property.".to_string(),
1787                        url: url.clone(),
1788                        recommendation: "Add \"@context\": \"https://schema.org\" to all JSON-LD \
1789                                         blocks."
1790                            .to_string(),
1791                    });
1792                }
1793                Some(ctx_val) => {
1794                    if ctx_val != "https://schema.org" && ctx_val != "schema.org" {
1795                        findings.push(Finding {
1796                            severity: Severity::Warning,
1797                            category: IssueCategory::Schema,
1798                            code: "SD003".to_string(),
1799                            title: "Non-standard @context".to_string(),
1800                            description: format!(
1801                                "JSON-LD @context is \"{ctx_val}\" instead of \
1802                                 \"https://schema.org\"."
1803                            ),
1804                            url: url.clone(),
1805                            recommendation: "Use \"https://schema.org\" as the @context."
1806                                .to_string(),
1807                        });
1808                    }
1809                }
1810            }
1811
1812            // Validate @type
1813            match &sd.r#type {
1814                None => {
1815                    findings.push(Finding {
1816                        severity: Severity::Error,
1817                        category: IssueCategory::Schema,
1818                        code: "SD004".to_string(),
1819                        title: "Missing @type".to_string(),
1820                        description: "JSON-LD block is missing the @type property.".to_string(),
1821                        url: url.clone(),
1822                        recommendation: "Add an appropriate @type to describe the content."
1823                            .to_string(),
1824                    });
1825                }
1826                Some(type_val) => {
1827                    if !RECOGNIZED_TYPES.contains(&type_val.as_str()) {
1828                        findings.push(Finding {
1829                            severity: Severity::Warning,
1830                            category: IssueCategory::Schema,
1831                            code: "SD005".to_string(),
1832                            title: "Unknown @type".to_string(),
1833                            description: format!(
1834                                "JSON-LD @type \"{type_val}\" is not a recognized Schema.org \
1835                                 type."
1836                            ),
1837                            url: url.clone(),
1838                            recommendation: "Verify this is a valid Schema.org type or use a \
1839                                             recognized type."
1840                                .to_string(),
1841                        });
1842                    }
1843
1844                    // 2.6 — Check required properties
1845                    let required = Self::required_properties(type_val);
1846                    if !required.is_empty() {
1847                        let mut missing = Vec::new();
1848                        for prop in required {
1849                            if sd.data.get(*prop).is_none() {
1850                                missing.push(*prop);
1851                            }
1852                        }
1853                        if !missing.is_empty() {
1854                            findings.push(Finding {
1855                                severity: Severity::Error,
1856                                category: IssueCategory::Schema,
1857                                code: "SD006".to_string(),
1858                                title: "Missing required properties".to_string(),
1859                                description: format!(
1860                                    "Schema type \"{type_val}\" is missing required properties: \
1861                                     {}.",
1862                                    missing.join(", ")
1863                                ),
1864                                url: url.clone(),
1865                                recommendation: format!(
1866                                    "Add the missing properties to the \"{type_val}\" schema."
1867                                ),
1868                            });
1869                        }
1870                    }
1871                }
1872            }
1873        }
1874
1875        findings
1876    }
1877}
1878
1879// ---------------------------------------------------------------------------
1880// 12. Content Quality Analyzer
1881// ---------------------------------------------------------------------------
1882
1883/// Stop words for keyword density analysis (common English words).
1884const STOP_WORDS: &[&str] = &[
1885    "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "is",
1886    "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will",
1887    "would", "could", "should", "may", "might", "shall", "can", "it", "its", "this", "that",
1888    "these", "those", "i", "you", "he", "she", "we", "they", "me", "him", "her", "us", "them",
1889    "my", "your", "his", "our", "their", "what", "which", "who", "whom", "where", "when", "why",
1890    "how", "all", "each", "every", "both", "few", "more", "most", "other", "some", "such", "no",
1891    "nor", "not", "only", "own", "same", "so", "than", "too", "very", "just", "about", "above",
1892    "after", "again", "also", "as", "before", "between", "down", "from", "here", "if", "into",
1893    "then", "there", "through", "under", "until", "up",
1894];
1895
1896pub struct ContentQualityAnalyzer;
1897
1898impl ContentQualityAnalyzer {
1899    pub fn new() -> Self {
1900        Self
1901    }
1902
1903    /// Count syllables in a word (approximate heuristic).
1904    fn count_syllables(word: &str) -> usize {
1905        let word = word.to_lowercase();
1906        let chars: Vec<char> = word.chars().collect();
1907        if chars.is_empty() {
1908            return 0;
1909        }
1910        if chars.len() <= 2 {
1911            return 1;
1912        }
1913
1914        let vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
1915        let mut count = 0;
1916        let mut prev_vowel = false;
1917
1918        for &c in &chars {
1919            let is_vowel = vowels.contains(&c);
1920            if is_vowel && !prev_vowel {
1921                count += 1;
1922            }
1923            prev_vowel = is_vowel;
1924        }
1925
1926        // Adjust for silent 'e'
1927        if chars.last() == Some(&'e') && count > 1 {
1928            count -= 1;
1929        }
1930
1931        count.max(1)
1932    }
1933
1934    /// Count sentences in text (by punctuation).
1935    fn count_sentences(text: &str) -> usize {
1936        if text.trim().is_empty() {
1937            return 0;
1938        }
1939        let count = text
1940            .chars()
1941            .filter(|&c| c == '.' || c == '!' || c == '?')
1942            .count();
1943        count.max(1)
1944    }
1945
1946    /// Flesch-Kincaid readability score (0-100, higher = easier).
1947    fn flesch_kincaid(words: usize, sentences: usize, syllables: usize) -> f64 {
1948        if words == 0 || sentences == 0 {
1949            return 0.0;
1950        }
1951        let score = 206.835
1952            - 1.015 * (words as f64 / sentences as f64)
1953            - 84.6 * (syllables as f64 / words as f64);
1954        score.clamp(0.0, 100.0)
1955    }
1956}
1957
1958impl Default for ContentQualityAnalyzer {
1959    fn default() -> Self {
1960        Self::new()
1961    }
1962}
1963
1964impl Analyzer for ContentQualityAnalyzer {
1965    fn name(&self) -> &str {
1966        "content-quality"
1967    }
1968
1969    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1970        let mut findings = Vec::new();
1971        let url = &ctx.page.url;
1972
1973        // 2.7 — Flesch-Kincaid readability
1974        let word_count = ctx.page.word_count;
1975        if word_count > 0 {
1976            let text = &ctx
1977                .page
1978                .headings
1979                .iter()
1980                .map(|h| h.text.as_str())
1981                .collect::<Vec<_>>()
1982                .join(" ");
1983            let sentences = Self::count_sentences(text);
1984            let syllables: usize = text.split_whitespace().map(Self::count_syllables).sum();
1985            let score = Self::flesch_kincaid(word_count, sentences.max(1), syllables);
1986
1987            findings.push(Finding {
1988                severity: Severity::Info,
1989                category: IssueCategory::Content,
1990                code: "CQ001".to_string(),
1991                title: "Flesch-Kincaid readability score".to_string(),
1992                description: format!(
1993                    "Readability score: {score:.1}/100 (words: {word_count}, sentences: \
1994                     {sentences}, syllables: {syllables})."
1995                ),
1996                url: url.clone(),
1997                recommendation: if score < 30.0 {
1998                    "Content is very difficult to read. Consider simplifying language and \
1999                     shortening sentences."
2000                        .to_string()
2001                } else if score < 50.0 {
2002                    "Content is fairly difficult to read. Aim for a score of 60+ for general \
2003                     audiences."
2004                        .to_string()
2005                } else if score < 70.0 {
2006                    "Content has moderate readability. Suitable for most audiences.".to_string()
2007                } else {
2008                    "Content is easy to read. Good for general audiences.".to_string()
2009                },
2010            });
2011        }
2012
2013        // Keyword density (top 10 terms from headings as proxy)
2014        let headings_text: String = ctx
2015            .page
2016            .headings
2017            .iter()
2018            .map(|h| h.text.as_str())
2019            .collect::<Vec<_>>()
2020            .join(" ");
2021
2022        if !headings_text.trim().is_empty() {
2023            let mut freq: HashMap<String, usize> = HashMap::new();
2024            for word in headings_text.split_whitespace() {
2025                let lower = word
2026                    .to_lowercase()
2027                    .chars()
2028                    .filter(|c| c.is_alphanumeric())
2029                    .collect::<String>();
2030                if lower.len() > 2 && !STOP_WORDS.contains(&lower.as_str()) {
2031                    *freq.entry(lower).or_default() += 1;
2032                }
2033            }
2034
2035            let mut terms: Vec<(String, usize)> = freq.into_iter().collect();
2036            terms.sort_by_key(|b| std::cmp::Reverse(b.1));
2037            terms.truncate(10);
2038
2039            if !terms.is_empty() {
2040                let display: String = terms
2041                    .iter()
2042                    .map(|(word, count)| format!("\"{}\" ({})", word, count))
2043                    .collect::<Vec<_>>()
2044                    .join(", ");
2045                findings.push(Finding {
2046                    severity: Severity::Info,
2047                    category: IssueCategory::Content,
2048                    code: "CQ002".to_string(),
2049                    title: "Top keywords".to_string(),
2050                    description: format!("Top 10 keyword occurrences in headings: {display}."),
2051                    url: url.clone(),
2052                    recommendation: "Ensure target keywords appear in headings and body content \
2053                                     naturally."
2054                        .to_string(),
2055                });
2056            }
2057        }
2058
2059        // Content-to-markup ratio (word count as proxy for content volume)
2060        if word_count == 0 {
2061            findings.push(Finding {
2062                severity: Severity::Warning,
2063                category: IssueCategory::Content,
2064                code: "CQ003".to_string(),
2065                title: "No content detected".to_string(),
2066                description: "The page has zero word count, which may indicate missing or \
2067                             hidden content."
2068                    .to_string(),
2069                url: url.clone(),
2070                recommendation: "Ensure the page has meaningful visible text content.".to_string(),
2071            });
2072        } else if word_count < 300 {
2073            // Skip thin content warning for utility pages — search engines don't penalize these
2074            let is_utility_page = url.contains("/account")
2075                || url.contains("/compare")
2076                || url.contains("/wishlist")
2077                || url.contains("/cart")
2078                || url.contains("/checkout")
2079                || url.contains("/login")
2080                || url.contains("/register")
2081                || url.contains("/forgot");
2082            if !is_utility_page {
2083                findings.push(Finding {
2084                    severity: Severity::Warning,
2085                    category: IssueCategory::Content,
2086                    code: "CQ004".to_string(),
2087                    title: "Thin content".to_string(),
2088                    description: format!(
2089                        "Page has only {word_count} words. Pages with fewer than 300 words may be \
2090                         considered thin content."
2091                    ),
2092                    url: url.clone(),
2093                    recommendation: "Expand the content to at least 300 words for better search \
2094                                     visibility."
2095                        .to_string(),
2096                });
2097            }
2098        } else if word_count > 3000 {
2099            findings.push(Finding {
2100                severity: Severity::Info,
2101                category: IssueCategory::Content,
2102                code: "CQ005".to_string(),
2103                title: "Long-form content".to_string(),
2104                description: format!(
2105                    "Page has {word_count} words. Consider whether all content is necessary or \
2106                     if it could be split into multiple pages."
2107                ),
2108                url: url.clone(),
2109                recommendation: "Long-form content is good for SEO but ensure it remains \
2110                                 scannable with proper headings."
2111                    .to_string(),
2112            });
2113        }
2114
2115        findings
2116    }
2117}
2118
2119// ---------------------------------------------------------------------------
2120// 13. Word Count Analyzer
2121// ---------------------------------------------------------------------------
2122
2123pub struct WordCountAnalyzer;
2124
2125impl WordCountAnalyzer {
2126    pub fn new() -> Self {
2127        Self
2128    }
2129
2130    /// Extract visible text from headings (proxy for page text).
2131    fn visible_text(ctx: &AnalysisContext) -> String {
2132        ctx.page
2133            .headings
2134            .iter()
2135            .map(|h| h.text.as_str())
2136            .collect::<Vec<_>>()
2137            .join(" ")
2138    }
2139}
2140
2141impl Default for WordCountAnalyzer {
2142    fn default() -> Self {
2143        Self::new()
2144    }
2145}
2146
2147impl Analyzer for WordCountAnalyzer {
2148    fn name(&self) -> &str {
2149        "word-count"
2150    }
2151
2152    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2153        let mut findings = Vec::new();
2154        let url = &ctx.page.url;
2155
2156        let text = Self::visible_text(ctx);
2157        let word_count = ctx.page.word_count;
2158        let char_count = text.chars().count();
2159
2160        // Count sentences
2161        let sentence_count = if text.trim().is_empty() {
2162            0
2163        } else {
2164            text.chars()
2165                .filter(|&c| c == '.' || c == '!' || c == '?')
2166                .count()
2167                .max(1)
2168        };
2169
2170        let avg_words_per_sentence = if sentence_count > 0 {
2171            word_count as f64 / sentence_count as f64
2172        } else {
2173            0.0
2174        };
2175
2176        findings.push(Finding {
2177            severity: Severity::Info,
2178            category: IssueCategory::Content,
2179            code: "WC001".to_string(),
2180            title: "Word count statistics".to_string(),
2181            description: format!(
2182                "Words: {word_count}, Characters: {char_count}, Sentences: {sentence_count}, \
2183                 Avg words/sentence: {avg_words_per_sentence:.1}."
2184            ),
2185            url: url.clone(),
2186            recommendation: String::new(),
2187        });
2188
2189        if word_count == 0 {
2190            findings.push(Finding {
2191                severity: Severity::Warning,
2192                category: IssueCategory::Content,
2193                code: "WC002".to_string(),
2194                title: "Zero word count".to_string(),
2195                description: "The page has no detectable words. This may indicate a rendering \
2196                             issue or an empty page."
2197                    .to_string(),
2198                url: url.clone(),
2199                recommendation: "Verify the page content is visible and not hidden behind \
2200                                 JavaScript or CSS."
2201                    .to_string(),
2202            });
2203        } else if word_count < 100 {
2204            // Skip very low word count warning for utility pages
2205            let is_utility_page = url.contains("/account")
2206                || url.contains("/compare")
2207                || url.contains("/wishlist")
2208                || url.contains("/cart")
2209                || url.contains("/checkout")
2210                || url.contains("/login")
2211                || url.contains("/register")
2212                || url.contains("/forgot");
2213            if !is_utility_page {
2214                findings.push(Finding {
2215                    severity: Severity::Warning,
2216                    category: IssueCategory::Content,
2217                    code: "WC003".to_string(),
2218                    title: "Very low word count".to_string(),
2219                    description: format!(
2220                        "Page has only {word_count} words. This is very thin content."
2221                    ),
2222                    url: url.clone(),
2223                    recommendation: "Add more substantive content to the page.".to_string(),
2224                });
2225            }
2226        }
2227
2228        if avg_words_per_sentence > 25.0 {
2229            findings.push(Finding {
2230                severity: Severity::Info,
2231                category: IssueCategory::Content,
2232                code: "WC004".to_string(),
2233                title: "Long average sentence length".to_string(),
2234                description: format!(
2235                    "Average sentence length is {avg_words_per_sentence:.1} words. Sentences \
2236                     longer than 25 words may be difficult to read."
2237                ),
2238                url: url.clone(),
2239                recommendation: "Break long sentences into shorter ones for better readability."
2240                    .to_string(),
2241            });
2242        }
2243
2244        findings
2245    }
2246}
2247
2248// ---------------------------------------------------------------------------
2249// 14. Security Header Analyzer
2250// ---------------------------------------------------------------------------
2251
2252pub struct SecurityHeaderAnalyzer;
2253
2254impl SecurityHeaderAnalyzer {
2255    pub fn new() -> Self {
2256        Self
2257    }
2258
2259    /// Look up a header value by name (case-insensitive).
2260    fn get_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
2261        headers
2262            .iter()
2263            .find(|(k, _)| k.eq_ignore_ascii_case(name))
2264            .map(|(_, v)| v.as_str())
2265    }
2266
2267    /// Validate a CSP directive string (basic syntax check).
2268    fn is_valid_csp(value: &str) -> bool {
2269        if value.trim().is_empty() {
2270            return false;
2271        }
2272        // CSP must contain at least one directive (e.g. "default-src 'self'")
2273        let directives = [
2274            "default-src",
2275            "script-src",
2276            "style-src",
2277            "img-src",
2278            "font-src",
2279            "connect-src",
2280            "frame-src",
2281            "object-src",
2282            "media-src",
2283            "child-src",
2284            "worker-src",
2285            "manifest-src",
2286            "form-action",
2287            "frame-ancestors",
2288            "base-uri",
2289            "upgrade-insecure-requests",
2290            "block-all-mixed-content",
2291        ];
2292        value.split(';').any(|part| {
2293            let trimmed = part.trim();
2294            directives.iter().any(|d| trimmed.starts_with(d))
2295        })
2296    }
2297
2298    /// Validate HSTS value (max-age, includeSubDomains, preload).
2299    fn validate_hsts(value: &str) -> Vec<String> {
2300        let mut issues = Vec::new();
2301        let lower = value.to_lowercase();
2302
2303        // Must contain max-age
2304        if !lower.contains("max-age=") {
2305            issues.push("missing max-age directive".to_string());
2306        } else if let Some(ma_pos) = lower.find("max-age=") {
2307            // SECURITY FIX: Use `lower` for slicing, not `value`.
2308            // `ma_pos` is the byte position in `lower`; slicing the
2309            // original `value` at that index is incorrect when the
2310            // original contains multi-byte or case-changing characters.
2311            let after = &lower[ma_pos + 8..];
2312            let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
2313            match num_str.parse::<u64>() {
2314                Ok(age) if age < 31536000 => {
2315                    issues.push(format!(
2316                        "max-age ({age}) is below recommended minimum of 31536000 (1 year)"
2317                    ));
2318                }
2319                Ok(_) => {} // acceptable
2320                Err(_) => {
2321                    issues.push("max-age is not a valid integer".to_string());
2322                }
2323            }
2324        }
2325
2326        issues
2327    }
2328
2329    /// Compute a security posture score (0-100) from the findings.
2330    fn compute_score(findings: &[Finding]) -> u32 {
2331        let mut score: i32 = 100;
2332        for f in findings {
2333            if f.code == "SEC012" {
2334                continue; // Don't count the score finding itself
2335            }
2336            match f.severity {
2337                Severity::Critical => score -= 20,
2338                Severity::Error => score -= 10,
2339                Severity::Warning => score -= 5,
2340                Severity::Info => {}
2341            }
2342        }
2343        score.max(0) as u32
2344    }
2345}
2346
2347impl Default for SecurityHeaderAnalyzer {
2348    fn default() -> Self {
2349        Self::new()
2350    }
2351}
2352
2353impl Analyzer for SecurityHeaderAnalyzer {
2354    fn name(&self) -> &str {
2355        "security-headers"
2356    }
2357
2358    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2359        let mut findings = Vec::new();
2360        let url = &ctx.page.url;
2361        let headers = ctx.headers;
2362
2363        // --- Content-Security-Policy ---
2364        match Self::get_header(headers, "Content-Security-Policy") {
2365            None => {
2366                findings.push(Finding {
2367                    severity: Severity::Warning,
2368                    category: IssueCategory::Security,
2369                    code: "SEC001".to_string(),
2370                    title: "Missing Content-Security-Policy header".to_string(),
2371                    description: "No Content-Security-Policy header was found. CSP helps prevent \
2372                                  XSS, clickjacking, and other code injection attacks."
2373                        .to_string(),
2374                    url: url.clone(),
2375                    recommendation: "Implement a Content-Security-Policy header. Start with \
2376                                     \"default-src 'self'\" and refine as needed."
2377                        .to_string(),
2378                });
2379            }
2380            Some(csp) => {
2381                if !Self::is_valid_csp(csp) {
2382                    findings.push(Finding {
2383                        severity: Severity::Warning,
2384                        category: IssueCategory::Security,
2385                        code: "SEC013".to_string(),
2386                        title: "Invalid Content-Security-Policy syntax".to_string(),
2387                        description: "The CSP header value does not appear to contain valid \
2388                                      directive syntax."
2389                            .to_string(),
2390                        url: url.clone(),
2391                        recommendation: "Ensure CSP contains at least one valid directive \
2392                                         (e.g. default-src, script-src)."
2393                            .to_string(),
2394                    });
2395                }
2396            }
2397        }
2398
2399        // --- Strict-Transport-Security (HSTS) ---
2400        match Self::get_header(headers, "Strict-Transport-Security") {
2401            None => {
2402                findings.push(Finding {
2403                    severity: Severity::Warning,
2404                    category: IssueCategory::Security,
2405                    code: "SEC002".to_string(),
2406                    title: "Missing Strict-Transport-Security header".to_string(),
2407                    description: "No Strict-Transport-Security (HSTS) header was found. HSTS \
2408                                  forces browsers to use HTTPS."
2409                        .to_string(),
2410                    url: url.clone(),
2411                    recommendation: "Add \"Strict-Transport-Security: max-age=31536000; \
2412                                     includeSubDomains; preload\"."
2413                        .to_string(),
2414                });
2415            }
2416            Some(hsts) => {
2417                let hsts_issues = Self::validate_hsts(hsts);
2418                for issue in &hsts_issues {
2419                    findings.push(Finding {
2420                        severity: Severity::Warning,
2421                        category: IssueCategory::Security,
2422                        code: "SEC014".to_string(),
2423                        title: "HSTS configuration issue".to_string(),
2424                        description: format!("HSTS header: {issue}."),
2425                        url: url.clone(),
2426                        recommendation: "Set max-age to at least 31536000 (1 year). Add \
2427                                         includeSubDomains and preload."
2428                            .to_string(),
2429                    });
2430                }
2431                if !hsts.to_lowercase().contains("includesubdomains") {
2432                    findings.push(Finding {
2433                        severity: Severity::Info,
2434                        category: IssueCategory::Security,
2435                        code: "SEC015".to_string(),
2436                        title: "HSTS missing includeSubDomains".to_string(),
2437                        description: "The HSTS header does not include the includeSubDomains \
2438                                      directive."
2439                            .to_string(),
2440                        url: url.clone(),
2441                        recommendation: "Add includeSubDomains to protect all subdomains."
2442                            .to_string(),
2443                    });
2444                }
2445                if !hsts.to_lowercase().contains("preload") {
2446                    findings.push(Finding {
2447                        severity: Severity::Info,
2448                        category: IssueCategory::Security,
2449                        code: "SEC016".to_string(),
2450                        title: "HSTS missing preload".to_string(),
2451                        description: "The HSTS header does not include the preload directive."
2452                            .to_string(),
2453                        url: url.clone(),
2454                        recommendation: "Consider adding preload for browser HSTS preload \
2455                                         list inclusion."
2456                            .to_string(),
2457                    });
2458                }
2459            }
2460        }
2461
2462        // --- X-Frame-Options ---
2463        match Self::get_header(headers, "X-Frame-Options") {
2464            None => {
2465                findings.push(Finding {
2466                    severity: Severity::Warning,
2467                    category: IssueCategory::Security,
2468                    code: "SEC003".to_string(),
2469                    title: "Missing X-Frame-Options header".to_string(),
2470                    description: "No X-Frame-Options header was found. This header prevents \
2471                                  clickjacking by controlling frame embedding."
2472                        .to_string(),
2473                    url: url.clone(),
2474                    recommendation: "Set X-Frame-Options to DENY or SAMEORIGIN.".to_string(),
2475                });
2476            }
2477            Some(value) => {
2478                let upper = value.to_uppercase().trim().to_string();
2479                if upper != "DENY" && upper != "SAMEORIGIN" {
2480                    findings.push(Finding {
2481                        severity: Severity::Warning,
2482                        category: IssueCategory::Security,
2483                        code: "SEC004".to_string(),
2484                        title: "Invalid X-Frame-Options value".to_string(),
2485                        description: format!(
2486                            "X-Frame-Options is \"{value}\" but must be DENY or SAMEORIGIN."
2487                        ),
2488                        url: url.clone(),
2489                        recommendation: "Set X-Frame-Options to DENY (preferred) or SAMEORIGIN."
2490                            .to_string(),
2491                    });
2492                }
2493            }
2494        }
2495
2496        // --- X-Content-Type-Options ---
2497        match Self::get_header(headers, "X-Content-Type-Options") {
2498            None => {
2499                findings.push(Finding {
2500                    severity: Severity::Warning,
2501                    category: IssueCategory::Security,
2502                    code: "SEC005".to_string(),
2503                    title: "Missing X-Content-Type-Options header".to_string(),
2504                    description: "No X-Content-Type-Options header was found. This header \
2505                                  prevents MIME-type sniffing."
2506                        .to_string(),
2507                    url: url.clone(),
2508                    recommendation: "Set X-Content-Type-Options to nosniff.".to_string(),
2509                });
2510            }
2511            Some(value) => {
2512                if value.trim().to_lowercase() != "nosniff" {
2513                    findings.push(Finding {
2514                        severity: Severity::Warning,
2515                        category: IssueCategory::Security,
2516                        code: "SEC006".to_string(),
2517                        title: "Invalid X-Content-Type-Options value".to_string(),
2518                        description: format!(
2519                            "X-Content-Type-Options is \"{value}\" but must be nosniff."
2520                        ),
2521                        url: url.clone(),
2522                        recommendation: "Set X-Content-Type-Options to nosniff.".to_string(),
2523                    });
2524                }
2525            }
2526        }
2527
2528        // --- Referrer-Policy ---
2529        const RECOMMENDED_REFERRER: &[&str] = &[
2530            "no-referrer",
2531            "no-referrer-when-downgrade",
2532            "origin",
2533            "origin-when-cross-origin",
2534            "same-origin",
2535            "strict-origin",
2536            "strict-origin-when-cross-origin",
2537            "unsafe-url",
2538        ];
2539        match Self::get_header(headers, "Referrer-Policy") {
2540            None => {
2541                findings.push(Finding {
2542                    severity: Severity::Info,
2543                    category: IssueCategory::Security,
2544                    code: "SEC007".to_string(),
2545                    title: "Missing Referrer-Policy header".to_string(),
2546                    description: "No Referrer-Policy header was found. This header controls how \
2547                                  much referrer information is sent with requests."
2548                        .to_string(),
2549                    url: url.clone(),
2550                    recommendation: "Set Referrer-Policy to strict-origin-when-cross-origin \
2551                                     or no-referrer for maximum privacy."
2552                        .to_string(),
2553                });
2554            }
2555            Some(value) => {
2556                if !RECOMMENDED_REFERRER.contains(&value.trim()) {
2557                    findings.push(Finding {
2558                        severity: Severity::Info,
2559                        category: IssueCategory::Security,
2560                        code: "SEC017".to_string(),
2561                        title: "Uncommon Referrer-Policy value".to_string(),
2562                        description: format!(
2563                            "Referrer-Policy \"{value}\" is not in the list of commonly used \
2564                             policies."
2565                        ),
2566                        url: url.clone(),
2567                        recommendation: "Consider using strict-origin-when-cross-origin or \
2568                                         no-referrer."
2569                            .to_string(),
2570                    });
2571                }
2572            }
2573        }
2574
2575        // --- Permissions-Policy ---
2576        if Self::get_header(headers, "Permissions-Policy").is_none() {
2577            findings.push(Finding {
2578                severity: Severity::Info,
2579                category: IssueCategory::Security,
2580                code: "SEC008".to_string(),
2581                title: "Missing Permissions-Policy header".to_string(),
2582                description: "No Permissions-Policy header was found. This header controls \
2583                              which browser features APIs can be used."
2584                    .to_string(),
2585                url: url.clone(),
2586                recommendation: "Consider setting Permissions-Policy to disable unused features \
2587                                 like camera, microphone, geolocation."
2588                    .to_string(),
2589            });
2590        } else if let Some(pp) = Self::get_header(headers, "Permissions-Policy") {
2591            // Check that dangerous features are restricted
2592            let dangerous = ["camera", "microphone", "geolocation"];
2593            for feature in &dangerous {
2594                if pp.to_lowercase().contains(feature)
2595                    && pp.to_lowercase().contains(&format!("{feature}=()"))
2596                {
2597                    // Feature is restricted (empty allowlist) — good
2598                } else if pp.to_lowercase().contains(feature) {
2599                    findings.push(Finding {
2600                        severity: Severity::Info,
2601                        category: IssueCategory::Security,
2602                        code: "SEC018".to_string(),
2603                        title: format!("Permissions-Policy: {feature} not restricted"),
2604                        description: format!(
2605                            "The {feature} feature in Permissions-Policy is not explicitly \
2606                             restricted."
2607                        ),
2608                        url: url.clone(),
2609                        recommendation: format!(
2610                            "Add {feature}=() to Permissions-Policy to disable it if not \
2611                             needed."
2612                        ),
2613                    });
2614                }
2615            }
2616        }
2617
2618        // --- Cross-Origin-Embedder-Policy (COEP) ---
2619        if Self::get_header(headers, "Cross-Origin-Embedder-Policy").is_none() {
2620            findings.push(Finding {
2621                severity: Severity::Info,
2622                category: IssueCategory::Security,
2623                code: "SEC009".to_string(),
2624                title: "Missing Cross-Origin-Embedder-Policy header".to_string(),
2625                description: "No COEP header was found. COEP prevents resources from loading \
2626                              cross-origin without explicit permission."
2627                    .to_string(),
2628                url: url.clone(),
2629                recommendation: "Set COEP to require-corp for stricter cross-origin isolation."
2630                    .to_string(),
2631            });
2632        }
2633
2634        // --- Cross-Origin-Opener-Policy (COOP) ---
2635        if Self::get_header(headers, "Cross-Origin-Opener-Policy").is_none() {
2636            findings.push(Finding {
2637                severity: Severity::Info,
2638                category: IssueCategory::Security,
2639                code: "SEC010".to_string(),
2640                title: "Missing Cross-Origin-Opener-Policy header".to_string(),
2641                description: "No COOP header was found. COOP isolates your browsing context \
2642                              from cross-origin popups."
2643                    .to_string(),
2644                url: url.clone(),
2645                recommendation: "Set COOP to same-origin for stricter isolation.".to_string(),
2646            });
2647        }
2648
2649        // --- Cross-Origin-Resource-Policy (CORP) ---
2650        if Self::get_header(headers, "Cross-Origin-Resource-Policy").is_none() {
2651            findings.push(Finding {
2652                severity: Severity::Info,
2653                category: IssueCategory::Security,
2654                code: "SEC011".to_string(),
2655                title: "Missing Cross-Origin-Resource-Policy header".to_string(),
2656                description: "No CORP header was found. CORP prevents cross-origin reads of \
2657                              embedded resources."
2658                    .to_string(),
2659                url: url.clone(),
2660                recommendation: "Set CORP to same-origin if the resource should only be used \
2661                                 by the same origin."
2662                    .to_string(),
2663            });
2664        }
2665
2666        // --- Security posture score ---
2667        let score = Self::compute_score(&findings);
2668        findings.push(Finding {
2669            severity: Severity::Info,
2670            category: IssueCategory::Security,
2671            code: "SEC012".to_string(),
2672            title: "Security posture score".to_string(),
2673            description: format!("Security header score: {score}/100."),
2674            url: url.clone(),
2675            recommendation: if score < 50 {
2676                "Security posture is weak. Prioritize adding CSP, HSTS, and frame-protecting \
2677                 headers."
2678                    .to_string()
2679            } else if score < 80 {
2680                "Security posture is moderate. Address remaining missing headers.".to_string()
2681            } else {
2682                "Security posture is strong. Minor improvements possible.".to_string()
2683            },
2684        });
2685
2686        findings
2687    }
2688}
2689
2690// ---------------------------------------------------------------------------
2691// 15. SSL Certificate Validator
2692// ---------------------------------------------------------------------------
2693
2694pub struct SslCertificateValidator {
2695    cert_info: Option<SslCertificateInfo>,
2696}
2697
2698impl SslCertificateValidator {
2699    /// Create a validator with pre-fetched certificate information.
2700    pub fn new(cert_info: Option<SslCertificateInfo>) -> Self {
2701        Self { cert_info }
2702    }
2703
2704    pub fn empty() -> Self {
2705        Self { cert_info: None }
2706    }
2707
2708    /// Parse an ISO 8601 date string into seconds since epoch for comparison.
2709    fn parse_epoch(s: &str) -> Option<i64> {
2710        chrono::DateTime::parse_from_rfc3339(s)
2711            .or_else(|_| chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ"))
2712            .ok()
2713            .map(|dt| dt.timestamp())
2714    }
2715
2716    /// Check if an algorithm is considered weak.
2717    ///
2718    /// Returns true for MD5, SHA-1, or RSA without SHA-256+.
2719    /// Explicit parentheses to clarify operator precedence:
2720    /// `(md5 || sha1) || (rsa && !sha256 && !sha384 && !sha512)`
2721    fn is_weak_algorithm(algo: &str) -> bool {
2722        let lower = algo.to_lowercase();
2723        (lower.contains("md5") || lower.contains("sha1"))
2724            || (lower.contains("with rsa encryption")
2725                && !lower.contains("sha256")
2726                && !lower.contains("sha384")
2727                && !lower.contains("sha512"))
2728    }
2729}
2730
2731impl Default for SslCertificateValidator {
2732    fn default() -> Self {
2733        Self::empty()
2734    }
2735}
2736
2737impl Analyzer for SslCertificateValidator {
2738    fn name(&self) -> &str {
2739        "ssl-certificate"
2740    }
2741
2742    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2743        let mut findings = Vec::new();
2744        let url = &ctx.page.url;
2745
2746        let info = match &self.cert_info {
2747            Some(i) => i,
2748            None => {
2749                findings.push(Finding {
2750                    severity: Severity::Info,
2751                    category: IssueCategory::Security,
2752                    code: "SSL007".to_string(),
2753                    title: "No SSL certificate data available".to_string(),
2754                    description: "No TLS certificate information was provided for validation."
2755                        .to_string(),
2756                    url: url.clone(),
2757                    recommendation: "Provide certificate data from the TLS connection to enable \
2758                                     SSL validation."
2759                        .to_string(),
2760                });
2761                return findings;
2762            }
2763        };
2764
2765        // --- Expired certificate ---
2766        if let Some(ref not_after) = info.not_after {
2767            if let Some(expiry_epoch) = Self::parse_epoch(not_after) {
2768                let now = chrono::Utc::now().timestamp();
2769                if now > expiry_epoch {
2770                    findings.push(Finding {
2771                        severity: Severity::Critical,
2772                        category: IssueCategory::Security,
2773                        code: "SSL001".to_string(),
2774                        title: "SSL certificate has expired".to_string(),
2775                        description: format!(
2776                            "Certificate expired on {not_after} ({} days ago).",
2777                            (now - expiry_epoch) / 86400
2778                        ),
2779                        url: url.clone(),
2780                        recommendation: "Renew the SSL certificate immediately. Expired \
2781                                         certificates cause browser security warnings."
2782                            .to_string(),
2783                    });
2784                } else {
2785                    let days_left = (expiry_epoch - now) / 86400;
2786                    if days_left < 30 {
2787                        findings.push(Finding {
2788                            severity: Severity::Warning,
2789                            category: IssueCategory::Security,
2790                            code: "SSL002".to_string(),
2791                            title: "SSL certificate expiring soon".to_string(),
2792                            description: format!(
2793                                "Certificate expires on {not_after} ({days_left} days remaining)."
2794                            ),
2795                            url: url.clone(),
2796                            recommendation: "Renew the certificate before it expires. Set up \
2797                                             auto-renewal (e.g. Let's Encrypt certbot)."
2798                                .to_string(),
2799                        });
2800                    }
2801                }
2802            }
2803        }
2804
2805        // --- Invalid certificate chain ---
2806        if !info.is_valid_chain {
2807            findings.push(Finding {
2808                severity: Severity::Critical,
2809                category: IssueCategory::Security,
2810                code: "SSL003".to_string(),
2811                title: "Invalid certificate chain".to_string(),
2812                description: "The TLS certificate chain did not validate successfully. Browsers \
2813                              will show a security warning."
2814                    .to_string(),
2815                url: url.clone(),
2816                recommendation: "Ensure the full certificate chain (including intermediate \
2817                                 certificates) is properly installed."
2818                    .to_string(),
2819            });
2820        }
2821
2822        // --- Self-signed certificate ---
2823        if info.is_self_signed {
2824            findings.push(Finding {
2825                severity: Severity::Error,
2826                category: IssueCategory::Security,
2827                code: "SSL006".to_string(),
2828                title: "Self-signed certificate detected".to_string(),
2829                description: "The certificate is self-signed and will not be trusted by browsers."
2830                    .to_string(),
2831                url: url.clone(),
2832                recommendation: "Use a certificate signed by a trusted Certificate Authority. \
2833                                 Consider Let's Encrypt for free trusted certificates."
2834                    .to_string(),
2835            });
2836        }
2837
2838        // --- Subject/SAN mismatch ---
2839        let page_host = Url::parse(url)
2840            .ok()
2841            .and_then(|u| u.host_str().map(String::from));
2842        if let Some(ref host) = page_host {
2843            let mut matched = false;
2844            if let Some(ref subject) = info.subject {
2845                if subject.eq_ignore_ascii_case(host) {
2846                    matched = true;
2847                }
2848                // Check wildcard match
2849                if let Some(wildcard_domain) = subject.strip_prefix("*.") {
2850                    if let Some(stripped_host) = host.strip_prefix('*') {
2851                        if stripped_host == wildcard_domain {
2852                            matched = true;
2853                        }
2854                    }
2855                    // Also handle bare wildcard: *.example.com matches sub.example.com
2856                    let parts: Vec<&str> = host.split('.').collect();
2857                    if parts.len() > 1 {
2858                        let root = parts[1..].join(".");
2859                        if wildcard_domain == root {
2860                            matched = true;
2861                        }
2862                    }
2863                }
2864            }
2865            for san in &info.san_entries {
2866                if san.eq_ignore_ascii_case(host) {
2867                    matched = true;
2868                    break;
2869                }
2870                // Wildcard SAN
2871                if let Some(wildcard_domain) = san.strip_prefix("*.") {
2872                    let parts: Vec<&str> = host.split('.').collect();
2873                    if parts.len() > 1 {
2874                        let root = parts[1..].join(".");
2875                        if wildcard_domain == root {
2876                            matched = true;
2877                            break;
2878                        }
2879                    }
2880                }
2881            }
2882            if !matched && !info.san_entries.is_empty() {
2883                findings.push(Finding {
2884                    severity: Severity::Error,
2885                    category: IssueCategory::Security,
2886                    code: "SSL004".to_string(),
2887                    title: "Subject/SAN does not match hostname".to_string(),
2888                    description: format!(
2889                        "Certificate subject {:?} and SANs {:?} do not match hostname \"{host}\".",
2890                        info.subject, info.san_entries,
2891                    ),
2892                    url: url.clone(),
2893                    recommendation: "Issue a certificate that includes the correct hostname in \
2894                                     the Subject CN or Subject Alternative Names."
2895                        .to_string(),
2896                });
2897            }
2898        }
2899
2900        // --- Weak signature algorithm ---
2901        if let Some(ref algo) = info.signature_algorithm {
2902            if Self::is_weak_algorithm(algo) {
2903                findings.push(Finding {
2904                    severity: Severity::Warning,
2905                    category: IssueCategory::Security,
2906                    code: "SSL005".to_string(),
2907                    title: "Weak signature algorithm".to_string(),
2908                    description: format!(
2909                        "Certificate uses signature algorithm \"{algo}\", which is considered \
2910                         weak."
2911                    ),
2912                    url: url.clone(),
2913                    recommendation: "Reissue the certificate with SHA-256 or stronger signature \
2914                                     algorithm."
2915                        .to_string(),
2916                });
2917            }
2918        }
2919
2920        // --- Certificate info summary ---
2921        findings.push(Finding {
2922            severity: Severity::Info,
2923            category: IssueCategory::Security,
2924            code: "SSL008".to_string(),
2925            title: "SSL certificate details".to_string(),
2926            description: format!(
2927                "Subject: {}, Issuer: {}, SANs: {}, Chain valid: {}, Self-signed: {}",
2928                info.subject.as_deref().unwrap_or("N/A"),
2929                info.issuer.as_deref().unwrap_or("N/A"),
2930                info.san_entries.len(),
2931                info.is_valid_chain,
2932                info.is_self_signed,
2933            ),
2934            url: url.clone(),
2935            recommendation: String::new(),
2936        });
2937
2938        findings
2939    }
2940}
2941
2942// ---------------------------------------------------------------------------
2943// 16. Mobile-Friendliness Checker
2944// ---------------------------------------------------------------------------
2945
2946pub struct MobileFriendlinessChecker;
2947
2948impl MobileFriendlinessChecker {
2949    pub fn new() -> Self {
2950        Self
2951    }
2952
2953    /// Parse viewport meta content into a map of directives.
2954    fn parse_viewport(viewport: &str) -> HashMap<String, String> {
2955        let mut map = HashMap::new();
2956        for part in viewport.split(',') {
2957            let part = part.trim();
2958            if let Some((key, value)) = part.split_once('=') {
2959                map.insert(key.trim().to_lowercase(), value.trim().to_lowercase());
2960            }
2961        }
2962        map
2963    }
2964}
2965
2966impl Default for MobileFriendlinessChecker {
2967    fn default() -> Self {
2968        Self::new()
2969    }
2970}
2971
2972impl Analyzer for MobileFriendlinessChecker {
2973    fn name(&self) -> &str {
2974        "mobile-friendliness"
2975    }
2976
2977    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2978        let mut findings = Vec::new();
2979        let url = &ctx.page.url;
2980
2981        // --- Viewport meta tag presence ---
2982        let viewport = match &ctx.page.meta.viewport {
2983            Some(v) => v,
2984            None => {
2985                findings.push(Finding {
2986                    severity: Severity::Error,
2987                    category: IssueCategory::Mobile,
2988                    code: "MOB001".to_string(),
2989                    title: "Missing viewport meta tag".to_string(),
2990                    description: "No viewport meta tag was found. Without it, the page will not \
2991                                  scale properly on mobile devices."
2992                        .to_string(),
2993                    url: url.clone(),
2994                    recommendation: "Add <meta name=\"viewport\" content=\"width=device-width, \
2995                                     initial-scale=1\"> to the <head>."
2996                        .to_string(),
2997                });
2998                return findings;
2999            }
3000        };
3001
3002        let directives = Self::parse_viewport(viewport);
3003
3004        // --- width=device-width ---
3005        match directives.get("width") {
3006            None => {
3007                findings.push(Finding {
3008                    severity: Severity::Warning,
3009                    category: IssueCategory::Mobile,
3010                    code: "MOB002".to_string(),
3011                    title: "Viewport missing width directive".to_string(),
3012                    description: "The viewport meta tag does not specify width=device-width."
3013                        .to_string(),
3014                    url: url.clone(),
3015                    recommendation: "Set width=device-width in the viewport meta tag.".to_string(),
3016                });
3017            }
3018            Some(w) if w != "device-width" => {
3019                findings.push(Finding {
3020                    severity: Severity::Error,
3021                    category: IssueCategory::Mobile,
3022                    code: "MOB003".to_string(),
3023                    title: "Viewport width is not device-width".to_string(),
3024                    description: format!(
3025                        "Viewport width is set to \"{w}\" instead of device-width. This forces \
3026                         a fixed layout."
3027                    ),
3028                    url: url.clone(),
3029                    recommendation: "Change viewport width to device-width for responsive layout."
3030                        .to_string(),
3031                });
3032            }
3033            _ => {} // device-width — correct
3034        }
3035
3036        // --- user-scalable=no or maximum-scale=1.0 ---
3037        if directives.get("user-scalable") == Some(&"no".to_string()) {
3038            findings.push(Finding {
3039                severity: Severity::Error,
3040                category: IssueCategory::Mobile,
3041                code: "MOB004".to_string(),
3042                title: "Zooming is disabled (user-scalable=no)".to_string(),
3043                description: "The viewport meta tag disables user zooming with \
3044                              user-scalable=no. This is a critical accessibility issue."
3045                    .to_string(),
3046                url: url.clone(),
3047                recommendation: "Remove user-scalable=no to allow pinch-to-zoom. This is \
3048                                 required for WCAG 2.1 compliance."
3049                    .to_string(),
3050            });
3051        }
3052
3053        if let Some(max_scale) = directives.get("maximum-scale") {
3054            if max_scale == "1" || max_scale == "1.0" {
3055                findings.push(Finding {
3056                    severity: Severity::Warning,
3057                    category: IssueCategory::Mobile,
3058                    code: "MOB005".to_string(),
3059                    title: "Maximum scale restricted".to_string(),
3060                    description: format!(
3061                        "maximum-scale={max_scale} limits zooming. While not as severe as \
3062                         user-scalable=no, it can still hinder accessibility."
3063                    ),
3064                    url: url.clone(),
3065                    recommendation: "Remove the maximum-scale constraint or set it to at least \
3066                                     5.0."
3067                        .to_string(),
3068                });
3069            }
3070        }
3071
3072        // --- initial-scale ---
3073        if let Some(scale) = directives.get("initial-scale") {
3074            if scale != "1" && scale != "1.0" {
3075                findings.push(Finding {
3076                    severity: Severity::Info,
3077                    category: IssueCategory::Mobile,
3078                    code: "MOB009".to_string(),
3079                    title: "Non-standard initial scale".to_string(),
3080                    description: format!(
3081                        "initial-scale is set to \"{scale}\" instead of the standard 1.0. This \
3082                         may cause unexpected zoom behavior on page load."
3083                    ),
3084                    url: url.clone(),
3085                    recommendation: "Set initial-scale=1 for a consistent mobile experience."
3086                        .to_string(),
3087                });
3088            }
3089        }
3090
3091        // MOB006-008 removed: placeholder findings that fire unconditionally on every page.
3092        // Touch targets, font sizes, and horizontal scrolling require CSS layout
3093        // computation or runtime testing that is not implemented. Emitting these
3094        // as Info on every page corrupts issue counts and undermines trust.
3095
3096        findings
3097    }
3098}
3099
3100// ---------------------------------------------------------------------------
3101// 17. Accessibility Analyzer (WCAG 2.1 AA)
3102// ---------------------------------------------------------------------------
3103
3104pub struct AccessibilityAnalyzer;
3105
3106impl AccessibilityAnalyzer {
3107    pub fn new() -> Self {
3108        Self
3109    }
3110
3111    /// Generic link text patterns that are not descriptive.
3112    const VAGUE_LINK_TEXTS: &[&str] = &[
3113        "click here",
3114        "here",
3115        "read more",
3116        "more",
3117        "learn more",
3118        "click",
3119        "link",
3120        "this",
3121        "go",
3122        "continue",
3123    ];
3124}
3125
3126impl Default for AccessibilityAnalyzer {
3127    fn default() -> Self {
3128        Self::new()
3129    }
3130}
3131
3132impl Analyzer for AccessibilityAnalyzer {
3133    fn name(&self) -> &str {
3134        "accessibility"
3135    }
3136
3137    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
3138        let mut findings = Vec::new();
3139        let url = &ctx.page.url;
3140
3141        // --- WCAG 1.1.1: Image alt text ---
3142        let images_without_alt: Vec<&str> = ctx
3143            .page
3144            .images
3145            .iter()
3146            .filter(|img| !img.has_alt || img.alt.trim().is_empty())
3147            .map(|img| img.src.as_str())
3148            .collect();
3149        if !images_without_alt.is_empty() {
3150            findings.push(Finding {
3151                severity: Severity::Error,
3152                category: IssueCategory::Accessibility,
3153                code: "A11Y001".to_string(),
3154                title: "Images missing alt text".to_string(),
3155                description: format!(
3156                    "{} image(s) missing alt attribute or have empty alt text: {}.",
3157                    images_without_alt.len(),
3158                    images_without_alt.join(", ")
3159                ),
3160                url: url.clone(),
3161                recommendation: "Add descriptive alt text to all images. Use alt=\"\" for \
3162                                 decorative images."
3163                    .to_string(),
3164            });
3165        }
3166
3167        // --- WCAG 1.3.1: Heading hierarchy ---
3168        if ctx.page.headings.is_empty() {
3169            findings.push(Finding {
3170                severity: Severity::Warning,
3171                category: IssueCategory::Accessibility,
3172                code: "A11Y002".to_string(),
3173                title: "No headings found".to_string(),
3174                description: "The page has no heading elements. Headings provide structure \
3175                              for screen reader users."
3176                    .to_string(),
3177                url: url.clone(),
3178                recommendation: "Add heading elements (H1-H6) to provide page structure."
3179                    .to_string(),
3180            });
3181        } else {
3182            let h1_count = ctx.page.headings.iter().filter(|h| h.level == 1).count();
3183            if h1_count == 0 {
3184                findings.push(Finding {
3185                    severity: Severity::Error,
3186                    category: IssueCategory::Accessibility,
3187                    code: "A11Y003".to_string(),
3188                    title: "Missing H1 heading".to_string(),
3189                    description: "No H1 heading found. Screen readers use H1 to identify \
3190                                  the main page topic."
3191                        .to_string(),
3192                    url: url.clone(),
3193                    recommendation: "Add exactly one H1 heading per page.".to_string(),
3194                });
3195            } else if h1_count > 1 {
3196                findings.push(Finding {
3197                    severity: Severity::Warning,
3198                    category: IssueCategory::Accessibility,
3199                    code: "A11Y004".to_string(),
3200                    title: "Multiple H1 headings".to_string(),
3201                    description: format!(
3202                        "Page has {h1_count} H1 headings. Use a single H1 for the main topic."
3203                    ),
3204                    url: url.clone(),
3205                    recommendation: "Use one H1 for the page title and H2+ for sections."
3206                        .to_string(),
3207                });
3208            }
3209
3210            // Skipped heading levels
3211            let mut prev_level: Option<u8> = None;
3212            for heading in &ctx.page.headings {
3213                if let Some(prev) = prev_level {
3214                    if heading.level > prev + 1 {
3215                        findings.push(Finding {
3216                            severity: Severity::Warning,
3217                            category: IssueCategory::Accessibility,
3218                            code: "A11Y005".to_string(),
3219                            title: "Skipped heading level".to_string(),
3220                            description: format!(
3221                                "Heading jumps from H{prev} to H{}, skipping intermediate \
3222                                 levels.",
3223                                heading.level
3224                            ),
3225                            url: url.clone(),
3226                            recommendation: format!(
3227                                "Use H{} after H{prev} to maintain document outline.",
3228                                prev + 1
3229                            ),
3230                        });
3231                        break; // Report first skip only
3232                    }
3233                }
3234                prev_level = Some(heading.level);
3235            }
3236        }
3237
3238        // --- WCAG 1.3.1: Landmark roles ---
3239        if !ctx.page.has_main_landmark {
3240            findings.push(Finding {
3241                severity: Severity::Warning,
3242                category: IssueCategory::Accessibility,
3243                code: "A11Y006".to_string(),
3244                title: "Missing main landmark".to_string(),
3245                description: "No <main> element or role=\"main\" found. Screen readers use \
3246                              landmarks for page navigation."
3247                    .to_string(),
3248                url: url.clone(),
3249                recommendation: "Wrap primary content in a <main> element.".to_string(),
3250            });
3251        }
3252
3253        if !ctx.page.has_nav_landmark {
3254            findings.push(Finding {
3255                severity: Severity::Info,
3256                category: IssueCategory::Accessibility,
3257                code: "A11Y007".to_string(),
3258                title: "No navigation landmark".to_string(),
3259                description: "No <nav> element or role=\"navigation\" found.".to_string(),
3260                url: url.clone(),
3261                recommendation: "Wrap navigation links in a <nav> element.".to_string(),
3262            });
3263        }
3264
3265        // --- WCAG 2.4.1: Skip navigation ---
3266        if !ctx.page.has_skip_link && ctx.page.has_nav_landmark {
3267            findings.push(Finding {
3268                severity: Severity::Warning,
3269                category: IssueCategory::Accessibility,
3270                code: "A11Y008".to_string(),
3271                title: "Missing skip navigation link".to_string(),
3272                description: "No skip-to-content link found. Keyboard users must tab through \
3273                              all navigation links to reach main content."
3274                    .to_string(),
3275                url: url.clone(),
3276                recommendation: "Add a skip link as the first focusable element: \
3277                                 <a href=\"#main\" class=\"skip-link\">Skip to content</a>."
3278                    .to_string(),
3279            });
3280        }
3281
3282        // --- WCAG 2.4.4: Link text quality ---
3283        for link in &ctx.page.links {
3284            let text_lower = link.text.trim().to_lowercase();
3285            // Check for accessible name: text content, aria-label, or img alt
3286            let has_accessible_name = !text_lower.is_empty()
3287                || link
3288                    .aria_label
3289                    .as_ref()
3290                    .is_some_and(|l| !l.trim().is_empty())
3291                || link.img_alt.as_ref().is_some_and(|a| !a.trim().is_empty());
3292            if !has_accessible_name {
3293                findings.push(Finding {
3294                    severity: Severity::Error,
3295                    category: IssueCategory::Accessibility,
3296                    code: "A11Y009".to_string(),
3297                    title: "Empty link text".to_string(),
3298                    description: format!(
3299                        "Link to \"{}\" has no text. Screen readers announce the URL, \
3300                         which is not descriptive.",
3301                        link.href
3302                    ),
3303                    url: url.clone(),
3304                    recommendation: "Add descriptive text or an aria-label to the link."
3305                        .to_string(),
3306                });
3307            } else if Self::VAGUE_LINK_TEXTS.contains(&text_lower.as_str()) {
3308                findings.push(Finding {
3309                    severity: Severity::Warning,
3310                    category: IssueCategory::Accessibility,
3311                    code: "A11Y010".to_string(),
3312                    title: "Non-descriptive link text".to_string(),
3313                    description: format!(
3314                        "Link text \"{}\" is vague and does not describe the destination.",
3315                        link.text
3316                    ),
3317                    url: url.clone(),
3318                    recommendation: "Use descriptive text that explains the link purpose \
3319                                     (e.g., \"View pricing details\" instead of \"click here\")."
3320                        .to_string(),
3321                });
3322            }
3323        }
3324
3325        // --- WCAG 1.3.1: Form label association ---
3326        for form in &ctx.page.forms {
3327            for input in &form.inputs {
3328                if !input.has_label {
3329                    let desc = match (&input.name, &input.input_type) {
3330                        (Some(n), Some(t)) => format!("input (name=\"{n}\", type=\"{t}\")"),
3331                        (Some(n), None) => format!("input (name=\"{n}\")"),
3332                        (None, Some(t)) => format!("input (type=\"{t}\")"),
3333                        (None, None) => "input".to_string(),
3334                    };
3335                    findings.push(Finding {
3336                        severity: Severity::Error,
3337                        category: IssueCategory::Accessibility,
3338                        code: "A11Y011".to_string(),
3339                        title: "Form input missing label".to_string(),
3340                        description: format!(
3341                            "{desc} has no associated <label>, aria-label, or aria-labelledby."
3342                        ),
3343                        url: url.clone(),
3344                        recommendation: "Add a <label for=\"id\"> element or an aria-label \
3345                                         attribute to the input."
3346                            .to_string(),
3347                    });
3348                }
3349            }
3350        }
3351
3352        // --- WCAG 2.1.1: Keyboard navigation (tabindex) ---
3353        if ctx.page.has_positive_tabindex {
3354            findings.push(Finding {
3355                severity: Severity::Error,
3356                category: IssueCategory::Accessibility,
3357                code: "A11Y012".to_string(),
3358                title: "Positive tabindex values detected".to_string(),
3359                description: "Elements with tabindex > 0 alter the natural tab order, \
3360                              making keyboard navigation unpredictable."
3361                    .to_string(),
3362                url: url.clone(),
3363                recommendation: "Use tabindex=\"0\" to add elements to the natural tab order \
3364                                 or tabindex=\"-1\" for programmatic focus only."
3365                    .to_string(),
3366            });
3367        }
3368
3369        // --- WCAG 4.1.2: ARIA usage ---
3370        if ctx.page.aria_role_count == 0 && !ctx.page.landmarks.is_empty() {
3371            // No ARIA roles used but landmarks exist via HTML — that's fine
3372        } else if ctx.page.aria_role_count > 0 && ctx.page.aria_label_count == 0 {
3373            findings.push(Finding {
3374                severity: Severity::Warning,
3375                category: IssueCategory::Accessibility,
3376                code: "A11Y013".to_string(),
3377                title: "ARIA roles without labels".to_string(),
3378                description: format!(
3379                    "{} ARIA role(s) found but no aria-label or aria-labelledby attributes. \
3380                     Custom roles require accessible names.",
3381                    ctx.page.aria_role_count
3382                ),
3383                url: url.clone(),
3384                recommendation: "Add aria-label or aria-labelledby to elements with custom \
3385                                 ARIA roles."
3386                    .to_string(),
3387            });
3388        }
3389
3390        // --- WCAG 1.3.1: Table accessibility ---
3391        if ctx.page.tables_total > 0 {
3392            let tables_without_headers = ctx.page.tables_total - ctx.page.tables_with_headers;
3393            if tables_without_headers > 0 {
3394                findings.push(Finding {
3395                    severity: Severity::Warning,
3396                    category: IssueCategory::Accessibility,
3397                    code: "A11Y014".to_string(),
3398                    title: "Table missing header cells".to_string(),
3399                    description: format!(
3400                        "{} of {} table(s) have no <th> header cells.",
3401                        tables_without_headers, ctx.page.tables_total
3402                    ),
3403                    url: url.clone(),
3404                    recommendation: "Use <th> elements for header cells and add scope \
3405                                     attributes for complex tables."
3406                        .to_string(),
3407                });
3408            }
3409
3410            let tables_without_captions = ctx.page.tables_total - ctx.page.tables_with_captions;
3411            if tables_without_captions > 0 {
3412                findings.push(Finding {
3413                    severity: Severity::Info,
3414                    category: IssueCategory::Accessibility,
3415                    code: "A11Y015".to_string(),
3416                    title: "Table missing caption".to_string(),
3417                    description: format!(
3418                        "{} of {} table(s) have no <caption> element.",
3419                        tables_without_captions, ctx.page.tables_total
3420                    ),
3421                    url: url.clone(),
3422                    recommendation: "Add a <caption> to describe the table purpose.".to_string(),
3423                });
3424            }
3425        }
3426
3427        // --- WCAG 1.4.4: Language attribute ---
3428        if !ctx.page.has_lang_attribute {
3429            findings.push(Finding {
3430                severity: Severity::Error,
3431                category: IssueCategory::Accessibility,
3432                code: "A11Y016".to_string(),
3433                title: "Missing html lang attribute".to_string(),
3434                description: "The <html> element has no lang attribute. Screen readers use \
3435                              this to select the correct pronunciation engine."
3436                    .to_string(),
3437                url: url.clone(),
3438                recommendation: "Add lang=\"en\" (or the appropriate language code) to the \
3439                                 <html> element."
3440                    .to_string(),
3441            });
3442        }
3443
3444        findings
3445    }
3446}
3447
3448// ---------------------------------------------------------------------------
3449// 18. Social Media Analyzer
3450// ---------------------------------------------------------------------------
3451
3452pub struct SocialMediaAnalyzer;
3453
3454impl SocialMediaAnalyzer {
3455    pub fn new() -> Self {
3456        Self
3457    }
3458
3459    /// Minimum recommended OG image width (pixels).
3460    const OG_MIN_WIDTH: u32 = 1200;
3461    /// Minimum recommended OG image height (pixels).
3462    const OG_MIN_HEIGHT: u32 = 630;
3463}
3464
3465impl Default for SocialMediaAnalyzer {
3466    fn default() -> Self {
3467        Self::new()
3468    }
3469}
3470
3471impl Analyzer for SocialMediaAnalyzer {
3472    fn name(&self) -> &str {
3473        "social-media"
3474    }
3475
3476    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
3477        let mut findings = Vec::new();
3478        let url = &ctx.page.url;
3479
3480        // --- OG image dimensions ---
3481        if ctx.page.og_image_width.is_none() && ctx.page.og_image_height.is_none() {
3482            // No OG image dimensions at all — check if there's an OG image
3483            if ctx.page.meta.og.image.is_some() {
3484                findings.push(Finding {
3485                    severity: Severity::Warning,
3486                    category: IssueCategory::Social,
3487                    code: "SOCIAL001".to_string(),
3488                    title: "OG image missing dimensions".to_string(),
3489                    description: "og:image is set but og:image:width and og:image:height \
3490                                  are missing. Social platforms may crop or scale the image \
3491                                  incorrectly."
3492                        .to_string(),
3493                    url: url.clone(),
3494                    recommendation: "Add <meta property=\"og:image:width\" content=\"1200\"> \
3495                                     and <meta property=\"og:image:height\" content=\"630\">."
3496                        .to_string(),
3497                });
3498            }
3499        } else {
3500            // Check dimensions meet minimum requirements
3501            if let Some(width) = ctx.page.og_image_width {
3502                if width < Self::OG_MIN_WIDTH {
3503                    findings.push(Finding {
3504                        severity: Severity::Warning,
3505                        category: IssueCategory::Social,
3506                        code: "SOCIAL002".to_string(),
3507                        title: "OG image too narrow".to_string(),
3508                        description: format!(
3509                            "og:image:width is {width}px, below the recommended minimum of \
3510                             {}px.",
3511                            Self::OG_MIN_WIDTH
3512                        ),
3513                        url: url.clone(),
3514                        recommendation: format!(
3515                            "Use an image at least {}x{} pixels for optimal social previews.",
3516                            Self::OG_MIN_WIDTH,
3517                            Self::OG_MIN_HEIGHT
3518                        ),
3519                    });
3520                }
3521            }
3522            if let Some(height) = ctx.page.og_image_height {
3523                if height < Self::OG_MIN_HEIGHT {
3524                    findings.push(Finding {
3525                        severity: Severity::Warning,
3526                        category: IssueCategory::Social,
3527                        code: "SOCIAL003".to_string(),
3528                        title: "OG image too short".to_string(),
3529                        description: format!(
3530                            "og:image:height is {height}px, below the recommended minimum of \
3531                             {}px.",
3532                            Self::OG_MIN_HEIGHT
3533                        ),
3534                        url: url.clone(),
3535                        recommendation: format!(
3536                            "Use an image at least {}x{} pixels for optimal social previews.",
3537                            Self::OG_MIN_WIDTH,
3538                            Self::OG_MIN_HEIGHT
3539                        ),
3540                    });
3541                }
3542            }
3543        }
3544
3545        // --- Twitter Card type ---
3546        match &ctx.page.meta.twitter.card {
3547            None => {
3548                findings.push(Finding {
3549                    severity: Severity::Warning,
3550                    category: IssueCategory::Social,
3551                    code: "SOCIAL004".to_string(),
3552                    title: "Missing Twitter Card type".to_string(),
3553                    description: "No twitter:card meta tag found. Twitter/X will not render \
3554                                  a rich preview without it."
3555                        .to_string(),
3556                    url: url.clone(),
3557                    recommendation:
3558                        "Add <meta name=\"twitter:card\" content=\"summary_large_image\">."
3559                            .to_string(),
3560                });
3561            }
3562            Some(card_type) => {
3563                let valid_types = ["summary", "summary_large_image", "app", "player"];
3564                if !valid_types.contains(&card_type.as_str()) {
3565                    findings.push(Finding {
3566                        severity: Severity::Warning,
3567                        category: IssueCategory::Social,
3568                        code: "SOCIAL005".to_string(),
3569                        title: "Invalid Twitter Card type".to_string(),
3570                        description: format!(
3571                            "twitter:card value \"{card_type}\" is not a recognized card type."
3572                        ),
3573                        url: url.clone(),
3574                        recommendation: "Use one of: summary, summary_large_image, app, player."
3575                            .to_string(),
3576                    });
3577                }
3578            }
3579        }
3580
3581        // --- Social preview completeness ---
3582        let og_required = [
3583            ("og:title", ctx.page.meta.og.title.is_some()),
3584            ("og:description", ctx.page.meta.og.description.is_some()),
3585            ("og:image", ctx.page.meta.og.image.is_some()),
3586            ("og:url", ctx.page.meta.og.url.is_some()),
3587            ("og:type", ctx.page.meta.og.r#type.is_some()),
3588        ];
3589
3590        let missing_og: Vec<&str> = og_required
3591            .iter()
3592            .filter(|(_, present)| !present)
3593            .map(|(name, _)| *name)
3594            .collect();
3595
3596        if !missing_og.is_empty() {
3597            findings.push(Finding {
3598                severity: Severity::Warning,
3599                category: IssueCategory::Social,
3600                code: "SOCIAL006".to_string(),
3601                title: "Incomplete Open Graph tags".to_string(),
3602                description: format!(
3603                    "Missing OG tags: {}. Social previews may be incomplete.",
3604                    missing_og.join(", ")
3605                ),
3606                url: url.clone(),
3607                recommendation: "Add all required OG tags for complete social media previews."
3608                    .to_string(),
3609            });
3610        }
3611
3612        let twitter_required = [
3613            ("twitter:title", ctx.page.meta.twitter.title.is_some()),
3614            (
3615                "twitter:description",
3616                ctx.page.meta.twitter.description.is_some(),
3617            ),
3618            ("twitter:image", ctx.page.meta.twitter.image.is_some()),
3619        ];
3620
3621        let missing_twitter: Vec<&str> = twitter_required
3622            .iter()
3623            .filter(|(_, present)| !present)
3624            .map(|(name, _)| *name)
3625            .collect();
3626
3627        if !missing_twitter.is_empty() {
3628            findings.push(Finding {
3629                severity: Severity::Info,
3630                category: IssueCategory::Social,
3631                code: "SOCIAL007".to_string(),
3632                title: "Incomplete Twitter Card tags".to_string(),
3633                description: format!(
3634                    "Missing Twitter tags: {}. Twitter/X previews may fall back to OG tags.",
3635                    missing_twitter.join(", ")
3636                ),
3637                url: url.clone(),
3638                recommendation: "Add Twitter-specific tags for optimal X/Twitter previews."
3639                    .to_string(),
3640            });
3641        }
3642
3643        // --- Social preview summary ---
3644        let og_score = og_required.iter().filter(|(_, p)| *p).count();
3645        let twitter_score = twitter_required.iter().filter(|(_, p)| *p).count();
3646        let total = og_required.len() + twitter_required.len();
3647        let score = og_score + twitter_score;
3648
3649        findings.push(Finding {
3650            severity: Severity::Info,
3651            category: IssueCategory::Social,
3652            code: "SOCIAL008".to_string(),
3653            title: "Social preview completeness score".to_string(),
3654            description: format!(
3655                "Social metadata score: {score}/{total} (OG: {og_score}/{og_len}, Twitter: \
3656                 {twitter_score}/{tw_len}).",
3657                og_len = og_required.len(),
3658                tw_len = twitter_required.len(),
3659            ),
3660            url: url.clone(),
3661            recommendation: if score < total {
3662                "Add missing social meta tags to improve how your page appears when shared."
3663                    .to_string()
3664            } else {
3665                "Social metadata is complete.".to_string()
3666            },
3667        });
3668
3669        findings
3670    }
3671}
3672
3673// ---------------------------------------------------------------------------
3674// 19. Entity Analyzer
3675// ---------------------------------------------------------------------------
3676
3677const PERSON_INDICATORS: &[&str] = &[
3678    "mr.",
3679    "mrs.",
3680    "ms.",
3681    "dr.",
3682    "prof.",
3683    "sir",
3684    "lord",
3685    "president",
3686    "ceo",
3687    "cto",
3688    "founder",
3689    "author",
3690    "by",
3691    "written by",
3692    "edited by",
3693    "interview with",
3694];
3695
3696const ORG_INDICATORS: &[&str] = &[
3697    "inc.",
3698    "llc",
3699    "ltd.",
3700    "corp.",
3701    "corporation",
3702    "company",
3703    "organization",
3704    "university",
3705    "institute",
3706    "foundation",
3707    "association",
3708    "group",
3709    "partners",
3710];
3711
3712const LOCATION_INDICATORS: &[&str] = &[
3713    "city",
3714    "state",
3715    "country",
3716    "province",
3717    "district",
3718    "region",
3719    "street",
3720    "avenue",
3721    "boulevard",
3722    "road",
3723    "lane",
3724    "square",
3725    "park",
3726    "mountain",
3727    "river",
3728    "lake",
3729    "island",
3730    "bay",
3731    "coast",
3732    "valley",
3733];
3734
3735pub struct EntityAnalyzer;
3736
3737impl EntityAnalyzer {
3738    pub fn new() -> Self {
3739        Self
3740    }
3741
3742    fn detect_people(text: &str) -> Vec<String> {
3743        let mut found = Vec::new();
3744        let lower = text.to_lowercase();
3745        for indicator in PERSON_INDICATORS {
3746            if lower.contains(indicator) {
3747                let words: Vec<&str> = text.split_whitespace().collect();
3748                let indicator_word = indicator.trim_end_matches('.');
3749                for (i, word) in words.iter().enumerate() {
3750                    let clean: String = word.chars().filter(|c| c.is_alphanumeric()).collect();
3751                    if clean.to_lowercase() == indicator_word && i + 1 < words.len() {
3752                        let mut name_parts = Vec::new();
3753                        for w in words.iter().skip(i + 1) {
3754                            let w_clean: String = w
3755                                .chars()
3756                                .filter(|c| c.is_alphanumeric() || *c == '-')
3757                                .collect();
3758                            let w_lower = w_clean.to_lowercase();
3759                            if w_clean
3760                                .chars()
3761                                .next()
3762                                .map(|c| c.is_uppercase())
3763                                .unwrap_or(false)
3764                                || w_lower == "de"
3765                                || w_lower == "van"
3766                                || w_lower == "von"
3767                                || w_lower == "la"
3768                                || w_lower == "le"
3769                            {
3770                                name_parts.push(w_clean);
3771                            } else {
3772                                break;
3773                            }
3774                        }
3775                        let name = name_parts.join(" ");
3776                        if !name.is_empty() {
3777                            found.push(name);
3778                        }
3779                    }
3780                }
3781            }
3782        }
3783        found.sort();
3784        found.dedup();
3785        found
3786    }
3787
3788    fn detect_organizations(text: &str) -> Vec<String> {
3789        let mut found = Vec::new();
3790        let lower = text.to_lowercase();
3791        for indicator in ORG_INDICATORS {
3792            if lower.contains(indicator) {
3793                for sentence in text.split(['.', '!', '?']) {
3794                    let words: Vec<&str> = sentence.split_whitespace().collect();
3795                    for (i, word) in words.iter().enumerate() {
3796                        if word.to_lowercase().contains(indicator) {
3797                            let start = i.saturating_sub(2);
3798                            let org: String = words[start..=i.min(words.len() - 1)]
3799                                .iter()
3800                                .map(|w| w.to_string())
3801                                .collect::<Vec<_>>()
3802                                .join(" ");
3803                            if org.len() > 3 {
3804                                found.push(org);
3805                            }
3806                        }
3807                    }
3808                }
3809            }
3810        }
3811        found.sort();
3812        found.dedup();
3813        found
3814    }
3815
3816    fn detect_locations(text: &str) -> Vec<String> {
3817        let mut found = Vec::new();
3818        let lower = text.to_lowercase();
3819        for indicator in LOCATION_INDICATORS {
3820            if lower.contains(indicator) {
3821                for sentence in text.split(['.', '!', '?']) {
3822                    let words: Vec<&str> = sentence.split_whitespace().collect();
3823                    for (i, word) in words.iter().enumerate() {
3824                        if word.to_lowercase().contains(indicator) {
3825                            let start = i.saturating_sub(2);
3826                            let loc: String = words[start..=i.min(words.len() - 1)]
3827                                .iter()
3828                                .map(|w| w.to_string())
3829                                .collect::<Vec<_>>()
3830                                .join(" ");
3831                            if loc.len() > 3 {
3832                                found.push(loc);
3833                            }
3834                        }
3835                    }
3836                }
3837            }
3838        }
3839        found.sort();
3840        found.dedup();
3841        found
3842    }
3843
3844    fn detect_topics(headings: &[crate::parser::Heading], word_count: usize) -> Vec<String> {
3845        if word_count == 0 {
3846            return Vec::new();
3847        }
3848        let mut freq: HashMap<String, usize> = HashMap::new();
3849        for heading in headings {
3850            for word in heading.text.split_whitespace() {
3851                let lower = word
3852                    .to_lowercase()
3853                    .chars()
3854                    .filter(|c| c.is_alphanumeric())
3855                    .collect::<String>();
3856                if lower.len() > 3 && !STOP_WORDS.contains(&lower.as_str()) {
3857                    *freq.entry(lower).or_default() += 1;
3858                }
3859            }
3860        }
3861        let mut terms: Vec<(String, usize)> = freq.into_iter().collect();
3862        terms.sort_by_key(|b| std::cmp::Reverse(b.1));
3863        terms.into_iter().take(5).map(|(w, _)| w).collect()
3864    }
3865
3866    fn analyze_sentiment(text: &str) -> (f64, &'static str) {
3867        let positive_words = [
3868            "good",
3869            "great",
3870            "excellent",
3871            "amazing",
3872            "wonderful",
3873            "best",
3874            "love",
3875            "happy",
3876            "fantastic",
3877            "superb",
3878            "outstanding",
3879            "perfect",
3880            "beautiful",
3881            "brilliant",
3882            "awesome",
3883            "nice",
3884            "pleasant",
3885            "delightful",
3886            "impressive",
3887            "remarkable",
3888            "magnificent",
3889            "splendid",
3890            "fabulous",
3891            "terrific",
3892        ];
3893        let negative_words = [
3894            "bad",
3895            "terrible",
3896            "horrible",
3897            "awful",
3898            "worst",
3899            "hate",
3900            "sad",
3901            "ugly",
3902            "poor",
3903            "disappointing",
3904            "boring",
3905            "annoying",
3906            "frustrating",
3907            "difficult",
3908            "broken",
3909            "failed",
3910            "error",
3911            "wrong",
3912            "problem",
3913            "issue",
3914            "bug",
3915            "fail",
3916            "crash",
3917            "dead",
3918        ];
3919        let words: Vec<String> = text
3920            .split_whitespace()
3921            .map(|w| {
3922                w.to_lowercase()
3923                    .chars()
3924                    .filter(|c| c.is_alphanumeric())
3925                    .collect()
3926            })
3927            .collect();
3928        if words.is_empty() {
3929            return (0.0, "neutral");
3930        }
3931        let pos_count = words
3932            .iter()
3933            .filter(|w| positive_words.contains(&w.as_str()))
3934            .count();
3935        let neg_count = words
3936            .iter()
3937            .filter(|w| negative_words.contains(&w.as_str()))
3938            .count();
3939        let total = words.len() as f64;
3940        let score = ((pos_count as f64 - neg_count as f64) / total * 100.0).round() / 100.0;
3941        let label = if score > 0.05 {
3942            "positive"
3943        } else if score < -0.05 {
3944            "negative"
3945        } else {
3946            "neutral"
3947        };
3948        (score, label)
3949    }
3950}
3951
3952impl Default for EntityAnalyzer {
3953    fn default() -> Self {
3954        Self::new()
3955    }
3956}
3957
3958impl Analyzer for EntityAnalyzer {
3959    fn name(&self) -> &str {
3960        "entity-analyzer"
3961    }
3962
3963    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
3964        let mut findings = Vec::new();
3965        let url = &ctx.page.url;
3966
3967        let headings_text: String = ctx
3968            .page
3969            .headings
3970            .iter()
3971            .map(|h| h.text.as_str())
3972            .collect::<Vec<_>>()
3973            .join(" ");
3974
3975        let text = &headings_text;
3976
3977        let people = Self::detect_people(text);
3978        let organizations = Self::detect_organizations(text);
3979        let locations = Self::detect_locations(text);
3980        let topics = Self::detect_topics(&ctx.page.headings, ctx.page.word_count);
3981        let (sentiment_score, sentiment_label) = Self::analyze_sentiment(text);
3982
3983        if !people.is_empty() {
3984            findings.push(Finding {
3985                severity: Severity::Info,
3986                category: IssueCategory::Content,
3987                code: "ENTITY001".to_string(),
3988                title: "People entities detected".to_string(),
3989                description: format!(
3990                    "Found {} people entity(ies): {}.",
3991                    people.len(),
3992                    people.join(", ")
3993                ),
3994                url: url.clone(),
3995                recommendation: "People entities can boost E-E-A-T signals. Link to author \
3996                                 profiles when applicable."
3997                    .to_string(),
3998            });
3999        }
4000
4001        if !organizations.is_empty() {
4002            findings.push(Finding {
4003                severity: Severity::Info,
4004                category: IssueCategory::Content,
4005                code: "ENTITY002".to_string(),
4006                title: "Organization entities detected".to_string(),
4007                description: format!(
4008                    "Found {} organization entity(ies): {}.",
4009                    organizations.len(),
4010                    organizations.join(", ")
4011                ),
4012                url: url.clone(),
4013                recommendation: "Organization entities help establish topical authority. \
4014                                 Consider adding Organization schema markup."
4015                    .to_string(),
4016            });
4017        }
4018
4019        if !locations.is_empty() {
4020            findings.push(Finding {
4021                severity: Severity::Info,
4022                category: IssueCategory::Content,
4023                code: "ENTITY003".to_string(),
4024                title: "Location entities detected".to_string(),
4025                description: format!(
4026                    "Found {} location entity(ies): {}.",
4027                    locations.len(),
4028                    locations.join(", ")
4029                ),
4030                url: url.clone(),
4031                recommendation: "Location entities are important for local SEO. Ensure \
4032                                 NAP consistency across the site."
4033                    .to_string(),
4034            });
4035        }
4036
4037        if !topics.is_empty() {
4038            let topic_display = topics.join(", ");
4039            findings.push(Finding {
4040                severity: Severity::Info,
4041                category: IssueCategory::Content,
4042                code: "ENTITY004".to_string(),
4043                title: "Detected topics and themes".to_string(),
4044                description: format!("Primary topics: {topic_display}."),
4045                url: url.clone(),
4046                recommendation: "Ensure these topics align with the target keywords and \
4047                                 page intent."
4048                    .to_string(),
4049            });
4050        }
4051
4052        findings.push(Finding {
4053            severity: Severity::Info,
4054            category: IssueCategory::Content,
4055            code: "ENTITY005".to_string(),
4056            title: "Content sentiment analysis".to_string(),
4057            description: format!("Sentiment score: {sentiment_score} ({sentiment_label})."),
4058            url: url.clone(),
4059            recommendation: if sentiment_label == "negative" {
4060                "Content has a negative sentiment tone. Consider revising for a more \
4061                 neutral or positive tone."
4062                    .to_string()
4063            } else if sentiment_label == "positive" {
4064                "Positive sentiment detected. This can improve user engagement.".to_string()
4065            } else {
4066                "Neutral sentiment detected.".to_string()
4067            },
4068        });
4069
4070        findings.push(Finding {
4071            severity: Severity::Info,
4072            category: IssueCategory::Content,
4073            code: "ENTITY006".to_string(),
4074            title: "Entity counts per page".to_string(),
4075            description: format!(
4076                "People: {}, Organizations: {}, Locations: {}, Topics: {}.",
4077                people.len(),
4078                organizations.len(),
4079                locations.len(),
4080                topics.len()
4081            ),
4082            url: url.clone(),
4083            recommendation: String::new(),
4084        });
4085
4086        findings
4087    }
4088}
4089
4090// ---------------------------------------------------------------------------
4091// 20. Enhanced Readability Analyzer
4092// ---------------------------------------------------------------------------
4093
4094pub struct EnhancedReadabilityAnalyzer;
4095
4096impl EnhancedReadabilityAnalyzer {
4097    pub fn new() -> Self {
4098        Self
4099    }
4100
4101    fn count_letters(text: &str) -> usize {
4102        text.chars().filter(|c| c.is_alphabetic()).count()
4103    }
4104
4105    fn count_sentences(text: &str) -> usize {
4106        if text.trim().is_empty() {
4107            return 1;
4108        }
4109        text.chars()
4110            .filter(|&c| c == '.' || c == '!' || c == '?')
4111            .count()
4112            .max(1)
4113    }
4114
4115    fn count_syllables(word: &str) -> usize {
4116        let word = word.to_lowercase();
4117        let chars: Vec<char> = word.chars().collect();
4118        if chars.is_empty() {
4119            return 0;
4120        }
4121        if chars.len() <= 2 {
4122            return 1;
4123        }
4124
4125        let vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
4126        let mut count = 0;
4127        let mut prev_vowel = false;
4128
4129        for &c in &chars {
4130            let is_vowel = vowels.contains(&c);
4131            if is_vowel && !prev_vowel {
4132                count += 1;
4133            }
4134            prev_vowel = is_vowel;
4135        }
4136
4137        if chars.last() == Some(&'e') && count > 1 {
4138            count -= 1;
4139        }
4140
4141        count.max(1)
4142    }
4143
4144    fn flesch_kincaid_grade(words: usize, sentences: usize, syllables: usize) -> f64 {
4145        if words == 0 || sentences == 0 {
4146            return 0.0;
4147        }
4148        0.39 * (words as f64 / sentences as f64) + 11.8 * (syllables as f64 / words as f64) - 15.59
4149    }
4150
4151    fn coleman_liau_index(letters: usize, words: usize, sentences: usize) -> f64 {
4152        if words == 0 {
4153            return 0.0;
4154        }
4155        let l = letters as f64 / words as f64 * 100.0;
4156        let s = sentences as f64 / words as f64 * 100.0;
4157        0.0588 * l - 0.296 * s - 15.8
4158    }
4159
4160    fn automated_readability_index(characters: usize, words: usize, sentences: usize) -> f64 {
4161        if words == 0 || sentences == 0 {
4162            return 0.0;
4163        }
4164        4.71 * (characters as f64 / words as f64) + 0.5 * (words as f64 / sentences as f64) - 21.43
4165    }
4166
4167    fn gunning_fog_index(words: usize, sentences: usize, complex_words: usize) -> f64 {
4168        if words == 0 || sentences == 0 {
4169            return 0.0;
4170        }
4171        0.4 * (words as f64 / sentences as f64 + 100.0 * complex_words as f64 / words as f64)
4172    }
4173
4174    fn flesch_reading_ease(words: usize, sentences: usize, syllables: usize) -> f64 {
4175        if words == 0 || sentences == 0 {
4176            return 0.0;
4177        }
4178        let score = 206.835
4179            - 1.015 * (words as f64 / sentences as f64)
4180            - 84.6 * (syllables as f64 / words as f64);
4181        score.clamp(0.0, 100.0)
4182    }
4183
4184    fn reading_ease_label(score: f64) -> &'static str {
4185        if score >= 90.0 {
4186            "very easy"
4187        } else if score >= 80.0 {
4188            "easy"
4189        } else if score >= 70.0 {
4190            "fairly easy"
4191        } else if score >= 60.0 {
4192            "standard"
4193        } else if score >= 50.0 {
4194            "fairly difficult"
4195        } else if score >= 30.0 {
4196            "difficult"
4197        } else {
4198            "very difficult"
4199        }
4200    }
4201
4202    fn grade_label(grade: f64) -> &'static str {
4203        if grade < 1.0 {
4204            "kindergarten"
4205        } else if grade < 6.0 {
4206            "elementary school"
4207        } else if grade < 9.0 {
4208            "middle school"
4209        } else if grade < 13.0 {
4210            "high school"
4211        } else if grade < 16.0 {
4212            "college"
4213        } else {
4214            "postgraduate"
4215        }
4216    }
4217}
4218
4219impl Default for EnhancedReadabilityAnalyzer {
4220    fn default() -> Self {
4221        Self::new()
4222    }
4223}
4224
4225impl Analyzer for EnhancedReadabilityAnalyzer {
4226    fn name(&self) -> &str {
4227        "enhanced-readability"
4228    }
4229
4230    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4231        let mut findings = Vec::new();
4232        let url = &ctx.page.url;
4233
4234        if ctx.page.word_count == 0 {
4235            return findings;
4236        }
4237
4238        let text = ctx
4239            .page
4240            .headings
4241            .iter()
4242            .map(|h| h.text.as_str())
4243            .collect::<Vec<_>>()
4244            .join(" ");
4245
4246        if text.trim().is_empty() {
4247            return findings;
4248        }
4249
4250        let words: Vec<&str> = text.split_whitespace().collect();
4251        let word_count = words.len();
4252        let sentence_count = Self::count_sentences(&text);
4253        let letter_count = Self::count_letters(&text);
4254        let syllable_count: usize = words.iter().map(|w| Self::count_syllables(w)).sum();
4255        let complex_words = Self::complex_words_count(&words);
4256
4257        let fk_grade = Self::flesch_kincaid_grade(word_count, sentence_count, syllable_count);
4258        let cl_index = Self::coleman_liau_index(letter_count, word_count, sentence_count);
4259        let ari = Self::automated_readability_index(letter_count, word_count, sentence_count);
4260        let fog = Self::gunning_fog_index(word_count, sentence_count, complex_words);
4261        let fre = Self::flesch_reading_ease(word_count, sentence_count, syllable_count);
4262
4263        findings.push(Finding {
4264            severity: Severity::Info,
4265            category: IssueCategory::Content,
4266            code: "READ001".to_string(),
4267            title: "Flesch-Kincaid Grade Level".to_string(),
4268            description: format!(
4269                "Grade level: {fk_grade:.1} ({})",
4270                Self::grade_label(fk_grade)
4271            ),
4272            url: url.clone(),
4273            recommendation: if fk_grade > 12.0 {
4274                "Content is at a college reading level. Consider simplifying for broader \
4275                 audiences."
4276                    .to_string()
4277            } else if fk_grade > 8.0 {
4278                "Content is at a high school reading level. Suitable for most web audiences."
4279                    .to_string()
4280            } else {
4281                "Content is easy to read for most audiences.".to_string()
4282            },
4283        });
4284
4285        findings.push(Finding {
4286            severity: Severity::Info,
4287            category: IssueCategory::Content,
4288            code: "READ002".to_string(),
4289            title: "Coleman-Liau Index".to_string(),
4290            description: format!("Index: {cl_index:.1}"),
4291            url: url.clone(),
4292            recommendation: if cl_index > 12.0 {
4293                "High Coleman-Liau index. Consider reducing sentence complexity.".to_string()
4294            } else {
4295                "Readability is within acceptable range.".to_string()
4296            },
4297        });
4298
4299        findings.push(Finding {
4300            severity: Severity::Info,
4301            category: IssueCategory::Content,
4302            code: "READ003".to_string(),
4303            title: "Automated Readability Index".to_string(),
4304            description: format!("ARI: {ari:.1}"),
4305            url: url.clone(),
4306            recommendation: if ari > 12.0 {
4307                "High ARI score. Content may be difficult for general audiences.".to_string()
4308            } else {
4309                "Readability is within acceptable range.".to_string()
4310            },
4311        });
4312
4313        findings.push(Finding {
4314            severity: Severity::Info,
4315            category: IssueCategory::Content,
4316            code: "READ004".to_string(),
4317            title: "Gunning Fog Index".to_string(),
4318            description: format!("Fog index: {fog:.1}"),
4319            url: url.clone(),
4320            recommendation: if fog > 17.0 {
4321                "Very high Fog index. Content is extremely complex. Simplify vocabulary \
4322                 and shorten sentences."
4323                    .to_string()
4324            } else if fog > 12.0 {
4325                "High Fog index. Consider simplifying for a broader audience.".to_string()
4326            } else {
4327                "Fog index is within acceptable range.".to_string()
4328            },
4329        });
4330
4331        findings.push(Finding {
4332            severity: Severity::Info,
4333            category: IssueCategory::Content,
4334            code: "READ005".to_string(),
4335            title: "Flesch Reading Ease score".to_string(),
4336            description: format!("Score: {fre:.1}/100 ({})", Self::reading_ease_label(fre)),
4337            url: url.clone(),
4338            recommendation: if fre < 30.0 {
4339                "Very difficult to read. Aim for a score of 60+ for general audiences.".to_string()
4340            } else if fre < 50.0 {
4341                "Fairly difficult. Consider simplifying language.".to_string()
4342            } else if fre < 70.0 {
4343                "Standard readability. Suitable for most web content.".to_string()
4344            } else {
4345                "Easy to read. Good for broad audiences.".to_string()
4346            },
4347        });
4348
4349        findings
4350    }
4351}
4352
4353impl EnhancedReadabilityAnalyzer {
4354    fn complex_words_count(words: &[&str]) -> usize {
4355        words
4356            .iter()
4357            .filter(|w| Self::count_syllables(w) >= 3)
4358            .count()
4359    }
4360}
4361
4362// ---------------------------------------------------------------------------
4363// 21. Keyword Analyzer
4364// ---------------------------------------------------------------------------
4365
4366pub struct KeywordAnalyzer {
4367    corpus_tf: HashMap<String, f64>,
4368}
4369
4370impl KeywordAnalyzer {
4371    pub fn new() -> Self {
4372        Self {
4373            corpus_tf: HashMap::new(),
4374        }
4375    }
4376
4377    pub fn with_corpus_tf(corpus_tf: HashMap<String, f64>) -> Self {
4378        Self { corpus_tf }
4379    }
4380
4381    fn tokenize(text: &str) -> Vec<String> {
4382        text.split_whitespace()
4383            .map(|w| {
4384                w.to_lowercase()
4385                    .chars()
4386                    .filter(|c| c.is_alphanumeric())
4387                    .collect()
4388            })
4389            .filter(|w: &String| w.len() > 2 && !STOP_WORDS.contains(&w.as_str()))
4390            .collect()
4391    }
4392
4393    fn compute_tf(tokens: &[String]) -> HashMap<String, f64> {
4394        let mut freq: HashMap<String, usize> = HashMap::new();
4395        for token in tokens {
4396            *freq.entry(token.clone()).or_default() += 1;
4397        }
4398        let total = tokens.len() as f64;
4399        freq.into_iter()
4400            .map(|(term, count)| (term, count as f64 / total))
4401            .collect()
4402    }
4403
4404    fn compute_tfidf(
4405        tf: &HashMap<String, f64>,
4406        corpus_tf: &HashMap<String, f64>,
4407    ) -> HashMap<String, f64> {
4408        let total_docs = corpus_tf.len().max(1) as f64;
4409        tf.iter()
4410            .map(|(term, tf_val)| {
4411                let df = corpus_tf.get(term).copied().unwrap_or(0.0);
4412                let idf = if df > 0.0 {
4413                    (total_docs / df).ln() + 1.0
4414                } else {
4415                    1.0
4416                };
4417                (term.clone(), tf_val * idf)
4418            })
4419            .collect()
4420    }
4421
4422    fn keyword_density(tokens: &[String], total_words: usize) -> HashMap<String, f64> {
4423        if total_words == 0 {
4424            return HashMap::new();
4425        }
4426        let mut freq: HashMap<String, usize> = HashMap::new();
4427        for token in tokens {
4428            *freq.entry(token.clone()).or_default() += 1;
4429        }
4430        freq.into_iter()
4431            .map(|(term, count)| (term, count as f64 / total_words as f64 * 100.0))
4432            .collect()
4433    }
4434
4435    fn detect_prominent_keywords(density: &HashMap<String, f64>) -> Vec<(String, f64)> {
4436        let mut prominent: Vec<(String, f64)> = density
4437            .iter()
4438            .filter(|(_, &d)| d >= 1.5)
4439            .map(|(k, &v)| (k.clone(), v))
4440            .collect();
4441        prominent.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
4442        prominent
4443    }
4444
4445    fn cooccurrence(tokens: &[String], window: usize) -> Vec<((String, String), usize)> {
4446        let mut pairs: HashMap<(String, String), usize> = HashMap::new();
4447        for i in 0..tokens.len() {
4448            let end = (i + window + 1).min(tokens.len());
4449            for j in (i + 1)..end {
4450                let mut pair = [tokens[i].clone(), tokens[j].clone()];
4451                pair.sort();
4452                *pairs.entry((pair[0].clone(), pair[1].clone())).or_default() += 1;
4453            }
4454        }
4455        let mut result: Vec<((String, String), usize)> = pairs.into_iter().collect();
4456        result.sort_by_key(|b| std::cmp::Reverse(b.1));
4457        result
4458    }
4459}
4460
4461impl Default for KeywordAnalyzer {
4462    fn default() -> Self {
4463        Self::new()
4464    }
4465}
4466
4467impl Analyzer for KeywordAnalyzer {
4468    fn name(&self) -> &str {
4469        "keyword-analyzer"
4470    }
4471
4472    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4473        let mut findings = Vec::new();
4474        let url = &ctx.page.url;
4475
4476        if ctx.page.word_count == 0 {
4477            return findings;
4478        }
4479
4480        let text = ctx
4481            .page
4482            .headings
4483            .iter()
4484            .map(|h| h.text.as_str())
4485            .collect::<Vec<_>>()
4486            .join(" ");
4487
4488        if text.trim().is_empty() {
4489            return findings;
4490        }
4491
4492        let tokens = Self::tokenize(&text);
4493        if tokens.is_empty() {
4494            return findings;
4495        }
4496
4497        let tf = Self::compute_tf(&tokens);
4498        let tfidf = Self::compute_tfidf(&tf, &self.corpus_tf);
4499        let density = Self::keyword_density(&tokens, ctx.page.word_count);
4500        let prominent = Self::detect_prominent_keywords(&density);
4501        let cooccur = Self::cooccurrence(&tokens, 3);
4502
4503        let mut tfidf_sorted: Vec<(&String, &f64)> = tfidf.iter().collect();
4504        tfidf_sorted.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
4505        let top_tfidf: Vec<String> = tfidf_sorted
4506            .iter()
4507            .take(10)
4508            .map(|(k, v)| format!("{k} ({v:.2})"))
4509            .collect();
4510
4511        if !top_tfidf.is_empty() {
4512            findings.push(Finding {
4513                severity: Severity::Info,
4514                category: IssueCategory::Content,
4515                code: "KW001".to_string(),
4516                title: "Top TF-IDF keywords".to_string(),
4517                description: format!("Top keywords by TF-IDF score: {}.", top_tfidf.join(", ")),
4518                url: url.clone(),
4519                recommendation: "TF-IDF highlights the most distinctive terms on this page. \
4520                                 Ensure these align with your target keywords."
4521                    .to_string(),
4522            });
4523        }
4524
4525        let mut density_sorted: Vec<(&String, &f64)> = density.iter().collect();
4526        density_sorted.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
4527        let top_density: Vec<String> = density_sorted
4528            .iter()
4529            .take(10)
4530            .map(|(k, v)| format!("{k} ({v:.1}%)"))
4531            .collect();
4532
4533        if !top_density.is_empty() {
4534            findings.push(Finding {
4535                severity: Severity::Info,
4536                category: IssueCategory::Content,
4537                code: "KW002".to_string(),
4538                title: "Keyword density".to_string(),
4539                description: format!(
4540                    "Top keyword densities (of {} words): {}.",
4541                    ctx.page.word_count,
4542                    top_density.join(", ")
4543                ),
4544                url: url.clone(),
4545                recommendation: "Ideal keyword density is 1-2%. Higher may indicate keyword \
4546                                 stuffing."
4547                    .to_string(),
4548            });
4549        }
4550
4551        if !prominent.is_empty() {
4552            let display: Vec<String> = prominent
4553                .iter()
4554                .map(|(k, v)| format!("\"{k}\" ({v:.1}%)"))
4555                .collect();
4556            findings.push(Finding {
4557                severity: if prominent.iter().any(|(_, d)| *d > 3.0) {
4558                    Severity::Warning
4559                } else {
4560                    Severity::Info
4561                },
4562                category: IssueCategory::Content,
4563                code: "KW003".to_string(),
4564                title: "Prominent keywords detected".to_string(),
4565                description: format!("Keywords with density >= 1.5%: {}.", display.join(", ")),
4566                url: url.clone(),
4567                recommendation: if prominent.iter().any(|(_, d)| *d > 3.0) {
4568                    "Some keywords exceed 3% density. This may be flagged as keyword \
4569                     stuffing by search engines."
4570                        .to_string()
4571                } else {
4572                    "Keyword densities are within acceptable range.".to_string()
4573                },
4574            });
4575        }
4576
4577        if !cooccur.is_empty() {
4578            let display: Vec<String> = cooccur
4579                .iter()
4580                .take(5)
4581                .map(|((a, b), c)| format!("\"{a}\" + \"{b}\" ({c})"))
4582                .collect();
4583            findings.push(Finding {
4584                severity: Severity::Info,
4585                category: IssueCategory::Content,
4586                code: "KW004".to_string(),
4587                title: "Keyword co-occurrence".to_string(),
4588                description: format!("Top keyword pairs: {}.", display.join(", ")),
4589                url: url.clone(),
4590                recommendation: "Co-occurring keywords help search engines understand topic \
4591                                 relationships."
4592                    .to_string(),
4593            });
4594        }
4595
4596        findings
4597    }
4598}
4599
4600// ---------------------------------------------------------------------------
4601// 22. E-commerce Signals Analyzer
4602// ---------------------------------------------------------------------------
4603
4604pub struct EcommerceSignalsAnalyzer;
4605
4606impl EcommerceSignalsAnalyzer {
4607    pub fn new() -> Self {
4608        Self
4609    }
4610
4611    fn detect_product_schema(sd: &crate::parser::StructuredData) -> bool {
4612        sd.r#type
4613            .as_deref()
4614            .map(|t| t == "Product" || t == "IndividualProduct" || t == "AggregateOffer")
4615            .unwrap_or(false)
4616    }
4617
4618    fn extract_price(data: &serde_json::Value) -> Option<String> {
4619        let direct = data
4620            .get("price")
4621            .or_else(|| data.get("lowPrice"))
4622            .or_else(|| data.get("highPrice"));
4623        if let Some(v) = direct {
4624            return Self::value_to_price(v);
4625        }
4626        let offers = data.get("offers")?;
4627        let offer = if offers.is_array() {
4628            offers.get(0)?
4629        } else {
4630            offers
4631        };
4632        let price_val = offer
4633            .get("price")
4634            .or_else(|| offer.get("lowPrice"))
4635            .or_else(|| offer.get("highPrice"))?;
4636        Self::value_to_price(price_val)
4637    }
4638
4639    fn value_to_price(v: &serde_json::Value) -> Option<String> {
4640        if let Some(s) = v.as_str() {
4641            if !s.is_empty() {
4642                return Some(s.to_string());
4643            }
4644        }
4645        v.as_f64().map(|p| format!("{p}"))
4646    }
4647
4648    fn extract_availability(data: &serde_json::Value) -> Option<String> {
4649        data.get("availability")
4650            .and_then(|v| v.as_str().map(String::from))
4651            .or_else(|| {
4652                data.get("offers")
4653                    .and_then(|o| o.get("availability"))
4654                    .and_then(|v| v.as_str().map(String::from))
4655            })
4656    }
4657
4658    fn detect_reviews(sd: &crate::parser::StructuredData) -> Vec<String> {
4659        let mut reviews = Vec::new();
4660        if let Some(obj) = sd.data.as_object() {
4661            if let Some(rating) = obj.get("aggregateRating") {
4662                if let Some(score) = rating.get("ratingValue").and_then(|v| {
4663                    v.as_f64()
4664                        .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
4665                }) {
4666                    reviews.push(format!("rating: {score}"));
4667                }
4668            }
4669            if let Some(r) = obj.get("reviewCount").or_else(|| obj.get("ratingCount")) {
4670                if let Some(count) = r
4671                    .as_f64()
4672                    .or_else(|| r.as_str().and_then(|s| s.parse::<f64>().ok()))
4673                {
4674                    reviews.push(format!("reviews: {count}"));
4675                }
4676            }
4677        }
4678        reviews
4679    }
4680
4681    fn detect_offers(sd: &crate::parser::StructuredData) -> bool {
4682        sd.data
4683            .get("offers")
4684            .or_else(|| sd.data.get("hasOffersCatalog"))
4685            .map(|v| !v.is_null())
4686            .unwrap_or(false)
4687    }
4688}
4689
4690impl Default for EcommerceSignalsAnalyzer {
4691    fn default() -> Self {
4692        Self::new()
4693    }
4694}
4695
4696impl Analyzer for EcommerceSignalsAnalyzer {
4697    fn name(&self) -> &str {
4698        "ecommerce-signals"
4699    }
4700
4701    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4702        let mut findings = Vec::new();
4703        let url = &ctx.page.url;
4704
4705        if ctx.page.structured_data.is_empty() {
4706            return findings;
4707        }
4708
4709        let mut has_product = false;
4710        let mut prices_found = Vec::new();
4711        let mut availability_found = Vec::new();
4712        let mut reviews_found = Vec::new();
4713        let mut offers_found = false;
4714
4715        for sd in &ctx.page.structured_data {
4716            if Self::detect_product_schema(sd) {
4717                has_product = true;
4718
4719                if let Some(price) = Self::extract_price(&sd.data) {
4720                    prices_found.push(price);
4721                }
4722                if let Some(avail) = Self::extract_availability(&sd.data) {
4723                    availability_found.push(avail);
4724                }
4725                reviews_found.extend(Self::detect_reviews(sd));
4726                if Self::detect_offers(sd) {
4727                    offers_found = true;
4728                }
4729            }
4730
4731            if sd.r#type.as_deref() == Some("Offer")
4732                || sd.r#type.as_deref() == Some("AggregateOffer")
4733            {
4734                if let Some(price) = Self::extract_price(&sd.data) {
4735                    prices_found.push(price);
4736                }
4737            }
4738
4739            if sd.r#type.as_deref() == Some("Review")
4740                || sd.r#type.as_deref() == Some("AggregateRating")
4741            {
4742                reviews_found.extend(Self::detect_reviews(sd));
4743            }
4744        }
4745
4746        if has_product {
4747            findings.push(Finding {
4748                severity: Severity::Info,
4749                category: IssueCategory::Schema,
4750                code: "ECOM001".to_string(),
4751                title: "Product schema detected".to_string(),
4752                description: "Product structured data found. This enables rich product \
4753                              results in search."
4754                    .to_string(),
4755                url: url.clone(),
4756                recommendation: "Ensure all required Product properties are present (name, \
4757                                 image, description, offers)."
4758                    .to_string(),
4759            });
4760        }
4761
4762        if !prices_found.is_empty() {
4763            findings.push(Finding {
4764                severity: Severity::Info,
4765                category: IssueCategory::Schema,
4766                code: "ECOM002".to_string(),
4767                title: "Price information detected".to_string(),
4768                description: format!(
4769                    "Prices found in structured data: {}.",
4770                    prices_found.join(", ")
4771                ),
4772                url: url.clone(),
4773                recommendation: "Verify prices match the visible page content.".to_string(),
4774            });
4775        }
4776
4777        if !availability_found.is_empty() {
4778            findings.push(Finding {
4779                severity: Severity::Info,
4780                category: IssueCategory::Schema,
4781                code: "ECOM003".to_string(),
4782                title: "Availability information detected".to_string(),
4783                description: format!("Availability: {}.", availability_found.join(", ")),
4784                url: url.clone(),
4785                recommendation: "Availability status should match the actual product state."
4786                    .to_string(),
4787            });
4788        }
4789
4790        if !reviews_found.is_empty() {
4791            findings.push(Finding {
4792                severity: Severity::Info,
4793                category: IssueCategory::Schema,
4794                code: "ECOM004".to_string(),
4795                title: "Review/rating information detected".to_string(),
4796                description: format!("Review data: {}.", reviews_found.join(", ")),
4797                url: url.clone(),
4798                recommendation: "Ratings and reviews enhance search result CTR. Keep them \
4799                                 updated."
4800                    .to_string(),
4801            });
4802        }
4803
4804        if offers_found {
4805            findings.push(Finding {
4806                severity: Severity::Info,
4807                category: IssueCategory::Schema,
4808                code: "ECOM005".to_string(),
4809                title: "Offer schema detected".to_string(),
4810                description: "Offer structured data found in product schema.".to_string(),
4811                url: url.clone(),
4812                recommendation: "Ensure offer includes price, priceCurrency, and availability."
4813                    .to_string(),
4814            });
4815        }
4816
4817        if !has_product && !prices_found.is_empty() {
4818            findings.push(Finding {
4819                severity: Severity::Warning,
4820                category: IssueCategory::Schema,
4821                code: "ECOM006".to_string(),
4822                title: "Price data without Product schema".to_string(),
4823                description: "Price information found but no Product schema type detected. \
4824                              Search engines may not interpret this as product data."
4825                    .to_string(),
4826                url: url.clone(),
4827                recommendation: "Add Product schema to wrap price and availability data."
4828                    .to_string(),
4829            });
4830        }
4831
4832        findings
4833    }
4834}
4835
4836// ---------------------------------------------------------------------------
4837// 23. International SEO Analyzer
4838// ---------------------------------------------------------------------------
4839
4840pub struct InternationalSeoAnalyzer {
4841    known_hrefs: HashMap<String, Vec<String>>,
4842}
4843
4844impl InternationalSeoAnalyzer {
4845    pub fn new() -> Self {
4846        Self {
4847            known_hrefs: HashMap::new(),
4848        }
4849    }
4850
4851    pub fn with_known_hrefs(known_hrefs: HashMap<String, Vec<String>>) -> Self {
4852        Self { known_hrefs }
4853    }
4854
4855    fn detect_locale_from_url(url: &str) -> Option<String> {
4856        if let Ok(parsed) = Url::parse(url) {
4857            let segments: Vec<&str> = parsed
4858                .path_segments()
4859                .map(|s| s.filter(|s| !s.is_empty()).collect())
4860                .unwrap_or_default();
4861            if let Some(first) = segments.first() {
4862                if Self::is_locale_segment(first) {
4863                    return Some(first.to_string());
4864                }
4865            }
4866        }
4867        None
4868    }
4869
4870    fn is_locale_segment(s: &str) -> bool {
4871        let parts: Vec<&str> = s.split('-').collect();
4872        match parts.len() {
4873            1 => {
4874                let lang = parts[0];
4875                lang.len() >= 2 && lang.len() <= 3 && lang.chars().all(|c| c.is_ascii_alphabetic())
4876            }
4877            2 => {
4878                let lang = parts[0];
4879                let region = parts[1];
4880                lang.len() >= 2
4881                    && lang.len() <= 3
4882                    && lang.chars().all(|c| c.is_ascii_alphabetic())
4883                    && ((region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()))
4884                        || (region.len() == 4 && region.chars().all(|c| c.is_ascii_digit())))
4885            }
4886            _ => false,
4887        }
4888    }
4889
4890    fn detect_multilang_content(
4891        hreflang: &[crate::meta::HreflangTag],
4892        html_lang: &Option<String>,
4893    ) -> bool {
4894        if !hreflang.is_empty() {
4895            return true;
4896        }
4897        if let Some(lang) = html_lang {
4898            let parts: Vec<&str> = lang.split('-').collect();
4899            if parts.len() >= 2 {
4900                return true;
4901            }
4902        }
4903        false
4904    }
4905}
4906
4907impl Default for InternationalSeoAnalyzer {
4908    fn default() -> Self {
4909        Self::new()
4910    }
4911}
4912
4913impl Analyzer for InternationalSeoAnalyzer {
4914    fn name(&self) -> &str {
4915        "international-seo"
4916    }
4917
4918    fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4919        let mut findings = Vec::new();
4920        let url = &ctx.page.url;
4921
4922        let hreflang_tags = &ctx.page.meta.hreflang;
4923
4924        if !hreflang_tags.is_empty() {
4925            for tag in hreflang_tags {
4926                // Skip locale mismatch check for x-default — it's a special fallback
4927                // that doesn't correspond to a specific locale segment in the URL.
4928                if tag.lang.to_lowercase() == "x-default" {
4929                    continue;
4930                }
4931                if let Some(locale) = Self::detect_locale_from_url(tag.url.as_str()) {
4932                    let locale_lower = locale.to_lowercase();
4933                    let tag_lang_lower = tag.lang.to_lowercase();
4934                    if locale_lower != tag_lang_lower
4935                        && !tag_lang_lower.starts_with(&locale_lower)
4936                        && !locale_lower.starts_with(&tag_lang_lower)
4937                    {
4938                        findings.push(Finding {
4939                            severity: Severity::Warning,
4940                            category: IssueCategory::Seo,
4941                            code: "ISEO001".to_string(),
4942                            title: "Hreflang URL locale mismatch".to_string(),
4943                            description: format!(
4944                                "Hreflang tag lang=\"{}\" points to URL \"{}\" which has \
4945                                 locale segment \"{}\".",
4946                                tag.lang, tag.url, locale
4947                            ),
4948                            url: url.clone(),
4949                            recommendation: "Ensure the hreflang URL path segment matches \
4950                                             the language code in the hreflang tag."
4951                                .to_string(),
4952                        });
4953                    }
4954                }
4955            }
4956
4957            let has_x_default = hreflang_tags.iter().any(|t| t.lang == "x-default");
4958            if !has_x_default {
4959                findings.push(Finding {
4960                    severity: Severity::Warning,
4961                    category: IssueCategory::Seo,
4962                    code: "ISEO002".to_string(),
4963                    title: "Missing x-default in enhanced hreflang".to_string(),
4964                    description: "No x-default hreflang found. This tag specifies the fallback \
4965                                  page for unmatched locales."
4966                        .to_string(),
4967                    url: url.clone(),
4968                    recommendation: "Add <link rel=\"alternate\" hreflang=\"x-default\" \
4969                                     href=\"...\"> pointing to the default language version."
4970                        .to_string(),
4971                });
4972            }
4973
4974            let lang_count: HashMap<String, usize> =
4975                hreflang_tags.iter().fold(HashMap::new(), |mut acc, t| {
4976                    *acc.entry(t.lang.clone()).or_insert(0) += 1;
4977                    acc
4978                });
4979            for (lang, count) in &lang_count {
4980                if *count > 1 && lang != "x-default" {
4981                    findings.push(Finding {
4982                        severity: Severity::Error,
4983                        category: IssueCategory::Seo,
4984                        code: "ISEO003".to_string(),
4985                        title: "Duplicate hreflang language".to_string(),
4986                        description: format!(
4987                            "Language \"{lang}\" appears {count} times. Each language code \
4988                             must be unique per page."
4989                        ),
4990                        url: url.clone(),
4991                        recommendation: "Remove duplicate hreflang tags for the same language."
4992                            .to_string(),
4993                    });
4994                }
4995            }
4996        }
4997
4998        let locale_in_url = Self::detect_locale_from_url(url);
4999        if locale_in_url.is_none()
5000            && ctx.page.meta.hreflang.is_empty()
5001            && ctx.page.html_lang.is_some()
5002        {
5003            findings.push(Finding {
5004                severity: Severity::Info,
5005                category: IssueCategory::Seo,
5006                code: "ISEO004".to_string(),
5007                title: "Single-language page without hreflang".to_string(),
5008                description: "Page has a lang attribute but no hreflang tags. If this \
5009                                  site serves multiple languages, add hreflang annotations."
5010                    .to_string(),
5011                url: url.clone(),
5012                recommendation: "For multilingual sites, add hreflang tags to all language \
5013                                     variants of each page."
5014                    .to_string(),
5015            });
5016        }
5017
5018        if let Some(canonical) = &ctx.page.meta.canonical {
5019            if let Some(hop_from) = self.known_hrefs.get(url) {
5020                for hop_url in hop_from {
5021                    if let Some(target_canonical) = self.known_hrefs.get(hop_url.as_str()) {
5022                        if !target_canonical.is_empty() {
5023                            let canonical_str = canonical.to_string();
5024                            if target_canonical.iter().any(|c| c == &canonical_str) {
5025                                findings.push(Finding {
5026                                    severity: Severity::Info,
5027                                    category: IssueCategory::Seo,
5028                                    code: "ISEO005".to_string(),
5029                                    title: "Canonical chain detected".to_string(),
5030                                    description: format!(
5031                                        "URL \"{url}\" canonical points to \"{}\", which is \
5032                                         itself canonicalized. This forms a chain.",
5033                                        canonical
5034                                    ),
5035                                    url: url.clone(),
5036                                    recommendation: "Ensure all pages point to the same \
5037                                                     final canonical URL to avoid crawl \
5038                                                     confusion."
5039                                        .to_string(),
5040                                });
5041                            }
5042                        }
5043                    }
5044                }
5045            }
5046        }
5047
5048        let is_multilang = Self::detect_multilang_content(hreflang_tags, &ctx.page.html_lang);
5049        if is_multilang {
5050            findings.push(Finding {
5051                severity: Severity::Info,
5052                category: IssueCategory::Seo,
5053                code: "ISEO006".to_string(),
5054                title: "Multi-language content detected".to_string(),
5055                description: "Page appears to be part of a multilingual setup. Ensure all \
5056                              language variants cross-reference each other via hreflang."
5057                    .to_string(),
5058                url: url.clone(),
5059                recommendation: "Each language version should have reciprocal hreflang tags \
5060                                 pointing to all other versions."
5061                    .to_string(),
5062            });
5063        }
5064
5065        findings
5066    }
5067}
5068
5069// ---------------------------------------------------------------------------
5070// Analyzer Registry
5071// ---------------------------------------------------------------------------
5072
5073/// Registry of SEO analyzers that can be run against crawled pages.
5074///
5075/// Manages a collection of [`Analyzer`] implementations and runs them
5076/// in parallel using rayon. The default registry includes 28+ analyzers
5077/// covering HTTP, SEO, content, links, images, security, accessibility,
5078/// and AI-specific checks.
5079///
5080/// # Examples
5081///
5082/// ```rust
5083/// use crawlkit_engine::{CrawlConfig, analyzers::AnalyzerRegistry};
5084///
5085/// let registry = AnalyzerRegistry::new(&CrawlConfig::default());
5086/// assert!(registry.len() > 20);
5087/// ```
5088pub struct AnalyzerRegistry {
5089    analyzers: Vec<Box<dyn Analyzer>>,
5090}
5091
5092impl AnalyzerRegistry {
5093    /// Create a registry with all default analyzers.
5094    ///
5095    /// Registers 28+ built-in analyzers covering HTTP status, redirects,
5096    /// canonical URLs, meta tags, headings, links, images, structured data,
5097    /// security, accessibility, social media, AI crawlers, and WASM patterns.
5098    pub fn new(_config: &CrawlConfig) -> Self {
5099        Self {
5100            analyzers: vec![
5101                Box::new(HttpStatusAnalyzer::new()),
5102                Box::new(RedirectChainAnalyzer::new()),
5103                Box::new(CanonicalUrlValidator::new()),
5104                Box::new(HreflangValidator::new()),
5105                Box::new(SitemapAnalyzer::empty()),
5106                Box::new(RobotsTxtAnalyzer::empty()),
5107                Box::new(MetaTagAnalyzer::new()),
5108                Box::new(HeadingHierarchyAnalyzer::new()),
5109                Box::new(LinkAnalyzer::new()),
5110                Box::new(ImageAnalyzer::new()),
5111                Box::new(StructuredDataValidator::new()),
5112                Box::new(ContentQualityAnalyzer::new()),
5113                Box::new(WordCountAnalyzer::new()),
5114                Box::new(SecurityHeaderAnalyzer::new()),
5115                Box::new(SslCertificateValidator::empty()),
5116                Box::new(MobileFriendlinessChecker::new()),
5117                Box::new(AccessibilityAnalyzer::new()),
5118                Box::new(SocialMediaAnalyzer::new()),
5119                Box::new(EntityAnalyzer::new()),
5120                Box::new(EnhancedReadabilityAnalyzer::new()),
5121                Box::new(KeywordAnalyzer::new()),
5122                Box::new(EcommerceSignalsAnalyzer::new()),
5123                Box::new(InternationalSeoAnalyzer::new()),
5124                // Phase 8: AI Search Optimization Analyzers
5125                Box::new(crate::ai_analyzers::AiCrawlerAccessibilityAnalyzer::new()),
5126                Box::new(crate::ai_analyzers::AiContentStructureAnalyzer::new()),
5127                Box::new(crate::ai_analyzers::AiCitationEligibilityAnalyzer::new()),
5128                Box::new(crate::ai_analyzers::AiAnswerBoxAnalyzer::new()),
5129                // Phase 8: WASM Error Detection Analyzers
5130                Box::new(crate::wasm_analyzers::WasmPatternAnalyzer::new()),
5131            ],
5132        }
5133    }
5134
5135    /// Create a registry with custom analyzers.
5136    ///
5137    /// Use this when you want full control over which analyzers are run,
5138    /// without the default set.
5139    pub fn with_analyzers(analyzers: Vec<Box<dyn Analyzer>>) -> Self {
5140        Self { analyzers }
5141    }
5142
5143    /// Add an analyzer to the registry.
5144    ///
5145    /// Custom analyzers are appended to the end and run after all
5146    /// previously registered analyzers.
5147    pub fn register(&mut self, analyzer: Box<dyn Analyzer>) {
5148        self.analyzers.push(analyzer);
5149    }
5150
5151    /// Run all analyzers on a page and collect findings.
5152    ///
5153    /// Analyzers run in parallel via rayon for performance. Returns a
5154    /// flat list of all [`Finding`]s from all analyzers.
5155    pub fn analyze(&self, ctx: &AnalysisContext, config: &CrawlConfig) -> Vec<Finding> {
5156        use rayon::prelude::*;
5157
5158        self.analyzers
5159            .par_iter()
5160            .flat_map(|a| a.analyze(ctx, config))
5161            .collect()
5162    }
5163
5164    /// Returns the number of registered analyzers.
5165    pub fn len(&self) -> usize {
5166        self.analyzers.len()
5167    }
5168
5169    /// Returns true if no analyzers are registered.
5170    pub fn is_empty(&self) -> bool {
5171        self.analyzers.is_empty()
5172    }
5173}
5174
5175// ---------------------------------------------------------------------------
5176// Tests
5177// ---------------------------------------------------------------------------
5178
5179#[cfg(test)]
5180mod tests {
5181    use super::*;
5182    use crate::meta::MetaTags;
5183    use crate::parser::{ExtractedImage, ExtractedLink, Heading, StructuredData};
5184    use crate::storage::{IssueCategory, Severity};
5185
5186    fn default_config() -> CrawlConfig {
5187        CrawlConfig::default()
5188    }
5189
5190    fn make_page(url: &str) -> ParsedPage {
5191        ParsedPage {
5192            url: url.to_string(),
5193            meta: MetaTags::default(),
5194            headings: Vec::new(),
5195            links: Vec::new(),
5196            images: Vec::new(),
5197            forms: Vec::new(),
5198            scripts: Vec::new(),
5199            styles: Vec::new(),
5200            structured_data: Vec::new(),
5201            word_count: 0,
5202            landmarks: Vec::new(),
5203            has_skip_link: false,
5204            has_main_landmark: false,
5205            has_nav_landmark: false,
5206            has_positive_tabindex: false,
5207            tabindex_negative_count: 0,
5208            aria_role_count: 0,
5209            aria_label_count: 0,
5210            has_lang_attribute: false,
5211            html_lang: None,
5212            has_aria_hidden: false,
5213            tables_with_headers: 0,
5214            tables_total: 0,
5215            tables_with_captions: 0,
5216            og_image_width: None,
5217            og_image_height: None,
5218        }
5219    }
5220
5221    fn make_ctx<'a>(page: &'a ParsedPage, status: Option<u16>) -> AnalysisContext<'a> {
5222        AnalysisContext {
5223            page,
5224            status_code: status,
5225            headers: &[],
5226            response_time: None,
5227            redirect_chain: &[],
5228            robots_txt: None,
5229        }
5230    }
5231
5232    // ---- HttpStatusAnalyzer ----
5233
5234    #[test]
5235    fn test_http_status_200() {
5236        let page = make_page("https://example.com");
5237        let ctx = make_ctx(&page, Some(200));
5238        let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5239        // Should have info about status category
5240        assert!(findings.iter().any(|f| f.code == "HTTP006"));
5241    }
5242
5243    #[test]
5244    fn test_http_status_404() {
5245        let page = make_page("https://example.com/missing");
5246        let ctx = make_ctx(&page, Some(404));
5247        let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5248        assert!(findings.iter().any(|f| f.code == "HTTP004"));
5249    }
5250
5251    #[test]
5252    fn test_http_status_500() {
5253        let page = make_page("https://example.com/error");
5254        let ctx = make_ctx(&page, Some(500));
5255        let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5256        assert!(findings.iter().any(|f| f.code == "HTTP005"));
5257        assert!(findings.iter().any(|f| f.severity == Severity::Critical));
5258    }
5259
5260    #[test]
5261    fn test_http_status_missing() {
5262        let page = make_page("https://example.com");
5263        let ctx = make_ctx(&page, None);
5264        let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5265        assert!(findings.iter().any(|f| f.code == "HTTP001"));
5266    }
5267
5268    #[test]
5269    fn test_http_status_soft_404_empty_body() {
5270        let mut page = make_page("https://example.com/soft404");
5271        page.word_count = 0;
5272        let ctx = make_ctx(&page, Some(200));
5273        let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5274        assert!(findings.iter().any(|f| f.code == "HTTP003"));
5275    }
5276
5277    #[test]
5278    fn test_http_status_slow_response() {
5279        let page = make_page("https://example.com/slow");
5280        let ctx = AnalysisContext {
5281            page: &page,
5282            status_code: Some(200),
5283            headers: &[],
5284            response_time: Some(Duration::from_secs(10)),
5285            redirect_chain: &[],
5286            robots_txt: None,
5287        };
5288        let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5289        assert!(findings.iter().any(|f| f.code == "HTTP002"));
5290    }
5291
5292    // ---- RedirectChainAnalyzer ----
5293
5294    #[test]
5295    fn test_redirect_no_hops() {
5296        let page = make_page("https://example.com");
5297        let ctx = make_ctx(&page, Some(200));
5298        let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5299        assert!(findings.is_empty());
5300    }
5301
5302    #[test]
5303    fn test_redirect_long_chain() {
5304        let hops: Vec<RedirectHop> = (0..7)
5305            .map(|i| RedirectHop {
5306                from: Url::parse(&format!("https://example.com/page{i}")).unwrap(),
5307                to: Url::parse(&format!("https://example.com/page{}", i + 1)).unwrap(),
5308                status_code: 301,
5309            })
5310            .collect();
5311        let page = make_page("https://example.com/page0");
5312        let ctx = AnalysisContext {
5313            page: &page,
5314            status_code: Some(200),
5315            headers: &[],
5316            response_time: None,
5317            redirect_chain: &hops,
5318            robots_txt: None,
5319        };
5320        let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5321        assert!(findings.iter().any(|f| f.code == "REDIR001"));
5322    }
5323
5324    #[test]
5325    fn test_redirect_loop() {
5326        let hops = vec![
5327            RedirectHop {
5328                from: Url::parse("https://example.com/a").unwrap(),
5329                to: Url::parse("https://example.com/b").unwrap(),
5330                status_code: 301,
5331            },
5332            RedirectHop {
5333                from: Url::parse("https://example.com/b").unwrap(),
5334                to: Url::parse("https://example.com/a").unwrap(),
5335                status_code: 301,
5336            },
5337        ];
5338        let page = make_page("https://example.com/a");
5339        let ctx = AnalysisContext {
5340            page: &page,
5341            status_code: Some(200),
5342            headers: &[],
5343            response_time: None,
5344            redirect_chain: &hops,
5345            robots_txt: None,
5346        };
5347        let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5348        assert!(findings.iter().any(|f| f.code == "REDIR002"));
5349    }
5350
5351    #[test]
5352    fn test_redirect_mixed_protocol() {
5353        let hops = vec![RedirectHop {
5354            from: Url::parse("http://example.com/page").unwrap(),
5355            to: Url::parse("https://example.com/page").unwrap(),
5356            status_code: 301,
5357        }];
5358        let page = make_page("http://example.com/page");
5359        let ctx = AnalysisContext {
5360            page: &page,
5361            status_code: Some(200),
5362            headers: &[],
5363            response_time: None,
5364            redirect_chain: &hops,
5365            robots_txt: None,
5366        };
5367        let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5368        assert!(findings.iter().any(|f| f.code == "REDIR003"));
5369    }
5370
5371    #[test]
5372    fn test_redirect_single_hop() {
5373        let hops = vec![RedirectHop {
5374            from: Url::parse("https://example.com/old").unwrap(),
5375            to: Url::parse("https://example.com/new").unwrap(),
5376            status_code: 301,
5377        }];
5378        let page = make_page("https://example.com/old");
5379        let ctx = AnalysisContext {
5380            page: &page,
5381            status_code: Some(200),
5382            headers: &[],
5383            response_time: None,
5384            redirect_chain: &hops,
5385            robots_txt: None,
5386        };
5387        let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5388        assert!(findings.iter().any(|f| f.code == "REDIR004"));
5389    }
5390
5391    // ---- CanonicalUrlValidator ----
5392
5393    #[test]
5394    fn test_canonical_missing() {
5395        let page = make_page("https://example.com/page");
5396        let ctx = make_ctx(&page, Some(200));
5397        let findings = CanonicalUrlValidator::new().analyze(&ctx, &default_config());
5398        assert!(findings.iter().any(|f| f.code == "CANON001"));
5399    }
5400
5401    #[test]
5402    fn test_canonical_self_referencing() {
5403        let mut page = make_page("https://example.com/page");
5404        page.meta.canonical = Some(Url::parse("https://example.com/page").unwrap());
5405        let ctx = make_ctx(&page, Some(200));
5406        let findings = CanonicalUrlValidator::new().analyze(&ctx, &default_config());
5407        // Self-referencing is fine — no mismatch finding
5408        assert!(!findings.iter().any(|f| f.code == "CANON003"));
5409    }
5410
5411    #[test]
5412    fn test_canonical_mismatch() {
5413        let mut page = make_page("https://example.com/page");
5414        page.meta.canonical = Some(Url::parse("https://example.com/other").unwrap());
5415        let ctx = make_ctx(&page, Some(200));
5416        let findings = CanonicalUrlValidator::new().analyze(&ctx, &default_config());
5417        assert!(findings.iter().any(|f| f.code == "CANON003"));
5418    }
5419
5420    // ---- HreflangValidator ----
5421
5422    #[test]
5423    fn test_hreflang_no_tags() {
5424        let page = make_page("https://example.com/en");
5425        let ctx = make_ctx(&page, Some(200));
5426        let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5427        assert!(findings.is_empty());
5428    }
5429
5430    #[test]
5431    fn test_hreflang_missing_x_default() {
5432        let mut page = make_page("https://example.com/en");
5433        page.meta.hreflang = vec![
5434            crate::meta::HreflangTag {
5435                lang: "en".to_string(),
5436                url: Url::parse("https://example.com/en").unwrap(),
5437            },
5438            crate::meta::HreflangTag {
5439                lang: "fr".to_string(),
5440                url: Url::parse("https://example.com/fr").unwrap(),
5441            },
5442        ];
5443        let ctx = make_ctx(&page, Some(200));
5444        let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5445        assert!(findings.iter().any(|f| f.code == "HREF001"));
5446    }
5447
5448    #[test]
5449    fn test_hreflang_invalid_locale() {
5450        let mut page = make_page("https://example.com/en");
5451        page.meta.hreflang = vec![
5452            crate::meta::HreflangTag {
5453                lang: "invalid-locale-code-too-long".to_string(),
5454                url: Url::parse("https://example.com/invalid").unwrap(),
5455            },
5456            crate::meta::HreflangTag {
5457                lang: "x-default".to_string(),
5458                url: Url::parse("https://example.com").unwrap(),
5459            },
5460        ];
5461        let ctx = make_ctx(&page, Some(200));
5462        let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5463        assert!(findings.iter().any(|f| f.code == "HREF002"));
5464    }
5465
5466    #[test]
5467    fn test_hreflang_duplicate_language() {
5468        let mut page = make_page("https://example.com/en");
5469        page.meta.hreflang = vec![
5470            crate::meta::HreflangTag {
5471                lang: "en".to_string(),
5472                url: Url::parse("https://example.com/en").unwrap(),
5473            },
5474            crate::meta::HreflangTag {
5475                lang: "en".to_string(),
5476                url: Url::parse("https://example.com/en-uk").unwrap(),
5477            },
5478            crate::meta::HreflangTag {
5479                lang: "x-default".to_string(),
5480                url: Url::parse("https://example.com").unwrap(),
5481            },
5482        ];
5483        let ctx = make_ctx(&page, Some(200));
5484        let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5485        assert!(findings.iter().any(|f| f.code == "HREF003"));
5486    }
5487
5488    #[test]
5489    fn test_hreflang_valid_with_x_default() {
5490        let mut page = make_page("https://example.com/en");
5491        page.meta.hreflang = vec![
5492            crate::meta::HreflangTag {
5493                lang: "en".to_string(),
5494                url: Url::parse("https://example.com/en").unwrap(),
5495            },
5496            crate::meta::HreflangTag {
5497                lang: "fr".to_string(),
5498                url: Url::parse("https://example.com/fr").unwrap(),
5499            },
5500            crate::meta::HreflangTag {
5501                lang: "x-default".to_string(),
5502                url: Url::parse("https://example.com").unwrap(),
5503            },
5504        ];
5505        let ctx = make_ctx(&page, Some(200));
5506        let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5507        // No errors for valid setup
5508        assert!(!findings.iter().any(|f| f.severity == Severity::Error));
5509    }
5510
5511    // ---- SitemapAnalyzer ----
5512
5513    #[test]
5514    fn test_sitemap_no_data() {
5515        let page = make_page("https://example.com/page");
5516        let ctx = make_ctx(&page, Some(200));
5517        let findings = SitemapAnalyzer::empty().analyze(&ctx, &default_config());
5518        assert!(findings.iter().any(|f| f.code == "SITEMAP001"));
5519    }
5520
5521    #[test]
5522    fn test_sitemap_url_not_found() {
5523        let mut known = HashSet::new();
5524        known.insert("https://example.com/other".to_string());
5525        let analyzer = SitemapAnalyzer::new(known, Vec::new());
5526        let page = make_page("https://example.com/page");
5527        let ctx = make_ctx(&page, Some(200));
5528        let findings = analyzer.analyze(&ctx, &default_config());
5529        assert!(findings.iter().any(|f| f.code == "SITEMAP002"));
5530    }
5531
5532    #[test]
5533    fn test_sitemap_url_found() {
5534        let mut known = HashSet::new();
5535        known.insert("https://example.com/page".to_string());
5536        let analyzer = SitemapAnalyzer::new(known, Vec::new());
5537        let page = make_page("https://example.com/page");
5538        let ctx = make_ctx(&page, Some(200));
5539        let findings = analyzer.analyze(&ctx, &default_config());
5540        assert!(!findings.iter().any(|f| f.code == "SITEMAP002"));
5541    }
5542
5543    #[test]
5544    fn test_sitemap_invalid_lastmod() {
5545        let mut known = HashSet::new();
5546        known.insert("https://example.com/page".to_string());
5547        let entries = vec![SitemapEntry {
5548            url: "https://example.com/page".to_string(),
5549            lastmod: Some("not-a-date".to_string()),
5550            changefreq: None,
5551            priority: None,
5552        }];
5553        let analyzer = SitemapAnalyzer::new(known, entries);
5554        let page = make_page("https://example.com/page");
5555        let ctx = make_ctx(&page, Some(200));
5556        let findings = analyzer.analyze(&ctx, &default_config());
5557        assert!(findings.iter().any(|f| f.code == "SITEMAP003"));
5558    }
5559
5560    #[test]
5561    fn test_sitemap_invalid_changefreq() {
5562        let mut known = HashSet::new();
5563        known.insert("https://example.com/page".to_string());
5564        let entries = vec![SitemapEntry {
5565            url: "https://example.com/page".to_string(),
5566            lastmod: None,
5567            changefreq: Some("sometimes".to_string()),
5568            priority: None,
5569        }];
5570        let analyzer = SitemapAnalyzer::new(known, entries);
5571        let page = make_page("https://example.com/page");
5572        let ctx = make_ctx(&page, Some(200));
5573        let findings = analyzer.analyze(&ctx, &default_config());
5574        assert!(findings.iter().any(|f| f.code == "SITEMAP004"));
5575    }
5576
5577    #[test]
5578    fn test_sitemap_invalid_priority() {
5579        let mut known = HashSet::new();
5580        known.insert("https://example.com/page".to_string());
5581        let entries = vec![SitemapEntry {
5582            url: "https://example.com/page".to_string(),
5583            lastmod: None,
5584            changefreq: None,
5585            priority: Some(2.5),
5586        }];
5587        let analyzer = SitemapAnalyzer::new(known, entries);
5588        let page = make_page("https://example.com/page");
5589        let ctx = make_ctx(&page, Some(200));
5590        let findings = analyzer.analyze(&ctx, &default_config());
5591        assert!(findings.iter().any(|f| f.code == "SITEMAP005"));
5592    }
5593
5594    #[test]
5595    fn test_sitemap_valid_metadata() {
5596        let mut known = HashSet::new();
5597        known.insert("https://example.com/page".to_string());
5598        let entries = vec![SitemapEntry {
5599            url: "https://example.com/page".to_string(),
5600            lastmod: Some("2024-01-15T10:30:00Z".to_string()),
5601            changefreq: Some("weekly".to_string()),
5602            priority: Some(0.8),
5603        }];
5604        let analyzer = SitemapAnalyzer::new(known, entries);
5605        let page = make_page("https://example.com/page");
5606        let ctx = make_ctx(&page, Some(200));
5607        let findings = analyzer.analyze(&ctx, &default_config());
5608        // No errors for valid metadata
5609        assert!(!findings.iter().any(|f| f.severity == Severity::Error));
5610    }
5611
5612    // ---- RobotsTxtAnalyzer ----
5613
5614    #[test]
5615    fn test_robots_empty() {
5616        let page = make_page("https://example.com/page");
5617        let ctx = make_ctx(&page, Some(200));
5618        let findings = RobotsTxtAnalyzer::empty().analyze(&ctx, &default_config());
5619        assert!(findings.is_empty());
5620    }
5621
5622    #[test]
5623    fn test_robots_disallowed() {
5624        let rules = vec![RobotsRule {
5625            user_agent: "*".to_string(),
5626            disallowed_paths: vec!["/admin".to_string()],
5627            allowed_paths: Vec::new(),
5628            crawl_delay: None,
5629            sitemaps: Vec::new(),
5630        }];
5631        let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5632        let page = make_page("https://example.com/admin/secret");
5633        let ctx = make_ctx(&page, Some(200));
5634        let findings = analyzer.analyze(&ctx, &default_config());
5635        assert!(findings.iter().any(|f| f.code == "ROBOT002"));
5636    }
5637
5638    #[test]
5639    fn test_robots_allowed() {
5640        let rules = vec![RobotsRule {
5641            user_agent: "*".to_string(),
5642            disallowed_paths: vec!["/admin".to_string()],
5643            allowed_paths: Vec::new(),
5644            crawl_delay: None,
5645            sitemaps: Vec::new(),
5646        }];
5647        let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5648        let page = make_page("https://example.com/page");
5649        let ctx = make_ctx(&page, Some(200));
5650        let findings = analyzer.analyze(&ctx, &default_config());
5651        assert!(!findings.iter().any(|f| f.code == "ROBOT002"));
5652    }
5653
5654    #[test]
5655    fn test_robots_allow_override() {
5656        let rules = vec![RobotsRule {
5657            user_agent: "*".to_string(),
5658            disallowed_paths: vec!["/admin".to_string()],
5659            allowed_paths: vec!["/admin/public".to_string()],
5660            crawl_delay: None,
5661            sitemaps: Vec::new(),
5662        }];
5663        let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5664        let page = make_page("https://example.com/admin/public/page");
5665        let ctx = make_ctx(&page, Some(200));
5666        let findings = analyzer.analyze(&ctx, &default_config());
5667        assert!(findings.iter().any(|f| f.code == "ROBOT001"));
5668    }
5669
5670    #[test]
5671    fn test_robots_high_crawl_delay() {
5672        let rules = vec![RobotsRule {
5673            user_agent: "*".to_string(),
5674            disallowed_paths: Vec::new(),
5675            allowed_paths: Vec::new(),
5676            crawl_delay: Some(20.0),
5677            sitemaps: Vec::new(),
5678        }];
5679        let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5680        let page = make_page("https://example.com/page");
5681        let ctx = make_ctx(&page, Some(200));
5682        let findings = analyzer.analyze(&ctx, &default_config());
5683        assert!(findings.iter().any(|f| f.code == "ROBOT003"));
5684    }
5685
5686    #[test]
5687    fn test_robots_invalid_sitemap_url() {
5688        let rules = vec![RobotsRule {
5689            user_agent: "*".to_string(),
5690            disallowed_paths: Vec::new(),
5691            allowed_paths: Vec::new(),
5692            crawl_delay: None,
5693            sitemaps: Vec::new(),
5694        }];
5695        let analyzer = RobotsTxtAnalyzer::new(rules, vec!["not-a-url".to_string()]);
5696        let page = make_page("https://example.com/page");
5697        let ctx = make_ctx(&page, Some(200));
5698        let findings = analyzer.analyze(&ctx, &default_config());
5699        assert!(findings.iter().any(|f| f.code == "ROBOT004"));
5700    }
5701
5702    // ---- MetaTagAnalyzer ----
5703
5704    #[test]
5705    fn test_meta_missing_title() {
5706        let page = make_page("https://example.com");
5707        let ctx = make_ctx(&page, Some(200));
5708        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5709        assert!(findings.iter().any(|f| f.code == "META001"));
5710    }
5711
5712    #[test]
5713    fn test_meta_title_too_short() {
5714        let mut page = make_page("https://example.com");
5715        page.meta.title = Some("Hi".to_string());
5716        let ctx = make_ctx(&page, Some(200));
5717        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5718        assert!(findings.iter().any(|f| f.code == "META002"));
5719    }
5720
5721    #[test]
5722    fn test_meta_title_too_long() {
5723        let mut page = make_page("https://example.com");
5724        page.meta.title = Some("A".repeat(80));
5725        let ctx = make_ctx(&page, Some(200));
5726        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5727        assert!(findings.iter().any(|f| f.code == "META003"));
5728    }
5729
5730    #[test]
5731    fn test_meta_title_just_right() {
5732        let mut page = make_page("https://example.com");
5733        page.meta.title = Some("A".repeat(45));
5734        let ctx = make_ctx(&page, Some(200));
5735        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5736        assert!(!findings.iter().any(|f| f.code == "META002"));
5737        assert!(!findings.iter().any(|f| f.code == "META003"));
5738    }
5739
5740    #[test]
5741    fn test_meta_missing_description() {
5742        let page = make_page("https://example.com");
5743        let ctx = make_ctx(&page, Some(200));
5744        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5745        assert!(findings.iter().any(|f| f.code == "META004"));
5746    }
5747
5748    #[test]
5749    fn test_meta_description_too_short() {
5750        let mut page = make_page("https://example.com");
5751        page.meta.description = Some("Short".to_string());
5752        let ctx = make_ctx(&page, Some(200));
5753        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5754        assert!(findings.iter().any(|f| f.code == "META005"));
5755    }
5756
5757    #[test]
5758    fn test_meta_description_too_long() {
5759        let mut page = make_page("https://example.com");
5760        page.meta.description = Some("A".repeat(200));
5761        let ctx = make_ctx(&page, Some(200));
5762        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5763        assert!(findings.iter().any(|f| f.code == "META006"));
5764    }
5765
5766    #[test]
5767    fn test_meta_missing_og_tags() {
5768        let page = make_page("https://example.com");
5769        let ctx = make_ctx(&page, Some(200));
5770        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5771        // Should flag og:title, og:image, og:url, og:type
5772        let og_codes: Vec<&str> = findings
5773            .iter()
5774            .filter(|f| f.code == "META007")
5775            .map(|f| f.title.as_str())
5776            .collect();
5777        assert!(og_codes.len() >= 4);
5778    }
5779
5780    #[test]
5781    fn test_meta_missing_twitter_tags() {
5782        let page = make_page("https://example.com");
5783        let ctx = make_ctx(&page, Some(200));
5784        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5785        // Should flag twitter:card, twitter:title, twitter:image
5786        let tw_count = findings.iter().filter(|f| f.code == "META008").count();
5787        assert!(tw_count >= 3);
5788    }
5789
5790    #[test]
5791    fn test_meta_missing_viewport() {
5792        let page = make_page("https://example.com");
5793        let ctx = make_ctx(&page, Some(200));
5794        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5795        assert!(findings.iter().any(|f| f.code == "META009"));
5796    }
5797
5798    #[test]
5799    fn test_meta_complete_tags() {
5800        let mut page = make_page("https://example.com");
5801        page.meta.title = Some("Perfect Title for SEO".to_string());
5802        page.meta.description = Some("A".repeat(140));
5803        page.meta.viewport = Some("width=device-width".to_string());
5804        page.meta.og.title = Some("OG Title".to_string());
5805        page.meta.og.image = Some("https://example.com/img.png".to_string());
5806        page.meta.og.url = Some("https://example.com".to_string());
5807        page.meta.og.r#type = Some("website".to_string());
5808        page.meta.twitter.card = Some("summary_large_image".to_string());
5809        page.meta.twitter.title = Some("Twitter Title".to_string());
5810        page.meta.twitter.image = Some("https://example.com/tw.png".to_string());
5811        let ctx = make_ctx(&page, Some(200));
5812        let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5813        // Should have no errors or warnings about missing tags
5814        assert!(!findings
5815            .iter()
5816            .any(|f| f.code == "META001" || f.code == "META004"));
5817    }
5818
5819    // ---- HeadingHierarchyAnalyzer ----
5820
5821    #[test]
5822    fn test_heading_no_headings() {
5823        let page = make_page("https://example.com");
5824        let ctx = make_ctx(&page, Some(200));
5825        let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5826        assert!(findings.iter().any(|f| f.code == "HEAD001"));
5827    }
5828
5829    #[test]
5830    fn test_heading_missing_h1() {
5831        let mut page = make_page("https://example.com");
5832        page.headings = vec![
5833            Heading {
5834                level: 2,
5835                text: "Section".to_string(),
5836                length: 7,
5837            },
5838            Heading {
5839                level: 3,
5840                text: "Sub".to_string(),
5841                length: 3,
5842            },
5843        ];
5844        let ctx = make_ctx(&page, Some(200));
5845        let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5846        assert!(findings.iter().any(|f| f.code == "HEAD002"));
5847    }
5848
5849    #[test]
5850    fn test_heading_multiple_h1() {
5851        let mut page = make_page("https://example.com");
5852        page.headings = vec![
5853            Heading {
5854                level: 1,
5855                text: "First".to_string(),
5856                length: 5,
5857            },
5858            Heading {
5859                level: 1,
5860                text: "Second".to_string(),
5861                length: 6,
5862            },
5863        ];
5864        let ctx = make_ctx(&page, Some(200));
5865        let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5866        assert!(findings.iter().any(|f| f.code == "HEAD003"));
5867    }
5868
5869    #[test]
5870    fn test_heading_skipped_level() {
5871        let mut page = make_page("https://example.com");
5872        page.headings = vec![
5873            Heading {
5874                level: 1,
5875                text: "Title".to_string(),
5876                length: 5,
5877            },
5878            Heading {
5879                level: 3,
5880                text: "Skipped H2".to_string(),
5881                length: 10,
5882            },
5883        ];
5884        let ctx = make_ctx(&page, Some(200));
5885        let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5886        assert!(findings.iter().any(|f| f.code == "HEAD004"));
5887    }
5888
5889    #[test]
5890    fn test_heading_valid_hierarchy() {
5891        let mut page = make_page("https://example.com");
5892        page.headings = vec![
5893            Heading {
5894                level: 1,
5895                text: "Main".to_string(),
5896                length: 4,
5897            },
5898            Heading {
5899                level: 2,
5900                text: "Section".to_string(),
5901                length: 7,
5902            },
5903            Heading {
5904                level: 2,
5905                text: "Section 2".to_string(),
5906                length: 9,
5907            },
5908            Heading {
5909                level: 3,
5910                text: "Sub".to_string(),
5911                length: 3,
5912            },
5913        ];
5914        let ctx = make_ctx(&page, Some(200));
5915        let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5916        assert!(!findings.iter().any(|f| f.code == "HEAD004"));
5917    }
5918
5919    #[test]
5920    fn test_heading_deep_hierarchy() {
5921        let mut page = make_page("https://example.com");
5922        page.headings = vec![
5923            Heading {
5924                level: 1,
5925                text: "H1".to_string(),
5926                length: 2,
5927            },
5928            Heading {
5929                level: 2,
5930                text: "H2".to_string(),
5931                length: 2,
5932            },
5933            Heading {
5934                level: 3,
5935                text: "H3".to_string(),
5936                length: 2,
5937            },
5938            Heading {
5939                level: 4,
5940                text: "H4".to_string(),
5941                length: 2,
5942            },
5943            Heading {
5944                level: 5,
5945                text: "H5".to_string(),
5946                length: 2,
5947            },
5948        ];
5949        let ctx = make_ctx(&page, Some(200));
5950        let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5951        assert!(findings.iter().any(|f| f.code == "HEAD005"));
5952    }
5953
5954    // ---- AnalyzerRegistry ----
5955
5956    #[test]
5957    fn test_registry_default() {
5958        let config = default_config();
5959        let registry = AnalyzerRegistry::new(&config);
5960        assert_eq!(registry.len(), 28);
5961        assert!(!registry.is_empty());
5962    }
5963
5964    #[test]
5965    fn test_registry_analyze() {
5966        let config = default_config();
5967        let registry = AnalyzerRegistry::new(&config);
5968        let mut page = make_page("https://example.com");
5969        page.meta.title = Some("Good Title Here for SEO".to_string());
5970        let ctx = make_ctx(&page, Some(200));
5971        let findings = registry.analyze(&ctx, &config);
5972        // Should produce findings from multiple analyzers
5973        assert!(!findings.is_empty());
5974    }
5975
5976    #[test]
5977    fn test_registry_custom() {
5978        struct DummyAnalyzer;
5979        impl Analyzer for DummyAnalyzer {
5980            fn name(&self) -> &str {
5981                "dummy"
5982            }
5983            fn analyze(&self, _ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
5984                vec![Finding {
5985                    severity: Severity::Info,
5986                    category: IssueCategory::Custom("test".to_string()),
5987                    code: "DUMMY001".to_string(),
5988                    title: "Dummy finding".to_string(),
5989                    description: "Test".to_string(),
5990                    url: String::new(),
5991                    recommendation: "None".to_string(),
5992                }]
5993            }
5994        }
5995        let mut registry = AnalyzerRegistry::with_analyzers(Vec::new());
5996        registry.register(Box::new(DummyAnalyzer));
5997        assert_eq!(registry.len(), 1);
5998
5999        let page = make_page("https://example.com");
6000        let ctx = make_ctx(&page, Some(200));
6001        let findings = registry.analyze(&ctx, &default_config());
6002        assert_eq!(findings.len(), 1);
6003        assert_eq!(findings[0].code, "DUMMY001");
6004    }
6005
6006    // ---- Edge cases for locale validation ----
6007
6008    #[test]
6009    fn test_valid_locales() {
6010        assert!(HreflangValidator::is_valid_locale("en"));
6011        assert!(HreflangValidator::is_valid_locale("fr"));
6012        assert!(HreflangValidator::is_valid_locale("de"));
6013        assert!(HreflangValidator::is_valid_locale("en-US"));
6014        assert!(HreflangValidator::is_valid_locale("fr-CA"));
6015        assert!(HreflangValidator::is_valid_locale("zh-CN"));
6016        assert!(HreflangValidator::is_valid_locale("x-default"));
6017    }
6018
6019    #[test]
6020    fn test_invalid_locales() {
6021        assert!(!HreflangValidator::is_valid_locale("e"));
6022        assert!(!HreflangValidator::is_valid_locale("english"));
6023        assert!(!HreflangValidator::is_valid_locale("en-us-extra"));
6024        assert!(!HreflangValidator::is_valid_locale("123"));
6025    }
6026
6027    // ---- Edge cases for soft 404 detection ----
6028
6029    #[test]
6030    fn test_soft_404_indicators() {
6031        assert!(HttpStatusAnalyzer::is_soft_404(
6032            "<html><body>Page Not Found</body></html>"
6033        ));
6034        assert!(HttpStatusAnalyzer::is_soft_404(
6035            "Error 404 — The page you requested does not exist."
6036        ));
6037        assert!(HttpStatusAnalyzer::is_soft_404(
6038            "Sorry, we couldn't find the page you're looking for."
6039        ));
6040        assert!(!HttpStatusAnalyzer::is_soft_404(
6041            "<html><body>Welcome to our site</body></html>"
6042        ));
6043    }
6044
6045    // ---- Edge cases for robots.txt path matching ----
6046
6047    #[test]
6048    fn test_robots_path_matching() {
6049        assert!(RobotsTxtAnalyzer::is_disallowed(
6050            "/admin/secret",
6051            &["/admin".to_string()]
6052        ));
6053        assert!(!RobotsTxtAnalyzer::is_disallowed(
6054            "/page",
6055            &["/admin".to_string()]
6056        ));
6057        assert!(RobotsTxtAnalyzer::is_allowed(
6058            "/admin/public",
6059            &["/admin/public".to_string()]
6060        ));
6061        assert!(!RobotsTxtAnalyzer::is_allowed(
6062            "/admin/secret",
6063            &["/admin/public".to_string()]
6064        ));
6065    }
6066
6067    // ---- Finding struct ----
6068
6069    #[test]
6070    fn test_finding_creation() {
6071        let finding = Finding {
6072            severity: Severity::Warning,
6073            category: IssueCategory::Seo,
6074            code: "TEST001".to_string(),
6075            title: "Test finding".to_string(),
6076            description: "A test finding for unit tests".to_string(),
6077            url: "https://example.com".to_string(),
6078            recommendation: "Fix it".to_string(),
6079        };
6080        assert_eq!(finding.severity, Severity::Warning);
6081        assert_eq!(finding.category, IssueCategory::Seo);
6082    }
6083
6084    // ---- Sitemap edge cases ----
6085
6086    #[test]
6087    fn test_sitemap_valid_lastmod_formats() {
6088        assert!(SitemapAnalyzer::is_valid_lastmod("2024-01-15T10:30:00Z"));
6089        assert!(SitemapAnalyzer::is_valid_lastmod("2024-01-15"));
6090        assert!(!SitemapAnalyzer::is_valid_lastmod("lastweek"));
6091    }
6092
6093    #[test]
6094    fn test_sitemap_valid_changefreq() {
6095        assert!(SitemapAnalyzer::is_valid_changefreq("daily"));
6096        assert!(SitemapAnalyzer::is_valid_changefreq("weekly"));
6097        assert!(SitemapAnalyzer::is_valid_changefreq("never"));
6098        assert!(!SitemapAnalyzer::is_valid_changefreq("sometimes"));
6099        assert!(!SitemapAnalyzer::is_valid_changefreq("often"));
6100    }
6101
6102    #[test]
6103    fn test_sitemap_valid_priority() {
6104        assert!(SitemapAnalyzer::is_valid_priority(0.0));
6105        assert!(SitemapAnalyzer::is_valid_priority(0.5));
6106        assert!(SitemapAnalyzer::is_valid_priority(1.0));
6107        assert!(!SitemapAnalyzer::is_valid_priority(-0.1));
6108        assert!(!SitemapAnalyzer::is_valid_priority(1.1));
6109    }
6110
6111    // ---- Full analysis integration ----
6112
6113    #[test]
6114    fn test_full_analysis_minimal_page() {
6115        let config = default_config();
6116        let registry = AnalyzerRegistry::new(&config);
6117        let page = make_page("https://example.com");
6118        let ctx = make_ctx(&page, Some(200));
6119        let findings = registry.analyze(&ctx, &config);
6120
6121        // A minimal page should produce several findings
6122        let codes: Vec<&str> = findings.iter().map(|f| f.code.as_str()).collect();
6123        assert!(codes.contains(&"META001")); // missing title
6124        assert!(codes.contains(&"META004")); // missing description
6125        assert!(codes.contains(&"CANON001")); // missing canonical
6126        assert!(codes.contains(&"HEAD001")); // no headings
6127    }
6128
6129    #[test]
6130    fn test_full_analysis_well_optimized_page() {
6131        let config = default_config();
6132        let registry = AnalyzerRegistry::new(&config);
6133
6134        let mut page = make_page("https://example.com/page");
6135        page.meta.title = Some("Optimized Page Title for Search".to_string());
6136        page.meta.description = Some("A".repeat(145));
6137        page.meta.canonical = Some(Url::parse("https://example.com/page").unwrap());
6138        page.meta.viewport = Some("width=device-width".to_string());
6139        page.meta.og.title = Some("OG Title".to_string());
6140        page.meta.og.image = Some("https://example.com/img.png".to_string());
6141        page.meta.og.url = Some("https://example.com/page".to_string());
6142        page.meta.og.r#type = Some("article".to_string());
6143        page.meta.twitter.card = Some("summary_large_image".to_string());
6144        page.meta.twitter.title = Some("Twitter Title".to_string());
6145        page.meta.twitter.image = Some("https://example.com/tw.png".to_string());
6146        page.headings = vec![
6147            Heading {
6148                level: 1,
6149                text: "Main Topic".to_string(),
6150                length: 10,
6151            },
6152            Heading {
6153                level: 2,
6154                text: "Section".to_string(),
6155                length: 7,
6156            },
6157        ];
6158        page.has_lang_attribute = true;
6159        page.html_lang = Some("en".to_string());
6160        page.has_main_landmark = true;
6161        page.has_nav_landmark = true;
6162        page.has_skip_link = true;
6163
6164        let ctx = make_ctx(&page, Some(200));
6165        let findings = registry.analyze(&ctx, &config);
6166
6167        // Should have few/no errors
6168        let errors: Vec<&Finding> = findings
6169            .iter()
6170            .filter(|f| f.severity == Severity::Error || f.severity == Severity::Critical)
6171            .collect();
6172        assert!(
6173            errors.is_empty(),
6174            "Well-optimized page should have no errors: {:?}",
6175            errors.iter().map(|f| &f.code).collect::<Vec<_>>()
6176        );
6177    }
6178
6179    // =========================================================================
6180    // LinkAnalyzer tests
6181    // =========================================================================
6182
6183    #[test]
6184    fn test_link_analyzer_no_links() {
6185        let page = make_page("https://example.com");
6186        let ctx = make_ctx(&page, Some(200));
6187        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6188        assert!(findings.iter().any(|f| f.code == "LINK001"));
6189        let link001 = findings.iter().find(|f| f.code == "LINK001").unwrap();
6190        assert!(link001.description.contains("Internal: 0"));
6191        assert!(link001.description.contains("External: 0"));
6192    }
6193
6194    #[test]
6195    fn test_link_analyzer_internal_external_counts() {
6196        let mut page = make_page("https://example.com");
6197        page.links = vec![
6198            ExtractedLink {
6199                href: "/about".to_string(),
6200                text: "About".to_string(),
6201                rel: vec![],
6202                is_external: false,
6203                aria_label: None,
6204                img_alt: None,
6205            },
6206            ExtractedLink {
6207                href: "/contact".to_string(),
6208                text: "Contact".to_string(),
6209                rel: vec![],
6210                is_external: false,
6211                aria_label: None,
6212                img_alt: None,
6213            },
6214            ExtractedLink {
6215                href: "https://external.com".to_string(),
6216                text: "External".to_string(),
6217                rel: vec![],
6218                is_external: true,
6219                aria_label: None,
6220                img_alt: None,
6221            },
6222        ];
6223        let ctx = make_ctx(&page, Some(200));
6224        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6225        let link001 = findings.iter().find(|f| f.code == "LINK001").unwrap();
6226        assert!(link001.description.contains("Internal: 2"));
6227        assert!(link001.description.contains("External: 1"));
6228    }
6229
6230    #[test]
6231    fn test_link_analyzer_nofollow_detection() {
6232        let mut page = make_page("https://example.com");
6233        page.links = vec![ExtractedLink {
6234            href: "https://external.com".to_string(),
6235            text: "Nofollow link".to_string(),
6236            rel: vec!["nofollow".to_string()],
6237            is_external: true,
6238            aria_label: None,
6239            img_alt: None,
6240        }];
6241        let ctx = make_ctx(&page, Some(200));
6242        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6243        assert!(findings.iter().any(|f| f.code == "LINK003"));
6244    }
6245
6246    #[test]
6247    fn test_link_analyzer_empty_anchor_text() {
6248        let mut page = make_page("https://example.com");
6249        page.links = vec![ExtractedLink {
6250            href: "/page".to_string(),
6251            text: String::new(),
6252            rel: vec![],
6253            is_external: false,
6254            aria_label: None,
6255            img_alt: None,
6256        }];
6257        let ctx = make_ctx(&page, Some(200));
6258        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6259        assert!(findings.iter().any(|f| f.code == "LINK004"));
6260    }
6261
6262    #[test]
6263    fn test_link_analyzer_short_anchor_text() {
6264        let mut page = make_page("https://example.com");
6265        page.links = vec![ExtractedLink {
6266            href: "/page".to_string(),
6267            text: "Go".to_string(),
6268            rel: vec![],
6269            is_external: false,
6270            aria_label: None,
6271            img_alt: None,
6272        }];
6273        let ctx = make_ctx(&page, Some(200));
6274        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6275        assert!(findings.iter().any(|f| f.code == "LINK005"));
6276    }
6277
6278    #[test]
6279    fn test_link_analyzer_broken_page_links() {
6280        let mut page = make_page("https://example.com");
6281        page.links = vec![ExtractedLink {
6282            href: "/broken".to_string(),
6283            text: "Broken link".to_string(),
6284            rel: vec![],
6285            is_external: false,
6286            aria_label: None,
6287            img_alt: None,
6288        }];
6289        let ctx = make_ctx(&page, Some(404));
6290        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6291        assert!(findings.iter().any(|f| f.code == "LINK002"));
6292    }
6293
6294    #[test]
6295    fn test_link_analyzer_orphan_page() {
6296        let mut inbound = HashMap::new();
6297        inbound.insert("https://example.com/orphan".to_string(), 0);
6298        let analyzer = LinkAnalyzer::with_inbound_links(inbound);
6299        let page = make_page("https://example.com/orphan");
6300        let ctx = make_ctx(&page, Some(200));
6301        let findings = analyzer.analyze(&ctx, &default_config());
6302        assert!(findings.iter().any(|f| f.code == "LINK006"));
6303    }
6304
6305    #[test]
6306    fn test_link_analyzer_not_orphan() {
6307        let mut inbound = HashMap::new();
6308        inbound.insert("https://example.com/page".to_string(), 3);
6309        let analyzer = LinkAnalyzer::with_inbound_links(inbound);
6310        let page = make_page("https://example.com/page");
6311        let ctx = make_ctx(&page, Some(200));
6312        let findings = analyzer.analyze(&ctx, &default_config());
6313        assert!(!findings.iter().any(|f| f.code == "LINK006"));
6314    }
6315
6316    #[test]
6317    fn test_link_analyzer_no_nofollow_when_absent() {
6318        let mut page = make_page("https://example.com");
6319        page.links = vec![ExtractedLink {
6320            href: "/page".to_string(),
6321            text: "Go".to_string(),
6322            rel: vec![],
6323            is_external: false,
6324            aria_label: None,
6325            img_alt: None,
6326        }];
6327        let ctx = make_ctx(&page, Some(200));
6328        let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6329        assert!(!findings.iter().any(|f| f.code == "LINK003"));
6330    }
6331
6332    // =========================================================================
6333    // ImageAnalyzer tests
6334    // =========================================================================
6335
6336    #[test]
6337    fn test_image_analyzer_no_images() {
6338        let page = make_page("https://example.com");
6339        let ctx = make_ctx(&page, Some(200));
6340        let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6341        assert!(findings.is_empty());
6342    }
6343
6344    #[test]
6345    fn test_image_analyzer_missing_alt_text() {
6346        let mut page = make_page("https://example.com");
6347        page.images = vec![ExtractedImage {
6348            src: "/img.png".to_string(),
6349            alt: String::new(),
6350            width: None,
6351            height: None,
6352            has_alt: false,
6353            is_lazy_loaded: false,
6354        }];
6355        let ctx = make_ctx(&page, Some(200));
6356        let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6357        assert!(findings.iter().any(|f| f.code == "IMG001"));
6358    }
6359
6360    #[test]
6361    fn test_image_analyzer_with_alt_text() {
6362        let mut page = make_page("https://example.com");
6363        page.images = vec![ExtractedImage {
6364            src: "/img.png".to_string(),
6365            alt: "A photo".to_string(),
6366            width: Some(100),
6367            height: Some(200),
6368            has_alt: true,
6369            is_lazy_loaded: false,
6370        }];
6371        let ctx = make_ctx(&page, Some(200));
6372        let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6373        assert!(!findings.iter().any(|f| f.code == "IMG001"));
6374    }
6375
6376    #[test]
6377    fn test_image_analyzer_lazy_loading() {
6378        let mut page = make_page("https://example.com");
6379        page.images = vec![
6380            ExtractedImage {
6381                src: "/a.png".to_string(),
6382                alt: "A".to_string(),
6383                width: None,
6384                height: None,
6385                has_alt: true,
6386                is_lazy_loaded: true,
6387            },
6388            ExtractedImage {
6389                src: "/b.png".to_string(),
6390                alt: "B".to_string(),
6391                width: None,
6392                height: None,
6393                has_alt: true,
6394                is_lazy_loaded: true,
6395            },
6396            ExtractedImage {
6397                src: "/c.png".to_string(),
6398                alt: "C".to_string(),
6399                width: None,
6400                height: None,
6401                has_alt: true,
6402                is_lazy_loaded: false,
6403            },
6404        ];
6405        let ctx = make_ctx(&page, Some(200));
6406        let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6407        assert!(findings.iter().any(|f| f.code == "IMG003"));
6408        let img003 = findings.iter().find(|f| f.code == "IMG003").unwrap();
6409        assert!(img003.description.contains("2 of 3"));
6410    }
6411
6412    #[test]
6413    fn test_image_analyzer_missing_dimensions() {
6414        let mut page = make_page("https://example.com");
6415        page.images = vec![ExtractedImage {
6416            src: "/no-dims.png".to_string(),
6417            alt: "No dims".to_string(),
6418            width: None,
6419            height: None,
6420            has_alt: true,
6421            is_lazy_loaded: false,
6422        }];
6423        let ctx = make_ctx(&page, Some(200));
6424        let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6425        assert!(findings.iter().any(|f| f.code == "IMG004"));
6426    }
6427
6428    #[test]
6429    fn test_image_analyzer_detect_format() {
6430        assert_eq!(
6431            ImageAnalyzer::detect_format("photo.jpg"),
6432            Some("jpeg".into())
6433        );
6434        assert_eq!(
6435            ImageAnalyzer::detect_format("photo.jpeg"),
6436            Some("jpeg".into())
6437        );
6438        assert_eq!(ImageAnalyzer::detect_format("pic.png"), Some("png".into()));
6439        assert_eq!(ImageAnalyzer::detect_format("anim.gif"), Some("gif".into()));
6440        assert_eq!(
6441            ImageAnalyzer::detect_format("modern.webp"),
6442            Some("webp".into())
6443        );
6444        assert_eq!(
6445            ImageAnalyzer::detect_format("new.avif"),
6446            Some("avif".into())
6447        );
6448        assert_eq!(ImageAnalyzer::detect_format("icon.svg"), Some("svg".into()));
6449        assert_eq!(
6450            ImageAnalyzer::detect_format("photo.jpg?v=1"),
6451            Some("jpeg".into())
6452        );
6453        assert_eq!(ImageAnalyzer::detect_format("no-ext"), None);
6454    }
6455
6456    // =========================================================================
6457    // StructuredDataValidator tests
6458    // =========================================================================
6459
6460    #[test]
6461    fn test_structured_data_no_data() {
6462        let page = make_page("https://example.com");
6463        let ctx = make_ctx(&page, Some(200));
6464        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6465        assert!(findings.iter().any(|f| f.code == "SD001"));
6466    }
6467
6468    #[test]
6469    fn test_structured_data_missing_context() {
6470        let mut page = make_page("https://example.com");
6471        page.structured_data = vec![StructuredData {
6472            context: None,
6473            r#type: Some("Article".to_string()),
6474            data: serde_json::json!({"@type": "Article", "headline": "Test"}),
6475        }];
6476        let ctx = make_ctx(&page, Some(200));
6477        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6478        assert!(findings.iter().any(|f| f.code == "SD002"));
6479    }
6480
6481    #[test]
6482    fn test_structured_data_wrong_context() {
6483        let mut page = make_page("https://example.com");
6484        page.structured_data = vec![StructuredData {
6485            context: Some("https://example.com/schema".to_string()),
6486            r#type: Some("Article".to_string()),
6487            data: serde_json::json!({"@type": "Article"}),
6488        }];
6489        let ctx = make_ctx(&page, Some(200));
6490        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6491        assert!(findings.iter().any(|f| f.code == "SD003"));
6492    }
6493
6494    #[test]
6495    fn test_structured_data_missing_type() {
6496        let mut page = make_page("https://example.com");
6497        page.structured_data = vec![StructuredData {
6498            context: Some("https://schema.org".to_string()),
6499            r#type: None,
6500            data: serde_json::json!({"@context": "https://schema.org"}),
6501        }];
6502        let ctx = make_ctx(&page, Some(200));
6503        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6504        assert!(findings.iter().any(|f| f.code == "SD004"));
6505    }
6506
6507    #[test]
6508    fn test_structured_data_unknown_type() {
6509        let mut page = make_page("https://example.com");
6510        page.structured_data = vec![StructuredData {
6511            context: Some("https://schema.org".to_string()),
6512            r#type: Some("CustomWidget".to_string()),
6513            data: serde_json::json!({"@context": "https://schema.org", "@type": "CustomWidget"}),
6514        }];
6515        let ctx = make_ctx(&page, Some(200));
6516        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6517        assert!(findings.iter().any(|f| f.code == "SD005"));
6518    }
6519
6520    #[test]
6521    fn test_structured_data_missing_required_properties() {
6522        let mut page = make_page("https://example.com");
6523        page.structured_data = vec![StructuredData {
6524            context: Some("https://schema.org".to_string()),
6525            r#type: Some("Article".to_string()),
6526            data: serde_json::json!({
6527                "@context": "https://schema.org",
6528                "@type": "Article"
6529            }),
6530        }];
6531        let ctx = make_ctx(&page, Some(200));
6532        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6533        assert!(findings.iter().any(|f| f.code == "SD006"));
6534        let sd006 = findings.iter().find(|f| f.code == "SD006").unwrap();
6535        assert!(sd006.description.contains("headline"));
6536        assert!(sd006.description.contains("author"));
6537    }
6538
6539    #[test]
6540    fn test_structured_data_valid_article() {
6541        let mut page = make_page("https://example.com");
6542        page.structured_data = vec![StructuredData {
6543            context: Some("https://schema.org".to_string()),
6544            r#type: Some("Article".to_string()),
6545            data: serde_json::json!({
6546                "@context": "https://schema.org",
6547                "@type": "Article",
6548                "headline": "Test Article",
6549                "author": "John Doe"
6550            }),
6551        }];
6552        let ctx = make_ctx(&page, Some(200));
6553        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6554        assert!(!findings.iter().any(|f| f.code == "SD006"));
6555    }
6556
6557    #[test]
6558    fn test_structured_data_valid_product() {
6559        let mut page = make_page("https://example.com");
6560        page.structured_data = vec![StructuredData {
6561            context: Some("https://schema.org".to_string()),
6562            r#type: Some("Product".to_string()),
6563            data: serde_json::json!({
6564                "@context": "https://schema.org",
6565                "@type": "Product",
6566                "name": "Widget"
6567            }),
6568        }];
6569        let ctx = make_ctx(&page, Some(200));
6570        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6571        assert!(!findings.iter().any(|f| f.code == "SD006"));
6572    }
6573
6574    #[test]
6575    fn test_structured_data_schema_org_context() {
6576        let mut page = make_page("https://example.com");
6577        page.structured_data = vec![StructuredData {
6578            context: Some("schema.org".to_string()),
6579            r#type: Some("WebSite".to_string()),
6580            data: serde_json::json!({
6581                "@context": "schema.org",
6582                "@type": "WebSite",
6583                "name": "My Site"
6584            }),
6585        }];
6586        let ctx = make_ctx(&page, Some(200));
6587        let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6588        // "schema.org" without https is accepted as valid
6589        assert!(!findings.iter().any(|f| f.code == "SD003"));
6590    }
6591
6592    // =========================================================================
6593    // ContentQualityAnalyzer tests
6594    // =========================================================================
6595
6596    #[test]
6597    fn test_content_quality_empty_page() {
6598        let page = make_page("https://example.com");
6599        let ctx = make_ctx(&page, Some(200));
6600        let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6601        // Should flag zero content
6602        assert!(findings.iter().any(|f| f.code == "CQ003"));
6603    }
6604
6605    #[test]
6606    fn test_content_quality_thin_content() {
6607        let mut page = make_page("https://example.com");
6608        page.word_count = 150;
6609        let ctx = make_ctx(&page, Some(200));
6610        let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6611        assert!(findings.iter().any(|f| f.code == "CQ004"));
6612    }
6613
6614    #[test]
6615    fn test_content_quality_long_form() {
6616        let mut page = make_page("https://example.com");
6617        page.word_count = 5000;
6618        let ctx = make_ctx(&page, Some(200));
6619        let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6620        assert!(findings.iter().any(|f| f.code == "CQ005"));
6621    }
6622
6623    #[test]
6624    fn test_content_quality_readability_score() {
6625        let mut page = make_page("https://example.com");
6626        page.word_count = 500;
6627        page.headings = vec![Heading {
6628            level: 1,
6629            text: "This is a simple heading for testing readability scores in content analysis"
6630                .to_string(),
6631            length: 66,
6632        }];
6633        let ctx = make_ctx(&page, Some(200));
6634        let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6635        assert!(findings.iter().any(|f| f.code == "CQ001"));
6636    }
6637
6638    #[test]
6639    fn test_content_quality_keyword_density() {
6640        let mut page = make_page("https://example.com");
6641        page.word_count = 500;
6642        page.headings = vec![
6643            Heading {
6644                level: 1,
6645                text: "Rust Programming Language Tutorial".to_string(),
6646                length: 33,
6647            },
6648            Heading {
6649                level: 2,
6650                text: "Rust Basics for Beginners".to_string(),
6651                length: 24,
6652            },
6653            Heading {
6654                level: 2,
6655                text: "Advanced Rust Programming".to_string(),
6656                length: 24,
6657            },
6658        ];
6659        let ctx = make_ctx(&page, Some(200));
6660        let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6661        assert!(findings.iter().any(|f| f.code == "CQ002"));
6662        let cq002 = findings.iter().find(|f| f.code == "CQ002").unwrap();
6663        assert!(cq002.description.contains("rust"));
6664    }
6665
6666    #[test]
6667    fn test_content_quality_syllable_counting() {
6668        assert_eq!(ContentQualityAnalyzer::count_syllables("cat"), 1);
6669        assert_eq!(ContentQualityAnalyzer::count_syllables("hello"), 2);
6670        assert_eq!(ContentQualityAnalyzer::count_syllables("beautiful"), 3);
6671        assert_eq!(ContentQualityAnalyzer::count_syllables("a"), 1);
6672        assert_eq!(ContentQualityAnalyzer::count_syllables(""), 0);
6673    }
6674
6675    #[test]
6676    fn test_content_quality_sentence_counting() {
6677        assert_eq!(ContentQualityAnalyzer::count_sentences("Hello world."), 1);
6678        assert_eq!(
6679            ContentQualityAnalyzer::count_sentences("First sentence. Second sentence! Third?"),
6680            3
6681        );
6682        assert_eq!(ContentQualityAnalyzer::count_sentences(""), 0);
6683        assert_eq!(ContentQualityAnalyzer::count_sentences("   "), 0);
6684    }
6685
6686    #[test]
6687    fn test_content_quality_flesch_kincaid() {
6688        // Simple text should score higher
6689        let score = ContentQualityAnalyzer::flesch_kincaid(100, 5, 150);
6690        assert!(score > 50.0);
6691
6692        // Complex text should score lower
6693        let score = ContentQualityAnalyzer::flesch_kincaid(100, 20, 300);
6694        assert!(score < 50.0);
6695
6696        // Zero words should return 0
6697        assert_eq!(ContentQualityAnalyzer::flesch_kincaid(0, 0, 0), 0.0);
6698    }
6699
6700    // =========================================================================
6701    // WordCountAnalyzer tests
6702    // =========================================================================
6703
6704    #[test]
6705    fn test_word_count_analyzer_empty() {
6706        let page = make_page("https://example.com");
6707        let ctx = make_ctx(&page, Some(200));
6708        let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6709        assert!(findings.iter().any(|f| f.code == "WC001"));
6710        assert!(findings.iter().any(|f| f.code == "WC002"));
6711    }
6712
6713    #[test]
6714    fn test_word_count_analyzer_with_content() {
6715        let mut page = make_page("https://example.com");
6716        page.word_count = 150;
6717        page.headings = vec![Heading {
6718            level: 1,
6719            text: "A page with some words".to_string(),
6720            length: 22,
6721        }];
6722        let ctx = make_ctx(&page, Some(200));
6723        let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6724        let wc001 = findings.iter().find(|f| f.code == "WC001").unwrap();
6725        assert!(wc001.description.contains("Words: 150"));
6726    }
6727
6728    #[test]
6729    fn test_word_count_analyzer_very_low() {
6730        let mut page = make_page("https://example.com");
6731        page.word_count = 50;
6732        page.headings = vec![Heading {
6733            level: 1,
6734            text: "Short".to_string(),
6735            length: 5,
6736        }];
6737        let ctx = make_ctx(&page, Some(200));
6738        let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6739        assert!(findings.iter().any(|f| f.code == "WC003"));
6740    }
6741
6742    #[test]
6743    fn test_word_count_analyzer_long_sentences() {
6744        let mut page = make_page("https://example.com");
6745        page.word_count = 100;
6746        page.headings = vec![Heading {
6747            level: 1,
6748            text: "This is a very long sentence that contains many words and should trigger \
6749                   the long sentence warning because it has more than twenty five words on average"
6750                .to_string(),
6751            length: 150,
6752        }];
6753        let ctx = make_ctx(&page, Some(200));
6754        let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6755        assert!(findings.iter().any(|f| f.code == "WC004"));
6756    }
6757
6758    #[test]
6759    fn test_word_count_analyzer_normal_content() {
6760        let mut page = make_page("https://example.com");
6761        page.word_count = 500;
6762        page.headings = vec![
6763            Heading {
6764                level: 1,
6765                text: "Main Title".to_string(),
6766                length: 10,
6767            },
6768            Heading {
6769                level: 2,
6770                text: "Section One".to_string(),
6771                length: 11,
6772            },
6773        ];
6774        let ctx = make_ctx(&page, Some(200));
6775        let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6776        // Should have WC001 but not WC002 or WC003
6777        assert!(findings.iter().any(|f| f.code == "WC001"));
6778        assert!(!findings.iter().any(|f| f.code == "WC002"));
6779        assert!(!findings.iter().any(|f| f.code == "WC003"));
6780    }
6781
6782    // =========================================================================
6783    // SecurityHeaderAnalyzer tests
6784    // =========================================================================
6785
6786    #[test]
6787    fn test_security_headers_none_present() {
6788        let page = make_page("https://example.com");
6789        let ctx = make_ctx(&page, Some(200));
6790        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6791        // Should flag missing CSP, HSTS, XFO, XCTO
6792        assert!(findings.iter().any(|f| f.code == "SEC001"));
6793        assert!(findings.iter().any(|f| f.code == "SEC002"));
6794        assert!(findings.iter().any(|f| f.code == "SEC003"));
6795        assert!(findings.iter().any(|f| f.code == "SEC005"));
6796        // Should have a score
6797        assert!(findings.iter().any(|f| f.code == "SEC012"));
6798    }
6799
6800    #[test]
6801    fn test_security_headers_all_present() {
6802        let headers = vec![
6803            (
6804                "Content-Security-Policy".to_string(),
6805                "default-src 'self'".to_string(),
6806            ),
6807            (
6808                "Strict-Transport-Security".to_string(),
6809                "max-age=31536000; includeSubDomains; preload".to_string(),
6810            ),
6811            ("X-Frame-Options".to_string(), "DENY".to_string()),
6812            ("X-Content-Type-Options".to_string(), "nosniff".to_string()),
6813            (
6814                "Referrer-Policy".to_string(),
6815                "strict-origin-when-cross-origin".to_string(),
6816            ),
6817            (
6818                "Permissions-Policy".to_string(),
6819                "camera=(), microphone=(), geolocation=()".to_string(),
6820            ),
6821            (
6822                "Cross-Origin-Embedder-Policy".to_string(),
6823                "require-corp".to_string(),
6824            ),
6825            (
6826                "Cross-Origin-Opener-Policy".to_string(),
6827                "same-origin".to_string(),
6828            ),
6829            (
6830                "Cross-Origin-Resource-Policy".to_string(),
6831                "same-origin".to_string(),
6832            ),
6833        ];
6834        let page = make_page("https://example.com");
6835        let ctx = AnalysisContext {
6836            page: &page,
6837            status_code: Some(200),
6838            headers: &headers,
6839            response_time: None,
6840            redirect_chain: &[],
6841            robots_txt: None,
6842        };
6843        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6844        // Should not flag any missing headers
6845        assert!(!findings.iter().any(|f| f.code == "SEC001"));
6846        assert!(!findings.iter().any(|f| f.code == "SEC002"));
6847        assert!(!findings.iter().any(|f| f.code == "SEC003"));
6848        assert!(!findings.iter().any(|f| f.code == "SEC005"));
6849        // Score should be high
6850        let score_finding = findings.iter().find(|f| f.code == "SEC012").unwrap();
6851        assert!(score_finding.description.contains("100/100"));
6852    }
6853
6854    #[test]
6855    fn test_security_headers_invalid_xfo() {
6856        let headers = vec![(
6857            "X-Frame-Options".to_string(),
6858            "ALLOW-FROM https://example.com".to_string(),
6859        )];
6860        let page = make_page("https://example.com");
6861        let ctx = AnalysisContext {
6862            page: &page,
6863            status_code: Some(200),
6864            headers: &headers,
6865            response_time: None,
6866            redirect_chain: &[],
6867            robots_txt: None,
6868        };
6869        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6870        assert!(findings.iter().any(|f| f.code == "SEC004"));
6871    }
6872
6873    #[test]
6874    fn test_security_headers_invalid_xcto() {
6875        let headers = vec![("X-Content-Type-Options".to_string(), "sniff".to_string())];
6876        let page = make_page("https://example.com");
6877        let ctx = AnalysisContext {
6878            page: &page,
6879            status_code: Some(200),
6880            headers: &headers,
6881            response_time: None,
6882            redirect_chain: &[],
6883            robots_txt: None,
6884        };
6885        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6886        assert!(findings.iter().any(|f| f.code == "SEC006"));
6887    }
6888
6889    #[test]
6890    fn test_security_headers_xfo_sameorigin() {
6891        let headers = vec![("X-Frame-Options".to_string(), "SAMEORIGIN".to_string())];
6892        let page = make_page("https://example.com");
6893        let ctx = AnalysisContext {
6894            page: &page,
6895            status_code: Some(200),
6896            headers: &headers,
6897            response_time: None,
6898            redirect_chain: &[],
6899            robots_txt: None,
6900        };
6901        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6902        // SAMEORIGIN is valid, should not flag SEC003 or SEC004
6903        assert!(!findings.iter().any(|f| f.code == "SEC003"));
6904        assert!(!findings.iter().any(|f| f.code == "SEC004"));
6905    }
6906
6907    #[test]
6908    fn test_security_headers_hsts_weak_max_age() {
6909        let headers = vec![(
6910            "Strict-Transport-Security".to_string(),
6911            "max-age=300".to_string(),
6912        )];
6913        let page = make_page("https://example.com");
6914        let ctx = AnalysisContext {
6915            page: &page,
6916            status_code: Some(200),
6917            headers: &headers,
6918            response_time: None,
6919            redirect_chain: &[],
6920            robots_txt: None,
6921        };
6922        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6923        assert!(findings.iter().any(|f| f.code == "SEC014"));
6924    }
6925
6926    #[test]
6927    fn test_security_headers_hsts_missing_max_age() {
6928        let headers = vec![(
6929            "Strict-Transport-Security".to_string(),
6930            "includeSubDomains".to_string(),
6931        )];
6932        let page = make_page("https://example.com");
6933        let ctx = AnalysisContext {
6934            page: &page,
6935            status_code: Some(200),
6936            headers: &headers,
6937            response_time: None,
6938            redirect_chain: &[],
6939            robots_txt: None,
6940        };
6941        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6942        assert!(findings.iter().any(|f| f.code == "SEC014"));
6943    }
6944
6945    #[test]
6946    fn test_security_headers_invalid_csp() {
6947        let headers = vec![(
6948            "Content-Security-Policy".to_string(),
6949            "invalid-value".to_string(),
6950        )];
6951        let page = make_page("https://example.com");
6952        let ctx = AnalysisContext {
6953            page: &page,
6954            status_code: Some(200),
6955            headers: &headers,
6956            response_time: None,
6957            redirect_chain: &[],
6958            robots_txt: None,
6959        };
6960        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6961        assert!(findings.iter().any(|f| f.code == "SEC013"));
6962    }
6963
6964    #[test]
6965    fn test_security_headers_valid_csp() {
6966        let headers = vec![(
6967            "Content-Security-Policy".to_string(),
6968            "default-src 'self'; script-src 'self'".to_string(),
6969        )];
6970        let page = make_page("https://example.com");
6971        let ctx = AnalysisContext {
6972            page: &page,
6973            status_code: Some(200),
6974            headers: &headers,
6975            response_time: None,
6976            redirect_chain: &[],
6977            robots_txt: None,
6978        };
6979        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6980        assert!(!findings.iter().any(|f| f.code == "SEC013"));
6981    }
6982
6983    #[test]
6984    fn test_security_headers_case_insensitive_lookup() {
6985        let headers = vec![
6986            (
6987                "content-security-policy".to_string(),
6988                "default-src 'self'".to_string(),
6989            ),
6990            (
6991                "strict-transport-security".to_string(),
6992                "max-age=63072000".to_string(),
6993            ),
6994            ("x-frame-options".to_string(), "DENY".to_string()),
6995            ("x-content-type-options".to_string(), "nosniff".to_string()),
6996        ];
6997        let page = make_page("https://example.com");
6998        let ctx = AnalysisContext {
6999            page: &page,
7000            status_code: Some(200),
7001            headers: &headers,
7002            response_time: None,
7003            redirect_chain: &[],
7004            robots_txt: None,
7005        };
7006        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
7007        // Lowercase header names should still be found
7008        assert!(!findings.iter().any(|f| f.code == "SEC001"));
7009        assert!(!findings.iter().any(|f| f.code == "SEC002"));
7010        assert!(!findings.iter().any(|f| f.code == "SEC003"));
7011        assert!(!findings.iter().any(|f| f.code == "SEC005"));
7012    }
7013
7014    #[test]
7015    fn test_security_headers_score_decreases_with_missing() {
7016        let page = make_page("https://example.com");
7017        let ctx = make_ctx(&page, Some(200));
7018        let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
7019        let score_finding = findings.iter().find(|f| f.code == "SEC012").unwrap();
7020        // With multiple missing headers, score should be well below 100
7021        assert!(score_finding.description.contains("/100"));
7022        // Parse the score
7023        let score_str = score_finding
7024            .description
7025            .split_whitespace()
7026            .find(|s| s.contains("/100"))
7027            .unwrap();
7028        let score_num: u32 = score_str.split('/').next().unwrap().parse().unwrap();
7029        assert!(score_num < 90);
7030    }
7031
7032    // =========================================================================
7033    // SslCertificateValidator tests
7034    // =========================================================================
7035
7036    #[test]
7037    fn test_ssl_no_data() {
7038        let page = make_page("https://example.com");
7039        let ctx = make_ctx(&page, Some(200));
7040        let findings = SslCertificateValidator::empty().analyze(&ctx, &default_config());
7041        assert!(findings.iter().any(|f| f.code == "SSL007"));
7042    }
7043
7044    #[test]
7045    fn test_ssl_expired_certificate() {
7046        let cert = SslCertificateInfo {
7047            subject: Some("example.com".to_string()),
7048            issuer: Some("Let's Encrypt".to_string()),
7049            san_entries: vec!["example.com".to_string()],
7050            not_before: Some("2024-01-01T00:00:00Z".to_string()),
7051            not_after: Some("2025-01-01T00:00:00Z".to_string()),
7052            is_valid_chain: true,
7053            is_self_signed: false,
7054            signature_algorithm: Some("SHA256withRSA".to_string()),
7055        };
7056        let validator = SslCertificateValidator::new(Some(cert));
7057        let page = make_page("https://example.com");
7058        let ctx = make_ctx(&page, Some(200));
7059        let findings = validator.analyze(&ctx, &default_config());
7060        assert!(findings.iter().any(|f| f.code == "SSL001"));
7061    }
7062
7063    #[test]
7064    fn test_ssl_expiring_soon() {
7065        let cert = SslCertificateInfo {
7066            subject: Some("example.com".to_string()),
7067            issuer: Some("Let's Encrypt".to_string()),
7068            san_entries: vec!["example.com".to_string()],
7069            not_before: Some("2025-06-01T00:00:00Z".to_string()),
7070            not_after: Some("2025-08-01T00:00:00Z".to_string()),
7071            is_valid_chain: true,
7072            is_self_signed: false,
7073            signature_algorithm: Some("SHA256withRSA".to_string()),
7074        };
7075        let validator = SslCertificateValidator::new(Some(cert));
7076        let page = make_page("https://example.com");
7077        let ctx = make_ctx(&page, Some(200));
7078        let findings = validator.analyze(&ctx, &default_config());
7079        // Should NOT be expired but should be flagged as expiring soon (or not, depending on
7080        // the current date relative to 2025-08-01)
7081        let has_expiry_finding = findings
7082            .iter()
7083            .any(|f| f.code == "SSL001" || f.code == "SSL002");
7084        // Given today is 2026, this cert IS expired
7085        assert!(has_expiry_finding);
7086    }
7087
7088    #[test]
7089    fn test_ssl_valid_certificate() {
7090        let cert = SslCertificateInfo {
7091            subject: Some("example.com".to_string()),
7092            issuer: Some("Let's Encrypt".to_string()),
7093            san_entries: vec!["example.com".to_string(), "www.example.com".to_string()],
7094            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7095            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7096            is_valid_chain: true,
7097            is_self_signed: false,
7098            signature_algorithm: Some("SHA256withRSA".to_string()),
7099        };
7100        let validator = SslCertificateValidator::new(Some(cert));
7101        let page = make_page("https://example.com");
7102        let ctx = make_ctx(&page, Some(200));
7103        let findings = validator.analyze(&ctx, &default_config());
7104        // Should not have critical or error findings
7105        assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
7106        assert!(!findings.iter().any(|f| f.severity == Severity::Error));
7107        // Should have the summary finding
7108        assert!(findings.iter().any(|f| f.code == "SSL008"));
7109    }
7110
7111    #[test]
7112    fn test_ssl_invalid_chain() {
7113        let cert = SslCertificateInfo {
7114            subject: Some("example.com".to_string()),
7115            issuer: Some("Unknown CA".to_string()),
7116            san_entries: vec!["example.com".to_string()],
7117            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7118            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7119            is_valid_chain: false,
7120            is_self_signed: false,
7121            signature_algorithm: Some("SHA256withRSA".to_string()),
7122        };
7123        let validator = SslCertificateValidator::new(Some(cert));
7124        let page = make_page("https://example.com");
7125        let ctx = make_ctx(&page, Some(200));
7126        let findings = validator.analyze(&ctx, &default_config());
7127        assert!(findings.iter().any(|f| f.code == "SSL003"));
7128    }
7129
7130    #[test]
7131    fn test_ssl_self_signed() {
7132        let cert = SslCertificateInfo {
7133            subject: Some("localhost".to_string()),
7134            issuer: Some("localhost".to_string()),
7135            san_entries: vec!["localhost".to_string()],
7136            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7137            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7138            is_valid_chain: true,
7139            is_self_signed: true,
7140            signature_algorithm: Some("SHA256withRSA".to_string()),
7141        };
7142        let validator = SslCertificateValidator::new(Some(cert));
7143        let page = make_page("https://localhost");
7144        let ctx = make_ctx(&page, Some(200));
7145        let findings = validator.analyze(&ctx, &default_config());
7146        assert!(findings.iter().any(|f| f.code == "SSL006"));
7147    }
7148
7149    #[test]
7150    fn test_ssl_subject_mismatch() {
7151        let cert = SslCertificateInfo {
7152            subject: Some("wrong.example.com".to_string()),
7153            issuer: Some("Let's Encrypt".to_string()),
7154            san_entries: vec!["wrong.example.com".to_string()],
7155            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7156            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7157            is_valid_chain: true,
7158            is_self_signed: false,
7159            signature_algorithm: Some("SHA256withRSA".to_string()),
7160        };
7161        let validator = SslCertificateValidator::new(Some(cert));
7162        let page = make_page("https://example.com");
7163        let ctx = make_ctx(&page, Some(200));
7164        let findings = validator.analyze(&ctx, &default_config());
7165        assert!(findings.iter().any(|f| f.code == "SSL004"));
7166    }
7167
7168    #[test]
7169    fn test_ssl_wildcard_match() {
7170        let cert = SslCertificateInfo {
7171            subject: Some("*.example.com".to_string()),
7172            issuer: Some("Let's Encrypt".to_string()),
7173            san_entries: vec!["*.example.com".to_string()],
7174            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7175            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7176            is_valid_chain: true,
7177            is_self_signed: false,
7178            signature_algorithm: Some("SHA256withRSA".to_string()),
7179        };
7180        let validator = SslCertificateValidator::new(Some(cert));
7181        let page = make_page("https://sub.example.com");
7182        let ctx = make_ctx(&page, Some(200));
7183        let findings = validator.analyze(&ctx, &default_config());
7184        assert!(!findings.iter().any(|f| f.code == "SSL004"));
7185    }
7186
7187    #[test]
7188    fn test_ssl_weak_algorithm() {
7189        let cert = SslCertificateInfo {
7190            subject: Some("example.com".to_string()),
7191            issuer: Some("Let's Encrypt".to_string()),
7192            san_entries: vec!["example.com".to_string()],
7193            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7194            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7195            is_valid_chain: true,
7196            is_self_signed: false,
7197            signature_algorithm: Some("SHA1withRSA".to_string()),
7198        };
7199        let validator = SslCertificateValidator::new(Some(cert));
7200        let page = make_page("https://example.com");
7201        let ctx = make_ctx(&page, Some(200));
7202        let findings = validator.analyze(&ctx, &default_config());
7203        assert!(findings.iter().any(|f| f.code == "SSL005"));
7204    }
7205
7206    #[test]
7207    fn test_ssl_strong_algorithm() {
7208        let cert = SslCertificateInfo {
7209            subject: Some("example.com".to_string()),
7210            issuer: Some("Let's Encrypt".to_string()),
7211            san_entries: vec!["example.com".to_string()],
7212            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7213            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7214            is_valid_chain: true,
7215            is_self_signed: false,
7216            signature_algorithm: Some("SHA256withECDSA".to_string()),
7217        };
7218        let validator = SslCertificateValidator::new(Some(cert));
7219        let page = make_page("https://example.com");
7220        let ctx = make_ctx(&page, Some(200));
7221        let findings = validator.analyze(&ctx, &default_config());
7222        assert!(!findings.iter().any(|f| f.code == "SSL005"));
7223    }
7224
7225    #[test]
7226    fn test_ssl_san_match() {
7227        let cert = SslCertificateInfo {
7228            subject: Some("other.com".to_string()),
7229            issuer: Some("Let's Encrypt".to_string()),
7230            san_entries: vec!["other.com".to_string(), "example.com".to_string()],
7231            not_before: Some("2025-01-01T00:00:00Z".to_string()),
7232            not_after: Some("2027-01-01T00:00:00Z".to_string()),
7233            is_valid_chain: true,
7234            is_self_signed: false,
7235            signature_algorithm: Some("SHA256withRSA".to_string()),
7236        };
7237        let validator = SslCertificateValidator::new(Some(cert));
7238        let page = make_page("https://example.com");
7239        let ctx = make_ctx(&page, Some(200));
7240        let findings = validator.analyze(&ctx, &default_config());
7241        // example.com is in SANs, so no mismatch
7242        assert!(!findings.iter().any(|f| f.code == "SSL004"));
7243    }
7244
7245    // =========================================================================
7246    // MobileFriendlinessChecker tests
7247    // =========================================================================
7248
7249    #[test]
7250    fn test_mobile_missing_viewport() {
7251        let page = make_page("https://example.com");
7252        let ctx = make_ctx(&page, Some(200));
7253        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7254        assert!(findings.iter().any(|f| f.code == "MOB001"));
7255    }
7256
7257    #[test]
7258    fn test_mobile_optimal_viewport() {
7259        let mut page = make_page("https://example.com");
7260        page.meta.viewport = Some("width=device-width, initial-scale=1".to_string());
7261        let ctx = make_ctx(&page, Some(200));
7262        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7263        assert!(!findings.iter().any(|f| f.code == "MOB001"));
7264        assert!(!findings.iter().any(|f| f.code == "MOB002"));
7265        assert!(!findings.iter().any(|f| f.code == "MOB003"));
7266        assert!(!findings.iter().any(|f| f.code == "MOB004"));
7267    }
7268
7269    #[test]
7270    fn test_mobile_user_scalable_no() {
7271        let mut page = make_page("https://example.com");
7272        page.meta.viewport =
7273            Some("width=device-width, initial-scale=1, user-scalable=no".to_string());
7274        let ctx = make_ctx(&page, Some(200));
7275        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7276        assert!(findings.iter().any(|f| f.code == "MOB004"));
7277    }
7278
7279    #[test]
7280    fn test_mobile_maximum_scale_restricted() {
7281        let mut page = make_page("https://example.com");
7282        page.meta.viewport =
7283            Some("width=device-width, initial-scale=1, maximum-scale=1.0".to_string());
7284        let ctx = make_ctx(&page, Some(200));
7285        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7286        assert!(findings.iter().any(|f| f.code == "MOB005"));
7287    }
7288
7289    #[test]
7290    fn test_mobile_fixed_width() {
7291        let mut page = make_page("https://example.com");
7292        page.meta.viewport = Some("width=980".to_string());
7293        let ctx = make_ctx(&page, Some(200));
7294        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7295        assert!(findings.iter().any(|f| f.code == "MOB003"));
7296    }
7297
7298    #[test]
7299    fn test_mobile_missing_width_directive() {
7300        let mut page = make_page("https://example.com");
7301        page.meta.viewport = Some("initial-scale=1".to_string());
7302        let ctx = make_ctx(&page, Some(200));
7303        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7304        assert!(findings.iter().any(|f| f.code == "MOB002"));
7305    }
7306
7307    #[test]
7308    fn test_mobile_non_standard_initial_scale() {
7309        let mut page = make_page("https://example.com");
7310        page.meta.viewport = Some("width=device-width, initial-scale=2.0".to_string());
7311        let ctx = make_ctx(&page, Some(200));
7312        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7313        assert!(findings.iter().any(|f| f.code == "MOB009"));
7314    }
7315
7316    #[test]
7317    fn test_mobile_parse_viewport() {
7318        let vp = MobileFriendlinessChecker::parse_viewport(
7319            "width=device-width, initial-scale=1, user-scalable=no",
7320        );
7321        assert_eq!(vp.get("width").unwrap(), "device-width");
7322        assert_eq!(vp.get("initial-scale").unwrap(), "1");
7323        assert_eq!(vp.get("user-scalable").unwrap(), "no");
7324    }
7325
7326    #[test]
7327    fn test_mobile_parse_viewport_empty() {
7328        let vp = MobileFriendlinessChecker::parse_viewport("");
7329        assert!(vp.is_empty());
7330    }
7331
7332    #[test]
7333    fn test_mobile_both_user_scalable_and_max_scale() {
7334        let mut page = make_page("https://example.com");
7335        page.meta.viewport = Some(
7336            "width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0".to_string(),
7337        );
7338        let ctx = make_ctx(&page, Some(200));
7339        let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7340        assert!(findings.iter().any(|f| f.code == "MOB004"));
7341        assert!(findings.iter().any(|f| f.code == "MOB005"));
7342    }
7343
7344    // =========================================================================
7345    // AccessibilityAnalyzer tests
7346    // =========================================================================
7347
7348    #[test]
7349    fn test_a11y_images_missing_alt() {
7350        let mut page = make_page("https://example.com");
7351        page.images = vec![
7352            ExtractedImage {
7353                src: "/a.png".to_string(),
7354                alt: String::new(),
7355                width: None,
7356                height: None,
7357                has_alt: false,
7358                is_lazy_loaded: false,
7359            },
7360            ExtractedImage {
7361                src: "/b.jpg".to_string(),
7362                alt: "Good alt".to_string(),
7363                width: None,
7364                height: None,
7365                has_alt: true,
7366                is_lazy_loaded: false,
7367            },
7368        ];
7369        let ctx = make_ctx(&page, Some(200));
7370        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7371        assert!(findings.iter().any(|f| f.code == "A11Y001"));
7372        let f = findings.iter().find(|f| f.code == "A11Y001").unwrap();
7373        assert!(f.description.contains("/a.png"));
7374    }
7375
7376    #[test]
7377    fn test_a11y_images_all_have_alt() {
7378        let mut page = make_page("https://example.com");
7379        page.images = vec![ExtractedImage {
7380            src: "/a.png".to_string(),
7381            alt: "Description".to_string(),
7382            width: None,
7383            height: None,
7384            has_alt: true,
7385            is_lazy_loaded: false,
7386        }];
7387        let ctx = make_ctx(&page, Some(200));
7388        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7389        assert!(!findings.iter().any(|f| f.code == "A11Y001"));
7390    }
7391
7392    #[test]
7393    fn test_a11y_no_headings() {
7394        let page = make_page("https://example.com");
7395        let ctx = make_ctx(&page, Some(200));
7396        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7397        assert!(findings.iter().any(|f| f.code == "A11Y002"));
7398    }
7399
7400    #[test]
7401    fn test_a11y_missing_h1() {
7402        let mut page = make_page("https://example.com");
7403        page.headings = vec![Heading {
7404            level: 2,
7405            text: "Section".to_string(),
7406            length: 7,
7407        }];
7408        let ctx = make_ctx(&page, Some(200));
7409        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7410        assert!(findings.iter().any(|f| f.code == "A11Y003"));
7411    }
7412
7413    #[test]
7414    fn test_a11y_multiple_h1() {
7415        let mut page = make_page("https://example.com");
7416        page.headings = vec![
7417            Heading {
7418                level: 1,
7419                text: "First".to_string(),
7420                length: 5,
7421            },
7422            Heading {
7423                level: 1,
7424                text: "Second".to_string(),
7425                length: 6,
7426            },
7427        ];
7428        let ctx = make_ctx(&page, Some(200));
7429        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7430        assert!(findings.iter().any(|f| f.code == "A11Y004"));
7431    }
7432
7433    #[test]
7434    fn test_a11y_skipped_heading_level() {
7435        let mut page = make_page("https://example.com");
7436        page.headings = vec![
7437            Heading {
7438                level: 1,
7439                text: "H1".to_string(),
7440                length: 2,
7441            },
7442            Heading {
7443                level: 3,
7444                text: "H3 skipped H2".to_string(),
7445                length: 13,
7446            },
7447        ];
7448        let ctx = make_ctx(&page, Some(200));
7449        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7450        assert!(findings.iter().any(|f| f.code == "A11Y005"));
7451    }
7452
7453    #[test]
7454    fn test_a11y_valid_heading_hierarchy() {
7455        let mut page = make_page("https://example.com");
7456        page.headings = vec![
7457            Heading {
7458                level: 1,
7459                text: "H1".to_string(),
7460                length: 2,
7461            },
7462            Heading {
7463                level: 2,
7464                text: "H2".to_string(),
7465                length: 2,
7466            },
7467            Heading {
7468                level: 2,
7469                text: "H2b".to_string(),
7470                length: 3,
7471            },
7472            Heading {
7473                level: 3,
7474                text: "H3".to_string(),
7475                length: 2,
7476            },
7477        ];
7478        let ctx = make_ctx(&page, Some(200));
7479        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7480        assert!(!findings.iter().any(|f| f.code == "A11Y005"));
7481    }
7482
7483    #[test]
7484    fn test_a11y_missing_main_landmark() {
7485        let mut page = make_page("https://example.com");
7486        page.has_main_landmark = false;
7487        let ctx = make_ctx(&page, Some(200));
7488        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7489        assert!(findings.iter().any(|f| f.code == "A11Y006"));
7490    }
7491
7492    #[test]
7493    fn test_a11y_has_main_landmark() {
7494        let mut page = make_page("https://example.com");
7495        page.has_main_landmark = true;
7496        let ctx = make_ctx(&page, Some(200));
7497        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7498        assert!(!findings.iter().any(|f| f.code == "A11Y006"));
7499    }
7500
7501    #[test]
7502    fn test_a11y_empty_link_text() {
7503        let mut page = make_page("https://example.com");
7504        page.links = vec![ExtractedLink {
7505            href: "/page".to_string(),
7506            text: String::new(),
7507            rel: vec![],
7508            is_external: false,
7509            aria_label: None,
7510            img_alt: None,
7511        }];
7512        let ctx = make_ctx(&page, Some(200));
7513        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7514        assert!(findings.iter().any(|f| f.code == "A11Y009"));
7515    }
7516
7517    #[test]
7518    fn test_a11y_vague_link_text() {
7519        let mut page = make_page("https://example.com");
7520        page.links = vec![ExtractedLink {
7521            href: "/page".to_string(),
7522            text: "click here".to_string(),
7523            rel: vec![],
7524            is_external: false,
7525            aria_label: None,
7526            img_alt: None,
7527        }];
7528        let ctx = make_ctx(&page, Some(200));
7529        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7530        assert!(findings.iter().any(|f| f.code == "A11Y010"));
7531    }
7532
7533    #[test]
7534    fn test_a11y_good_link_text() {
7535        let mut page = make_page("https://example.com");
7536        page.links = vec![ExtractedLink {
7537            href: "/pricing".to_string(),
7538            text: "View pricing details".to_string(),
7539            rel: vec![],
7540            is_external: false,
7541            aria_label: None,
7542            img_alt: None,
7543        }];
7544        let ctx = make_ctx(&page, Some(200));
7545        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7546        assert!(!findings.iter().any(|f| f.code == "A11Y009"));
7547        assert!(!findings.iter().any(|f| f.code == "A11Y010"));
7548    }
7549
7550    #[test]
7551    fn test_a11y_form_input_missing_label() {
7552        use crate::parser::ExtractedInput;
7553        let mut page = make_page("https://example.com");
7554        page.forms = vec![crate::parser::ExtractedForm {
7555            action: None,
7556            method: "post".to_string(),
7557            input_count: 1,
7558            has_file_input: false,
7559            has_search_input: false,
7560            inputs: vec![ExtractedInput {
7561                input_type: Some("text".to_string()),
7562                name: Some("email".to_string()),
7563                id: None,
7564                has_label: false,
7565                aria_label: None,
7566                aria_labelledby: None,
7567                aria_describedby: None,
7568                placeholder: Some("Enter email".to_string()),
7569                required: true,
7570            }],
7571            has_fieldset: false,
7572            has_legend: false,
7573        }];
7574        let ctx = make_ctx(&page, Some(200));
7575        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7576        assert!(findings.iter().any(|f| f.code == "A11Y011"));
7577    }
7578
7579    #[test]
7580    fn test_a11y_form_input_with_label() {
7581        use crate::parser::ExtractedInput;
7582        let mut page = make_page("https://example.com");
7583        page.forms = vec![crate::parser::ExtractedForm {
7584            action: None,
7585            method: "post".to_string(),
7586            input_count: 1,
7587            has_file_input: false,
7588            has_search_input: false,
7589            inputs: vec![ExtractedInput {
7590                input_type: Some("text".to_string()),
7591                name: Some("email".to_string()),
7592                id: Some("email-input".to_string()),
7593                has_label: true,
7594                aria_label: None,
7595                aria_labelledby: None,
7596                aria_describedby: None,
7597                placeholder: None,
7598                required: true,
7599            }],
7600            has_fieldset: false,
7601            has_legend: false,
7602        }];
7603        let ctx = make_ctx(&page, Some(200));
7604        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7605        assert!(!findings.iter().any(|f| f.code == "A11Y011"));
7606    }
7607
7608    #[test]
7609    fn test_a11y_positive_tabindex() {
7610        let mut page = make_page("https://example.com");
7611        page.has_positive_tabindex = true;
7612        let ctx = make_ctx(&page, Some(200));
7613        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7614        assert!(findings.iter().any(|f| f.code == "A11Y012"));
7615    }
7616
7617    #[test]
7618    fn test_a11y_no_positive_tabindex() {
7619        let mut page = make_page("https://example.com");
7620        page.has_positive_tabindex = false;
7621        let ctx = make_ctx(&page, Some(200));
7622        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7623        assert!(!findings.iter().any(|f| f.code == "A11Y012"));
7624    }
7625
7626    #[test]
7627    fn test_a11y_table_missing_headers() {
7628        let mut page = make_page("https://example.com");
7629        page.tables_total = 2;
7630        page.tables_with_headers = 1;
7631        let ctx = make_ctx(&page, Some(200));
7632        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7633        assert!(findings.iter().any(|f| f.code == "A11Y014"));
7634        let f = findings.iter().find(|f| f.code == "A11Y014").unwrap();
7635        assert!(f.description.contains("1 of 2"));
7636    }
7637
7638    #[test]
7639    fn test_a11y_table_with_headers() {
7640        let mut page = make_page("https://example.com");
7641        page.tables_total = 2;
7642        page.tables_with_headers = 2;
7643        let ctx = make_ctx(&page, Some(200));
7644        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7645        assert!(!findings.iter().any(|f| f.code == "A11Y014"));
7646    }
7647
7648    #[test]
7649    fn test_a11y_missing_lang_attribute() {
7650        let mut page = make_page("https://example.com");
7651        page.has_lang_attribute = false;
7652        let ctx = make_ctx(&page, Some(200));
7653        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7654        assert!(findings.iter().any(|f| f.code == "A11Y016"));
7655    }
7656
7657    #[test]
7658    fn test_a11y_has_lang_attribute() {
7659        let mut page = make_page("https://example.com");
7660        page.has_lang_attribute = true;
7661        page.html_lang = Some("en".to_string());
7662        let ctx = make_ctx(&page, Some(200));
7663        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7664        assert!(!findings.iter().any(|f| f.code == "A11Y016"));
7665    }
7666
7667    #[test]
7668    fn test_a11y_no_images_no_findings() {
7669        let page = make_page("https://example.com");
7670        let ctx = make_ctx(&page, Some(200));
7671        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7672        // Should not have A11Y001 (images missing alt) when there are no images
7673        assert!(!findings.iter().any(|f| f.code == "A11Y001"));
7674    }
7675
7676    #[test]
7677    fn test_a11y_well_accessible_page() {
7678        let mut page = make_page("https://example.com");
7679        page.headings = vec![
7680            Heading {
7681                level: 1,
7682                text: "Page Title".to_string(),
7683                length: 10,
7684            },
7685            Heading {
7686                level: 2,
7687                text: "Section".to_string(),
7688                length: 7,
7689            },
7690        ];
7691        page.has_main_landmark = true;
7692        page.has_nav_landmark = true;
7693        page.has_skip_link = true;
7694        page.has_lang_attribute = true;
7695        page.html_lang = Some("en".to_string());
7696        page.links = vec![ExtractedLink {
7697            href: "/page".to_string(),
7698            text: "click here".to_string(),
7699            rel: vec![],
7700            is_external: false,
7701            aria_label: None,
7702            img_alt: None,
7703        }];
7704        let ctx = make_ctx(&page, Some(200));
7705        let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7706        // Well-accessible page should have no errors
7707        let errors: Vec<&Finding> = findings
7708            .iter()
7709            .filter(|f| f.severity == Severity::Error)
7710            .collect();
7711        assert!(
7712            errors.is_empty(),
7713            "Unexpected errors: {:?}",
7714            errors.iter().map(|f| &f.code).collect::<Vec<_>>()
7715        );
7716    }
7717
7718    // =========================================================================
7719    // SocialMediaAnalyzer tests
7720    // =========================================================================
7721
7722    #[test]
7723    fn test_social_og_image_no_dimensions() {
7724        let mut page = make_page("https://example.com");
7725        page.meta.og.image = Some("https://example.com/og.png".to_string());
7726        let ctx = make_ctx(&page, Some(200));
7727        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7728        assert!(findings.iter().any(|f| f.code == "SOCIAL001"));
7729    }
7730
7731    #[test]
7732    fn test_social_og_image_adequate_dimensions() {
7733        let mut page = make_page("https://example.com");
7734        page.meta.og.image = Some("https://example.com/og.png".to_string());
7735        page.og_image_width = Some(1200);
7736        page.og_image_height = Some(630);
7737        let ctx = make_ctx(&page, Some(200));
7738        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7739        assert!(!findings.iter().any(|f| f.code == "SOCIAL001"));
7740        assert!(!findings.iter().any(|f| f.code == "SOCIAL002"));
7741        assert!(!findings.iter().any(|f| f.code == "SOCIAL003"));
7742    }
7743
7744    #[test]
7745    fn test_social_og_image_too_small() {
7746        let mut page = make_page("https://example.com");
7747        page.meta.og.image = Some("https://example.com/og.png".to_string());
7748        page.og_image_width = Some(600);
7749        page.og_image_height = Some(315);
7750        let ctx = make_ctx(&page, Some(200));
7751        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7752        assert!(findings.iter().any(|f| f.code == "SOCIAL002"));
7753        assert!(findings.iter().any(|f| f.code == "SOCIAL003"));
7754    }
7755
7756    #[test]
7757    fn test_social_og_image_width_only_too_narrow() {
7758        let mut page = make_page("https://example.com");
7759        page.meta.og.image = Some("https://example.com/og.png".to_string());
7760        page.og_image_width = Some(800);
7761        page.og_image_height = Some(630);
7762        let ctx = make_ctx(&page, Some(200));
7763        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7764        assert!(findings.iter().any(|f| f.code == "SOCIAL002"));
7765        assert!(!findings.iter().any(|f| f.code == "SOCIAL003"));
7766    }
7767
7768    #[test]
7769    fn test_social_missing_twitter_card() {
7770        let page = make_page("https://example.com");
7771        let ctx = make_ctx(&page, Some(200));
7772        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7773        assert!(findings.iter().any(|f| f.code == "SOCIAL004"));
7774    }
7775
7776    #[test]
7777    fn test_social_valid_twitter_card() {
7778        let mut page = make_page("https://example.com");
7779        page.meta.twitter.card = Some("summary_large_image".to_string());
7780        let ctx = make_ctx(&page, Some(200));
7781        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7782        assert!(!findings.iter().any(|f| f.code == "SOCIAL004"));
7783        assert!(!findings.iter().any(|f| f.code == "SOCIAL005"));
7784    }
7785
7786    #[test]
7787    fn test_social_invalid_twitter_card() {
7788        let mut page = make_page("https://example.com");
7789        page.meta.twitter.card = Some("invalid_type".to_string());
7790        let ctx = make_ctx(&page, Some(200));
7791        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7792        assert!(findings.iter().any(|f| f.code == "SOCIAL005"));
7793    }
7794
7795    #[test]
7796    fn test_social_incomplete_og_tags() {
7797        let page = make_page("https://example.com");
7798        let ctx = make_ctx(&page, Some(200));
7799        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7800        // Missing og:title, og:description, og:image, og:url, og:type
7801        assert!(findings.iter().any(|f| f.code == "SOCIAL006"));
7802        let f = findings.iter().find(|f| f.code == "SOCIAL006").unwrap();
7803        assert!(f.description.contains("og:title"));
7804    }
7805
7806    #[test]
7807    fn test_social_complete_og_tags() {
7808        let mut page = make_page("https://example.com");
7809        page.meta.og.title = Some("Title".to_string());
7810        page.meta.og.description = Some("Description".to_string());
7811        page.meta.og.image = Some("https://example.com/og.png".to_string());
7812        page.meta.og.url = Some("https://example.com".to_string());
7813        page.meta.og.r#type = Some("website".to_string());
7814        let ctx = make_ctx(&page, Some(200));
7815        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7816        assert!(!findings.iter().any(|f| f.code == "SOCIAL006"));
7817    }
7818
7819    #[test]
7820    fn test_social_completeness_score() {
7821        let mut page = make_page("https://example.com");
7822        page.meta.og.title = Some("Title".to_string());
7823        page.meta.og.description = Some("Description".to_string());
7824        page.meta.og.image = Some("https://example.com/og.png".to_string());
7825        page.meta.og.url = Some("https://example.com".to_string());
7826        page.meta.og.r#type = Some("website".to_string());
7827        page.meta.twitter.card = Some("summary_large_image".to_string());
7828        page.meta.twitter.title = Some("Title".to_string());
7829        page.meta.twitter.description = Some("Desc".to_string());
7830        page.meta.twitter.image = Some("https://example.com/tw.png".to_string());
7831        let ctx = make_ctx(&page, Some(200));
7832        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7833        let score = findings.iter().find(|f| f.code == "SOCIAL008").unwrap();
7834        assert!(score.description.contains("8/8"));
7835    }
7836
7837    #[test]
7838    fn test_social_no_og_image_no_dimension_warning() {
7839        let mut page = make_page("https://example.com");
7840        page.meta.og.image = None;
7841        let ctx = make_ctx(&page, Some(200));
7842        let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7843        // No OG image = no dimension warning needed
7844        assert!(!findings.iter().any(|f| f.code == "SOCIAL001"));
7845    }
7846
7847    #[test]
7848    fn test_social_all_twitter_cards_valid() {
7849        for card_type in &["summary", "summary_large_image", "app", "player"] {
7850            let mut page = make_page("https://example.com");
7851            page.meta.twitter.card = Some(card_type.to_string());
7852            let ctx = make_ctx(&page, Some(200));
7853            let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7854            assert!(
7855                !findings.iter().any(|f| f.code == "SOCIAL005"),
7856                "Card type {card_type} should be valid"
7857            );
7858        }
7859    }
7860
7861    // =========================================================================
7862    // EntityAnalyzer tests
7863    // =========================================================================
7864
7865    #[test]
7866    fn test_entity_analyzer_empty_page() {
7867        let page = make_page("https://example.com");
7868        let ctx = make_ctx(&page, Some(200));
7869        let findings = EntityAnalyzer::new().analyze(&ctx, &default_config());
7870        assert!(findings.iter().any(|f| f.code == "ENTITY005"));
7871        assert!(findings.iter().any(|f| f.code == "ENTITY006"));
7872    }
7873
7874    #[test]
7875    fn test_entity_detect_people() {
7876        let text = "Written by Dr. John Smith and edited by Prof. Jane Doe.";
7877        let people = EntityAnalyzer::detect_people(text);
7878        assert!(!people.is_empty());
7879    }
7880
7881    #[test]
7882    fn test_entity_detect_organizations() {
7883        let text = "This product is made by Acme Corporation and Widget Inc.";
7884        let orgs = EntityAnalyzer::detect_organizations(text);
7885        assert!(!orgs.is_empty());
7886    }
7887
7888    #[test]
7889    fn test_entity_detect_locations() {
7890        let text = "The city of London and the mountain region are beautiful.";
7891        let locs = EntityAnalyzer::detect_locations(text);
7892        assert!(!locs.is_empty());
7893    }
7894
7895    #[test]
7896    fn test_entity_topics() {
7897        let headings = vec![
7898            Heading {
7899                level: 1,
7900                text: "Rust Programming Language Tutorial".to_string(),
7901                length: 33,
7902            },
7903            Heading {
7904                level: 2,
7905                text: "Advanced Rust Programming Concepts".to_string(),
7906                length: 35,
7907            },
7908        ];
7909        let topics = EntityAnalyzer::detect_topics(&headings, 500);
7910        assert!(topics
7911            .iter()
7912            .any(|t| t.contains("rust") || t.contains("programming")));
7913    }
7914
7915    #[test]
7916    fn test_entity_sentiment_positive() {
7917        let (score, label) = EntityAnalyzer::analyze_sentiment(
7918            "This is a great and amazing product, truly wonderful and excellent!",
7919        );
7920        assert!(score > 0.0);
7921        assert_eq!(label, "positive");
7922    }
7923
7924    #[test]
7925    fn test_entity_sentiment_negative() {
7926        let (score, label) = EntityAnalyzer::analyze_sentiment(
7927            "This is a terrible and horrible product, truly awful and bad!",
7928        );
7929        assert!(score < 0.0);
7930        assert_eq!(label, "negative");
7931    }
7932
7933    #[test]
7934    fn test_entity_sentiment_neutral() {
7935        let (score, label) =
7936            EntityAnalyzer::analyze_sentiment("The page contains information about the topic.");
7937        assert_eq!(label, "neutral");
7938        assert!((-0.05..=0.05).contains(&score));
7939    }
7940
7941    #[test]
7942    fn test_entity_analyzer_with_people() {
7943        let mut page = make_page("https://example.com");
7944        page.headings = vec![Heading {
7945            level: 1,
7946            text: "Interview with Dr. Alice Smith".to_string(),
7947            length: 29,
7948        }];
7949        page.word_count = 500;
7950        let ctx = make_ctx(&page, Some(200));
7951        let findings = EntityAnalyzer::new().analyze(&ctx, &default_config());
7952        assert!(findings.iter().any(|f| f.code == "ENTITY001"));
7953    }
7954
7955    // =========================================================================
7956    // EnhancedReadabilityAnalyzer tests
7957    // =========================================================================
7958
7959    #[test]
7960    fn test_readability_empty_page() {
7961        let page = make_page("https://example.com");
7962        let ctx = make_ctx(&page, Some(200));
7963        let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
7964        assert!(findings.is_empty());
7965    }
7966
7967    #[test]
7968    fn test_readability_simple_text() {
7969        let mut page = make_page("https://example.com");
7970        page.word_count = 100;
7971        page.headings = vec![Heading {
7972            level: 1,
7973            text: "The cat sat on the mat. The dog ran in the park.".to_string(),
7974            length: 48,
7975        }];
7976        let ctx = make_ctx(&page, Some(200));
7977        let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
7978        assert!(findings.iter().any(|f| f.code == "READ001"));
7979        assert!(findings.iter().any(|f| f.code == "READ005"));
7980        let fre = findings.iter().find(|f| f.code == "READ005").unwrap();
7981        assert!(fre.description.contains("Score:"));
7982    }
7983
7984    #[test]
7985    fn test_readability_complex_text() {
7986        let mut page = make_page("https://example.com");
7987        page.word_count = 200;
7988        page.headings = vec![Heading {
7989            level: 1,
7990            text: "The implementation of sophisticated algorithmic methodologies \
7991                   necessitates comprehensive understanding of computational complexity \
7992                   and theoretical frameworks"
7993                .to_string(),
7994            length: 150,
7995        }];
7996        let ctx = make_ctx(&page, Some(200));
7997        let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
7998        assert!(findings.iter().any(|f| f.code == "READ001"));
7999        let fk = findings.iter().find(|f| f.code == "READ001").unwrap();
8000        assert!(fk.description.contains("Grade level:"));
8001    }
8002
8003    #[test]
8004    fn test_readability_syllable_counting() {
8005        assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("cat"), 1);
8006        assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("hello"), 2);
8007        assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("beautiful"), 3);
8008        assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("a"), 1);
8009        assert_eq!(EnhancedReadabilityAnalyzer::count_syllables(""), 0);
8010    }
8011
8012    #[test]
8013    fn test_readability_indices() {
8014        let mut page = make_page("https://example.com");
8015        page.word_count = 500;
8016        page.headings = vec![Heading {
8017            level: 1,
8018            text: "A comprehensive guide to understanding modern web development \
8019                   practices and techniques for beginners and experienced developers"
8020                .to_string(),
8021            length: 140,
8022        }];
8023        let ctx = make_ctx(&page, Some(200));
8024        let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
8025        assert!(findings.iter().any(|f| f.code == "READ001"));
8026        assert!(findings.iter().any(|f| f.code == "READ002"));
8027        assert!(findings.iter().any(|f| f.code == "READ003"));
8028        assert!(findings.iter().any(|f| f.code == "READ004"));
8029        assert!(findings.iter().any(|f| f.code == "READ005"));
8030    }
8031
8032    #[test]
8033    fn test_readability_grade_label() {
8034        assert_eq!(
8035            EnhancedReadabilityAnalyzer::grade_label(0.5),
8036            "kindergarten"
8037        );
8038        assert_eq!(
8039            EnhancedReadabilityAnalyzer::grade_label(4.0),
8040            "elementary school"
8041        );
8042        assert_eq!(
8043            EnhancedReadabilityAnalyzer::grade_label(7.0),
8044            "middle school"
8045        );
8046        assert_eq!(
8047            EnhancedReadabilityAnalyzer::grade_label(10.0),
8048            "high school"
8049        );
8050        assert_eq!(EnhancedReadabilityAnalyzer::grade_label(14.0), "college");
8051        assert_eq!(
8052            EnhancedReadabilityAnalyzer::grade_label(18.0),
8053            "postgraduate"
8054        );
8055    }
8056
8057    #[test]
8058    fn test_readability_ease_label() {
8059        assert_eq!(
8060            EnhancedReadabilityAnalyzer::reading_ease_label(95.0),
8061            "very easy"
8062        );
8063        assert_eq!(
8064            EnhancedReadabilityAnalyzer::reading_ease_label(85.0),
8065            "easy"
8066        );
8067        assert_eq!(
8068            EnhancedReadabilityAnalyzer::reading_ease_label(75.0),
8069            "fairly easy"
8070        );
8071        assert_eq!(
8072            EnhancedReadabilityAnalyzer::reading_ease_label(65.0),
8073            "standard"
8074        );
8075        assert_eq!(
8076            EnhancedReadabilityAnalyzer::reading_ease_label(55.0),
8077            "fairly difficult"
8078        );
8079        assert_eq!(
8080            EnhancedReadabilityAnalyzer::reading_ease_label(35.0),
8081            "difficult"
8082        );
8083        assert_eq!(
8084            EnhancedReadabilityAnalyzer::reading_ease_label(20.0),
8085            "very difficult"
8086        );
8087    }
8088
8089    // =========================================================================
8090    // KeywordAnalyzer tests
8091    // =========================================================================
8092
8093    #[test]
8094    fn test_keyword_analyzer_empty_page() {
8095        let page = make_page("https://example.com");
8096        let ctx = make_ctx(&page, Some(200));
8097        let findings = KeywordAnalyzer::new().analyze(&ctx, &default_config());
8098        assert!(findings.is_empty());
8099    }
8100
8101    #[test]
8102    fn test_keyword_analyzer_with_content() {
8103        let mut page = make_page("https://example.com");
8104        page.word_count = 500;
8105        page.headings = vec![
8106            Heading {
8107                level: 1,
8108                text: "Rust Programming Language Tutorial for Beginners".to_string(),
8109                length: 48,
8110            },
8111            Heading {
8112                level: 2,
8113                text: "Learn Rust Programming Today".to_string(),
8114                length: 28,
8115            },
8116        ];
8117        let ctx = make_ctx(&page, Some(200));
8118        let findings = KeywordAnalyzer::new().analyze(&ctx, &default_config());
8119        assert!(findings.iter().any(|f| f.code == "KW001"));
8120        assert!(findings.iter().any(|f| f.code == "KW002"));
8121    }
8122
8123    #[test]
8124    fn test_keyword_tokenize() {
8125        let tokens = KeywordAnalyzer::tokenize("Rust Programming Language Tutorial");
8126        assert!(tokens.contains(&"rust".to_string()));
8127        assert!(tokens.contains(&"programming".to_string()));
8128        assert!(tokens.contains(&"language".to_string()));
8129        assert!(tokens.contains(&"tutorial".to_string()));
8130    }
8131
8132    #[test]
8133    fn test_keyword_tf() {
8134        let tokens = vec![
8135            "rust".to_string(),
8136            "programming".to_string(),
8137            "rust".to_string(),
8138            "language".to_string(),
8139        ];
8140        let tf = KeywordAnalyzer::compute_tf(&tokens);
8141        assert!(*tf.get("rust").unwrap() > 0.4);
8142    }
8143
8144    #[test]
8145    fn test_keyword_density() {
8146        let tokens = vec![
8147            "rust".to_string(),
8148            "programming".to_string(),
8149            "rust".to_string(),
8150        ];
8151        let density = KeywordAnalyzer::keyword_density(&tokens, 100);
8152        assert!(*density.get("rust").unwrap() > 1.5);
8153    }
8154
8155    #[test]
8156    fn test_keyword_prominent_detection() {
8157        let mut density = HashMap::new();
8158        density.insert("rust".to_string(), 3.5);
8159        density.insert("programming".to_string(), 1.0);
8160        let prominent = KeywordAnalyzer::detect_prominent_keywords(&density);
8161        assert_eq!(prominent.len(), 1);
8162        assert_eq!(prominent[0].0, "rust");
8163    }
8164
8165    #[test]
8166    fn test_keyword_cooccurrence() {
8167        let tokens = vec![
8168            "rust".to_string(),
8169            "programming".to_string(),
8170            "language".to_string(),
8171            "rust".to_string(),
8172            "programming".to_string(),
8173        ];
8174        let cooccur = KeywordAnalyzer::cooccurrence(&tokens, 2);
8175        assert!(!cooccur.is_empty());
8176    }
8177
8178    #[test]
8179    fn test_keyword_analyzer_prominent_warning() {
8180        let mut page = make_page("https://example.com");
8181        page.word_count = 100;
8182        page.headings = vec![Heading {
8183            level: 1,
8184            text: "rust rust rust rust rust rust rust rust rust rust rust".to_string(),
8185            length: 55,
8186        }];
8187        let ctx = make_ctx(&page, Some(200));
8188        let findings = KeywordAnalyzer::new().analyze(&ctx, &default_config());
8189        let kw003 = findings.iter().find(|f| f.code == "KW003");
8190        assert!(kw003.is_some());
8191        assert_eq!(kw003.unwrap().severity, Severity::Warning);
8192    }
8193
8194    // =========================================================================
8195    // EcommerceSignalsAnalyzer tests
8196    // =========================================================================
8197
8198    #[test]
8199    fn test_ecom_empty_page() {
8200        let page = make_page("https://example.com");
8201        let ctx = make_ctx(&page, Some(200));
8202        let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8203        assert!(findings.is_empty());
8204    }
8205
8206    #[test]
8207    fn test_ecom_product_schema() {
8208        let mut page = make_page("https://example.com/product");
8209        page.structured_data = vec![StructuredData {
8210            context: Some("https://schema.org".to_string()),
8211            r#type: Some("Product".to_string()),
8212            data: serde_json::json!({
8213                "@context": "https://schema.org",
8214                "@type": "Product",
8215                "name": "Widget",
8216                "offers": {
8217                    "@type": "Offer",
8218                    "price": "29.99",
8219                    "priceCurrency": "USD",
8220                    "availability": "https://schema.org/InStock"
8221                }
8222            }),
8223        }];
8224        let ctx = make_ctx(&page, Some(200));
8225        let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8226        assert!(findings.iter().any(|f| f.code == "ECOM001"));
8227        assert!(findings.iter().any(|f| f.code == "ECOM002"));
8228        assert!(findings.iter().any(|f| f.code == "ECOM003"));
8229        assert!(findings.iter().any(|f| f.code == "ECOM005"));
8230    }
8231
8232    #[test]
8233    fn test_ecom_product_with_reviews() {
8234        let mut page = make_page("https://example.com/product");
8235        page.structured_data = vec![StructuredData {
8236            context: Some("https://schema.org".to_string()),
8237            r#type: Some("Product".to_string()),
8238            data: serde_json::json!({
8239                "@context": "https://schema.org",
8240                "@type": "Product",
8241                "name": "Widget",
8242                "aggregateRating": {
8243                    "@type": "AggregateRating",
8244                    "ratingValue": "4.5",
8245                    "reviewCount": "120"
8246                }
8247            }),
8248        }];
8249        let ctx = make_ctx(&page, Some(200));
8250        let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8251        assert!(findings.iter().any(|f| f.code == "ECOM004"));
8252    }
8253
8254    #[test]
8255    fn test_ecom_price_without_product() {
8256        let mut page = make_page("https://example.com/product");
8257        page.structured_data = vec![StructuredData {
8258            context: Some("https://schema.org".to_string()),
8259            r#type: Some("Offer".to_string()),
8260            data: serde_json::json!({
8261                "@context": "https://schema.org",
8262                "@type": "Offer",
8263                "price": "19.99",
8264                "priceCurrency": "USD"
8265            }),
8266        }];
8267        let ctx = make_ctx(&page, Some(200));
8268        let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8269        assert!(findings.iter().any(|f| f.code == "ECOM006"));
8270    }
8271
8272    #[test]
8273    fn test_ecom_no_price() {
8274        let mut page = make_page("https://example.com/product");
8275        page.structured_data = vec![StructuredData {
8276            context: Some("https://schema.org".to_string()),
8277            r#type: Some("Product".to_string()),
8278            data: serde_json::json!({
8279                "@context": "https://schema.org",
8280                "@type": "Product",
8281                "name": "Widget"
8282            }),
8283        }];
8284        let ctx = make_ctx(&page, Some(200));
8285        let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8286        assert!(findings.iter().any(|f| f.code == "ECOM001"));
8287        assert!(!findings.iter().any(|f| f.code == "ECOM002"));
8288    }
8289
8290    #[test]
8291    fn test_ecom_non_product_schema() {
8292        let mut page = make_page("https://example.com/article");
8293        page.structured_data = vec![StructuredData {
8294            context: Some("https://schema.org".to_string()),
8295            r#type: Some("Article".to_string()),
8296            data: serde_json::json!({
8297                "@context": "https://schema.org",
8298                "@type": "Article",
8299                "headline": "Test"
8300            }),
8301        }];
8302        let ctx = make_ctx(&page, Some(200));
8303        let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8304        assert!(findings.is_empty());
8305    }
8306
8307    // =========================================================================
8308    // InternationalSeoAnalyzer tests
8309    // =========================================================================
8310
8311    #[test]
8312    fn test_iseo_empty_page() {
8313        let mut page = make_page("https://example.com");
8314        page.html_lang = Some("en".to_string());
8315        let ctx = make_ctx(&page, Some(200));
8316        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8317        assert!(findings.iter().any(|f| f.code == "ISEO004"));
8318    }
8319
8320    #[test]
8321    fn test_iseo_detect_locale_from_url() {
8322        assert_eq!(
8323            InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/en/page"),
8324            Some("en".to_string())
8325        );
8326        assert_eq!(
8327            InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/fr/page"),
8328            Some("fr".to_string())
8329        );
8330        assert_eq!(
8331            InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/en-US/page"),
8332            Some("en-US".to_string())
8333        );
8334        assert_eq!(
8335            InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/page"),
8336            None
8337        );
8338    }
8339
8340    #[test]
8341    fn test_iseo_is_locale_segment() {
8342        assert!(InternationalSeoAnalyzer::is_locale_segment("en"));
8343        assert!(InternationalSeoAnalyzer::is_locale_segment("fr"));
8344        assert!(InternationalSeoAnalyzer::is_locale_segment("en-US"));
8345        assert!(InternationalSeoAnalyzer::is_locale_segment("zh-CN"));
8346        assert!(!InternationalSeoAnalyzer::is_locale_segment("page"));
8347        assert!(!InternationalSeoAnalyzer::is_locale_segment("e"));
8348    }
8349
8350    #[test]
8351    fn test_iseo_hreflang_url_locale_mismatch() {
8352        let mut page = make_page("https://example.com/en");
8353        page.meta.hreflang = vec![crate::meta::HreflangTag {
8354            lang: "fr".to_string(),
8355            url: Url::parse("https://example.com/en/about").unwrap(),
8356        }];
8357        let ctx = make_ctx(&page, Some(200));
8358        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8359        assert!(findings.iter().any(|f| f.code == "ISEO001"));
8360    }
8361
8362    #[test]
8363    fn test_iseo_missing_x_default() {
8364        let mut page = make_page("https://example.com/en");
8365        page.meta.hreflang = vec![
8366            crate::meta::HreflangTag {
8367                lang: "en".to_string(),
8368                url: Url::parse("https://example.com/en").unwrap(),
8369            },
8370            crate::meta::HreflangTag {
8371                lang: "fr".to_string(),
8372                url: Url::parse("https://example.com/fr").unwrap(),
8373            },
8374        ];
8375        let ctx = make_ctx(&page, Some(200));
8376        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8377        assert!(findings.iter().any(|f| f.code == "ISEO002"));
8378    }
8379
8380    #[test]
8381    fn test_iseo_duplicate_language() {
8382        let mut page = make_page("https://example.com/en");
8383        page.meta.hreflang = vec![
8384            crate::meta::HreflangTag {
8385                lang: "en".to_string(),
8386                url: Url::parse("https://example.com/en").unwrap(),
8387            },
8388            crate::meta::HreflangTag {
8389                lang: "en".to_string(),
8390                url: Url::parse("https://example.com/en-uk").unwrap(),
8391            },
8392        ];
8393        let ctx = make_ctx(&page, Some(200));
8394        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8395        assert!(findings.iter().any(|f| f.code == "ISEO003"));
8396    }
8397
8398    #[test]
8399    fn test_iseo_multilang_content() {
8400        let mut page = make_page("https://example.com/en");
8401        page.meta.hreflang = vec![
8402            crate::meta::HreflangTag {
8403                lang: "en".to_string(),
8404                url: Url::parse("https://example.com/en").unwrap(),
8405            },
8406            crate::meta::HreflangTag {
8407                lang: "x-default".to_string(),
8408                url: Url::parse("https://example.com").unwrap(),
8409            },
8410        ];
8411        page.html_lang = Some("en".to_string());
8412        let ctx = make_ctx(&page, Some(200));
8413        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8414        assert!(findings.iter().any(|f| f.code == "ISEO006"));
8415    }
8416
8417    #[test]
8418    fn test_iseo_valid_hreflang_with_xdefault() {
8419        let mut page = make_page("https://example.com/en");
8420        page.meta.hreflang = vec![
8421            crate::meta::HreflangTag {
8422                lang: "en".to_string(),
8423                url: Url::parse("https://example.com/en").unwrap(),
8424            },
8425            crate::meta::HreflangTag {
8426                lang: "fr".to_string(),
8427                url: Url::parse("https://example.com/fr").unwrap(),
8428            },
8429            crate::meta::HreflangTag {
8430                lang: "x-default".to_string(),
8431                url: Url::parse("https://example.com").unwrap(),
8432            },
8433        ];
8434        let ctx = make_ctx(&page, Some(200));
8435        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8436        assert!(!findings.iter().any(|f| f.severity == Severity::Error));
8437    }
8438
8439    #[test]
8440    fn test_iseo_locale_detection_from_url() {
8441        let locale = InternationalSeoAnalyzer::detect_locale_from_url(
8442            "https://example.com/de/products/widget",
8443        );
8444        assert_eq!(locale, Some("de".to_string()));
8445    }
8446
8447    #[test]
8448    fn test_iseo_no_locale_no_hreflang() {
8449        let mut page = make_page("https://example.com/products");
8450        page.html_lang = None;
8451        let ctx = make_ctx(&page, Some(200));
8452        let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8453        assert!(!findings.iter().any(|f| f.code == "ISEO004"));
8454    }
8455
8456    #[test]
8457    fn test_iseo_multilang_detect() {
8458        assert!(InternationalSeoAnalyzer::detect_multilang_content(
8459            &[crate::meta::HreflangTag {
8460                lang: "en".to_string(),
8461                url: Url::parse("https://example.com/en").unwrap(),
8462            }],
8463            &None,
8464        ));
8465        assert!(InternationalSeoAnalyzer::detect_multilang_content(
8466            &[],
8467            &Some("en-US".to_string()),
8468        ));
8469        assert!(!InternationalSeoAnalyzer::detect_multilang_content(
8470            &[],
8471            &Some("en".to_string()),
8472        ));
8473    }
8474}