turbovault_core/
validation.rs

1//! Content validation system.
2//!
3//! Provides validators for markdown content, frontmatter, links, and other
4//! vault elements. Extensible validator trait allows custom validation rules.
5
6use crate::models::{Frontmatter, Link, VaultFile};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9
10/// Severity level for validation issues
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Severity {
13    /// Informational message (not a problem)
14    Info,
15    /// Warning (should be addressed but not critical)
16    Warning,
17    /// Error (should be fixed)
18    Error,
19    /// Critical error (must be fixed)
20    Critical,
21}
22
23impl Severity {
24    /// Check if this severity is considered a failure
25    pub fn is_failure(&self) -> bool {
26        matches!(self, Self::Error | Self::Critical)
27    }
28}
29
30/// A validation issue found in content
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ValidationIssue {
33    /// Severity of the issue
34    pub severity: Severity,
35    /// Category of the issue
36    pub category: String,
37    /// Human-readable message
38    pub message: String,
39    /// Location in the file (line number, optional)
40    pub line: Option<usize>,
41    /// Suggested fix (optional)
42    pub suggestion: Option<String>,
43}
44
45impl ValidationIssue {
46    /// Create a new validation issue
47    pub fn new(
48        severity: Severity,
49        category: impl Into<String>,
50        message: impl Into<String>,
51    ) -> Self {
52        Self {
53            severity,
54            category: category.into(),
55            message: message.into(),
56            line: None,
57            suggestion: None,
58        }
59    }
60
61    /// Set the line number
62    pub fn with_line(mut self, line: usize) -> Self {
63        self.line = Some(line);
64        self
65    }
66
67    /// Set a suggested fix
68    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
69        self.suggestion = Some(suggestion.into());
70        self
71    }
72}
73
74/// Result of validating content
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ValidationReport {
77    /// Whether validation passed (no errors/critical issues)
78    pub passed: bool,
79    /// All issues found
80    pub issues: Vec<ValidationIssue>,
81    /// Summary counts by severity
82    pub summary: ValidationSummary,
83}
84
85/// Summary of validation results
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct ValidationSummary {
88    pub info_count: usize,
89    pub warning_count: usize,
90    pub error_count: usize,
91    pub critical_count: usize,
92}
93
94impl ValidationReport {
95    /// Create a new validation report
96    pub fn new() -> Self {
97        Self {
98            passed: true,
99            issues: Vec::new(),
100            summary: ValidationSummary::default(),
101        }
102    }
103
104    /// Add an issue to the report
105    pub fn add_issue(&mut self, issue: ValidationIssue) {
106        // Update summary
107        match issue.severity {
108            Severity::Info => self.summary.info_count += 1,
109            Severity::Warning => self.summary.warning_count += 1,
110            Severity::Error => {
111                self.summary.error_count += 1;
112                self.passed = false;
113            }
114            Severity::Critical => {
115                self.summary.critical_count += 1;
116                self.passed = false;
117            }
118        }
119
120        self.issues.push(issue);
121    }
122
123    /// Merge another report into this one
124    pub fn merge(&mut self, other: ValidationReport) {
125        for issue in other.issues {
126            self.add_issue(issue);
127        }
128    }
129
130    /// Get issues by severity
131    pub fn issues_by_severity(&self, severity: Severity) -> Vec<&ValidationIssue> {
132        self.issues
133            .iter()
134            .filter(|i| i.severity == severity)
135            .collect()
136    }
137
138    /// Check if there are any failures
139    pub fn has_failures(&self) -> bool {
140        !self.passed
141    }
142
143    /// Total issue count
144    pub fn total_issues(&self) -> usize {
145        self.issues.len()
146    }
147}
148
149impl Default for ValidationReport {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155/// Trait for content validators
156pub trait Validator {
157    /// Validate content and return a report
158    fn validate(&self, file: &VaultFile) -> ValidationReport;
159
160    /// Name of this validator
161    fn name(&self) -> &str;
162}
163
164/// Validates frontmatter structure and required fields
165#[derive(Debug, Clone)]
166pub struct FrontmatterValidator {
167    required_fields: HashSet<String>,
168}
169
170impl FrontmatterValidator {
171    /// Create a new frontmatter validator
172    pub fn new() -> Self {
173        Self {
174            required_fields: HashSet::new(),
175        }
176    }
177
178    /// Require a specific field to be present
179    pub fn require_field(mut self, field: impl Into<String>) -> Self {
180        self.required_fields.insert(field.into());
181        self
182    }
183
184    /// Validate a frontmatter object
185    fn validate_frontmatter(&self, frontmatter: &Frontmatter) -> ValidationReport {
186        let mut report = ValidationReport::new();
187
188        // Check required fields
189        for field in &self.required_fields {
190            if !frontmatter.data.contains_key(field) {
191                report.add_issue(
192                    ValidationIssue::new(
193                        Severity::Error,
194                        "frontmatter",
195                        format!("Missing required field: {}", field),
196                    )
197                    .with_suggestion(format!("Add '{}:' to frontmatter", field)),
198                );
199            }
200        }
201
202        // Validate tags format if present
203        if let Some(tags_value) = frontmatter.data.get("tags") {
204            match tags_value {
205                serde_json::Value::Array(arr) => {
206                    for (idx, tag) in arr.iter().enumerate() {
207                        if !tag.is_string() {
208                            report.add_issue(ValidationIssue::new(
209                                Severity::Warning,
210                                "frontmatter",
211                                format!("Tag at index {} is not a string", idx),
212                            ));
213                        }
214                    }
215                }
216                serde_json::Value::String(_) => {
217                    // Single string tag is OK
218                }
219                _ => {
220                    report.add_issue(ValidationIssue::new(
221                        Severity::Warning,
222                        "frontmatter",
223                        "Tags should be an array of strings or a single string",
224                    ));
225                }
226            }
227        }
228
229        report
230    }
231}
232
233impl Default for FrontmatterValidator {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl Validator for FrontmatterValidator {
240    fn validate(&self, file: &VaultFile) -> ValidationReport {
241        if let Some(ref frontmatter) = file.frontmatter {
242            self.validate_frontmatter(frontmatter)
243        } else if !self.required_fields.is_empty() {
244            let mut report = ValidationReport::new();
245            report.add_issue(ValidationIssue::new(
246                Severity::Error,
247                "frontmatter",
248                "File has no frontmatter but required fields are specified",
249            ));
250            report
251        } else {
252            ValidationReport::new()
253        }
254    }
255
256    fn name(&self) -> &str {
257        "FrontmatterValidator"
258    }
259}
260
261/// Validates link syntax and format
262#[derive(Debug, Clone)]
263pub struct LinkValidator {
264    check_fragments: bool,
265}
266
267impl LinkValidator {
268    /// Create a new link validator
269    pub fn new() -> Self {
270        Self {
271            check_fragments: true,
272        }
273    }
274
275    /// Enable or disable fragment validation
276    pub fn check_fragments(mut self, check: bool) -> Self {
277        self.check_fragments = check;
278        self
279    }
280
281    /// Validate a single link
282    fn validate_link(&self, link: &Link, line: usize) -> Vec<ValidationIssue> {
283        let mut issues = Vec::new();
284
285        // Check for empty target
286        if link.target.is_empty() {
287            issues.push(
288                ValidationIssue::new(Severity::Error, "link", "Empty link target")
289                    .with_line(line)
290                    .with_suggestion("Provide a target for the link or remove it"),
291            );
292        }
293
294        // Check for suspicious characters in wikilinks
295        if link.target.contains("http://") || link.target.contains("https://") {
296            issues.push(
297                ValidationIssue::new(
298                    Severity::Warning,
299                    "link",
300                    format!("URL in wikilink syntax: {}", link.target),
301                )
302                .with_line(line)
303                .with_suggestion("Use markdown link syntax [text](url) for external links"),
304            );
305        }
306
307        // Check for fragments without base
308        if self.check_fragments && link.target.starts_with('#') && link.target.len() > 1 {
309            issues.push(
310                ValidationIssue::new(
311                    Severity::Info,
312                    "link",
313                    format!("Fragment-only link: {}", link.target),
314                )
315                .with_line(line)
316                .with_suggestion("Fragment links reference headings in the current file"),
317            );
318        }
319
320        issues
321    }
322}
323
324impl Default for LinkValidator {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330impl Validator for LinkValidator {
331    fn validate(&self, file: &VaultFile) -> ValidationReport {
332        let mut report = ValidationReport::new();
333
334        for link in &file.links {
335            let line = link.position.line;
336
337            for issue in self.validate_link(link, line) {
338                report.add_issue(issue);
339            }
340        }
341
342        report
343    }
344
345    fn name(&self) -> &str {
346        "LinkValidator"
347    }
348}
349
350/// Validates file content structure
351#[derive(Debug, Clone)]
352pub struct ContentValidator {
353    min_length: Option<usize>,
354    max_length: Option<usize>,
355    require_heading: bool,
356}
357
358impl ContentValidator {
359    /// Create a new content validator
360    pub fn new() -> Self {
361        Self {
362            min_length: None,
363            max_length: None,
364            require_heading: false,
365        }
366    }
367
368    /// Set minimum content length
369    pub fn min_length(mut self, min: usize) -> Self {
370        self.min_length = Some(min);
371        self
372    }
373
374    /// Set maximum content length
375    pub fn max_length(mut self, max: usize) -> Self {
376        self.max_length = Some(max);
377        self
378    }
379
380    /// Require at least one heading
381    pub fn require_heading(mut self) -> Self {
382        self.require_heading = true;
383        self
384    }
385}
386
387impl Default for ContentValidator {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393impl Validator for ContentValidator {
394    fn validate(&self, file: &VaultFile) -> ValidationReport {
395        let mut report = ValidationReport::new();
396
397        let content_len = file.content.len();
398
399        // Check minimum length
400        if let Some(min) = self.min_length
401            && content_len < min
402        {
403            report.add_issue(
404                ValidationIssue::new(
405                    Severity::Warning,
406                    "content",
407                    format!(
408                        "Content too short: {} bytes (minimum: {})",
409                        content_len, min
410                    ),
411                )
412                .with_suggestion("Add more content to the note"),
413            );
414        }
415
416        // Check maximum length
417        if let Some(max) = self.max_length
418            && content_len > max
419        {
420            report.add_issue(
421                ValidationIssue::new(
422                    Severity::Warning,
423                    "content",
424                    format!("Content too long: {} bytes (maximum: {})", content_len, max),
425                )
426                .with_suggestion("Consider splitting into multiple notes"),
427            );
428        }
429
430        // Check for heading if required
431        if self.require_heading && file.headings.is_empty() {
432            report.add_issue(
433                ValidationIssue::new(Severity::Warning, "content", "No headings found")
434                    .with_suggestion("Add at least one heading (# Title)"),
435            );
436        }
437
438        report
439    }
440
441    fn name(&self) -> &str {
442        "ContentValidator"
443    }
444}
445
446/// Composite validator that runs multiple validators
447pub struct CompositeValidator {
448    validators: Vec<Box<dyn Validator>>,
449}
450
451impl CompositeValidator {
452    /// Create a new composite validator
453    pub fn new() -> Self {
454        Self {
455            validators: Vec::new(),
456        }
457    }
458
459    /// Add a validator
460    pub fn add_validator(mut self, validator: Box<dyn Validator>) -> Self {
461        self.validators.push(validator);
462        self
463    }
464
465    /// Create a default validator with common rules
466    pub fn default_rules() -> Self {
467        Self::new()
468            .add_validator(Box::new(FrontmatterValidator::new()))
469            .add_validator(Box::new(LinkValidator::new()))
470            .add_validator(Box::new(ContentValidator::new()))
471    }
472}
473
474impl Default for CompositeValidator {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480impl Validator for CompositeValidator {
481    fn validate(&self, file: &VaultFile) -> ValidationReport {
482        let mut report = ValidationReport::new();
483
484        for validator in &self.validators {
485            let sub_report = validator.validate(file);
486            report.merge(sub_report);
487        }
488
489        report
490    }
491
492    fn name(&self) -> &str {
493        "CompositeValidator"
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::SourcePosition;
501    use crate::models::{FileMetadata, LinkType};
502    use std::collections::HashSet;
503    use std::path::PathBuf;
504
505    fn create_test_file() -> VaultFile {
506        VaultFile {
507            path: PathBuf::from("test.md"),
508            content: "# Test\nSome content".to_string(),
509            metadata: FileMetadata {
510                path: PathBuf::from("test.md"),
511                size: 20,
512                created_at: 0.0,
513                modified_at: 0.0,
514                checksum: "abc123".to_string(),
515                is_attachment: false,
516            },
517            frontmatter: None,
518            headings: Vec::new(),
519            links: Vec::new(),
520            backlinks: HashSet::new(),
521            blocks: Vec::new(),
522            tags: Vec::new(),
523            callouts: Vec::new(),
524            tasks: Vec::new(),
525            is_parsed: true,
526            parse_error: None,
527            last_parsed: Some(0.0),
528        }
529    }
530
531    #[test]
532    fn test_validation_issue_creation() {
533        let issue = ValidationIssue::new(Severity::Error, "test", "Test message");
534        assert_eq!(issue.severity, Severity::Error);
535        assert_eq!(issue.category, "test");
536        assert_eq!(issue.message, "Test message");
537        assert!(issue.line.is_none());
538        assert!(issue.suggestion.is_none());
539    }
540
541    #[test]
542    fn test_validation_issue_with_line() {
543        let issue = ValidationIssue::new(Severity::Error, "test", "Test").with_line(42);
544        assert_eq!(issue.line, Some(42));
545    }
546
547    #[test]
548    fn test_validation_issue_with_suggestion() {
549        let issue = ValidationIssue::new(Severity::Error, "test", "Test").with_suggestion("Fix it");
550        assert_eq!(issue.suggestion, Some("Fix it".to_string()));
551    }
552
553    #[test]
554    fn test_severity_is_failure() {
555        assert!(!Severity::Info.is_failure());
556        assert!(!Severity::Warning.is_failure());
557        assert!(Severity::Error.is_failure());
558        assert!(Severity::Critical.is_failure());
559    }
560
561    #[test]
562    fn test_validation_report_creation() {
563        let report = ValidationReport::new();
564        assert!(report.passed);
565        assert_eq!(report.issues.len(), 0);
566        assert_eq!(report.summary.error_count, 0);
567    }
568
569    #[test]
570    fn test_validation_report_add_issue() {
571        let mut report = ValidationReport::new();
572        report.add_issue(ValidationIssue::new(Severity::Warning, "test", "Warning"));
573        assert!(report.passed);
574        assert_eq!(report.summary.warning_count, 1);
575
576        report.add_issue(ValidationIssue::new(Severity::Error, "test", "Error"));
577        assert!(!report.passed);
578        assert_eq!(report.summary.error_count, 1);
579    }
580
581    #[test]
582    fn test_validation_report_merge() {
583        let mut report1 = ValidationReport::new();
584        report1.add_issue(ValidationIssue::new(Severity::Warning, "test", "Warning"));
585
586        let mut report2 = ValidationReport::new();
587        report2.add_issue(ValidationIssue::new(Severity::Error, "test", "Error"));
588
589        report1.merge(report2);
590        assert!(!report1.passed);
591        assert_eq!(report1.summary.warning_count, 1);
592        assert_eq!(report1.summary.error_count, 1);
593        assert_eq!(report1.total_issues(), 2);
594    }
595
596    #[test]
597    fn test_frontmatter_validator_no_requirements() {
598        let validator = FrontmatterValidator::new();
599        let file = create_test_file();
600        let report = validator.validate(&file);
601        assert!(report.passed);
602    }
603
604    #[test]
605    fn test_frontmatter_validator_missing_required_field() {
606        let validator = FrontmatterValidator::new().require_field("title");
607        let file = create_test_file();
608        let report = validator.validate(&file);
609        assert!(!report.passed);
610        assert_eq!(report.summary.error_count, 1);
611    }
612
613    #[test]
614    fn test_frontmatter_validator_with_required_field() {
615        use std::collections::HashMap;
616
617        let validator = FrontmatterValidator::new().require_field("title");
618        let mut file = create_test_file();
619        let mut data = HashMap::new();
620        data.insert("title".to_string(), serde_json::json!("Test Title"));
621        let frontmatter = Frontmatter {
622            data,
623            position: SourcePosition::start(),
624        };
625        file.frontmatter = Some(frontmatter);
626
627        let report = validator.validate(&file);
628        assert!(report.passed);
629    }
630
631    #[test]
632    fn test_link_validator_empty_target() {
633        let validator = LinkValidator::new();
634        let mut file = create_test_file();
635        file.links.push(Link {
636            type_: LinkType::WikiLink,
637            source_file: PathBuf::from("test.md"),
638            target: "".to_string(),
639            display_text: None,
640            position: SourcePosition::start(),
641            resolved_target: None,
642            is_valid: false,
643        });
644
645        let report = validator.validate(&file);
646        assert!(!report.passed);
647        assert_eq!(report.summary.error_count, 1);
648    }
649
650    #[test]
651    fn test_link_validator_url_in_wikilink() {
652        let validator = LinkValidator::new();
653        let mut file = create_test_file();
654        file.links.push(Link {
655            type_: LinkType::WikiLink,
656            source_file: PathBuf::from("test.md"),
657            target: "https://example.com".to_string(),
658            display_text: None,
659            position: SourcePosition::start(),
660            resolved_target: None,
661            is_valid: false,
662        });
663
664        let report = validator.validate(&file);
665        assert!(report.passed); // Warning, not error
666        assert_eq!(report.summary.warning_count, 1);
667    }
668
669    #[test]
670    fn test_link_validator_fragment_only() {
671        let validator = LinkValidator::new();
672        let mut file = create_test_file();
673        file.links.push(Link {
674            type_: LinkType::WikiLink,
675            source_file: PathBuf::from("test.md"),
676            target: "#heading".to_string(),
677            display_text: None,
678            position: SourcePosition::start(),
679            resolved_target: None,
680            is_valid: false,
681        });
682
683        let report = validator.validate(&file);
684        assert!(report.passed);
685        assert_eq!(report.summary.info_count, 1);
686    }
687
688    #[test]
689    fn test_content_validator_min_length() {
690        let validator = ContentValidator::new().min_length(100);
691        let file = create_test_file();
692        let report = validator.validate(&file);
693        assert!(report.passed); // Warning, not error
694        assert_eq!(report.summary.warning_count, 1);
695    }
696
697    #[test]
698    fn test_content_validator_max_length() {
699        let validator = ContentValidator::new().max_length(10);
700        let file = create_test_file();
701        let report = validator.validate(&file);
702        assert!(report.passed); // Warning, not error
703        assert_eq!(report.summary.warning_count, 1);
704    }
705
706    #[test]
707    fn test_content_validator_require_heading() {
708        let validator = ContentValidator::new().require_heading();
709        let mut file = create_test_file();
710        file.headings.clear(); // Remove headings
711
712        let report = validator.validate(&file);
713        assert!(report.passed); // Warning, not error
714        assert_eq!(report.summary.warning_count, 1);
715    }
716
717    #[test]
718    fn test_composite_validator() {
719        let validator = CompositeValidator::new()
720            .add_validator(Box::new(FrontmatterValidator::new().require_field("title")))
721            .add_validator(Box::new(LinkValidator::new()))
722            .add_validator(Box::new(ContentValidator::new().min_length(100)));
723
724        let file = create_test_file();
725        let report = validator.validate(&file);
726
727        // Should have issues from multiple validators
728        assert!(!report.passed); // Frontmatter error
729        assert!(report.summary.error_count > 0);
730        assert!(report.summary.warning_count > 0);
731    }
732
733    #[test]
734    fn test_validation_report_issues_by_severity() {
735        let mut report = ValidationReport::new();
736        report.add_issue(ValidationIssue::new(Severity::Warning, "test", "W1"));
737        report.add_issue(ValidationIssue::new(Severity::Error, "test", "E1"));
738        report.add_issue(ValidationIssue::new(Severity::Warning, "test", "W2"));
739
740        let warnings = report.issues_by_severity(Severity::Warning);
741        assert_eq!(warnings.len(), 2);
742
743        let errors = report.issues_by_severity(Severity::Error);
744        assert_eq!(errors.len(), 1);
745    }
746
747    #[test]
748    fn test_validator_name() {
749        let frontmatter = FrontmatterValidator::new();
750        assert_eq!(frontmatter.name(), "FrontmatterValidator");
751
752        let link = LinkValidator::new();
753        assert_eq!(link.name(), "LinkValidator");
754
755        let content = ContentValidator::new();
756        assert_eq!(content.name(), "ContentValidator");
757    }
758}