axis_core/
checker.rs

1use crate::models::{Issue, Report, PageLoadResult, Severity, Category};
2use scraper::{Html, Selector};
3
4/// Core accessibility checker implementation
5pub struct AccessibilityChecker;
6
7impl AccessibilityChecker {
8    /// Create a new accessibility checker
9    pub fn new() -> Self {
10        Self
11    }
12
13    /// Check accessibility of a page and return a comprehensive report
14    pub fn check_accessibility(&self, load_result: PageLoadResult) -> Report {
15        let mut report = Report::new(load_result.website_url.as_deref());
16
17        // Set basic page data
18        report.page_load_time = load_result.load_time;
19        report.request_count = load_result.request_count;
20        report.page_size = load_result.page_size;
21        report.uses_cdn = self.detect_cdn_usage(&load_result.document);
22        self.calculate_environmental_impact(&mut report);
23
24        // Run all accessibility checks
25        report.issues.extend(self.check_alt_text(&load_result.document));
26        report.issues.extend(self.check_labels(&load_result.document));
27        report.issues.extend(self.check_title(&load_result.document));
28        report.issues.extend(self.check_heading_hierarchy(&load_result.document));
29        report.issues.extend(self.check_color_contrast(&load_result.document));
30        report.issues.extend(self.check_lang_attributes(&load_result.document));
31        report.issues.extend(self.check_best_practices(&load_result.document));
32
33        // Performance and environment checks
34        report.issues.extend(self.check_performance(&load_result));
35        report.issues.extend(self.check_environment(&load_result.document, &load_result));
36
37        // Calculate final scores
38        report.recalculate();
39
40        report
41    }
42
43    /// Check for missing alt text on images
44    fn check_alt_text(&self, document: &Html) -> Vec<Issue> {
45        let mut issues = Vec::new();
46        let selector = Selector::parse("img").unwrap();
47
48        for img in document.select(&selector) {
49            let has_alt = img.value().attr("alt").is_some();
50            let alt_value = img.value().attr("alt").unwrap_or("");
51            let has_aria_label = img.value().attr("aria-label").is_some();
52            let has_aria_labelledby = img.value().attr("aria-labelledby").is_some();
53            let is_decorative = img.value().attr("role")
54                .map(|r| r == "presentation")
55                .unwrap_or(false);
56
57            if !has_alt && !has_aria_label && !has_aria_labelledby && !is_decorative {
58                issues.push(Issue::new(
59                    "Missing Alt Text",
60                    Some(&img.html()),
61                    Some("Add alt attribute describing the image, or use alt='' for decorative images"),
62                    Severity::Warning,
63                    Some("<img src='image.jpg' alt='Description of image'>"),
64                    Category::Accessibility,
65                ));
66            } else if has_alt && alt_value.trim().is_empty() && !is_decorative {
67                issues.push(Issue::new(
68                    "Empty Alt Text",
69                    Some(&img.html()),
70                    Some("Provide meaningful alt text or use alt='' for decorative images"),
71                    Severity::Warning,
72                    Some("<img src='image.jpg' alt='Description of image'>"),
73                    Category::Accessibility,
74                ));
75            }
76        }
77
78        issues
79    }
80
81    /// Check form labels
82    fn check_labels(&self, document: &Html) -> Vec<Issue> {
83        let mut issues = Vec::new();
84        let input_selector = Selector::parse("input, select, textarea").unwrap();
85
86        for input in document.select(&input_selector) {
87            let input_type = input.value().attr("type").unwrap_or("text");
88            let id = input.value().attr("id");
89
90            // Skip certain input types
91            if matches!(input_type, "hidden" | "submit" | "button" | "image") {
92                continue;
93            }
94
95            let has_label = if let Some(id) = id {
96                let label_selector = format!("label[for=\"{}\"]", id);
97                document.select(&Selector::parse(&label_selector).unwrap()).next().is_some()
98            } else {
99                false
100            };
101
102            let has_aria_label = input.value().attr("aria-label").is_some();
103            let has_aria_labelledby = input.value().attr("aria-labelledby").is_some();
104            let is_wrapped_in_label = input.parent()
105                .and_then(|p| p.value().as_element())
106                .map(|e| e.name() == "label")
107                .unwrap_or(false);
108
109            if !has_label && !has_aria_label && !has_aria_labelledby && !is_wrapped_in_label {
110                issues.push(Issue::new(
111                    "Missing Label",
112                    Some(&input.html()),
113                    Some("Add label with for attribute, aria-label, or wrap in label element"),
114                    Severity::Warning,
115                    Some("<label for='inputId'>Label text</label><input id='inputId' type='text'>"),
116                    Category::Accessibility,
117                ));
118            }
119        }
120
121        issues
122    }
123
124    /// Check for page title
125    fn check_title(&self, document: &Html) -> Vec<Issue> {
126        let mut issues = Vec::new();
127        let title_selector = Selector::parse("title").unwrap();
128
129        if document.select(&title_selector).next().is_none() {
130            issues.push(Issue::new(
131                "Missing Title",
132                Some("<head>...</head>"),
133                Some("Add <title> tag in <head>"),
134                Severity::Error,
135                Some("<title>Page Title</title>"),
136                Category::Accessibility,
137            ));
138        }
139
140        issues
141    }
142
143    /// Check heading hierarchy
144    fn check_heading_hierarchy(&self, document: &Html) -> Vec<Issue> {
145        let mut issues = Vec::new();
146        let heading_selector = Selector::parse("h1, h2, h3, h4, h5, h6").unwrap();
147
148        let headings: Vec<_> = document.select(&heading_selector).collect();
149        if headings.len() <= 1 {
150            return issues;
151        }
152
153        let mut last_level = 0;
154        let mut skip_count = 0;
155
156        for heading in &headings {
157            let tag_name = heading.value().name();
158            let level = tag_name.chars().nth(1)
159                .and_then(|c| c.to_digit(10))
160                .unwrap_or(1) as i32;
161
162            if last_level > 0 && level > last_level + 1 {
163                skip_count += 1;
164                if skip_count <= 3 {
165                    issues.push(Issue::new(
166                        "Heading Hierarchy Skip",
167                        Some(&heading.html()),
168                        Some("Consider using intermediate heading levels for better structure"),
169                        Severity::Info,
170                        Some(&format!("Use h{} before jumping to h{}", last_level + 1, level)),
171                        Category::Accessibility,
172                    ));
173                }
174            }
175            last_level = level;
176        }
177
178        // Check for missing H1
179        let has_h1 = headings.iter().any(|h| h.value().name() == "h1");
180        if !has_h1 {
181            issues.push(Issue::new(
182                "Missing H1 Heading",
183                Some("<body>"),
184                Some("Add an h1 element as the main page heading"),
185                Severity::Warning,
186                Some("<h1>Main Page Title</h1>"),
187                Category::Accessibility,
188            ));
189        }
190
191        issues
192    }
193
194    /// Check color contrast (simplified - only inline styles)
195    fn check_color_contrast(&self, document: &Html) -> Vec<Issue> {
196        let mut issues = Vec::new();
197        let style_selector = Selector::parse("[style]").unwrap();
198
199        for element in document.select(&style_selector) {
200            if let Some(style) = element.value().attr("style") {
201                // This is a simplified check - real implementation would parse CSS properly
202                if style.contains("color:") && style.contains("background-color:") {
203                    issues.push(Issue::new(
204                        "Color Contrast Check Needed",
205                        Some(&element.html()),
206                        Some("Verify text meets WCAG contrast requirements (4.5:1 minimum)"),
207                        Severity::Info,
208                        Some("Use a color contrast checker tool"),
209                        Category::Accessibility,
210                    ));
211                }
212            }
213        }
214
215        issues
216    }
217
218    /// Check language attributes
219    fn check_lang_attributes(&self, document: &Html) -> Vec<Issue> {
220        let mut issues = Vec::new();
221        let html_selector = Selector::parse("html").unwrap();
222
223        if let Some(html) = document.select(&html_selector).next() {
224            if html.value().attr("lang").is_none() {
225                issues.push(Issue::new(
226                    "Missing lang Attribute",
227                    Some("<html>"),
228                    Some("Add lang attribute to html tag"),
229                    Severity::Warning,
230                    Some("<html lang=\"en\">"),
231                    Category::Accessibility,
232                ));
233            }
234        }
235
236        issues
237    }
238
239    /// Check best practices
240    fn check_best_practices(&self, document: &Html) -> Vec<Issue> {
241        let mut issues = Vec::new();
242        let favicon_selector = Selector::parse("link[rel*='icon']").unwrap();
243
244        if document.select(&favicon_selector).next().is_none() {
245            issues.push(Issue::new(
246                "Missing Favicon",
247                Some("<head>"),
248                Some("Add favicon link"),
249                Severity::Info,
250                Some("<link rel=\"icon\" href=\"favicon.ico\">"),
251                Category::BestPractices,
252            ));
253        }
254
255        issues
256    }
257
258    /// Check performance issues
259    fn check_performance(&self, load_result: &PageLoadResult) -> Vec<Issue> {
260        let mut issues = Vec::new();
261
262        if load_result.load_time > 3.0 {
263            issues.push(Issue::new(
264                "Slow Page Load",
265                Some(&format!("Load time: {:.2}s", load_result.load_time)),
266                Some("Optimize images, minify assets, use caching"),
267                Severity::Warning,
268                Some("Use lazy loading, compress assets"),
269                Category::Performance,
270            ));
271        }
272
273        if load_result.request_count > 50 {
274            issues.push(Issue::new(
275                "High Request Count",
276                Some(&format!("Requests: {}", load_result.request_count)),
277                Some("Combine files, use sprites, reduce dependencies"),
278                Severity::Warning,
279                Some("Bundle CSS/JS files"),
280                Category::Performance,
281            ));
282        }
283
284        issues
285    }
286
287    /// Check environmental impact
288    fn check_environment(&self, _document: &Html, load_result: &PageLoadResult) -> Vec<Issue> {
289        let mut issues = Vec::new();
290
291        if load_result.page_size > 5 * 1024 * 1024 {
292            issues.push(Issue::new(
293                "Large Page Size",
294                Some(&format!("Size: {:.2} MB", load_result.page_size as f64 / (1024.0 * 1024.0))),
295                Some("Optimize images, minify assets, remove unused code"),
296                Severity::Warning,
297                Some("Compress images, use WebP format"),
298                Category::Environment,
299            ));
300        }
301
302        issues
303    }
304
305    /// Detect CDN usage
306    fn detect_cdn_usage(&self, document: &Html) -> bool {
307        let script_selector = Selector::parse("script[src]").unwrap();
308        let link_selector = Selector::parse("link[href]").unwrap();
309
310        let cdn_domains = [
311            "cdn.jsdelivr.net",
312            "cdnjs.cloudflare.com",
313            "unpkg.com",
314            "ajax.googleapis.com",
315            "code.jquery.com",
316            "stackpath.bootstrapcdn.com",
317            "maxcdn.bootstrapcdn.com"
318        ];
319
320        for script in document.select(&script_selector) {
321            if let Some(src) = script.value().attr("src") {
322                if cdn_domains.iter().any(|domain| src.contains(domain)) {
323                    return true;
324                }
325            }
326        }
327
328        for link in document.select(&link_selector) {
329            if let Some(href) = link.value().attr("href") {
330                if cdn_domains.iter().any(|domain| href.contains(domain)) {
331                    return true;
332                }
333            }
334        }
335
336        false
337    }
338
339    /// Calculate environmental impact
340    fn calculate_environmental_impact(&self, report: &mut Report) {
341        let data_transfer_gb = report.page_size as f64 / (1024.0 * 1024.0 * 1024.0);
342        let base_energy = 0.01;
343        let data_energy = data_transfer_gb * 200.0;
344        let request_energy = report.request_count as f64 * 0.0001;
345
346        let mut total_energy = base_energy + data_energy + request_energy;
347
348        if report.uses_cdn {
349            total_energy *= 0.7; // 30% reduction with CDN
350        }
351
352        report.energy_consumption_kwh = total_energy;
353        report.co2_emissions_grams = total_energy * 500.0; // 0.5 kg CO₂ per kWh
354
355        report.environmental_rating = if report.co2_emissions_grams < 10.0 {
356            "Eco"
357        } else if report.co2_emissions_grams < 50.0 {
358            "Moderate"
359        } else {
360            "High Impact"
361        }.to_string();
362    }
363
364    /// Export report to text format
365    pub fn export_to_text(&self, report: &Report) -> String {
366        let mut output = String::new();
367
368        output.push_str("Web Accessibility Report\n");
369        output.push_str(&format!("Website: {}\n", report.website_url.as_deref().unwrap_or("N/A")));
370        output.push_str(&format!("Total Issues: {}\n", report.total_issues));
371        output.push_str(&format!("Errors: {}, Warnings: {}, Info: {}\n",
372            report.error_count, report.warning_count, report.info_count));
373        output.push_str(&format!("Accessibility Score: {}/100\n", report.accessibility_score));
374        output.push_str(&format!("Compliance Status: {}\n", report.compliance_status));
375
376        if report.page_load_time > 0.0 {
377            output.push_str(&format!("Page Load Time: {:.2}s\n", report.page_load_time));
378            output.push_str(&format!("Request Count: {}\n", report.request_count));
379            output.push_str(&format!("Page Size: {:.2} MB\n", report.page_size as f64 / (1024.0 * 1024.0)));
380            output.push_str(&format!("Uses CDN: {}\n", if report.uses_cdn { "Yes" } else { "No" }));
381            output.push_str(&format!("Energy Consumption: {:.4} kWh\n", report.energy_consumption_kwh));
382            output.push_str(&format!("CO₂ Emissions: {:.2} grams\n", report.co2_emissions_grams));
383            output.push_str(&format!("Environmental Rating: {}\n", report.environmental_rating));
384        }
385
386        output.push_str("\nIssues:\n");
387        for issue in &report.issues {
388            output.push_str(&format!("\nCategory: {:?}\n", issue.category));
389            output.push_str(&format!("Type: {}\n", issue.issue_type));
390            output.push_str(&format!("Severity: {:?}\n", issue.severity));
391            if let Some(snippet) = &issue.element_snippet {
392                output.push_str(&format!("Element: {}\n", snippet));
393            }
394            if let Some(fix) = &issue.suggested_fix {
395                output.push_str(&format!("Fix: {}\n", fix));
396            }
397            if let Some(example) = &issue.fix_example {
398                output.push_str(&format!("Example: {}\n", example));
399            }
400            output.push_str("---\n");
401        }
402
403        output
404    }
405}
406
407impl Default for AccessibilityChecker {
408    fn default() -> Self {
409        Self::new()
410    }
411}