1use crate::models::{Issue, Report, PageLoadResult, Severity, Category};
2use scraper::{Html, Selector};
3
4pub struct AccessibilityChecker;
6
7impl AccessibilityChecker {
8 pub fn new() -> Self {
10 Self
11 }
12
13 pub fn check_accessibility(&self, load_result: PageLoadResult) -> Report {
15 let mut report = Report::new(load_result.website_url.as_deref());
16
17 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 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 report.issues.extend(self.check_performance(&load_result));
35 report.issues.extend(self.check_environment(&load_result.document, &load_result));
36
37 report.recalculate();
39
40 report
41 }
42
43 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
351
352 report.energy_consumption_kwh = total_energy;
353 report.co2_emissions_grams = total_energy * 500.0; 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 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}