Skip to main content

lintel_check/
validate.rs

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    /// Glob patterns to find files (empty = auto-discover)
21    pub globs: Vec<String>,
22
23    /// Exclude files matching these globs (repeatable)
24    pub exclude: Vec<String>,
25
26    /// Cache directory for remote schemas
27    pub cache_dir: Option<String>,
28
29    /// Disable schema caching
30    pub no_cache: bool,
31
32    /// Disable `SchemaStore` catalog matching
33    pub no_catalog: bool,
34
35    /// Force file format for all inputs
36    pub format: Option<parsers::FileFormat>,
37
38    /// Directory to search for `lintel.toml` (defaults to cwd)
39    pub config_dir: Option<PathBuf>,
40}
41
42/// A single lint error produced during validation.
43pub enum LintError {
44    Parse(ParseDiagnostic),
45    Validation(ValidationDiagnostic),
46    File(FileDiagnostic),
47}
48
49impl LintError {
50    /// File path associated with this error.
51    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    /// Human-readable error message.
60    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    /// Byte offset in the source file (for sorting).
69    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    /// Convert into a boxed miette Diagnostic for rich rendering.
78    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
87/// A file that was checked and the schema it resolved to.
88pub struct CheckedFile {
89    pub path: String,
90    pub schema: String,
91    /// `None` for local schemas and builtins; `Some` for remote schemas.
92    pub cache_status: Option<CacheStatus>,
93}
94
95/// Result of a validation run.
96pub 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
111// ---------------------------------------------------------------------------
112// Internal types
113// ---------------------------------------------------------------------------
114
115/// A file that has been parsed and matched to a schema URI.
116struct ParsedFile {
117    path: String,
118    content: String,
119    instance: Value,
120    /// Original schema URI before rewrites (for override matching).
121    original_schema_uri: String,
122}
123
124// ---------------------------------------------------------------------------
125// Config loading
126// ---------------------------------------------------------------------------
127
128/// Locate `lintel.toml`, load the full config, and return the config directory.
129/// Returns `(config, config_dir, config_path)`.  When no config is found or
130/// cwd is unavailable the config is default and `config_path` is `None`.
131fn 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
152// ---------------------------------------------------------------------------
153// File collection
154// ---------------------------------------------------------------------------
155
156/// Collect input files from globs/directories, applying exclude filters.
157fn 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
189// ---------------------------------------------------------------------------
190// lintel.toml self-validation
191// ---------------------------------------------------------------------------
192
193/// Validate `lintel.toml` against its built-in schema.
194fn 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
232// ---------------------------------------------------------------------------
233// Phase 1: Parse files and resolve schema URIs
234// ---------------------------------------------------------------------------
235
236/// Try parsing content with each known format, returning the first success.
237///
238/// JSONC is tried first (superset of JSON, handles comments), then YAML and
239/// TOML which cover the most common config formats, followed by the rest.
240fn 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
253/// Parse each file, extract its schema URI, apply rewrites, and group by
254/// resolved schema URI.
255fn 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        // For unrecognized extensions, only proceed if a catalog or config mapping matches.
286        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        // Parse the file content.
297        let (parser, instance): (Box<dyn Parser>, Value) = if let Some(fmt) = detected_format {
298            // Known format — parse with detected/overridden format.
299            let parser = parsers::parser_for(fmt);
300            match parser.parse(&content, &path_str) {
301                Ok(val) => (parser, val),
302                Err(parse_err) => {
303                    // JSONC fallback for .json files that match a catalog entry.
304                    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            // Unrecognized extension with catalog/config match — try all parsers.
324            match try_parse_all(&content, &path_str) {
325                Some((fmt, val)) => (parsers::parser_for(fmt), val),
326                None => continue,
327            }
328        };
329
330        // Skip markdown files with no frontmatter
331        if instance.is_null() {
332            continue;
333        }
334
335        // Schema resolution priority:
336        // 1. Inline $schema / YAML modeline (always wins)
337        // 2. Custom schema mappings from lintel.toml [schemas]
338        // 3. Catalog matching (SchemaStore + additional registries)
339        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        // Keep original URI for override matching (before rewrites)
357        let original_schema_uri = schema_uri.clone();
358
359        // Apply rewrite rules, then resolve // paths relative to lintel.toml
360        let schema_uri = config::apply_rewrites(&schema_uri, &config.rewrite);
361        let schema_uri = config::resolve_double_slash(&schema_uri, config_dir);
362
363        // Resolve relative local paths against the file's parent directory.
364        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
387// ---------------------------------------------------------------------------
388// Phase 2: Schema fetching, compilation, and instance validation
389// ---------------------------------------------------------------------------
390
391/// Fetch a schema by URI, returning its parsed JSON and cache status.
392fn 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
426/// Report the same error for every file in a schema group.
427fn 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
451/// Mark every file in a group as checked (no errors).
452fn 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
470/// Validate all files in a group against an already-compiled validator.
471fn 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
503// ---------------------------------------------------------------------------
504// Public API
505// ---------------------------------------------------------------------------
506
507/// # Errors
508///
509/// Returns an error if file collection or schema validation encounters an I/O error.
510pub async fn run<C: HttpClient>(args: &ValidateArgs, client: C) -> Result<ValidateResult> {
511    run_with(args, client, |_| {}).await
512}
513
514/// Like [`run`], but calls `on_check` each time a file is checked, allowing
515/// callers to stream progress (e.g. verbose output) as files are processed.
516///
517/// # Errors
518///
519/// Returns an error if file collection or schema validation encounters an I/O error.
520#[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        // Default Lintel catalog (github:lintel-rs/catalog)
544        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        // SchemaStore catalog
554        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        // Additional registries from lintel.toml
561        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    // Validate lintel.toml against its own schema
575    if let Some(config_path) = config_path {
576        validate_config(&config_path, &mut errors, &mut checked, &mut on_check)?;
577    }
578
579    // Phase 1: Parse files and resolve schema URIs
580    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    // Phase 2: Compile each schema once and validate all matching files
590    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        // If ANY file in the group matches a `validate_formats = false` override,
603        // disable format validation for the whole group (they share one compiled validator).
604        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                // When format validation is disabled and the compilation error
618                // is a uri-reference issue (e.g. Rust-style $ref paths in
619                // vector.json), skip validation silently.
620                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    // Sort errors for deterministic output (by path, then by span offset)
655    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    /// Build glob patterns that scan one or more testdata directories for all supported file types.
698    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    // --- Directory scanning tests ---
734
735    #[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    // --- Directory as positional arg ---
794
795    #[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    // --- Exclude filter ---
891
892    #[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    // --- Cache options ---
914
915    #[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        // Schema was fetched once and cached
931        let entries: Vec<_> = fs::read_dir(cache_tmp.path())?.collect();
932        assert_eq!(entries.len(), 1);
933        Ok(())
934    }
935
936    // --- Local schema ---
937
938    #[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    // --- JSON5 / JSONC tests ---
1020
1021    #[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    // --- Catalog-based schema matching ---
1090
1091    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    // --- TOML tests ---
1220
1221    #[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    // --- Rewrite rules + // resolution ---
1252
1253    #[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); // lintel.toml + config.json
1289        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    // --- Format validation override ---
1324
1325    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        // Use **/data.json to match the absolute path from the tempdir.
1381        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    // --- Unrecognized extension handling ---
1409
1410    #[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        // File has .cfg extension (unrecognized) but content is valid JSON
1435        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        // File matches catalog but content isn't parseable by any format
1475        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        // File has .cfg extension, content is valid JSON but fails schema validation
1510        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}