1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use glob::glob;
7use serde_json::Value;
8
9use crate::catalog::{self, CompiledCatalog};
10use crate::config;
11use crate::diagnostics::{
12 FileDiagnostic, ParseDiagnostic, ValidationDiagnostic, find_instance_path_offset,
13};
14use crate::discover;
15use crate::parsers::{self, FileFormat, JsoncParser, Parser};
16use crate::registry;
17use crate::retriever::{CacheStatus, HttpClient, SchemaCache, default_cache_dir};
18
19pub struct ValidateArgs {
20 pub globs: Vec<String>,
22
23 pub exclude: Vec<String>,
25
26 pub cache_dir: Option<String>,
28
29 pub no_cache: bool,
31
32 pub no_catalog: bool,
34
35 pub format: Option<parsers::FileFormat>,
37
38 pub config_dir: Option<PathBuf>,
40}
41
42pub enum LintError {
44 Parse(ParseDiagnostic),
45 Validation(ValidationDiagnostic),
46 File(FileDiagnostic),
47}
48
49impl LintError {
50 pub fn path(&self) -> &str {
52 match self {
53 LintError::Parse(d) => d.src.name(),
54 LintError::Validation(d) => &d.path,
55 LintError::File(d) => &d.path,
56 }
57 }
58
59 pub fn message(&self) -> &str {
61 match self {
62 LintError::Parse(d) => &d.message,
63 LintError::Validation(d) => &d.message,
64 LintError::File(d) => &d.message,
65 }
66 }
67
68 fn offset(&self) -> usize {
70 match self {
71 LintError::Parse(d) => d.span.offset(),
72 LintError::Validation(d) => d.span.offset(),
73 LintError::File(_) => 0,
74 }
75 }
76
77 pub fn into_diagnostic(self) -> Box<dyn miette::Diagnostic + Send + Sync> {
79 match self {
80 LintError::Parse(d) => Box::new(d),
81 LintError::Validation(d) => Box::new(d),
82 LintError::File(d) => Box::new(d),
83 }
84 }
85}
86
87pub struct CheckedFile {
89 pub path: String,
90 pub schema: String,
91 pub cache_status: Option<CacheStatus>,
93}
94
95pub struct ValidateResult {
97 pub errors: Vec<LintError>,
98 pub checked: Vec<CheckedFile>,
99}
100
101impl ValidateResult {
102 pub fn has_errors(&self) -> bool {
103 !self.errors.is_empty()
104 }
105
106 pub fn files_checked(&self) -> usize {
107 self.checked.len()
108 }
109}
110
111struct ParsedFile {
117 path: String,
118 content: String,
119 instance: Value,
120 original_schema_uri: String,
122}
123
124fn load_config(search_dir: Option<&Path>) -> (config::Config, PathBuf, Option<PathBuf>) {
132 let start_dir = match search_dir {
133 Some(d) => d.to_path_buf(),
134 None => match std::env::current_dir() {
135 Ok(d) => d,
136 Err(_) => return (config::Config::default(), PathBuf::from("."), None),
137 },
138 };
139
140 let Some(config_path) = config::find_config_path(&start_dir) else {
141 return (config::Config::default(), start_dir, None);
142 };
143
144 let dir = config_path.parent().unwrap_or(&start_dir).to_path_buf();
145 let cfg = config::find_and_load(&start_dir)
146 .ok()
147 .flatten()
148 .unwrap_or_default();
149 (cfg, dir, Some(config_path))
150}
151
152fn collect_files(globs: &[String], exclude: &[String]) -> Result<Vec<PathBuf>> {
158 if globs.is_empty() {
159 return discover::discover_files(".", exclude);
160 }
161
162 let mut result = Vec::new();
163 for pattern in globs {
164 let path = Path::new(pattern);
165 if path.is_dir() {
166 result.extend(discover::discover_files(pattern, exclude)?);
167 } else {
168 for entry in glob(pattern).with_context(|| format!("invalid glob: {pattern}"))? {
169 let path = entry?;
170 if path.is_file() && !is_excluded(&path, exclude) {
171 result.push(path);
172 }
173 }
174 }
175 }
176 Ok(result)
177}
178
179fn is_excluded(path: &Path, excludes: &[String]) -> bool {
180 let path_str = match path.to_str() {
181 Some(s) => s.strip_prefix("./").unwrap_or(s),
182 None => return false,
183 };
184 excludes
185 .iter()
186 .any(|pattern| glob_match::glob_match(pattern, path_str))
187}
188
189fn validate_config(
195 config_path: &Path,
196 errors: &mut Vec<LintError>,
197 checked: &mut Vec<CheckedFile>,
198 on_check: &mut impl FnMut(&CheckedFile),
199) -> Result<()> {
200 let content = fs::read_to_string(config_path)?;
201 let config_value: Value = toml::from_str(&content)
202 .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", config_path.display()))?;
203 let schema_value: Value = serde_json::from_str(include_str!(concat!(
204 env!("OUT_DIR"),
205 "/lintel-config.schema.json"
206 )))
207 .context("failed to parse embedded lintel config schema")?;
208 if let Ok(validator) = jsonschema::options().build(&schema_value) {
209 let path_str = config_path.display().to_string();
210 for error in validator.iter_errors(&config_value) {
211 let ip = error.instance_path().to_string();
212 let offset = find_instance_path_offset(&content, &ip);
213 errors.push(LintError::Validation(ValidationDiagnostic {
214 src: miette::NamedSource::new(&path_str, content.clone()),
215 span: offset.into(),
216 path: path_str.clone(),
217 instance_path: ip,
218 message: error.to_string(),
219 }));
220 }
221 let cf = CheckedFile {
222 path: path_str,
223 schema: "(builtin)".to_string(),
224 cache_status: None,
225 };
226 on_check(&cf);
227 checked.push(cf);
228 }
229 Ok(())
230}
231
232fn try_parse_all(content: &str, file_name: &str) -> Option<(parsers::FileFormat, Value)> {
241 use parsers::FileFormat::{Json, Json5, Jsonc, Markdown, Toml, Yaml};
242 const FORMATS: [parsers::FileFormat; 6] = [Jsonc, Yaml, Toml, Json, Json5, Markdown];
243
244 for fmt in FORMATS {
245 let parser = parsers::parser_for(fmt);
246 if let Ok(val) = parser.parse(content, file_name) {
247 return Some((fmt, val));
248 }
249 }
250 None
251}
252
253fn parse_and_group_files(
256 files: &[PathBuf],
257 args: &ValidateArgs,
258 config: &config::Config,
259 config_dir: &Path,
260 compiled_catalogs: &[CompiledCatalog],
261 errors: &mut Vec<LintError>,
262) -> BTreeMap<String, Vec<ParsedFile>> {
263 let mut schema_groups: BTreeMap<String, Vec<ParsedFile>> = BTreeMap::new();
264
265 for path in files {
266 let content = match fs::read_to_string(path) {
267 Ok(c) => c,
268 Err(e) => {
269 errors.push(LintError::File(FileDiagnostic {
270 path: path.display().to_string(),
271 message: format!("failed to read: {e}"),
272 }));
273 continue;
274 }
275 };
276
277 let path_str = path.display().to_string();
278 let file_name = path
279 .file_name()
280 .and_then(|n| n.to_str())
281 .unwrap_or(&path_str);
282
283 let detected_format = args.format.or_else(|| parsers::detect_format(path));
284
285 if detected_format.is_none() {
287 let has_match = config.find_schema_mapping(&path_str, file_name).is_some()
288 || compiled_catalogs
289 .iter()
290 .any(|cat| cat.find_schema(&path_str, file_name).is_some());
291 if !has_match {
292 continue;
293 }
294 }
295
296 let (parser, instance): (Box<dyn Parser>, Value) = if let Some(fmt) = detected_format {
298 let parser = parsers::parser_for(fmt);
300 match parser.parse(&content, &path_str) {
301 Ok(val) => (parser, val),
302 Err(parse_err) => {
303 if fmt == FileFormat::Json
305 && compiled_catalogs
306 .iter()
307 .any(|cat| cat.find_schema(&path_str, file_name).is_some())
308 {
309 match JsoncParser.parse(&content, &path_str) {
310 Ok(val) => (parsers::parser_for(FileFormat::Jsonc), val),
311 Err(jsonc_err) => {
312 errors.push(LintError::Parse(jsonc_err));
313 continue;
314 }
315 }
316 } else {
317 errors.push(LintError::Parse(parse_err));
318 continue;
319 }
320 }
321 }
322 } else {
323 match try_parse_all(&content, &path_str) {
325 Some((fmt, val)) => (parsers::parser_for(fmt), val),
326 None => continue,
327 }
328 };
329
330 if instance.is_null() {
332 continue;
333 }
334
335 let schema_uri = parser
340 .extract_schema_uri(&content, &instance)
341 .or_else(|| {
342 config
343 .find_schema_mapping(&path_str, file_name)
344 .map(str::to_string)
345 })
346 .or_else(|| {
347 compiled_catalogs
348 .iter()
349 .find_map(|cat| cat.find_schema(&path_str, file_name))
350 .map(str::to_string)
351 });
352 let Some(schema_uri) = schema_uri else {
353 continue;
354 };
355
356 let original_schema_uri = schema_uri.clone();
358
359 let schema_uri = config::apply_rewrites(&schema_uri, &config.rewrite);
361 let schema_uri = config::resolve_double_slash(&schema_uri, config_dir);
362
363 let is_remote = schema_uri.starts_with("http://") || schema_uri.starts_with("https://");
365 let schema_uri = if is_remote {
366 schema_uri
367 } else {
368 path.parent()
369 .map(|parent| parent.join(&schema_uri).to_string_lossy().to_string())
370 .unwrap_or(schema_uri)
371 };
372
373 schema_groups
374 .entry(schema_uri)
375 .or_default()
376 .push(ParsedFile {
377 path: path_str,
378 content,
379 instance,
380 original_schema_uri,
381 });
382 }
383
384 schema_groups
385}
386
387fn fetch_schema<C: HttpClient>(
393 schema_uri: &str,
394 retriever: &SchemaCache<C>,
395 group: &[ParsedFile],
396 errors: &mut Vec<LintError>,
397 checked: &mut Vec<CheckedFile>,
398 on_check: &mut impl FnMut(&CheckedFile),
399) -> Option<(Value, Option<CacheStatus>)> {
400 let is_remote = schema_uri.starts_with("http://") || schema_uri.starts_with("https://");
401
402 let result: Result<(Value, Option<CacheStatus>), String> = if is_remote {
403 retriever
404 .fetch(schema_uri)
405 .map(|(v, status)| (v, Some(status)))
406 .map_err(|e| format!("failed to fetch schema: {schema_uri}: {e}"))
407 } else {
408 fs::read_to_string(schema_uri)
409 .map_err(|e| format!("failed to read local schema {schema_uri}: {e}"))
410 .and_then(|content| {
411 serde_json::from_str::<Value>(&content)
412 .map(|v| (v, None))
413 .map_err(|e| format!("failed to parse local schema {schema_uri}: {e}"))
414 })
415 };
416
417 match result {
418 Ok(value) => Some(value),
419 Err(message) => {
420 report_group_error(&message, schema_uri, None, group, errors, checked, on_check);
421 None
422 }
423 }
424}
425
426fn report_group_error(
428 message: &str,
429 schema_uri: &str,
430 cache_status: Option<CacheStatus>,
431 group: &[ParsedFile],
432 errors: &mut Vec<LintError>,
433 checked: &mut Vec<CheckedFile>,
434 on_check: &mut impl FnMut(&CheckedFile),
435) {
436 for pf in group {
437 let cf = CheckedFile {
438 path: pf.path.clone(),
439 schema: schema_uri.to_string(),
440 cache_status,
441 };
442 on_check(&cf);
443 checked.push(cf);
444 errors.push(LintError::File(FileDiagnostic {
445 path: pf.path.clone(),
446 message: message.to_string(),
447 }));
448 }
449}
450
451fn mark_group_checked(
453 schema_uri: &str,
454 cache_status: Option<CacheStatus>,
455 group: &[ParsedFile],
456 checked: &mut Vec<CheckedFile>,
457 on_check: &mut impl FnMut(&CheckedFile),
458) {
459 for pf in group {
460 let cf = CheckedFile {
461 path: pf.path.clone(),
462 schema: schema_uri.to_string(),
463 cache_status,
464 };
465 on_check(&cf);
466 checked.push(cf);
467 }
468}
469
470fn validate_group(
472 validator: &jsonschema::Validator,
473 schema_uri: &str,
474 cache_status: Option<CacheStatus>,
475 group: &[ParsedFile],
476 errors: &mut Vec<LintError>,
477 checked: &mut Vec<CheckedFile>,
478 on_check: &mut impl FnMut(&CheckedFile),
479) {
480 for pf in group {
481 let cf = CheckedFile {
482 path: pf.path.clone(),
483 schema: schema_uri.to_string(),
484 cache_status,
485 };
486 on_check(&cf);
487 checked.push(cf);
488
489 for error in validator.iter_errors(&pf.instance) {
490 let ip = error.instance_path().to_string();
491 let offset = find_instance_path_offset(&pf.content, &ip);
492 errors.push(LintError::Validation(ValidationDiagnostic {
493 src: miette::NamedSource::new(&pf.path, pf.content.clone()),
494 span: offset.into(),
495 path: pf.path.clone(),
496 instance_path: ip,
497 message: error.to_string(),
498 }));
499 }
500 }
501}
502
503pub async fn run<C: HttpClient>(args: &ValidateArgs, client: C) -> Result<ValidateResult> {
511 run_with(args, client, |_| {}).await
512}
513
514#[allow(clippy::too_many_lines)]
521pub async fn run_with<C: HttpClient>(
522 args: &ValidateArgs,
523 client: C,
524 mut on_check: impl FnMut(&CheckedFile),
525) -> Result<ValidateResult> {
526 let cache_dir = if args.no_cache {
527 None
528 } else {
529 Some(
530 args.cache_dir
531 .as_ref()
532 .map_or_else(default_cache_dir, PathBuf::from),
533 )
534 };
535 let retriever = SchemaCache::new(cache_dir, client.clone());
536
537 let (config, config_dir, config_path) = load_config(args.config_dir.as_deref());
538 let files = collect_files(&args.globs, &args.exclude)?;
539
540 let mut compiled_catalogs = Vec::new();
541
542 if !args.no_catalog {
543 match registry::fetch(&retriever, registry::DEFAULT_REGISTRY) {
545 Ok(cat) => compiled_catalogs.push(CompiledCatalog::compile(&cat)),
546 Err(e) => {
547 eprintln!(
548 "warning: failed to fetch default catalog {}: {e}",
549 registry::DEFAULT_REGISTRY
550 );
551 }
552 }
553 match catalog::fetch_catalog(&retriever) {
555 Ok(cat) => compiled_catalogs.push(CompiledCatalog::compile(&cat)),
556 Err(e) => {
557 eprintln!("warning: failed to fetch SchemaStore catalog: {e}");
558 }
559 }
560 for registry_url in &config.registries {
562 match registry::fetch(&retriever, registry_url) {
563 Ok(cat) => compiled_catalogs.push(CompiledCatalog::compile(&cat)),
564 Err(e) => {
565 eprintln!("warning: failed to fetch registry {registry_url}: {e}");
566 }
567 }
568 }
569 }
570
571 let mut errors: Vec<LintError> = Vec::new();
572 let mut checked: Vec<CheckedFile> = Vec::new();
573
574 if let Some(config_path) = config_path {
576 validate_config(&config_path, &mut errors, &mut checked, &mut on_check)?;
577 }
578
579 let schema_groups = parse_and_group_files(
581 &files,
582 args,
583 &config,
584 &config_dir,
585 &compiled_catalogs,
586 &mut errors,
587 );
588
589 for (schema_uri, group) in &schema_groups {
591 let Some((schema_value, cache_status)) = fetch_schema(
592 schema_uri,
593 &retriever,
594 group,
595 &mut errors,
596 &mut checked,
597 &mut on_check,
598 ) else {
599 continue;
600 };
601
602 let validate_formats = group.iter().all(|pf| {
605 config
606 .should_validate_formats(&pf.path, &[&pf.original_schema_uri, schema_uri.as_str()])
607 });
608
609 let validator = match jsonschema::async_options()
610 .with_retriever(retriever.clone())
611 .should_validate_formats(validate_formats)
612 .build(&schema_value)
613 .await
614 {
615 Ok(v) => v,
616 Err(e) => {
617 if !validate_formats && e.to_string().contains("uri-reference") {
621 mark_group_checked(
622 schema_uri,
623 cache_status,
624 group,
625 &mut checked,
626 &mut on_check,
627 );
628 continue;
629 }
630 report_group_error(
631 &format!("failed to compile schema: {e}"),
632 schema_uri,
633 cache_status,
634 group,
635 &mut errors,
636 &mut checked,
637 &mut on_check,
638 );
639 continue;
640 }
641 };
642
643 validate_group(
644 &validator,
645 schema_uri,
646 cache_status,
647 group,
648 &mut errors,
649 &mut checked,
650 &mut on_check,
651 );
652 }
653
654 errors.sort_by(|a, b| {
656 a.path()
657 .cmp(b.path())
658 .then_with(|| a.offset().cmp(&b.offset()))
659 });
660
661 Ok(ValidateResult { errors, checked })
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::retriever::HttpClient;
668 use std::collections::HashMap;
669 use std::error::Error;
670 use std::path::Path;
671
672 #[derive(Clone)]
673 struct MockClient(HashMap<String, String>);
674
675 impl HttpClient for MockClient {
676 fn get(&self, uri: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
677 self.0
678 .get(uri)
679 .cloned()
680 .ok_or_else(|| format!("mock: no response for {uri}").into())
681 }
682 }
683
684 fn mock(entries: &[(&str, &str)]) -> MockClient {
685 MockClient(
686 entries
687 .iter()
688 .map(|(k, v)| (k.to_string(), v.to_string()))
689 .collect(),
690 )
691 }
692
693 fn testdata() -> PathBuf {
694 Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata")
695 }
696
697 fn scenario_globs(dirs: &[&str]) -> Vec<String> {
699 dirs.iter()
700 .flat_map(|dir| {
701 let base = testdata().join(dir);
702 vec![
703 base.join("*.json").to_string_lossy().to_string(),
704 base.join("*.yaml").to_string_lossy().to_string(),
705 base.join("*.yml").to_string_lossy().to_string(),
706 base.join("*.json5").to_string_lossy().to_string(),
707 base.join("*.jsonc").to_string_lossy().to_string(),
708 base.join("*.toml").to_string_lossy().to_string(),
709 ]
710 })
711 .collect()
712 }
713
714 fn args_for_dirs(dirs: &[&str]) -> ValidateArgs {
715 ValidateArgs {
716 globs: scenario_globs(dirs),
717 exclude: vec![],
718 cache_dir: None,
719 no_cache: true,
720 no_catalog: true,
721 format: None,
722 config_dir: None,
723 }
724 }
725
726 const SCHEMA: &str =
727 r#"{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}"#;
728
729 fn schema_mock() -> MockClient {
730 mock(&[("https://example.com/schema.json", SCHEMA)])
731 }
732
733 #[tokio::test]
736 async fn no_matching_files() -> anyhow::Result<()> {
737 let tmp = tempfile::tempdir()?;
738 let pattern = tmp.path().join("*.json").to_string_lossy().to_string();
739 let c = ValidateArgs {
740 globs: vec![pattern],
741 exclude: vec![],
742 cache_dir: None,
743 no_cache: true,
744 no_catalog: true,
745 format: None,
746 config_dir: None,
747 };
748 let result = run(&c, mock(&[])).await?;
749 assert!(!result.has_errors());
750 Ok(())
751 }
752
753 #[tokio::test]
754 async fn dir_all_valid() -> anyhow::Result<()> {
755 let c = args_for_dirs(&["positive_tests"]);
756 let result = run(&c, schema_mock()).await?;
757 assert!(!result.has_errors());
758 Ok(())
759 }
760
761 #[tokio::test]
762 async fn dir_all_invalid() -> anyhow::Result<()> {
763 let c = args_for_dirs(&["negative_tests"]);
764 let result = run(&c, schema_mock()).await?;
765 assert!(result.has_errors());
766 Ok(())
767 }
768
769 #[tokio::test]
770 async fn dir_mixed_valid_and_invalid() -> anyhow::Result<()> {
771 let c = args_for_dirs(&["positive_tests", "negative_tests"]);
772 let result = run(&c, schema_mock()).await?;
773 assert!(result.has_errors());
774 Ok(())
775 }
776
777 #[tokio::test]
778 async fn dir_no_schemas_skipped() -> anyhow::Result<()> {
779 let c = args_for_dirs(&["no_schema"]);
780 let result = run(&c, mock(&[])).await?;
781 assert!(!result.has_errors());
782 Ok(())
783 }
784
785 #[tokio::test]
786 async fn dir_valid_with_no_schema_files() -> anyhow::Result<()> {
787 let c = args_for_dirs(&["positive_tests", "no_schema"]);
788 let result = run(&c, schema_mock()).await?;
789 assert!(!result.has_errors());
790 Ok(())
791 }
792
793 #[tokio::test]
796 async fn directory_arg_discovers_files() -> anyhow::Result<()> {
797 let dir = testdata().join("positive_tests");
798 let c = ValidateArgs {
799 globs: vec![dir.to_string_lossy().to_string()],
800 exclude: vec![],
801 cache_dir: None,
802 no_cache: true,
803 no_catalog: true,
804 format: None,
805 config_dir: None,
806 };
807 let result = run(&c, schema_mock()).await?;
808 assert!(!result.has_errors());
809 assert!(result.files_checked() > 0);
810 Ok(())
811 }
812
813 #[tokio::test]
814 async fn multiple_directory_args() -> anyhow::Result<()> {
815 let pos_dir = testdata().join("positive_tests");
816 let no_schema_dir = testdata().join("no_schema");
817 let c = ValidateArgs {
818 globs: vec![
819 pos_dir.to_string_lossy().to_string(),
820 no_schema_dir.to_string_lossy().to_string(),
821 ],
822 exclude: vec![],
823 cache_dir: None,
824 no_cache: true,
825 no_catalog: true,
826 format: None,
827 config_dir: None,
828 };
829 let result = run(&c, schema_mock()).await?;
830 assert!(!result.has_errors());
831 Ok(())
832 }
833
834 #[tokio::test]
835 async fn mix_directory_and_glob_args() -> anyhow::Result<()> {
836 let dir = testdata().join("positive_tests");
837 let glob_pattern = testdata()
838 .join("no_schema")
839 .join("*.json")
840 .to_string_lossy()
841 .to_string();
842 let c = ValidateArgs {
843 globs: vec![dir.to_string_lossy().to_string(), glob_pattern],
844 exclude: vec![],
845 cache_dir: None,
846 no_cache: true,
847 no_catalog: true,
848 format: None,
849 config_dir: None,
850 };
851 let result = run(&c, schema_mock()).await?;
852 assert!(!result.has_errors());
853 Ok(())
854 }
855
856 #[tokio::test]
857 async fn malformed_json_parse_error() -> anyhow::Result<()> {
858 let base = testdata().join("malformed");
859 let c = ValidateArgs {
860 globs: vec![base.join("*.json").to_string_lossy().to_string()],
861 exclude: vec![],
862 cache_dir: None,
863 no_cache: true,
864 no_catalog: true,
865 format: None,
866 config_dir: None,
867 };
868 let result = run(&c, mock(&[])).await?;
869 assert!(result.has_errors());
870 Ok(())
871 }
872
873 #[tokio::test]
874 async fn malformed_yaml_parse_error() -> anyhow::Result<()> {
875 let base = testdata().join("malformed");
876 let c = ValidateArgs {
877 globs: vec![base.join("*.yaml").to_string_lossy().to_string()],
878 exclude: vec![],
879 cache_dir: None,
880 no_cache: true,
881 no_catalog: true,
882 format: None,
883 config_dir: None,
884 };
885 let result = run(&c, mock(&[])).await?;
886 assert!(result.has_errors());
887 Ok(())
888 }
889
890 #[tokio::test]
893 async fn exclude_filters_files_in_dir() -> anyhow::Result<()> {
894 let base = testdata().join("negative_tests");
895 let c = ValidateArgs {
896 globs: scenario_globs(&["positive_tests", "negative_tests"]),
897 exclude: vec![
898 base.join("missing_name.json").to_string_lossy().to_string(),
899 base.join("missing_name.toml").to_string_lossy().to_string(),
900 base.join("missing_name.yaml").to_string_lossy().to_string(),
901 ],
902 cache_dir: None,
903 no_cache: true,
904 no_catalog: true,
905 format: None,
906 config_dir: None,
907 };
908 let result = run(&c, schema_mock()).await?;
909 assert!(!result.has_errors());
910 Ok(())
911 }
912
913 #[tokio::test]
916 async fn custom_cache_dir() -> anyhow::Result<()> {
917 let cache_tmp = tempfile::tempdir()?;
918 let c = ValidateArgs {
919 globs: scenario_globs(&["positive_tests"]),
920 exclude: vec![],
921 cache_dir: Some(cache_tmp.path().to_string_lossy().to_string()),
922 no_cache: false,
923 no_catalog: true,
924 format: None,
925 config_dir: None,
926 };
927 let result = run(&c, schema_mock()).await?;
928 assert!(!result.has_errors());
929
930 let entries: Vec<_> = fs::read_dir(cache_tmp.path())?.collect();
932 assert_eq!(entries.len(), 1);
933 Ok(())
934 }
935
936 #[tokio::test]
939 async fn json_valid_with_local_schema() -> anyhow::Result<()> {
940 let tmp = tempfile::tempdir()?;
941 let schema_path = tmp.path().join("schema.json");
942 fs::write(&schema_path, SCHEMA)?;
943
944 let f = tmp.path().join("valid.json");
945 fs::write(
946 &f,
947 format!(
948 r#"{{"$schema":"{}","name":"hello"}}"#,
949 schema_path.to_string_lossy()
950 ),
951 )?;
952
953 let pattern = tmp.path().join("*.json").to_string_lossy().to_string();
954 let c = ValidateArgs {
955 globs: vec![pattern],
956 exclude: vec![],
957 cache_dir: None,
958 no_cache: true,
959 no_catalog: true,
960 format: None,
961 config_dir: None,
962 };
963 let result = run(&c, mock(&[])).await?;
964 assert!(!result.has_errors());
965 Ok(())
966 }
967
968 #[tokio::test]
969 async fn yaml_valid_with_local_schema() -> anyhow::Result<()> {
970 let tmp = tempfile::tempdir()?;
971 let schema_path = tmp.path().join("schema.json");
972 fs::write(&schema_path, SCHEMA)?;
973
974 let f = tmp.path().join("valid.yaml");
975 fs::write(
976 &f,
977 format!(
978 "# yaml-language-server: $schema={}\nname: hello\n",
979 schema_path.to_string_lossy()
980 ),
981 )?;
982
983 let pattern = tmp.path().join("*.yaml").to_string_lossy().to_string();
984 let c = ValidateArgs {
985 globs: vec![pattern],
986 exclude: vec![],
987 cache_dir: None,
988 no_cache: true,
989 no_catalog: true,
990 format: None,
991 config_dir: None,
992 };
993 let result = run(&c, mock(&[])).await?;
994 assert!(!result.has_errors());
995 Ok(())
996 }
997
998 #[tokio::test]
999 async fn missing_local_schema_errors() -> anyhow::Result<()> {
1000 let tmp = tempfile::tempdir()?;
1001 let f = tmp.path().join("ref.json");
1002 fs::write(&f, r#"{"$schema":"/nonexistent/schema.json"}"#)?;
1003
1004 let pattern = tmp.path().join("*.json").to_string_lossy().to_string();
1005 let c = ValidateArgs {
1006 globs: vec![pattern],
1007 exclude: vec![],
1008 cache_dir: None,
1009 no_cache: true,
1010 no_catalog: true,
1011 format: None,
1012 config_dir: None,
1013 };
1014 let result = run(&c, mock(&[])).await?;
1015 assert!(result.has_errors());
1016 Ok(())
1017 }
1018
1019 #[tokio::test]
1022 async fn json5_valid_with_schema() -> anyhow::Result<()> {
1023 let tmp = tempfile::tempdir()?;
1024 let schema_path = tmp.path().join("schema.json");
1025 fs::write(&schema_path, SCHEMA)?;
1026
1027 let f = tmp.path().join("config.json5");
1028 fs::write(
1029 &f,
1030 format!(
1031 r#"{{
1032 // JSON5 comment
1033 "$schema": "{}",
1034 name: "hello",
1035}}"#,
1036 schema_path.to_string_lossy()
1037 ),
1038 )?;
1039
1040 let pattern = tmp.path().join("*.json5").to_string_lossy().to_string();
1041 let c = ValidateArgs {
1042 globs: vec![pattern],
1043 exclude: vec![],
1044 cache_dir: None,
1045 no_cache: true,
1046 no_catalog: true,
1047 format: None,
1048 config_dir: None,
1049 };
1050 let result = run(&c, mock(&[])).await?;
1051 assert!(!result.has_errors());
1052 Ok(())
1053 }
1054
1055 #[tokio::test]
1056 async fn jsonc_valid_with_schema() -> anyhow::Result<()> {
1057 let tmp = tempfile::tempdir()?;
1058 let schema_path = tmp.path().join("schema.json");
1059 fs::write(&schema_path, SCHEMA)?;
1060
1061 let f = tmp.path().join("config.jsonc");
1062 fs::write(
1063 &f,
1064 format!(
1065 r#"{{
1066 /* JSONC comment */
1067 "$schema": "{}",
1068 "name": "hello"
1069}}"#,
1070 schema_path.to_string_lossy()
1071 ),
1072 )?;
1073
1074 let pattern = tmp.path().join("*.jsonc").to_string_lossy().to_string();
1075 let c = ValidateArgs {
1076 globs: vec![pattern],
1077 exclude: vec![],
1078 cache_dir: None,
1079 no_cache: true,
1080 no_catalog: true,
1081 format: None,
1082 config_dir: None,
1083 };
1084 let result = run(&c, mock(&[])).await?;
1085 assert!(!result.has_errors());
1086 Ok(())
1087 }
1088
1089 const GH_WORKFLOW_SCHEMA: &str = r#"{
1092 "type": "object",
1093 "properties": {
1094 "name": { "type": "string" },
1095 "on": {},
1096 "jobs": { "type": "object" }
1097 },
1098 "required": ["on", "jobs"]
1099 }"#;
1100
1101 fn gh_catalog_json() -> String {
1102 r#"{"schemas":[{
1103 "name": "GitHub Workflow",
1104 "url": "https://www.schemastore.org/github-workflow.json",
1105 "fileMatch": [
1106 "**/.github/workflows/*.yml",
1107 "**/.github/workflows/*.yaml"
1108 ]
1109 }]}"#
1110 .to_string()
1111 }
1112
1113 #[tokio::test]
1114 async fn catalog_matches_github_workflow_valid() -> anyhow::Result<()> {
1115 let tmp = tempfile::tempdir()?;
1116 let wf_dir = tmp.path().join(".github/workflows");
1117 fs::create_dir_all(&wf_dir)?;
1118 fs::write(
1119 wf_dir.join("ci.yml"),
1120 "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: []\n",
1121 )?;
1122
1123 let pattern = wf_dir.join("*.yml").to_string_lossy().to_string();
1124 let client = mock(&[
1125 (
1126 "https://www.schemastore.org/api/json/catalog.json",
1127 &gh_catalog_json(),
1128 ),
1129 (
1130 "https://www.schemastore.org/github-workflow.json",
1131 GH_WORKFLOW_SCHEMA,
1132 ),
1133 ]);
1134 let c = ValidateArgs {
1135 globs: vec![pattern],
1136 exclude: vec![],
1137 cache_dir: None,
1138 no_cache: true,
1139 no_catalog: false,
1140 format: None,
1141 config_dir: None,
1142 };
1143 let result = run(&c, client).await?;
1144 assert!(!result.has_errors());
1145 Ok(())
1146 }
1147
1148 #[tokio::test]
1149 async fn catalog_matches_github_workflow_invalid() -> anyhow::Result<()> {
1150 let tmp = tempfile::tempdir()?;
1151 let wf_dir = tmp.path().join(".github/workflows");
1152 fs::create_dir_all(&wf_dir)?;
1153 fs::write(wf_dir.join("bad.yml"), "name: Broken\n")?;
1154
1155 let pattern = wf_dir.join("*.yml").to_string_lossy().to_string();
1156 let client = mock(&[
1157 (
1158 "https://www.schemastore.org/api/json/catalog.json",
1159 &gh_catalog_json(),
1160 ),
1161 (
1162 "https://www.schemastore.org/github-workflow.json",
1163 GH_WORKFLOW_SCHEMA,
1164 ),
1165 ]);
1166 let c = ValidateArgs {
1167 globs: vec![pattern],
1168 exclude: vec![],
1169 cache_dir: None,
1170 no_cache: true,
1171 no_catalog: false,
1172 format: None,
1173 config_dir: None,
1174 };
1175 let result = run(&c, client).await?;
1176 assert!(result.has_errors());
1177 Ok(())
1178 }
1179
1180 #[tokio::test]
1181 async fn auto_discover_finds_github_workflows() -> anyhow::Result<()> {
1182 let tmp = tempfile::tempdir()?;
1183 let wf_dir = tmp.path().join(".github/workflows");
1184 fs::create_dir_all(&wf_dir)?;
1185 fs::write(
1186 wf_dir.join("ci.yml"),
1187 "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: []\n",
1188 )?;
1189
1190 let client = mock(&[
1191 (
1192 "https://www.schemastore.org/api/json/catalog.json",
1193 &gh_catalog_json(),
1194 ),
1195 (
1196 "https://www.schemastore.org/github-workflow.json",
1197 GH_WORKFLOW_SCHEMA,
1198 ),
1199 ]);
1200 let c = ValidateArgs {
1201 globs: vec![],
1202 exclude: vec![],
1203 cache_dir: None,
1204 no_cache: true,
1205 no_catalog: false,
1206 format: None,
1207 config_dir: None,
1208 };
1209
1210 let orig_dir = std::env::current_dir()?;
1211 std::env::set_current_dir(tmp.path())?;
1212 let result = run(&c, client).await?;
1213 std::env::set_current_dir(orig_dir)?;
1214
1215 assert!(!result.has_errors());
1216 Ok(())
1217 }
1218
1219 #[tokio::test]
1222 async fn toml_valid_with_schema() -> anyhow::Result<()> {
1223 let tmp = tempfile::tempdir()?;
1224 let schema_path = tmp.path().join("schema.json");
1225 fs::write(&schema_path, SCHEMA)?;
1226
1227 let f = tmp.path().join("config.toml");
1228 fs::write(
1229 &f,
1230 format!(
1231 "# $schema: {}\nname = \"hello\"\n",
1232 schema_path.to_string_lossy()
1233 ),
1234 )?;
1235
1236 let pattern = tmp.path().join("*.toml").to_string_lossy().to_string();
1237 let c = ValidateArgs {
1238 globs: vec![pattern],
1239 exclude: vec![],
1240 cache_dir: None,
1241 no_cache: true,
1242 no_catalog: true,
1243 format: None,
1244 config_dir: None,
1245 };
1246 let result = run(&c, mock(&[])).await?;
1247 assert!(!result.has_errors());
1248 Ok(())
1249 }
1250
1251 #[tokio::test]
1254 async fn rewrite_rule_with_double_slash_resolves_schema() -> anyhow::Result<()> {
1255 let tmp = tempfile::tempdir()?;
1256
1257 let schemas_dir = tmp.path().join("schemas");
1258 fs::create_dir_all(&schemas_dir)?;
1259 fs::write(schemas_dir.join("test.json"), SCHEMA)?;
1260
1261 fs::write(
1262 tmp.path().join("lintel.toml"),
1263 r#"
1264[rewrite]
1265"http://localhost:9000/" = "//schemas/"
1266"#,
1267 )?;
1268
1269 let f = tmp.path().join("config.json");
1270 fs::write(
1271 &f,
1272 r#"{"$schema":"http://localhost:9000/test.json","name":"hello"}"#,
1273 )?;
1274
1275 let pattern = tmp.path().join("*.json").to_string_lossy().to_string();
1276 let c = ValidateArgs {
1277 globs: vec![pattern],
1278 exclude: vec![],
1279 cache_dir: None,
1280 no_cache: true,
1281 no_catalog: true,
1282 format: None,
1283 config_dir: Some(tmp.path().to_path_buf()),
1284 };
1285
1286 let result = run(&c, mock(&[])).await?;
1287 assert!(!result.has_errors());
1288 assert_eq!(result.files_checked(), 2); Ok(())
1290 }
1291
1292 #[tokio::test]
1293 async fn double_slash_schema_resolves_relative_to_config() -> anyhow::Result<()> {
1294 let tmp = tempfile::tempdir()?;
1295
1296 let schemas_dir = tmp.path().join("schemas");
1297 fs::create_dir_all(&schemas_dir)?;
1298 fs::write(schemas_dir.join("test.json"), SCHEMA)?;
1299
1300 fs::write(tmp.path().join("lintel.toml"), "")?;
1301
1302 let sub = tmp.path().join("deeply/nested");
1303 fs::create_dir_all(&sub)?;
1304 let f = sub.join("config.json");
1305 fs::write(&f, r#"{"$schema":"//schemas/test.json","name":"hello"}"#)?;
1306
1307 let pattern = sub.join("*.json").to_string_lossy().to_string();
1308 let c = ValidateArgs {
1309 globs: vec![pattern],
1310 exclude: vec![],
1311 cache_dir: None,
1312 no_cache: true,
1313 no_catalog: true,
1314 format: None,
1315 config_dir: Some(tmp.path().to_path_buf()),
1316 };
1317
1318 let result = run(&c, mock(&[])).await?;
1319 assert!(!result.has_errors());
1320 Ok(())
1321 }
1322
1323 const FORMAT_SCHEMA: &str = r#"{
1326 "type": "object",
1327 "properties": {
1328 "link": { "type": "string", "format": "uri-reference" }
1329 }
1330 }"#;
1331
1332 #[tokio::test]
1333 async fn format_errors_reported_without_override() -> anyhow::Result<()> {
1334 let tmp = tempfile::tempdir()?;
1335 let schema_path = tmp.path().join("schema.json");
1336 fs::write(&schema_path, FORMAT_SCHEMA)?;
1337
1338 let f = tmp.path().join("data.json");
1339 fs::write(
1340 &f,
1341 format!(
1342 r#"{{"$schema":"{}","link":"not a valid {{uri}}"}}"#,
1343 schema_path.to_string_lossy()
1344 ),
1345 )?;
1346
1347 let pattern = tmp.path().join("data.json").to_string_lossy().to_string();
1348 let c = ValidateArgs {
1349 globs: vec![pattern],
1350 exclude: vec![],
1351 cache_dir: None,
1352 no_cache: true,
1353 no_catalog: true,
1354 format: None,
1355 config_dir: Some(tmp.path().to_path_buf()),
1356 };
1357 let result = run(&c, mock(&[])).await?;
1358 assert!(
1359 result.has_errors(),
1360 "expected format error without override"
1361 );
1362 Ok(())
1363 }
1364
1365 #[tokio::test]
1366 async fn format_errors_suppressed_with_override() -> anyhow::Result<()> {
1367 let tmp = tempfile::tempdir()?;
1368 let schema_path = tmp.path().join("schema.json");
1369 fs::write(&schema_path, FORMAT_SCHEMA)?;
1370
1371 let f = tmp.path().join("data.json");
1372 fs::write(
1373 &f,
1374 format!(
1375 r#"{{"$schema":"{}","link":"not a valid {{uri}}"}}"#,
1376 schema_path.to_string_lossy()
1377 ),
1378 )?;
1379
1380 fs::write(
1382 tmp.path().join("lintel.toml"),
1383 r#"
1384[[override]]
1385files = ["**/data.json"]
1386validate_formats = false
1387"#,
1388 )?;
1389
1390 let pattern = tmp.path().join("data.json").to_string_lossy().to_string();
1391 let c = ValidateArgs {
1392 globs: vec![pattern],
1393 exclude: vec![],
1394 cache_dir: None,
1395 no_cache: true,
1396 no_catalog: true,
1397 format: None,
1398 config_dir: Some(tmp.path().to_path_buf()),
1399 };
1400 let result = run(&c, mock(&[])).await?;
1401 assert!(
1402 !result.has_errors(),
1403 "expected no errors with validate_formats = false override"
1404 );
1405 Ok(())
1406 }
1407
1408 #[tokio::test]
1411 async fn unrecognized_extension_skipped_without_catalog() -> anyhow::Result<()> {
1412 let tmp = tempfile::tempdir()?;
1413 fs::write(tmp.path().join("config.nix"), r#"{"name":"hello"}"#)?;
1414
1415 let pattern = tmp.path().join("config.nix").to_string_lossy().to_string();
1416 let c = ValidateArgs {
1417 globs: vec![pattern],
1418 exclude: vec![],
1419 cache_dir: None,
1420 no_cache: true,
1421 no_catalog: true,
1422 format: None,
1423 config_dir: Some(tmp.path().to_path_buf()),
1424 };
1425 let result = run(&c, mock(&[])).await?;
1426 assert!(!result.has_errors());
1427 assert_eq!(result.files_checked(), 0);
1428 Ok(())
1429 }
1430
1431 #[tokio::test]
1432 async fn unrecognized_extension_parsed_when_catalog_matches() -> anyhow::Result<()> {
1433 let tmp = tempfile::tempdir()?;
1434 fs::write(
1436 tmp.path().join("myapp.cfg"),
1437 r#"{"name":"hello","on":"push","jobs":{"build":{}}}"#,
1438 )?;
1439
1440 let catalog_json = r#"{"schemas":[{
1441 "name": "MyApp Config",
1442 "url": "https://example.com/myapp.schema.json",
1443 "fileMatch": ["*.cfg"]
1444 }]}"#;
1445 let schema =
1446 r#"{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}"#;
1447
1448 let pattern = tmp.path().join("myapp.cfg").to_string_lossy().to_string();
1449 let client = mock(&[
1450 (
1451 "https://www.schemastore.org/api/json/catalog.json",
1452 catalog_json,
1453 ),
1454 ("https://example.com/myapp.schema.json", schema),
1455 ]);
1456 let c = ValidateArgs {
1457 globs: vec![pattern],
1458 exclude: vec![],
1459 cache_dir: None,
1460 no_cache: true,
1461 no_catalog: false,
1462 format: None,
1463 config_dir: Some(tmp.path().to_path_buf()),
1464 };
1465 let result = run(&c, client).await?;
1466 assert!(!result.has_errors());
1467 assert_eq!(result.files_checked(), 1);
1468 Ok(())
1469 }
1470
1471 #[tokio::test]
1472 async fn unrecognized_extension_unparseable_skipped() -> anyhow::Result<()> {
1473 let tmp = tempfile::tempdir()?;
1474 fs::write(
1476 tmp.path().join("myapp.cfg"),
1477 "{ pkgs, ... }: { packages = [ pkgs.git ]; }",
1478 )?;
1479
1480 let catalog_json = r#"{"schemas":[{
1481 "name": "MyApp Config",
1482 "url": "https://example.com/myapp.schema.json",
1483 "fileMatch": ["*.cfg"]
1484 }]}"#;
1485
1486 let pattern = tmp.path().join("myapp.cfg").to_string_lossy().to_string();
1487 let client = mock(&[(
1488 "https://www.schemastore.org/api/json/catalog.json",
1489 catalog_json,
1490 )]);
1491 let c = ValidateArgs {
1492 globs: vec![pattern],
1493 exclude: vec![],
1494 cache_dir: None,
1495 no_cache: true,
1496 no_catalog: false,
1497 format: None,
1498 config_dir: Some(tmp.path().to_path_buf()),
1499 };
1500 let result = run(&c, client).await?;
1501 assert!(!result.has_errors());
1502 assert_eq!(result.files_checked(), 0);
1503 Ok(())
1504 }
1505
1506 #[tokio::test]
1507 async fn unrecognized_extension_invalid_against_schema() -> anyhow::Result<()> {
1508 let tmp = tempfile::tempdir()?;
1509 fs::write(tmp.path().join("myapp.cfg"), r#"{"wrong":"field"}"#)?;
1511
1512 let catalog_json = r#"{"schemas":[{
1513 "name": "MyApp Config",
1514 "url": "https://example.com/myapp.schema.json",
1515 "fileMatch": ["*.cfg"]
1516 }]}"#;
1517 let schema =
1518 r#"{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}"#;
1519
1520 let pattern = tmp.path().join("myapp.cfg").to_string_lossy().to_string();
1521 let client = mock(&[
1522 (
1523 "https://www.schemastore.org/api/json/catalog.json",
1524 catalog_json,
1525 ),
1526 ("https://example.com/myapp.schema.json", schema),
1527 ]);
1528 let c = ValidateArgs {
1529 globs: vec![pattern],
1530 exclude: vec![],
1531 cache_dir: None,
1532 no_cache: true,
1533 no_catalog: false,
1534 format: None,
1535 config_dir: Some(tmp.path().to_path_buf()),
1536 };
1537 let result = run(&c, client).await?;
1538 assert!(result.has_errors());
1539 assert_eq!(result.files_checked(), 1);
1540 Ok(())
1541 }
1542}