Skip to main content

probador/
lint.rs

1//! Content Linting Module
2//!
3//! Validates served content (HTML, CSS, JS, WASM, JSON) to catch errors early.
4//!
5//! ## Supported Linters
6//!
7//! | File Type | Checks |
8//! |-----------|--------|
9//! | HTML | Valid structure, missing attributes, broken links |
10//! | CSS | Parse errors, unknown properties |
11//! | JavaScript | Syntax errors, module resolution |
12//! | WASM | Valid module structure |
13//! | JSON | Parse validity |
14
15#![allow(clippy::must_use_candidate)]
16#![allow(clippy::missing_panics_doc)]
17#![allow(clippy::missing_errors_doc)]
18#![allow(clippy::module_name_repetitions)]
19#![allow(clippy::missing_const_for_fn)]
20#![allow(clippy::uninlined_format_args)]
21#![allow(clippy::cast_possible_truncation)]
22#![allow(clippy::struct_excessive_bools)]
23#![allow(clippy::manual_let_else)]
24#![allow(clippy::unused_self)]
25#![allow(clippy::format_push_string)]
26
27use serde::{Deserialize, Serialize};
28use std::path::{Path, PathBuf};
29
30/// Lint severity levels
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum LintSeverity {
34    /// Error - must fix
35    Error,
36    /// Warning - should fix
37    Warning,
38    /// Info - suggestion
39    Info,
40}
41
42impl LintSeverity {
43    /// Get display string
44    #[must_use]
45    pub const fn as_str(&self) -> &'static str {
46        match self {
47            Self::Error => "ERROR",
48            Self::Warning => "WARN",
49            Self::Info => "INFO",
50        }
51    }
52
53    /// Get symbol for display
54    #[must_use]
55    pub const fn symbol(&self) -> &'static str {
56        match self {
57            Self::Error => "✗",
58            Self::Warning => "⚠",
59            Self::Info => "ℹ",
60        }
61    }
62}
63
64/// A single lint result
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct LintResult {
67    /// File path
68    pub file: PathBuf,
69    /// Line number (if applicable)
70    pub line: Option<u32>,
71    /// Column number (if applicable)
72    pub column: Option<u32>,
73    /// Severity level
74    pub severity: LintSeverity,
75    /// Lint code (e.g., "HTML001")
76    pub code: String,
77    /// Human-readable message
78    pub message: String,
79    /// Suggestion for fix
80    pub suggestion: Option<String>,
81}
82
83impl LintResult {
84    /// Create a new lint error
85    pub fn error(
86        file: impl Into<PathBuf>,
87        code: impl Into<String>,
88        message: impl Into<String>,
89    ) -> Self {
90        Self {
91            file: file.into(),
92            line: None,
93            column: None,
94            severity: LintSeverity::Error,
95            code: code.into(),
96            message: message.into(),
97            suggestion: None,
98        }
99    }
100
101    /// Create a new lint warning
102    pub fn warning(
103        file: impl Into<PathBuf>,
104        code: impl Into<String>,
105        message: impl Into<String>,
106    ) -> Self {
107        Self {
108            file: file.into(),
109            line: None,
110            column: None,
111            severity: LintSeverity::Warning,
112            code: code.into(),
113            message: message.into(),
114            suggestion: None,
115        }
116    }
117
118    /// Create a new lint info
119    pub fn info(
120        file: impl Into<PathBuf>,
121        code: impl Into<String>,
122        message: impl Into<String>,
123    ) -> Self {
124        Self {
125            file: file.into(),
126            line: None,
127            column: None,
128            severity: LintSeverity::Info,
129            code: code.into(),
130            message: message.into(),
131            suggestion: None,
132        }
133    }
134
135    /// Set line number
136    #[must_use]
137    pub fn at_line(mut self, line: u32) -> Self {
138        self.line = Some(line);
139        self
140    }
141
142    /// Set column number
143    #[must_use]
144    pub fn at_column(mut self, column: u32) -> Self {
145        self.column = Some(column);
146        self
147    }
148
149    /// Set suggestion
150    #[must_use]
151    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
152        self.suggestion = Some(suggestion.into());
153        self
154    }
155}
156
157/// Lint report for a directory
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct LintReport {
160    /// Root directory
161    pub root: PathBuf,
162    /// All lint results
163    pub results: Vec<LintResult>,
164    /// Number of errors
165    pub errors: usize,
166    /// Number of warnings
167    pub warnings: usize,
168    /// Number of infos
169    pub infos: usize,
170    /// Number of files checked
171    pub files_checked: usize,
172}
173
174impl LintReport {
175    /// Create a new lint report
176    pub fn new(root: impl Into<PathBuf>) -> Self {
177        Self {
178            root: root.into(),
179            results: Vec::new(),
180            errors: 0,
181            warnings: 0,
182            infos: 0,
183            files_checked: 0,
184        }
185    }
186
187    /// Add a lint result
188    pub fn add(&mut self, result: LintResult) {
189        match result.severity {
190            LintSeverity::Error => self.errors += 1,
191            LintSeverity::Warning => self.warnings += 1,
192            LintSeverity::Info => self.infos += 1,
193        }
194        self.results.push(result);
195    }
196
197    /// Check if there are any errors
198    #[must_use]
199    pub fn has_errors(&self) -> bool {
200        self.errors > 0
201    }
202
203    /// Check if the lint passed (no errors)
204    #[must_use]
205    pub fn passed(&self) -> bool {
206        !self.has_errors()
207    }
208}
209
210/// Content linter
211#[derive(Debug)]
212pub struct ContentLinter {
213    /// Root directory to lint
214    root: PathBuf,
215    /// Lint HTML files
216    pub lint_html: bool,
217    /// Lint CSS files
218    pub lint_css: bool,
219    /// Lint JavaScript files
220    pub lint_js: bool,
221    /// Lint WASM files
222    pub lint_wasm: bool,
223    /// Lint JSON files
224    pub lint_json: bool,
225}
226
227impl ContentLinter {
228    /// Create a new content linter
229    pub fn new(root: impl Into<PathBuf>) -> Self {
230        Self {
231            root: root.into(),
232            lint_html: true,
233            lint_css: true,
234            lint_js: true,
235            lint_wasm: true,
236            lint_json: true,
237        }
238    }
239
240    /// Lint all files in the directory
241    pub fn lint(&self) -> LintReport {
242        let mut report = LintReport::new(&self.root);
243        self.lint_directory(&self.root, &mut report);
244        report
245    }
246
247    /// Lint a single file
248    pub fn lint_file(&self, path: &Path) -> Vec<LintResult> {
249        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
250
251        match extension {
252            "html" | "htm" if self.lint_html => self.lint_html_file(path),
253            "css" if self.lint_css => self.lint_css_file(path),
254            "js" | "mjs" if self.lint_js => self.lint_js_file(path),
255            "wasm" if self.lint_wasm => self.lint_wasm_file(path),
256            "json" if self.lint_json => self.lint_json_file(path),
257            _ => Vec::new(),
258        }
259    }
260
261    fn lint_directory(&self, dir: &Path, report: &mut LintReport) {
262        let entries = match std::fs::read_dir(dir) {
263            Ok(e) => e,
264            Err(_) => return,
265        };
266
267        for entry in entries.flatten() {
268            let path = entry.path();
269
270            // Skip hidden files and common ignore patterns
271            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
272            if name.starts_with('.') || name == "node_modules" || name == "target" {
273                continue;
274            }
275
276            if path.is_dir() {
277                self.lint_directory(&path, report);
278            } else {
279                let results = self.lint_file(&path);
280                if !results.is_empty() {
281                    report.files_checked += 1;
282                    for result in results {
283                        report.add(result);
284                    }
285                } else if self.is_lintable(&path) {
286                    report.files_checked += 1;
287                }
288            }
289        }
290    }
291
292    fn is_lintable(&self, path: &Path) -> bool {
293        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
294        matches!(
295            extension,
296            "html" | "htm" | "css" | "js" | "mjs" | "wasm" | "json"
297        )
298    }
299
300    fn lint_html_file(&self, path: &Path) -> Vec<LintResult> {
301        let mut results = Vec::new();
302
303        let content = match std::fs::read_to_string(path) {
304            Ok(c) => c,
305            Err(e) => {
306                results.push(LintResult::error(
307                    path,
308                    "HTML000",
309                    format!("Cannot read file: {e}"),
310                ));
311                return results;
312            }
313        };
314
315        // Check for DOCTYPE
316        if !content
317            .trim_start()
318            .to_lowercase()
319            .starts_with("<!doctype html")
320        {
321            results.push(
322                LintResult::warning(path, "HTML001", "Missing <!DOCTYPE html> declaration")
323                    .at_line(1)
324                    .with_suggestion("Add <!DOCTYPE html> at the start of the file"),
325            );
326        }
327
328        // Check for basic structure
329        let content_lower = content.to_lowercase();
330        if !content_lower.contains("<html") {
331            results.push(LintResult::error(path, "HTML002", "Missing <html> element"));
332        }
333        if !content_lower.contains("<head") {
334            results.push(LintResult::warning(
335                path,
336                "HTML003",
337                "Missing <head> element",
338            ));
339        }
340        if !content_lower.contains("<body") {
341            results.push(LintResult::warning(
342                path,
343                "HTML004",
344                "Missing <body> element",
345            ));
346        }
347
348        // Check for unclosed tags (simple heuristic)
349        let open_divs = content_lower.matches("<div").count();
350        let close_divs = content_lower.matches("</div>").count();
351        if open_divs != close_divs {
352            results.push(LintResult::warning(
353                path,
354                "HTML005",
355                format!(
356                    "Mismatched <div> tags: {} open, {} close",
357                    open_divs, close_divs
358                ),
359            ));
360        }
361
362        // Check for images without alt
363        for (line_num, line) in content.lines().enumerate() {
364            let line_lower = line.to_lowercase();
365            if line_lower.contains("<img") && !line_lower.contains("alt=") {
366                results.push(
367                    LintResult::warning(path, "HTML006", "<img> tag missing alt attribute")
368                        .at_line((line_num + 1) as u32)
369                        .with_suggestion("Add alt attribute for accessibility"),
370                );
371            }
372        }
373
374        results
375    }
376
377    fn lint_css_file(&self, path: &Path) -> Vec<LintResult> {
378        let mut results = Vec::new();
379
380        let content = match std::fs::read_to_string(path) {
381            Ok(c) => c,
382            Err(e) => {
383                results.push(LintResult::error(
384                    path,
385                    "CSS000",
386                    format!("Cannot read file: {e}"),
387                ));
388                return results;
389            }
390        };
391
392        // Check for basic syntax errors
393        let open_braces = content.matches('{').count();
394        let close_braces = content.matches('}').count();
395        if open_braces != close_braces {
396            results.push(LintResult::error(
397                path,
398                "CSS001",
399                format!(
400                    "Mismatched braces: {} open, {} close",
401                    open_braces, close_braces
402                ),
403            ));
404        }
405
406        // Check for vendor prefixes without standard property
407        for (line_num, line) in content.lines().enumerate() {
408            let trimmed = line.trim();
409
410            // Check for webkit without standard
411            if trimmed.starts_with("-webkit-") && !trimmed.starts_with("-webkit-") {
412                let prop = trimmed.split(':').next().unwrap_or("");
413                let standard = prop.trim_start_matches("-webkit-");
414                results.push(
415                    LintResult::info(path, "CSS002", format!("Vendor prefix {} used", prop))
416                        .at_line((line_num + 1) as u32)
417                        .with_suggestion(format!("Also include standard property: {}", standard)),
418                );
419            }
420
421            // Check for empty rules
422            if trimmed == "{}" {
423                results.push(
424                    LintResult::warning(path, "CSS003", "Empty CSS rule")
425                        .at_line((line_num + 1) as u32),
426                );
427            }
428        }
429
430        results
431    }
432
433    fn lint_js_file(&self, path: &Path) -> Vec<LintResult> {
434        let mut results = Vec::new();
435
436        let content = match std::fs::read_to_string(path) {
437            Ok(c) => c,
438            Err(e) => {
439                results.push(LintResult::error(
440                    path,
441                    "JS000",
442                    format!("Cannot read file: {e}"),
443                ));
444                return results;
445            }
446        };
447
448        // Check for basic syntax issues
449        let open_braces = content.matches('{').count();
450        let close_braces = content.matches('}').count();
451        if open_braces != close_braces {
452            results.push(LintResult::error(
453                path,
454                "JS001",
455                format!(
456                    "Mismatched braces: {} open, {} close",
457                    open_braces, close_braces
458                ),
459            ));
460        }
461
462        let open_parens = content.matches('(').count();
463        let close_parens = content.matches(')').count();
464        if open_parens != close_parens {
465            results.push(LintResult::error(
466                path,
467                "JS002",
468                format!(
469                    "Mismatched parentheses: {} open, {} close",
470                    open_parens, close_parens
471                ),
472            ));
473        }
474
475        // Check for common issues
476        for (line_num, line) in content.lines().enumerate() {
477            // Check for console.log in production
478            if line.contains("console.log") {
479                results.push(
480                    LintResult::info(path, "JS003", "console.log found")
481                        .at_line((line_num + 1) as u32)
482                        .with_suggestion("Remove console.log before production"),
483                );
484            }
485
486            // Check for debugger statements
487            if line.trim().starts_with("debugger") {
488                results.push(
489                    LintResult::warning(path, "JS004", "debugger statement found")
490                        .at_line((line_num + 1) as u32)
491                        .with_suggestion("Remove debugger statements before production"),
492                );
493            }
494        }
495
496        results
497    }
498
499    fn lint_wasm_file(&self, path: &Path) -> Vec<LintResult> {
500        let mut results = Vec::new();
501
502        let content = match std::fs::read(path) {
503            Ok(c) => c,
504            Err(e) => {
505                results.push(LintResult::error(
506                    path,
507                    "WASM000",
508                    format!("Cannot read file: {e}"),
509                ));
510                return results;
511            }
512        };
513
514        // Check WASM magic number
515        if content.len() < 8 {
516            results.push(LintResult::error(
517                path,
518                "WASM001",
519                "File too small to be valid WASM",
520            ));
521            return results;
522        }
523
524        // WASM magic number: 0x00 0x61 0x73 0x6D (0asm)
525        if content[0..4] != [0x00, 0x61, 0x73, 0x6D] {
526            results.push(
527                LintResult::error(path, "WASM002", "Invalid WASM magic number")
528                    .with_suggestion("File does not appear to be a valid WebAssembly module"),
529            );
530        }
531
532        // Check version (should be 0x01 0x00 0x00 0x00 for version 1)
533        if content[4..8] != [0x01, 0x00, 0x00, 0x00] {
534            let version = u32::from_le_bytes([content[4], content[5], content[6], content[7]]);
535            results.push(LintResult::warning(
536                path,
537                "WASM003",
538                format!("Unexpected WASM version: {}", version),
539            ));
540        }
541
542        results
543    }
544
545    fn lint_json_file(&self, path: &Path) -> Vec<LintResult> {
546        let mut results = Vec::new();
547
548        let content = match std::fs::read_to_string(path) {
549            Ok(c) => c,
550            Err(e) => {
551                results.push(LintResult::error(
552                    path,
553                    "JSON000",
554                    format!("Cannot read file: {e}"),
555                ));
556                return results;
557            }
558        };
559
560        // Try to parse JSON
561        if let Err(e) = serde_json::from_str::<serde_json::Value>(&content) {
562            let line = e.line();
563            let column = e.column();
564            results.push(
565                LintResult::error(path, "JSON001", format!("Invalid JSON: {}", e))
566                    .at_line(line as u32)
567                    .at_column(column as u32),
568            );
569        }
570
571        results
572    }
573}
574
575/// Render lint report as text
576pub fn render_lint_report(report: &LintReport) -> String {
577    let mut output = String::new();
578
579    output.push_str(&format!("LINT REPORT: {}\n", report.root.display()));
580    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
581
582    if report.results.is_empty() {
583        output.push_str("✓ All files passed linting\n");
584    } else {
585        // Group by file
586        let mut by_file: std::collections::HashMap<&Path, Vec<&LintResult>> =
587            std::collections::HashMap::new();
588        for result in &report.results {
589            by_file.entry(&result.file).or_default().push(result);
590        }
591
592        for (file, results) in by_file {
593            let relative = file.strip_prefix(&report.root).unwrap_or(file);
594            output.push_str(&format!("{}:\n", relative.display()));
595
596            for result in results {
597                let location = match (result.line, result.column) {
598                    (Some(l), Some(c)) => format!("Line {}:{}", l, c),
599                    (Some(l), None) => format!("Line {}", l),
600                    _ => String::new(),
601                };
602
603                output.push_str(&format!(
604                    "  {} {} [{}] {}\n",
605                    result.severity.symbol(),
606                    location,
607                    result.code,
608                    result.message
609                ));
610
611                if let Some(ref suggestion) = result.suggestion {
612                    output.push_str(&format!("      Suggestion: {}\n", suggestion));
613                }
614            }
615            output.push('\n');
616        }
617    }
618
619    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
620    output.push_str(&format!(
621        "Summary: {} errors, {} warnings, {} files checked\n",
622        report.errors, report.warnings, report.files_checked
623    ));
624
625    output
626}
627
628/// Render lint report as JSON
629pub fn render_lint_json(report: &LintReport) -> Result<String, serde_json::Error> {
630    serde_json::to_string_pretty(report)
631}
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used, clippy::expect_used)]
635mod tests {
636    use super::*;
637    use tempfile::TempDir;
638
639    #[test]
640    fn test_lint_severity_as_str() {
641        assert_eq!(LintSeverity::Error.as_str(), "ERROR");
642        assert_eq!(LintSeverity::Warning.as_str(), "WARN");
643        assert_eq!(LintSeverity::Info.as_str(), "INFO");
644    }
645
646    #[test]
647    fn test_lint_severity_symbol() {
648        assert_eq!(LintSeverity::Error.symbol(), "✗");
649        assert_eq!(LintSeverity::Warning.symbol(), "⚠");
650        assert_eq!(LintSeverity::Info.symbol(), "ℹ");
651    }
652
653    #[test]
654    fn test_lint_result_builder() {
655        let result = LintResult::error("test.html", "HTML001", "Test error")
656            .at_line(10)
657            .at_column(5)
658            .with_suggestion("Fix it");
659
660        assert_eq!(result.file, PathBuf::from("test.html"));
661        assert_eq!(result.code, "HTML001");
662        assert_eq!(result.line, Some(10));
663        assert_eq!(result.column, Some(5));
664        assert_eq!(result.suggestion, Some("Fix it".to_string()));
665    }
666
667    #[test]
668    fn test_lint_report_add() {
669        let mut report = LintReport::new("./");
670        report.add(LintResult::error("a.html", "E001", "error"));
671        report.add(LintResult::warning("b.css", "W001", "warning"));
672        report.add(LintResult::info("c.js", "I001", "info"));
673
674        assert_eq!(report.errors, 1);
675        assert_eq!(report.warnings, 1);
676        assert_eq!(report.infos, 1);
677        assert!(report.has_errors());
678        assert!(!report.passed());
679    }
680
681    #[test]
682    fn test_lint_report_no_errors() {
683        let mut report = LintReport::new("./");
684        report.add(LintResult::warning("a.css", "W001", "warning"));
685
686        assert!(!report.has_errors());
687        assert!(report.passed());
688    }
689
690    #[test]
691    fn test_lint_html_missing_doctype() {
692        let temp = TempDir::new().unwrap();
693        let html_path = temp.path().join("test.html");
694        std::fs::write(&html_path, "<html><head></head><body></body></html>").unwrap();
695
696        let linter = ContentLinter::new(temp.path());
697        let results = linter.lint_file(&html_path);
698
699        assert!(results.iter().any(|r| r.code == "HTML001"));
700    }
701
702    #[test]
703    fn test_lint_html_valid() {
704        let temp = TempDir::new().unwrap();
705        let html_path = temp.path().join("test.html");
706        std::fs::write(
707            &html_path,
708            "<!DOCTYPE html><html><head></head><body></body></html>",
709        )
710        .unwrap();
711
712        let linter = ContentLinter::new(temp.path());
713        let results = linter.lint_file(&html_path);
714
715        assert!(results.iter().all(|r| r.severity != LintSeverity::Error));
716    }
717
718    #[test]
719    fn test_lint_html_missing_alt() {
720        let temp = TempDir::new().unwrap();
721        let html_path = temp.path().join("test.html");
722        std::fs::write(
723            &html_path,
724            "<!DOCTYPE html><html><head></head><body><img src=\"test.png\"></body></html>",
725        )
726        .unwrap();
727
728        let linter = ContentLinter::new(temp.path());
729        let results = linter.lint_file(&html_path);
730
731        assert!(results.iter().any(|r| r.code == "HTML006"));
732    }
733
734    #[test]
735    fn test_lint_css_mismatched_braces() {
736        let temp = TempDir::new().unwrap();
737        let css_path = temp.path().join("test.css");
738        std::fs::write(&css_path, "body { color: red;").unwrap();
739
740        let linter = ContentLinter::new(temp.path());
741        let results = linter.lint_file(&css_path);
742
743        assert!(results.iter().any(|r| r.code == "CSS001"));
744    }
745
746    #[test]
747    fn test_lint_js_debugger() {
748        let temp = TempDir::new().unwrap();
749        let js_path = temp.path().join("test.js");
750        std::fs::write(&js_path, "function test() {\n  debugger;\n}").unwrap();
751
752        let linter = ContentLinter::new(temp.path());
753        let results = linter.lint_file(&js_path);
754
755        assert!(results.iter().any(|r| r.code == "JS004"));
756    }
757
758    #[test]
759    fn test_lint_json_invalid() {
760        let temp = TempDir::new().unwrap();
761        let json_path = temp.path().join("test.json");
762        std::fs::write(&json_path, "{invalid json}").unwrap();
763
764        let linter = ContentLinter::new(temp.path());
765        let results = linter.lint_file(&json_path);
766
767        assert!(results.iter().any(|r| r.code == "JSON001"));
768    }
769
770    #[test]
771    fn test_lint_json_valid() {
772        let temp = TempDir::new().unwrap();
773        let json_path = temp.path().join("test.json");
774        std::fs::write(&json_path, r#"{"key": "value"}"#).unwrap();
775
776        let linter = ContentLinter::new(temp.path());
777        let results = linter.lint_file(&json_path);
778
779        assert!(results.is_empty());
780    }
781
782    #[test]
783    fn test_lint_wasm_invalid_magic() {
784        let temp = TempDir::new().unwrap();
785        let wasm_path = temp.path().join("test.wasm");
786        std::fs::write(&wasm_path, b"not wasm data here").unwrap();
787
788        let linter = ContentLinter::new(temp.path());
789        let results = linter.lint_file(&wasm_path);
790
791        assert!(results.iter().any(|r| r.code == "WASM002"));
792    }
793
794    #[test]
795    fn test_lint_wasm_valid() {
796        let temp = TempDir::new().unwrap();
797        let wasm_path = temp.path().join("test.wasm");
798        // Valid WASM magic + version 1
799        std::fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]).unwrap();
800
801        let linter = ContentLinter::new(temp.path());
802        let results = linter.lint_file(&wasm_path);
803
804        assert!(results.is_empty());
805    }
806
807    #[test]
808    fn test_render_lint_report() {
809        let mut report = LintReport::new("./test");
810        report.files_checked = 3;
811        report.add(LintResult::error("test.html", "HTML001", "Missing DOCTYPE"));
812
813        let output = render_lint_report(&report);
814
815        assert!(output.contains("LINT REPORT"));
816        assert!(output.contains("HTML001"));
817        assert!(output.contains("1 errors"));
818    }
819
820    #[test]
821    fn test_render_lint_json() {
822        let report = LintReport::new("./test");
823        let json = render_lint_json(&report).unwrap();
824
825        assert!(json.contains("\"root\""));
826        assert!(json.contains("\"results\""));
827    }
828
829    #[test]
830    fn test_lint_directory_full() {
831        let temp = TempDir::new().unwrap();
832
833        // Create various test files
834        std::fs::write(
835            temp.path().join("index.html"),
836            "<!DOCTYPE html><html><head></head><body></body></html>",
837        )
838        .unwrap();
839        std::fs::write(temp.path().join("style.css"), "body { color: red; }").unwrap();
840        std::fs::write(temp.path().join("app.js"), "function test() {}").unwrap();
841        std::fs::write(temp.path().join("data.json"), r#"{"key": "value"}"#).unwrap();
842
843        let linter = ContentLinter::new(temp.path());
844        let report = linter.lint();
845
846        // Should have checked files
847        assert!(report.files_checked >= 4);
848        // All valid files, should pass
849        assert!(report.passed());
850    }
851
852    #[test]
853    fn test_lint_directory_with_errors() {
854        let temp = TempDir::new().unwrap();
855
856        // Create files with issues
857        std::fs::write(temp.path().join("bad.html"), "<html>no doctype</html>").unwrap();
858        std::fs::write(temp.path().join("bad.css"), "body { color: red").unwrap(); // missing }
859        std::fs::write(temp.path().join("bad.json"), "{invalid}").unwrap();
860
861        let linter = ContentLinter::new(temp.path());
862        let report = linter.lint();
863
864        assert!(report.errors > 0);
865        assert!(!report.passed());
866    }
867
868    #[test]
869    fn test_lint_directory_nested() {
870        let temp = TempDir::new().unwrap();
871
872        // Create nested directory structure
873        let subdir = temp.path().join("subdir");
874        std::fs::create_dir(&subdir).unwrap();
875        std::fs::write(
876            subdir.join("nested.html"),
877            "<!DOCTYPE html><html><head></head><body></body></html>",
878        )
879        .unwrap();
880
881        let linter = ContentLinter::new(temp.path());
882        let report = linter.lint();
883
884        // Should find nested file
885        assert!(report.files_checked >= 1);
886    }
887
888    #[test]
889    fn test_lint_directory_skips_hidden() {
890        let temp = TempDir::new().unwrap();
891
892        // Create hidden directory
893        let hidden = temp.path().join(".hidden");
894        std::fs::create_dir(&hidden).unwrap();
895        std::fs::write(hidden.join("test.html"), "<html>bad</html>").unwrap();
896
897        // Create visible file for comparison
898        std::fs::write(
899            temp.path().join("visible.html"),
900            "<!DOCTYPE html><html><head></head><body></body></html>",
901        )
902        .unwrap();
903
904        let linter = ContentLinter::new(temp.path());
905        let report = linter.lint();
906
907        // Should only find the visible file
908        assert_eq!(report.files_checked, 1);
909    }
910
911    #[test]
912    fn test_lint_directory_skips_node_modules() {
913        let temp = TempDir::new().unwrap();
914
915        // Create node_modules directory
916        let node_modules = temp.path().join("node_modules");
917        std::fs::create_dir(&node_modules).unwrap();
918        std::fs::write(node_modules.join("lib.js"), "console.log('test');").unwrap();
919
920        let linter = ContentLinter::new(temp.path());
921        let report = linter.lint();
922
923        // Should skip node_modules
924        assert_eq!(report.files_checked, 0);
925    }
926
927    #[test]
928    fn test_lint_file_unknown_extension() {
929        let temp = TempDir::new().unwrap();
930        let txt_path = temp.path().join("test.txt");
931        std::fs::write(&txt_path, "Just some text").unwrap();
932
933        let linter = ContentLinter::new(temp.path());
934        let results = linter.lint_file(&txt_path);
935
936        // Unknown extension returns empty results
937        assert!(results.is_empty());
938    }
939
940    #[test]
941    fn test_lint_file_mjs_extension() {
942        let temp = TempDir::new().unwrap();
943        let mjs_path = temp.path().join("test.mjs");
944        std::fs::write(&mjs_path, "export function test() {}").unwrap();
945
946        let linter = ContentLinter::new(temp.path());
947        let results = linter.lint_file(&mjs_path);
948
949        // Should lint .mjs files
950        assert!(results.is_empty()); // Valid JS
951    }
952
953    #[test]
954    fn test_lint_disabled_html() {
955        let temp = TempDir::new().unwrap();
956        let html_path = temp.path().join("test.html");
957        std::fs::write(&html_path, "<html>no doctype</html>").unwrap();
958
959        let mut linter = ContentLinter::new(temp.path());
960        linter.lint_html = false;
961
962        let results = linter.lint_file(&html_path);
963        assert!(results.is_empty()); // Disabled
964    }
965
966    #[test]
967    fn test_lint_disabled_css() {
968        let temp = TempDir::new().unwrap();
969        let css_path = temp.path().join("test.css");
970        std::fs::write(&css_path, "body { color: red").unwrap(); // Missing brace
971
972        let mut linter = ContentLinter::new(temp.path());
973        linter.lint_css = false;
974
975        let results = linter.lint_file(&css_path);
976        assert!(results.is_empty()); // Disabled
977    }
978
979    #[test]
980    fn test_lint_disabled_js() {
981        let temp = TempDir::new().unwrap();
982        let js_path = temp.path().join("test.js");
983        std::fs::write(&js_path, "console.log('debug');").unwrap();
984
985        let mut linter = ContentLinter::new(temp.path());
986        linter.lint_js = false;
987
988        let results = linter.lint_file(&js_path);
989        assert!(results.is_empty()); // Disabled
990    }
991
992    #[test]
993    fn test_lint_disabled_wasm() {
994        let temp = TempDir::new().unwrap();
995        let wasm_path = temp.path().join("test.wasm");
996        std::fs::write(&wasm_path, b"not valid wasm").unwrap();
997
998        let mut linter = ContentLinter::new(temp.path());
999        linter.lint_wasm = false;
1000
1001        let results = linter.lint_file(&wasm_path);
1002        assert!(results.is_empty()); // Disabled
1003    }
1004
1005    #[test]
1006    fn test_lint_disabled_json() {
1007        let temp = TempDir::new().unwrap();
1008        let json_path = temp.path().join("test.json");
1009        std::fs::write(&json_path, "{invalid}").unwrap();
1010
1011        let mut linter = ContentLinter::new(temp.path());
1012        linter.lint_json = false;
1013
1014        let results = linter.lint_file(&json_path);
1015        assert!(results.is_empty()); // Disabled
1016    }
1017
1018    #[test]
1019    fn test_lint_wasm_too_small() {
1020        let temp = TempDir::new().unwrap();
1021        let wasm_path = temp.path().join("tiny.wasm");
1022        std::fs::write(&wasm_path, b"tiny").unwrap();
1023
1024        let linter = ContentLinter::new(temp.path());
1025        let results = linter.lint_file(&wasm_path);
1026
1027        assert!(results.iter().any(|r| r.code == "WASM001"));
1028    }
1029
1030    #[test]
1031    fn test_lint_wasm_wrong_version() {
1032        let temp = TempDir::new().unwrap();
1033        let wasm_path = temp.path().join("oldversion.wasm");
1034        // Valid magic, but version 2
1035        std::fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6D, 0x02, 0x00, 0x00, 0x00]).unwrap();
1036
1037        let linter = ContentLinter::new(temp.path());
1038        let results = linter.lint_file(&wasm_path);
1039
1040        assert!(results.iter().any(|r| r.code == "WASM003"));
1041    }
1042
1043    #[test]
1044    fn test_lint_html_missing_html_tag() {
1045        let temp = TempDir::new().unwrap();
1046        let html_path = temp.path().join("test.html");
1047        std::fs::write(&html_path, "<!DOCTYPE html><head></head><body></body>").unwrap();
1048
1049        let linter = ContentLinter::new(temp.path());
1050        let results = linter.lint_file(&html_path);
1051
1052        assert!(results.iter().any(|r| r.code == "HTML002"));
1053    }
1054
1055    #[test]
1056    fn test_lint_html_missing_head() {
1057        let temp = TempDir::new().unwrap();
1058        let html_path = temp.path().join("test.html");
1059        std::fs::write(&html_path, "<!DOCTYPE html><html><body></body></html>").unwrap();
1060
1061        let linter = ContentLinter::new(temp.path());
1062        let results = linter.lint_file(&html_path);
1063
1064        assert!(results.iter().any(|r| r.code == "HTML003"));
1065    }
1066
1067    #[test]
1068    fn test_lint_html_missing_body() {
1069        let temp = TempDir::new().unwrap();
1070        let html_path = temp.path().join("test.html");
1071        std::fs::write(&html_path, "<!DOCTYPE html><html><head></head></html>").unwrap();
1072
1073        let linter = ContentLinter::new(temp.path());
1074        let results = linter.lint_file(&html_path);
1075
1076        assert!(results.iter().any(|r| r.code == "HTML004"));
1077    }
1078
1079    #[test]
1080    fn test_lint_html_mismatched_divs() {
1081        let temp = TempDir::new().unwrap();
1082        let html_path = temp.path().join("test.html");
1083        std::fs::write(
1084            &html_path,
1085            "<!DOCTYPE html><html><head></head><body><div><div></div></body></html>",
1086        )
1087        .unwrap();
1088
1089        let linter = ContentLinter::new(temp.path());
1090        let results = linter.lint_file(&html_path);
1091
1092        assert!(results.iter().any(|r| r.code == "HTML005"));
1093    }
1094
1095    #[test]
1096    fn test_lint_css_empty_rule() {
1097        let temp = TempDir::new().unwrap();
1098        let css_path = temp.path().join("test.css");
1099        // CSS003 triggers when line is exactly "{}"
1100        std::fs::write(&css_path, "body\n{}\n.empty\n{}").unwrap();
1101
1102        let linter = ContentLinter::new(temp.path());
1103        let results = linter.lint_file(&css_path);
1104
1105        assert!(results.iter().any(|r| r.code == "CSS003"));
1106    }
1107
1108    #[test]
1109    fn test_lint_js_console_log() {
1110        let temp = TempDir::new().unwrap();
1111        let js_path = temp.path().join("test.js");
1112        std::fs::write(&js_path, "console.log('debugging');").unwrap();
1113
1114        let linter = ContentLinter::new(temp.path());
1115        let results = linter.lint_file(&js_path);
1116
1117        assert!(results.iter().any(|r| r.code == "JS003"));
1118    }
1119
1120    #[test]
1121    fn test_lint_js_mismatched_braces() {
1122        let temp = TempDir::new().unwrap();
1123        let js_path = temp.path().join("test.js");
1124        std::fs::write(&js_path, "function test() { return 1;").unwrap();
1125
1126        let linter = ContentLinter::new(temp.path());
1127        let results = linter.lint_file(&js_path);
1128
1129        assert!(results.iter().any(|r| r.code == "JS001"));
1130    }
1131
1132    #[test]
1133    fn test_lint_js_mismatched_parens() {
1134        let temp = TempDir::new().unwrap();
1135        let js_path = temp.path().join("test.js");
1136        std::fs::write(&js_path, "function test( { return 1; }").unwrap();
1137
1138        let linter = ContentLinter::new(temp.path());
1139        let results = linter.lint_file(&js_path);
1140
1141        assert!(results.iter().any(|r| r.code == "JS002"));
1142    }
1143
1144    #[test]
1145    fn test_is_lintable() {
1146        let temp = TempDir::new().unwrap();
1147        let linter = ContentLinter::new(temp.path());
1148
1149        assert!(linter.is_lintable(Path::new("test.html")));
1150        assert!(linter.is_lintable(Path::new("test.htm")));
1151        assert!(linter.is_lintable(Path::new("test.css")));
1152        assert!(linter.is_lintable(Path::new("test.js")));
1153        assert!(linter.is_lintable(Path::new("test.mjs")));
1154        assert!(linter.is_lintable(Path::new("test.wasm")));
1155        assert!(linter.is_lintable(Path::new("test.json")));
1156        assert!(!linter.is_lintable(Path::new("test.txt")));
1157        assert!(!linter.is_lintable(Path::new("test.rs")));
1158    }
1159
1160    #[test]
1161    fn test_render_report_with_location() {
1162        let mut report = LintReport::new("./test");
1163        report.add(
1164            LintResult::error("test.html", "HTML001", "Test")
1165                .at_line(5)
1166                .at_column(10),
1167        );
1168
1169        let output = render_lint_report(&report);
1170        assert!(output.contains("Line 5:10"));
1171    }
1172
1173    #[test]
1174    fn test_render_report_with_line_only() {
1175        let mut report = LintReport::new("./test");
1176        report.add(LintResult::warning("test.css", "CSS001", "Test").at_line(3));
1177
1178        let output = render_lint_report(&report);
1179        assert!(output.contains("Line 3"));
1180    }
1181
1182    #[test]
1183    fn test_render_report_empty() {
1184        let report = LintReport::new("./test");
1185        let output = render_lint_report(&report);
1186
1187        assert!(output.contains("All files passed linting"));
1188    }
1189
1190    #[test]
1191    fn test_render_report_with_suggestion() {
1192        let mut report = LintReport::new("./test");
1193        report.add(
1194            LintResult::info("test.js", "JS001", "Found issue")
1195                .with_suggestion("Try fixing it this way"),
1196        );
1197
1198        let output = render_lint_report(&report);
1199        assert!(output.contains("Suggestion: Try fixing it this way"));
1200    }
1201
1202    #[test]
1203    fn test_lint_file_read_error() {
1204        let linter = ContentLinter::new("/tmp");
1205
1206        // Try to lint a non-existent file
1207        let results = linter.lint_file(Path::new("/nonexistent/test.html"));
1208        assert!(results.iter().any(|r| r.code == "HTML000"));
1209
1210        let results = linter.lint_file(Path::new("/nonexistent/test.css"));
1211        assert!(results.iter().any(|r| r.code == "CSS000"));
1212
1213        let results = linter.lint_file(Path::new("/nonexistent/test.js"));
1214        assert!(results.iter().any(|r| r.code == "JS000"));
1215
1216        let results = linter.lint_file(Path::new("/nonexistent/test.wasm"));
1217        assert!(results.iter().any(|r| r.code == "WASM000"));
1218
1219        let results = linter.lint_file(Path::new("/nonexistent/test.json"));
1220        assert!(results.iter().any(|r| r.code == "JSON000"));
1221    }
1222}