Skip to main content

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