1use std::collections::HashMap;
14use std::path::Path;
15
16use anyhow::{Context, Result};
17use indexmap::IndexMap;
18use regex::Regex;
19use serde::Serialize;
20use serde_json::Value;
21
22use hyalo_core::filename_template::FilenameTemplate;
23use hyalo_core::frontmatter::{read_frontmatter, write_frontmatter};
24use hyalo_core::schema::{self, PropertyConstraint, SchemaConfig, TypeSchema};
25use hyalo_core::util::is_iso8601_date;
26
27use crate::output::{CommandOutcome, Format, format_success};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum Severity {
37 Error,
38 Warn,
39}
40
41impl std::fmt::Display for Severity {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::Error => f.write_str("error"),
45 Self::Warn => f.write_str("warn"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub struct Violation {
53 pub severity: Severity,
54 pub message: String,
55}
56
57#[derive(Debug, Serialize)]
59pub struct FileLintResult {
60 pub file: String,
61 pub violations: Vec<Violation>,
62}
63
64#[derive(Debug, Clone, Serialize)]
66pub struct FixAction {
67 pub kind: String,
69 pub property: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub old: Option<String>,
74 pub new: String,
76}
77
78#[derive(Debug, Serialize)]
83pub struct LintOutput {
84 pub files: Vec<FileLintResult>,
85 pub total: usize,
87 pub files_checked: usize,
89 #[serde(skip_serializing_if = "Vec::is_empty")]
92 pub fixes: Vec<FileFixResult>,
93 #[serde(skip_serializing_if = "std::ops::Not::not")]
95 pub dry_run: bool,
96}
97
98#[derive(Debug, Clone, Serialize)]
100pub struct FileFixResult {
101 pub file: String,
102 pub actions: Vec<FixAction>,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct LintCounts {
108 pub errors: usize,
109 pub warnings: usize,
110 pub files_with_issues: usize,
112}
113
114pub fn lint_files(
123 files: &[(std::path::PathBuf, String)],
124 schema: &SchemaConfig,
125) -> Result<(CommandOutcome, LintCounts)> {
126 lint_files_with_options(files, schema, FixMode::Off)
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum FixMode {
132 Off,
134 Apply,
136 DryRun,
138}
139
140pub fn lint_files_with_options(
147 files: &[(std::path::PathBuf, String)],
148 schema: &SchemaConfig,
149 fix: FixMode,
150) -> Result<(CommandOutcome, LintCounts)> {
151 let mut results: Vec<FileLintResult> = Vec::new();
152 let mut counts = LintCounts::default();
153 let mut fix_results: Vec<FileFixResult> = Vec::new();
154
155 for (full_path, rel_path) in files {
156 let (file_result, file_fixes) = lint_file_with_fix(full_path, rel_path, schema, fix)?;
157 for v in &file_result.violations {
158 match v.severity {
159 Severity::Error => counts.errors += 1,
160 Severity::Warn => counts.warnings += 1,
161 }
162 }
163 if !file_result.violations.is_empty() {
164 counts.files_with_issues += 1;
165 }
166 if !file_fixes.actions.is_empty() {
167 fix_results.push(file_fixes);
168 }
169 results.push(file_result);
170 }
171
172 let files_checked = files.len();
173 let total = counts.errors + counts.warnings;
174 let output = LintOutput {
175 files: results,
176 total,
177 files_checked,
178 fixes: fix_results,
179 dry_run: matches!(fix, FixMode::DryRun),
180 };
181
182 let val = serde_json::to_value(&output).context("failed to serialize lint output")?;
183 let outcome = CommandOutcome::success(format_success(Format::Json, &val));
184
185 Ok((outcome, counts))
186}
187
188pub fn lint_counts_only(
190 files: &[(std::path::PathBuf, String)],
191 schema: &SchemaConfig,
192) -> Result<LintCounts> {
193 let mut counts = LintCounts::default();
194 for (full_path, rel_path) in files {
195 let file_result = lint_file(full_path, rel_path, schema)?;
196 for v in &file_result.violations {
197 match v.severity {
198 Severity::Error => counts.errors += 1,
199 Severity::Warn => counts.warnings += 1,
200 }
201 }
202 if !file_result.violations.is_empty() {
203 counts.files_with_issues += 1;
204 }
205 }
206 Ok(counts)
207}
208
209pub fn lint_counts_from_properties<'a>(
214 entries: impl Iterator<Item = (&'a str, &'a IndexMap<String, Value>, bool)>,
215 schema: &SchemaConfig,
216) -> LintCounts {
217 let mut counts = LintCounts::default();
218 for (rel_path, properties, has_tags) in entries {
219 let violations = validate_properties(rel_path, properties, has_tags, schema);
220 for v in &violations {
221 match v.severity {
222 Severity::Error => counts.errors += 1,
223 Severity::Warn => counts.warnings += 1,
224 }
225 }
226 if !violations.is_empty() {
227 counts.files_with_issues += 1;
228 }
229 }
230 counts
231}
232
233fn lint_file(full_path: &Path, rel_path: &str, schema: &SchemaConfig) -> Result<FileLintResult> {
238 let (result, _) = lint_file_with_fix(full_path, rel_path, schema, FixMode::Off)?;
239 Ok(result)
240}
241
242fn lint_file_with_fix(
244 full_path: &Path,
245 rel_path: &str,
246 schema: &SchemaConfig,
247 fix: FixMode,
248) -> Result<(FileLintResult, FileFixResult)> {
249 let properties = match read_frontmatter(full_path) {
250 Ok(props) => props,
251 Err(e) if hyalo_core::frontmatter::is_parse_error(&e) => {
252 return Ok((
254 FileLintResult {
255 file: rel_path.to_owned(),
256 violations: vec![Violation {
257 severity: Severity::Error,
258 message: format!("could not parse frontmatter: {e}"),
259 }],
260 },
261 FileFixResult {
262 file: rel_path.to_owned(),
263 actions: Vec::new(),
264 },
265 ));
266 }
267 Err(e) => return Err(e).context(format!("reading {rel_path}")),
268 };
269
270 let (final_props, actions) = if matches!(fix, FixMode::Apply | FixMode::DryRun) {
272 let mut mutable = properties.clone();
273 let actions = apply_fixes(rel_path, &mut mutable, schema);
274 if matches!(fix, FixMode::Apply) && !actions.is_empty() {
275 write_frontmatter(full_path, &mutable)
276 .with_context(|| format!("writing fixed frontmatter to {rel_path}"))?;
277 }
278 (mutable, actions)
279 } else {
280 (properties, Vec::new())
281 };
282
283 let has_tags = final_props.contains_key("tags");
284 let violations = validate_properties(rel_path, &final_props, has_tags, schema);
285 Ok((
286 FileLintResult {
287 file: rel_path.to_owned(),
288 violations,
289 },
290 FileFixResult {
291 file: rel_path.to_owned(),
292 actions,
293 },
294 ))
295}
296
297const ENUM_TYPO_MAX_DISTANCE: usize = 2;
305
306fn apply_fixes(
310 rel_path: &str,
311 props: &mut IndexMap<String, Value>,
312 schema: &SchemaConfig,
313) -> Vec<FixAction> {
314 let mut actions: Vec<FixAction> = Vec::new();
315
316 if !props.contains_key("type")
318 && let Some(inferred) = infer_type_from_path(rel_path, schema)
319 {
320 props.shift_insert(0, "type".to_owned(), Value::String(inferred.clone()));
322 actions.push(FixAction {
323 kind: "infer-type".to_owned(),
324 property: "type".to_owned(),
325 old: None,
326 new: inferred,
327 });
328 }
329
330 let doc_type: Option<String> = props.get("type").and_then(|v| match v {
332 Value::String(s) => Some(s.clone()),
333 _ => None,
334 });
335 let effective_schema: TypeSchema = match &doc_type {
336 Some(t) => schema.merged_schema_for_type(t),
337 None => schema.default_schema().clone(),
338 };
339
340 let mut inserted: std::collections::HashSet<String> = std::collections::HashSet::new();
344 for req in &effective_schema.required {
345 if !props.contains_key(req.as_str())
346 && let Some(raw) = effective_schema.defaults.get(req.as_str())
347 {
348 let value = schema::expand_default(raw);
349 props.insert(req.clone(), Value::String(value.clone()));
350 inserted.insert(req.clone());
351 actions.push(FixAction {
352 kind: "insert-default".to_owned(),
353 property: req.clone(),
354 old: None,
355 new: value,
356 });
357 }
358 }
359 for (name, raw) in &effective_schema.defaults {
361 if inserted.contains(name) || props.contains_key(name.as_str()) {
362 continue;
363 }
364 let value = schema::expand_default(raw);
365 props.insert(name.clone(), Value::String(value.clone()));
366 actions.push(FixAction {
367 kind: "insert-default".to_owned(),
368 property: name.clone(),
369 old: None,
370 new: value,
371 });
372 }
373
374 let prop_names: Vec<String> = props.keys().cloned().collect();
376 for name in prop_names {
377 let Some(constraint) = effective_schema.properties.get(name.as_str()) else {
378 continue;
379 };
380 let Some(current) = props.get(name.as_str()).cloned() else {
382 continue;
383 };
384 match constraint {
385 PropertyConstraint::Enum { values } => {
386 let Value::String(s) = ¤t else { continue };
387 if values.iter().any(|v| v == s) {
388 continue;
389 }
390 if let Some((suggestion, dist)) = values
391 .iter()
392 .map(|v| (v, strsim::levenshtein(s, v.as_str())))
393 .min_by_key(|(_, d)| *d)
394 && dist <= ENUM_TYPO_MAX_DISTANCE
395 {
396 let old = s.clone();
397 let new_value = suggestion.clone();
398 props.insert(name.clone(), Value::String(new_value.clone()));
399 actions.push(FixAction {
400 kind: "fix-enum-typo".to_owned(),
401 property: name.clone(),
402 old: Some(old),
403 new: new_value,
404 });
405 }
406 }
407 PropertyConstraint::Date => {
408 let Value::String(s) = ¤t else { continue };
409 if is_iso8601_date(s) {
410 continue;
411 }
412 if let Some(normalized) = normalize_date(s) {
413 let old = s.clone();
414 props.insert(name.clone(), Value::String(normalized.clone()));
415 actions.push(FixAction {
416 kind: "normalize-date".to_owned(),
417 property: name.clone(),
418 old: Some(old),
419 new: normalized,
420 });
421 }
422 }
423 _ => {}
424 }
425 }
426
427 actions
428}
429
430fn infer_type_from_path(rel_path: &str, schema: &SchemaConfig) -> Option<String> {
434 let mut matches: Vec<String> = Vec::new();
435 for (type_name, ts) in &schema.types {
436 let Some(template_str) = &ts.filename_template else {
437 continue;
438 };
439 let Ok(template) = FilenameTemplate::parse(template_str) else {
440 continue;
441 };
442 if template.matches(rel_path) {
443 matches.push(type_name.clone());
444 }
445 }
446 if matches.len() == 1 {
447 matches.pop()
448 } else {
449 None
450 }
451}
452
453fn normalize_date(s: &str) -> Option<String> {
460 let parts: Vec<&str> = s.split('-').collect();
461 if parts.len() != 3 {
462 return None;
463 }
464 let y = parts[0];
465 let m = parts[1];
466 let d = parts[2];
467 if y.len() != 4 || !y.bytes().all(|b| b.is_ascii_digit()) {
468 return None;
469 }
470 if m.is_empty() || m.len() > 2 || !m.bytes().all(|b| b.is_ascii_digit()) {
471 return None;
472 }
473 if d.is_empty() || d.len() > 2 || !d.bytes().all(|b| b.is_ascii_digit()) {
474 return None;
475 }
476 let yi: i32 = y.parse().ok()?;
477 let mi: u32 = m.parse().ok()?;
478 let di: u32 = d.parse().ok()?;
479 if !(1..=12).contains(&mi) {
480 return None;
481 }
482 let max_day = match mi {
483 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
484 4 | 6 | 9 | 11 => 30,
485 2 => {
486 let leap = (yi % 4 == 0 && yi % 100 != 0) || (yi % 400 == 0);
487 if leap { 29 } else { 28 }
488 }
489 _ => return None,
490 };
491 if !(1..=max_day).contains(&di) {
492 return None;
493 }
494 Some(format!("{y}-{mi:02}-{di:02}"))
495}
496
497fn validate_properties(
502 _rel_path: &str,
503 properties: &IndexMap<String, Value>,
504 has_tags: bool,
505 schema: &SchemaConfig,
506) -> Vec<Violation> {
507 let mut violations: Vec<Violation> = Vec::new();
508
509 let type_value = properties.get("type");
511 let doc_type: Option<String> = type_value.and_then(|v| match v {
512 Value::String(s) => Some(s.clone()),
513 _ => None,
514 });
515
516 if let Some(v) = type_value
520 && doc_type.is_none()
521 {
522 violations.push(Violation {
523 severity: Severity::Error,
524 message: format!("property \"type\" expected string, got {v}"),
525 });
526 }
527
528 if type_value.is_none() && !schema.is_empty() {
530 violations.push(Violation {
531 severity: Severity::Warn,
532 message: "no 'type' property — validating against default schema only".to_owned(),
533 });
534 }
535
536 let effective_schema: TypeSchema = match &doc_type {
538 Some(t) => schema.merged_schema_for_type(t),
539 None => schema.default_schema().clone(),
540 };
541
542 for req in &effective_schema.required {
544 if !properties.contains_key(req.as_str()) {
545 let type_hint = doc_type
546 .as_deref()
547 .map(|t| format!(" (type: {t})"))
548 .unwrap_or_default();
549 violations.push(Violation {
550 severity: Severity::Error,
551 message: format!("missing required property \"{req}\"{type_hint}"),
552 });
553 }
554 }
555
556 if !has_tags && !schema.types.is_empty() {
558 violations.push(Violation {
559 severity: Severity::Warn,
560 message: "no tags defined".to_owned(),
561 });
562 }
563
564 let mut regex_cache: HashMap<String, Result<Regex, String>> = HashMap::new();
568
569 for (name, value) in properties {
571 if name == "tags" {
575 if let Some(constraint) = effective_schema.properties.get(name.as_str())
576 && let Some(v) = validate_constraint(name, value, constraint, &mut regex_cache)
577 {
578 violations.push(v);
579 }
580 continue;
581 }
582 let implicitly_accepted = name == "type" || effective_schema.required.contains(name);
585
586 if let Some(constraint) = effective_schema.properties.get(name.as_str()) {
587 if let Some(v) = validate_constraint(name, value, constraint, &mut regex_cache) {
588 violations.push(v);
589 }
590 } else if !effective_schema.properties.is_empty() && !implicitly_accepted {
591 violations.push(Violation {
595 severity: Severity::Warn,
596 message: format!("property \"{name}\" is not declared in schema"),
597 });
598 }
599 }
600
601 violations
602}
603
604fn validate_constraint(
609 name: &str,
610 value: &Value,
611 constraint: &PropertyConstraint,
612 regex_cache: &mut HashMap<String, Result<Regex, String>>,
613) -> Option<Violation> {
614 match constraint {
615 PropertyConstraint::String { pattern } => {
616 let Some(s) = value_as_str(value) else {
617 return Some(Violation {
618 severity: Severity::Error,
619 message: format!("property \"{name}\" expected string, got {value}"),
620 });
621 };
622 if let Some(pat) = pattern {
623 let entry = regex_cache
625 .entry(pat.clone())
626 .or_insert_with(|| Regex::new(pat).map_err(|e| e.to_string()));
627 match entry {
628 Ok(re) => {
629 if !re.is_match(s) {
630 return Some(Violation {
631 severity: Severity::Error,
632 message: format!(
633 "property \"{name}\" value {s:?} does not match pattern {pat:?}"
634 ),
635 });
636 }
637 }
638 Err(e) => {
639 return Some(Violation {
640 severity: Severity::Error,
641 message: format!("property \"{name}\": invalid pattern {pat:?}: {e}"),
642 });
643 }
644 }
645 }
646 None
647 }
648 PropertyConstraint::Date => {
649 let Some(s) = value_as_str(value) else {
650 return Some(Violation {
651 severity: Severity::Error,
652 message: format!("property \"{name}\" expected date (YYYY-MM-DD), got {value}"),
653 });
654 };
655 if !is_iso8601_date(s) {
656 return Some(Violation {
657 severity: Severity::Error,
658 message: format!("property \"{name}\" expected date (YYYY-MM-DD), got \"{s}\""),
659 });
660 }
661 None
662 }
663 PropertyConstraint::Number => {
664 if !matches!(value, Value::Number(_)) {
665 return Some(Violation {
666 severity: Severity::Error,
667 message: format!("property \"{name}\" expected number, got {value}"),
668 });
669 }
670 None
671 }
672 PropertyConstraint::Boolean => {
673 if !matches!(value, Value::Bool(_)) {
674 return Some(Violation {
675 severity: Severity::Error,
676 message: format!("property \"{name}\" expected boolean, got {value}"),
677 });
678 }
679 None
680 }
681 PropertyConstraint::List => {
682 if !matches!(value, Value::Array(_)) {
683 return Some(Violation {
684 severity: Severity::Error,
685 message: format!("property \"{name}\" expected list, got {value}"),
686 });
687 }
688 None
689 }
690 PropertyConstraint::Enum { values } => {
691 let Some(s) = value_as_str(value) else {
692 return Some(Violation {
693 severity: Severity::Error,
694 message: format!(
695 "property \"{name}\" expected one of [{}], got {value}",
696 values.join(", ")
697 ),
698 });
699 };
700 if values.contains(&s.to_owned()) {
701 return None;
702 }
703 let suggestion = values
705 .iter()
706 .min_by_key(|v| strsim::levenshtein(s, v.as_str()))
707 .map(|v| format!(" (did you mean \"{v}\"?)"))
708 .unwrap_or_default();
709 Some(Violation {
710 severity: Severity::Error,
711 message: format!(
712 "property \"{name}\" value \"{s}\" not in [{}]{suggestion}",
713 values.join(", ")
714 ),
715 })
716 }
717 }
718}
719
720fn value_as_str(v: &Value) -> Option<&str> {
722 if let Value::String(s) = v {
723 Some(s.as_str())
724 } else {
725 None
726 }
727}
728
729#[cfg(test)]
738mod tests {
739 use super::*;
740 use hyalo_core::schema::{PropertyConstraint, SchemaConfig, TypeSchema};
741 use std::collections::HashMap;
742
743 fn make_schema(
744 default_required: &[&str],
745 type_name: &str,
746 type_required: &[&str],
747 type_properties: HashMap<&str, PropertyConstraint>,
748 ) -> SchemaConfig {
749 let default = TypeSchema {
750 required: default_required.iter().map(ToString::to_string).collect(),
751 ..Default::default()
752 };
753 let mut props: HashMap<String, PropertyConstraint> = HashMap::new();
754 for (k, v) in type_properties {
755 props.insert(k.to_owned(), v);
756 }
757 let type_schema = TypeSchema {
758 required: type_required.iter().map(ToString::to_string).collect(),
759 properties: props,
760 ..Default::default()
761 };
762 let mut types = HashMap::new();
763 types.insert(type_name.to_owned(), type_schema);
764 SchemaConfig { default, types }
765 }
766
767 #[test]
770 fn valid_date() {
771 assert!(is_iso8601_date("2026-04-13"));
772 }
773
774 #[test]
775 fn normalize_date_padding_and_calendar() {
776 assert_eq!(normalize_date("2026-4-9"), Some("2026-04-09".to_owned()));
778 assert_eq!(normalize_date("2024-2-29"), Some("2024-02-29".to_owned()));
780 assert_eq!(normalize_date("2023-2-29"), None);
781 assert_eq!(normalize_date("2026-02-31"), None);
783 assert_eq!(normalize_date("2026-04-31"), None);
784 assert_eq!(normalize_date("2026-13-01"), None);
785 }
786
787 #[test]
788 fn invalid_date_format() {
789 assert!(!is_iso8601_date("April 13"));
790 assert!(!is_iso8601_date("13-04-2026"));
791 assert!(!is_iso8601_date("2026/04/13"));
792 }
793
794 fn vc(name: &str, value: &Value, c: &PropertyConstraint) -> Option<Violation> {
796 let mut cache = HashMap::new();
797 validate_constraint(name, value, c, &mut cache)
798 }
799
800 #[test]
803 fn date_constraint_valid() {
804 let v = vc(
805 "date",
806 &Value::String("2026-04-13".into()),
807 &PropertyConstraint::Date,
808 );
809 assert!(v.is_none());
810 }
811
812 #[test]
813 fn date_constraint_invalid() {
814 let v = vc(
815 "date",
816 &Value::String("April 13".into()),
817 &PropertyConstraint::Date,
818 );
819 assert!(matches!(
820 v,
821 Some(Violation {
822 severity: Severity::Error,
823 ..
824 })
825 ));
826 }
827
828 #[test]
829 fn enum_constraint_valid() {
830 let v = vc(
831 "status",
832 &Value::String("planned".into()),
833 &PropertyConstraint::Enum {
834 values: vec!["planned".into(), "done".into()],
835 },
836 );
837 assert!(v.is_none());
838 }
839
840 #[test]
841 fn enum_constraint_invalid_with_suggestion() {
842 let v = vc(
843 "status",
844 &Value::String("planed".into()),
845 &PropertyConstraint::Enum {
846 values: vec!["planned".into(), "done".into()],
847 },
848 );
849 let viol = v.expect("expected violation");
850 assert_eq!(viol.severity, Severity::Error);
851 assert!(viol.message.contains("did you mean \"planned\""));
852 }
853
854 #[test]
855 fn number_constraint_valid() {
856 let v = vc(
857 "priority",
858 &Value::Number(5.into()),
859 &PropertyConstraint::Number,
860 );
861 assert!(v.is_none());
862 }
863
864 #[test]
865 fn number_constraint_invalid() {
866 let v = vc(
867 "priority",
868 &Value::String("five".into()),
869 &PropertyConstraint::Number,
870 );
871 assert!(matches!(
872 v,
873 Some(Violation {
874 severity: Severity::Error,
875 ..
876 })
877 ));
878 }
879
880 #[test]
881 fn boolean_constraint_valid() {
882 let v = vc("draft", &Value::Bool(true), &PropertyConstraint::Boolean);
883 assert!(v.is_none());
884 }
885
886 #[test]
887 fn boolean_constraint_invalid() {
888 let v = vc(
889 "draft",
890 &Value::String("yes".into()),
891 &PropertyConstraint::Boolean,
892 );
893 assert!(matches!(
894 v,
895 Some(Violation {
896 severity: Severity::Error,
897 ..
898 })
899 ));
900 }
901
902 #[test]
903 fn list_constraint_valid() {
904 let v = vc("tags", &Value::Array(vec![]), &PropertyConstraint::List);
905 assert!(v.is_none());
906 }
907
908 #[test]
909 fn list_constraint_invalid() {
910 let v = vc(
911 "tags",
912 &Value::String("rust".into()),
913 &PropertyConstraint::List,
914 );
915 assert!(matches!(
916 v,
917 Some(Violation {
918 severity: Severity::Error,
919 ..
920 })
921 ));
922 }
923
924 #[test]
925 fn string_pattern_constraint_valid() {
926 let v = vc(
927 "branch",
928 &Value::String("iter-42/my-feature".into()),
929 &PropertyConstraint::String {
930 pattern: Some(r"^iter-\d+/".into()),
931 },
932 );
933 assert!(v.is_none());
934 }
935
936 #[test]
937 fn string_pattern_constraint_invalid() {
938 let v = vc(
939 "branch",
940 &Value::String("feature/my-branch".into()),
941 &PropertyConstraint::String {
942 pattern: Some(r"^iter-\d+/".into()),
943 },
944 );
945 assert!(matches!(
946 v,
947 Some(Violation {
948 severity: Severity::Error,
949 ..
950 })
951 ));
952 }
953
954 #[test]
957 fn lint_file_missing_required() {
958 let dir = tempfile::tempdir().unwrap();
959 let path = dir.path().join("note.md");
960 std::fs::write(&path, "---\ntitle: Hello\n---\nBody\n").unwrap();
961
962 let schema = make_schema(&["title", "date"], "note", &[], HashMap::new());
963 let result = lint_file(&path, "note.md", &schema).unwrap();
964 assert!(
967 result
968 .violations
969 .iter()
970 .any(|v| v.severity == Severity::Error
971 && v.message.contains("missing required property \"date\""))
972 );
973 }
974
975 #[test]
976 fn lint_file_no_type_warn() {
977 let dir = tempfile::tempdir().unwrap();
978 let path = dir.path().join("note.md");
979 std::fs::write(&path, "---\ntitle: Hello\n---\nBody\n").unwrap();
980
981 let schema = make_schema(&["title"], "note", &[], HashMap::new());
982 let result = lint_file(&path, "note.md", &schema).unwrap();
983 assert!(
984 result
985 .violations
986 .iter()
987 .any(|v| v.severity == Severity::Warn && v.message.contains("no 'type' property"))
988 );
989 }
990
991 #[test]
992 fn lint_file_no_violations_clean_file() {
993 let dir = tempfile::tempdir().unwrap();
994 let path = dir.path().join("note.md");
995 std::fs::write(
996 &path,
997 "---\ntitle: Hello\ntype: note\ntags:\n - rust\n---\nBody\n",
998 )
999 .unwrap();
1000
1001 let schema = make_schema(&["title"], "note", &[], HashMap::new());
1002 let result = lint_file(&path, "note.md", &schema).unwrap();
1003 assert!(result.violations.is_empty());
1004 }
1005
1006 #[test]
1007 fn lint_no_schema_no_violations() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let path = dir.path().join("note.md");
1010 std::fs::write(&path, "---\ntitle: Hello\n---\nBody\n").unwrap();
1011
1012 let schema = SchemaConfig::default();
1013 let files = vec![(path, "note.md".to_owned())];
1014 let (_, counts) = lint_files(&files, &schema).unwrap();
1015 assert_eq!(counts.errors, 0);
1016 assert_eq!(counts.warnings, 0);
1017 }
1018}