1use crate::models::{Frontmatter, Link, VaultFile};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Severity {
13 Info,
15 Warning,
17 Error,
19 Critical,
21}
22
23impl Severity {
24 pub fn is_failure(&self) -> bool {
26 matches!(self, Self::Error | Self::Critical)
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ValidationIssue {
33 pub severity: Severity,
35 pub category: String,
37 pub message: String,
39 pub line: Option<usize>,
41 pub suggestion: Option<String>,
43}
44
45impl ValidationIssue {
46 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 pub fn with_line(mut self, line: usize) -> Self {
63 self.line = Some(line);
64 self
65 }
66
67 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
69 self.suggestion = Some(suggestion.into());
70 self
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ValidationReport {
77 pub passed: bool,
79 pub issues: Vec<ValidationIssue>,
81 pub summary: ValidationSummary,
83}
84
85#[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 pub fn new() -> Self {
97 Self {
98 passed: true,
99 issues: Vec::new(),
100 summary: ValidationSummary::default(),
101 }
102 }
103
104 pub fn add_issue(&mut self, issue: ValidationIssue) {
106 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 pub fn merge(&mut self, other: ValidationReport) {
125 for issue in other.issues {
126 self.add_issue(issue);
127 }
128 }
129
130 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 pub fn has_failures(&self) -> bool {
140 !self.passed
141 }
142
143 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
155pub trait Validator {
157 fn validate(&self, file: &VaultFile) -> ValidationReport;
159
160 fn name(&self) -> &str;
162}
163
164#[derive(Debug, Clone)]
166pub struct FrontmatterValidator {
167 required_fields: HashSet<String>,
168}
169
170impl FrontmatterValidator {
171 pub fn new() -> Self {
173 Self {
174 required_fields: HashSet::new(),
175 }
176 }
177
178 pub fn require_field(mut self, field: impl Into<String>) -> Self {
180 self.required_fields.insert(field.into());
181 self
182 }
183
184 fn validate_frontmatter(&self, frontmatter: &Frontmatter) -> ValidationReport {
186 let mut report = ValidationReport::new();
187
188 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 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 }
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#[derive(Debug, Clone)]
263pub struct LinkValidator {
264 check_fragments: bool,
265}
266
267impl LinkValidator {
268 pub fn new() -> Self {
270 Self {
271 check_fragments: true,
272 }
273 }
274
275 pub fn check_fragments(mut self, check: bool) -> Self {
277 self.check_fragments = check;
278 self
279 }
280
281 fn validate_link(&self, link: &Link, line: usize) -> Vec<ValidationIssue> {
283 let mut issues = Vec::new();
284
285 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 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 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#[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 pub fn new() -> Self {
361 Self {
362 min_length: None,
363 max_length: None,
364 require_heading: false,
365 }
366 }
367
368 pub fn min_length(mut self, min: usize) -> Self {
370 self.min_length = Some(min);
371 self
372 }
373
374 pub fn max_length(mut self, max: usize) -> Self {
376 self.max_length = Some(max);
377 self
378 }
379
380 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 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 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 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
446pub struct CompositeValidator {
448 validators: Vec<Box<dyn Validator>>,
449}
450
451impl CompositeValidator {
452 pub fn new() -> Self {
454 Self {
455 validators: Vec::new(),
456 }
457 }
458
459 pub fn add_validator(mut self, validator: Box<dyn Validator>) -> Self {
461 self.validators.push(validator);
462 self
463 }
464
465 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); 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); 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); 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(); let report = validator.validate(&file);
713 assert!(report.passed); 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 assert!(!report.passed); 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}