Skip to main content

lintel_config/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use schemars::{JsonSchema, schema_for};
7use serde::Deserialize;
8use serde_json::Value;
9
10const CONFIG_FILENAME: &str = "lintel.toml";
11
12fn example_file_pattern() -> Vec<String> {
13    vec!["schemas/vector.json".into()]
14}
15
16fn example_file_glob() -> Vec<String> {
17    vec!["schemas/**/*.json".into()]
18}
19
20fn example_file_config() -> Vec<String> {
21    vec!["config/*.yaml".into()]
22}
23
24fn example_schema_url() -> Vec<String> {
25    vec!["https://json.schemastore.org/vector.json".into()]
26}
27
28fn example_schema_glob() -> Vec<String> {
29    vec!["https://json.schemastore.org/*.json".into()]
30}
31
32fn example_exclude() -> Vec<String> {
33    vec![
34        "vendor/**".into(),
35        "testdata/**".into(),
36        "*.generated.json".into(),
37    ]
38}
39
40fn example_registry() -> Vec<String> {
41    vec!["https://example.com/custom-catalog.json".into()]
42}
43
44/// Conditional settings applied to files or schemas matching specific patterns.
45///
46/// Each `[[override]]` block targets files by path glob, schemas by URI glob,
47/// or both. When a file matches, the settings in that block override the
48/// top-level defaults. Earlier entries (from child configs) take priority over
49/// later entries (from parent configs).
50///
51/// In TOML, override blocks are written as `[[override]]` (double brackets) to
52/// create an array of tables.
53#[derive(Debug, Default, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55#[schemars(title = "Override Rule")]
56pub struct Override {
57    /// Glob patterns matched against instance file paths (relative to the
58    /// working directory).
59    ///
60    /// Use standard glob syntax: `*` matches any single path component, `**`
61    /// matches zero or more path components, and `?` matches a single
62    /// character.
63    #[schemars(
64        title = "File Patterns",
65        example = example_file_pattern(),
66        example = example_file_glob(),
67        example = example_file_config(),
68    )]
69    #[serde(default)]
70    pub files: Vec<String>,
71
72    /// Glob patterns matched against schema URIs.
73    ///
74    /// Each pattern is tested against both the original URI (before rewrite
75    /// rules) and the resolved URI (after rewrites and `//` prefix
76    /// resolution), so you can match on either form.
77    #[schemars(
78        title = "Schema Patterns",
79        example = example_schema_url(),
80        example = example_schema_glob(),
81    )]
82    #[serde(default)]
83    pub schemas: Vec<String>,
84
85    /// Enable or disable JSON Schema `format` keyword validation for matching
86    /// files.
87    ///
88    /// When `true`, string values are validated against built-in formats such
89    /// as `date-time`, `email`, `uri`, etc. When `false`, format annotations
90    /// are ignored during validation. When omitted, this override does not
91    /// affect the format validation setting and the next matching override (or
92    /// the default of `true`) applies.
93    #[schemars(title = "Validate Formats")]
94    #[serde(default)]
95    pub validate_formats: Option<bool>,
96}
97
98/// Configuration file for the Lintel JSON/YAML schema validator.
99///
100/// Lintel walks up the directory tree from the validated file looking for
101/// `lintel.toml` files and merges them together. Settings in child directories
102/// take priority over parent directories. Set `root = true` to stop the upward
103/// search.
104///
105/// Place `lintel.toml` at your project root (or any subdirectory that needs
106/// different settings).
107#[derive(Debug, Default, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109#[schemars(title = "lintel.toml")]
110pub struct Config {
111    /// Mark this configuration file as the project root.
112    ///
113    /// When `true`, Lintel stops walking up the directory tree and will not
114    /// merge any `lintel.toml` files from parent directories. Use this at your
115    /// repository root to prevent inheriting settings from enclosing
116    /// directories.
117    #[serde(default)]
118    pub root: bool,
119
120    /// Glob patterns for files to exclude from validation.
121    ///
122    /// Matched against file paths relative to the working directory. Standard
123    /// glob syntax is supported: `*` matches within a single directory, `**`
124    /// matches across directory boundaries.
125    #[schemars(title = "Exclude Patterns", example = example_exclude())]
126    #[serde(default)]
127    pub exclude: Vec<String>,
128
129    /// Custom schema-to-file mappings.
130    ///
131    /// Keys are glob patterns matched against file paths; values are schema
132    /// URLs (or `//`-prefixed local paths) to apply. These mappings take
133    /// priority over catalog auto-detection but are overridden by inline
134    /// `$schema` properties and YAML modeline comments.
135    ///
136    /// Example:
137    /// ```toml
138    /// [schemas]
139    /// "config/*.yaml" = "https://json.schemastore.org/github-workflow.json"
140    /// "myschema.json" = "//schemas/custom.json"
141    /// ```
142    #[schemars(title = "Schema Mappings")]
143    #[serde(default)]
144    pub schemas: HashMap<String, String>,
145
146    /// Disable the built-in Lintel catalog.
147    ///
148    /// When `true`, only `SchemaStore` and any additional registries listed in
149    /// `registries` are used for schema auto-detection. The default Lintel
150    /// catalog (which provides curated schema mappings) is skipped.
151    #[schemars(title = "No Default Catalog")]
152    #[serde(default, rename = "no-default-catalog")]
153    pub no_default_catalog: bool,
154
155    /// Additional schema catalog URLs to fetch alongside `SchemaStore`.
156    ///
157    /// Each entry should be a URL pointing to a JSON file in `SchemaStore`
158    /// catalog format (`{"schemas": [...]}`).
159    ///
160    /// Registries from child configs appear first, followed by parent
161    /// registries (duplicates are removed). This lets child directories add
162    /// project-specific catalogs while inheriting organization-wide ones.
163    #[schemars(title = "Additional Registries", example = example_registry())]
164    #[serde(default)]
165    pub registries: Vec<String>,
166
167    /// Schema URI rewrite rules.
168    ///
169    /// Keys are URI prefixes to match; values are replacement prefixes. The
170    /// longest matching prefix wins. Use `//` as a value prefix to reference
171    /// paths relative to the directory containing `lintel.toml`.
172    ///
173    /// Example:
174    /// ```toml
175    /// [rewrite]
176    /// "http://localhost:8000/" = "//schemas/"
177    /// ```
178    /// This rewrites `http://localhost:8000/foo.json` to
179    /// `//schemas/foo.json`, which then resolves to a local file relative to
180    /// the config directory.
181    #[schemars(title = "Rewrite Rules")]
182    #[serde(default)]
183    pub rewrite: HashMap<String, String>,
184
185    /// Per-file or per-schema override rules.
186    ///
187    /// In TOML, each override is written as a `[[override]]` block (double
188    /// brackets). Earlier entries take priority; child config overrides come
189    /// before parent config overrides after merging.
190    #[serde(default, rename = "override")]
191    pub overrides: Vec<Override>,
192}
193
194impl Config {
195    /// Merge a parent config into this one.  Child values take priority:
196    /// - `exclude`: parent entries are appended (child entries come first)
197    /// - `schemas`: parent entries are added only if the key is not already present
198    /// - `registries`: parent entries are appended (deduped)
199    /// - `rewrite`: parent entries are added only if the key is not already present
200    /// - `root` is not inherited
201    fn merge_parent(&mut self, parent: Config) {
202        self.exclude.extend(parent.exclude);
203        for (k, v) in parent.schemas {
204            self.schemas.entry(k).or_insert(v);
205        }
206        for url in parent.registries {
207            if !self.registries.contains(&url) {
208                self.registries.push(url);
209            }
210        }
211        for (k, v) in parent.rewrite {
212            self.rewrite.entry(k).or_insert(v);
213        }
214        // Child overrides come first (higher priority), then parent overrides.
215        self.overrides.extend(parent.overrides);
216    }
217
218    /// Find a custom schema mapping for the given file path.
219    ///
220    /// Matches against the `[schemas]` table using glob patterns.
221    /// Returns the schema URL if a match is found.
222    pub fn find_schema_mapping(&self, path: &str, file_name: &str) -> Option<&str> {
223        let path = path.strip_prefix("./").unwrap_or(path);
224        for (pattern, url) in &self.schemas {
225            if glob_match::glob_match(pattern, path) || glob_match::glob_match(pattern, file_name) {
226                return Some(url);
227            }
228        }
229        None
230    }
231
232    /// Check whether format validation should be enabled for a given file.
233    ///
234    /// `path` is the instance file path.  `schema_uris` is a slice of schema
235    /// URIs to match against (typically the original URI before rewrites and
236    /// the resolved URI after rewrites + `//` resolution).
237    ///
238    /// Returns `false` if any matching `[[override]]` sets
239    /// `validate_formats = false`.  Defaults to `true` when no override matches.
240    pub fn should_validate_formats(&self, path: &str, schema_uris: &[&str]) -> bool {
241        let path = path.strip_prefix("./").unwrap_or(path);
242        for ov in &self.overrides {
243            let file_match = !ov.files.is_empty()
244                && ov.files.iter().any(|pat| glob_match::glob_match(pat, path));
245            let schema_match = !ov.schemas.is_empty()
246                && schema_uris.iter().any(|uri| {
247                    ov.schemas
248                        .iter()
249                        .any(|pat| glob_match::glob_match(pat, uri))
250                });
251            if (file_match || schema_match)
252                && let Some(val) = ov.validate_formats
253            {
254                return val;
255            }
256        }
257        true
258    }
259}
260
261/// Apply rewrite rules to a schema URI. If the URI starts with any key in
262/// `rewrites`, that prefix is replaced with the corresponding value.
263/// The longest matching prefix wins.
264pub fn apply_rewrites<S: ::core::hash::BuildHasher>(
265    uri: &str,
266    rewrites: &HashMap<String, String, S>,
267) -> String {
268    let mut best_match: Option<(&str, &str)> = None;
269    for (from, to) in rewrites {
270        if uri.starts_with(from.as_str())
271            && best_match.is_none_or(|(prev_from, _)| from.len() > prev_from.len())
272        {
273            best_match = Some((from.as_str(), to.as_str()));
274        }
275    }
276    match best_match {
277        Some((from, to)) => format!("{to}{}", &uri[from.len()..]),
278        None => uri.to_string(),
279    }
280}
281
282/// Resolve a `//`-prefixed path relative to the given root directory (the
283/// directory containing `lintel.toml`). Non-`//` paths are returned unchanged.
284pub fn resolve_double_slash(uri: &str, config_dir: &Path) -> String {
285    if let Some(rest) = uri.strip_prefix("//") {
286        config_dir.join(rest).to_string_lossy().to_string()
287    } else {
288        uri.to_string()
289    }
290}
291
292/// Generate the JSON Schema for `lintel.toml` as a `serde_json::Value`.
293///
294/// # Panics
295///
296/// Panics if the schema cannot be serialized to JSON (should never happen).
297pub fn schema() -> Value {
298    serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
299}
300
301/// Find the nearest `lintel.toml` starting from `start_dir`, walking upward.
302/// Returns the path to `lintel.toml`, or `None` if not found.
303pub fn find_config_path(start_dir: &Path) -> Option<PathBuf> {
304    let mut dir = start_dir.to_path_buf();
305    loop {
306        let candidate = dir.join(CONFIG_FILENAME);
307        if candidate.is_file() {
308            return Some(candidate);
309        }
310        if !dir.pop() {
311            break;
312        }
313    }
314    None
315}
316
317/// Search for `lintel.toml` files starting from `start_dir`, walking up.
318/// Merges all configs found until one with `root = true` is hit (inclusive).
319/// Returns the merged config, or `None` if no config file was found.
320///
321/// # Errors
322///
323/// Returns an error if a config file exists but cannot be read or parsed.
324pub fn find_and_load(start_dir: &Path) -> Result<Option<Config>, anyhow::Error> {
325    let mut configs: Vec<Config> = Vec::new();
326    let mut dir = start_dir.to_path_buf();
327
328    loop {
329        let candidate = dir.join(CONFIG_FILENAME);
330        if candidate.is_file() {
331            let content = std::fs::read_to_string(&candidate)?;
332            let cfg: Config = toml::from_str(&content)
333                .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", candidate.display()))?;
334            let is_root = cfg.root;
335            configs.push(cfg);
336            if is_root {
337                break;
338            }
339        }
340        if !dir.pop() {
341            break;
342        }
343    }
344
345    if configs.is_empty() {
346        return Ok(None);
347    }
348
349    // configs[0] is the closest (child), last is the farthest (root-most parent)
350    let mut merged = configs.remove(0);
351    for parent in configs {
352        merged.merge_parent(parent);
353    }
354    Ok(Some(merged))
355}
356
357/// Load config from the current working directory (walking upward).
358///
359/// # Errors
360///
361/// Returns an error if a config file exists but cannot be read or parsed.
362pub fn load() -> Result<Config, anyhow::Error> {
363    let cwd = std::env::current_dir()?;
364    Ok(find_and_load(&cwd)?.unwrap_or_default())
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use std::fs;
371
372    #[test]
373    fn loads_config_from_directory() -> anyhow::Result<()> {
374        let tmp = tempfile::tempdir()?;
375        fs::write(
376            tmp.path().join("lintel.toml"),
377            r#"exclude = ["testdata/**"]"#,
378        )?;
379
380        let config = find_and_load(tmp.path())?.expect("config should exist");
381        assert_eq!(config.exclude, vec!["testdata/**"]);
382        Ok(())
383    }
384
385    #[test]
386    fn walks_up_to_find_config() -> anyhow::Result<()> {
387        let tmp = tempfile::tempdir()?;
388        let sub = tmp.path().join("a/b/c");
389        fs::create_dir_all(&sub)?;
390        fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["vendor/**"]"#)?;
391
392        let config = find_and_load(&sub)?.expect("config should exist");
393        assert_eq!(config.exclude, vec!["vendor/**"]);
394        Ok(())
395    }
396
397    #[test]
398    fn returns_none_when_no_config() -> anyhow::Result<()> {
399        let tmp = tempfile::tempdir()?;
400        let config = find_and_load(tmp.path())?;
401        assert!(config.is_none());
402        Ok(())
403    }
404
405    #[test]
406    fn empty_config_is_valid() -> anyhow::Result<()> {
407        let tmp = tempfile::tempdir()?;
408        fs::write(tmp.path().join("lintel.toml"), "")?;
409
410        let config = find_and_load(tmp.path())?.expect("config should exist");
411        assert!(config.exclude.is_empty());
412        assert!(config.rewrite.is_empty());
413        Ok(())
414    }
415
416    #[test]
417    fn rejects_unknown_fields() -> anyhow::Result<()> {
418        let tmp = tempfile::tempdir()?;
419        fs::write(tmp.path().join("lintel.toml"), "bogus = true")?;
420
421        let result = find_and_load(tmp.path());
422        assert!(result.is_err());
423        Ok(())
424    }
425
426    #[test]
427    fn loads_rewrite_rules() -> anyhow::Result<()> {
428        let tmp = tempfile::tempdir()?;
429        fs::write(
430            tmp.path().join("lintel.toml"),
431            r#"
432[rewrite]
433"http://localhost:8000/" = "//schemastore/src/"
434"#,
435        )?;
436
437        let config = find_and_load(tmp.path())?.expect("config should exist");
438        assert_eq!(
439            config.rewrite.get("http://localhost:8000/"),
440            Some(&"//schemastore/src/".to_string())
441        );
442        Ok(())
443    }
444
445    // --- root = true ---
446
447    #[test]
448    fn root_true_stops_walk() -> anyhow::Result<()> {
449        let tmp = tempfile::tempdir()?;
450        let sub = tmp.path().join("child");
451        fs::create_dir_all(&sub)?;
452
453        // Parent config
454        fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#)?;
455
456        // Child config with root = true
457        fs::write(
458            sub.join("lintel.toml"),
459            "root = true\nexclude = [\"child/**\"]",
460        )?;
461
462        let config = find_and_load(&sub)?.expect("config should exist");
463        assert_eq!(config.exclude, vec!["child/**"]);
464        // Parent's "parent/**" should NOT be included
465        Ok(())
466    }
467
468    #[test]
469    fn merges_parent_without_root() -> anyhow::Result<()> {
470        let tmp = tempfile::tempdir()?;
471        let sub = tmp.path().join("child");
472        fs::create_dir_all(&sub)?;
473
474        // Parent config
475        fs::write(
476            tmp.path().join("lintel.toml"),
477            r#"
478exclude = ["parent/**"]
479
480[rewrite]
481"http://parent/" = "//parent/"
482"#,
483        )?;
484
485        // Child config (no root = true)
486        fs::write(
487            sub.join("lintel.toml"),
488            r#"
489exclude = ["child/**"]
490
491[rewrite]
492"http://child/" = "//child/"
493"#,
494        )?;
495
496        let config = find_and_load(&sub)?.expect("config should exist");
497        // Child excludes come first, then parent
498        assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
499        // Both rewrite rules present
500        assert_eq!(
501            config.rewrite.get("http://child/"),
502            Some(&"//child/".to_string())
503        );
504        assert_eq!(
505            config.rewrite.get("http://parent/"),
506            Some(&"//parent/".to_string())
507        );
508        Ok(())
509    }
510
511    #[test]
512    fn child_rewrite_wins_on_conflict() -> anyhow::Result<()> {
513        let tmp = tempfile::tempdir()?;
514        let sub = tmp.path().join("child");
515        fs::create_dir_all(&sub)?;
516
517        fs::write(
518            tmp.path().join("lintel.toml"),
519            r#"
520[rewrite]
521"http://example/" = "//parent-value/"
522"#,
523        )?;
524
525        fs::write(
526            sub.join("lintel.toml"),
527            r#"
528[rewrite]
529"http://example/" = "//child-value/"
530"#,
531        )?;
532
533        let config = find_and_load(&sub)?.expect("config should exist");
534        assert_eq!(
535            config.rewrite.get("http://example/"),
536            Some(&"//child-value/".to_string())
537        );
538        Ok(())
539    }
540
541    // --- apply_rewrites ---
542
543    #[test]
544    fn rewrite_matching_prefix() {
545        let mut rewrites = HashMap::new();
546        rewrites.insert(
547            "http://localhost:8000/".to_string(),
548            "//schemastore/src/".to_string(),
549        );
550        let result = apply_rewrites("http://localhost:8000/schemas/foo.json", &rewrites);
551        assert_eq!(result, "//schemastore/src/schemas/foo.json");
552    }
553
554    #[test]
555    fn rewrite_no_match() {
556        let mut rewrites = HashMap::new();
557        rewrites.insert(
558            "http://localhost:8000/".to_string(),
559            "//schemastore/src/".to_string(),
560        );
561        let result = apply_rewrites("https://example.com/schema.json", &rewrites);
562        assert_eq!(result, "https://example.com/schema.json");
563    }
564
565    #[test]
566    fn rewrite_longest_prefix_wins() {
567        let mut rewrites = HashMap::new();
568        rewrites.insert("http://localhost/".to_string(), "//short/".to_string());
569        rewrites.insert(
570            "http://localhost/api/v2/".to_string(),
571            "//long/".to_string(),
572        );
573        let result = apply_rewrites("http://localhost/api/v2/schema.json", &rewrites);
574        assert_eq!(result, "//long/schema.json");
575    }
576
577    // --- resolve_double_slash ---
578
579    #[test]
580    fn resolve_double_slash_prefix() {
581        let config_dir = Path::new("/home/user/project");
582        let result = resolve_double_slash("//schemas/foo.json", config_dir);
583        assert_eq!(result, "/home/user/project/schemas/foo.json");
584    }
585
586    #[test]
587    fn resolve_double_slash_no_prefix() {
588        let config_dir = Path::new("/home/user/project");
589        let result = resolve_double_slash("https://example.com/s.json", config_dir);
590        assert_eq!(result, "https://example.com/s.json");
591    }
592
593    #[test]
594    fn resolve_double_slash_relative_path_unchanged() {
595        let config_dir = Path::new("/home/user/project");
596        let result = resolve_double_slash("./schemas/foo.json", config_dir);
597        assert_eq!(result, "./schemas/foo.json");
598    }
599
600    // --- Override parsing ---
601
602    #[test]
603    fn parses_override_blocks() -> anyhow::Result<()> {
604        let tmp = tempfile::tempdir()?;
605        fs::write(
606            tmp.path().join("lintel.toml"),
607            r#"
608[[override]]
609files = ["schemas/vector.json"]
610validate_formats = false
611
612[[override]]
613files = ["schemas/other.json"]
614validate_formats = true
615"#,
616        )?;
617
618        let config = find_and_load(tmp.path())?.expect("config should exist");
619        assert_eq!(config.overrides.len(), 2);
620        assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
621        assert_eq!(config.overrides[0].validate_formats, Some(false));
622        assert_eq!(config.overrides[1].validate_formats, Some(true));
623        Ok(())
624    }
625
626    #[test]
627    fn override_validate_formats_defaults_to_none() -> anyhow::Result<()> {
628        let tmp = tempfile::tempdir()?;
629        fs::write(
630            tmp.path().join("lintel.toml"),
631            r#"
632[[override]]
633files = ["schemas/vector.json"]
634"#,
635        )?;
636
637        let config = find_and_load(tmp.path())?.expect("config should exist");
638        assert_eq!(config.overrides.len(), 1);
639        assert_eq!(config.overrides[0].validate_formats, None);
640        Ok(())
641    }
642
643    // --- should_validate_formats ---
644
645    #[test]
646    fn should_validate_formats_default_true() {
647        let config = Config::default();
648        assert!(config.should_validate_formats("anything.json", &[]));
649    }
650
651    #[test]
652    fn should_validate_formats_matching_file_override() {
653        let config = Config {
654            overrides: vec![Override {
655                files: vec!["schemas/vector.json".to_string()],
656                validate_formats: Some(false),
657                ..Default::default()
658            }],
659            ..Default::default()
660        };
661        assert!(!config.should_validate_formats("schemas/vector.json", &[]));
662        assert!(config.should_validate_formats("schemas/other.json", &[]));
663    }
664
665    #[test]
666    fn should_validate_formats_matching_schema_override() {
667        let config = Config {
668            overrides: vec![Override {
669                schemas: vec!["https://json.schemastore.org/vector.json".to_string()],
670                validate_formats: Some(false),
671                ..Default::default()
672            }],
673            ..Default::default()
674        };
675        // Matches via schema URI
676        assert!(!config.should_validate_formats(
677            "some/file.toml",
678            &["https://json.schemastore.org/vector.json"]
679        ));
680        // No match
681        assert!(config.should_validate_formats(
682            "some/file.toml",
683            &["https://json.schemastore.org/other.json"]
684        ));
685    }
686
687    #[test]
688    fn should_validate_formats_schema_glob() {
689        let config = Config {
690            overrides: vec![Override {
691                schemas: vec!["https://json.schemastore.org/*.json".to_string()],
692                validate_formats: Some(false),
693                ..Default::default()
694            }],
695            ..Default::default()
696        };
697        assert!(
698            !config
699                .should_validate_formats("any.toml", &["https://json.schemastore.org/vector.json"])
700        );
701    }
702
703    #[test]
704    fn should_validate_formats_matches_resolved_uri() {
705        let config = Config {
706            overrides: vec![Override {
707                schemas: vec!["/local/schemas/vector.json".to_string()],
708                validate_formats: Some(false),
709                ..Default::default()
710            }],
711            ..Default::default()
712        };
713        // original doesn't match, but resolved does
714        assert!(!config.should_validate_formats(
715            "any.toml",
716            &[
717                "https://json.schemastore.org/vector.json",
718                "/local/schemas/vector.json"
719            ]
720        ));
721    }
722
723    #[test]
724    fn should_validate_formats_glob_pattern() {
725        let config = Config {
726            overrides: vec![Override {
727                files: vec!["schemas/**/*.json".to_string()],
728                validate_formats: Some(false),
729                ..Default::default()
730            }],
731            ..Default::default()
732        };
733        assert!(!config.should_validate_formats("schemas/deep/nested.json", &[]));
734        assert!(config.should_validate_formats("other/file.json", &[]));
735    }
736
737    #[test]
738    fn should_validate_formats_strips_dot_slash() {
739        let config = Config {
740            overrides: vec![Override {
741                files: vec!["schemas/vector.json".to_string()],
742                validate_formats: Some(false),
743                ..Default::default()
744            }],
745            ..Default::default()
746        };
747        assert!(!config.should_validate_formats("./schemas/vector.json", &[]));
748    }
749
750    #[test]
751    fn should_validate_formats_first_match_wins() {
752        let config = Config {
753            overrides: vec![
754                Override {
755                    files: vec!["schemas/vector.json".to_string()],
756                    validate_formats: Some(false),
757                    ..Default::default()
758                },
759                Override {
760                    files: vec!["schemas/**".to_string()],
761                    validate_formats: Some(true),
762                    ..Default::default()
763                },
764            ],
765            ..Default::default()
766        };
767        // First override matches, returns false
768        assert!(!config.should_validate_formats("schemas/vector.json", &[]));
769        // Second override matches for other files, returns true
770        assert!(config.should_validate_formats("schemas/other.json", &[]));
771    }
772
773    #[test]
774    fn should_validate_formats_skips_none_override() {
775        let config = Config {
776            overrides: vec![
777                Override {
778                    files: vec!["schemas/vector.json".to_string()],
779                    validate_formats: None, // no opinion
780                    ..Default::default()
781                },
782                Override {
783                    files: vec!["schemas/**".to_string()],
784                    validate_formats: Some(false),
785                    ..Default::default()
786                },
787            ],
788            ..Default::default()
789        };
790        // First override matches but has None, so falls through to second
791        assert!(!config.should_validate_formats("schemas/vector.json", &[]));
792    }
793
794    // --- Override merge behavior ---
795
796    #[test]
797    fn merge_overrides_child_first() -> anyhow::Result<()> {
798        let tmp = tempfile::tempdir()?;
799        let sub = tmp.path().join("child");
800        fs::create_dir_all(&sub)?;
801
802        fs::write(
803            tmp.path().join("lintel.toml"),
804            r#"
805[[override]]
806files = ["schemas/**"]
807validate_formats = true
808"#,
809        )?;
810
811        fs::write(
812            sub.join("lintel.toml"),
813            r#"
814[[override]]
815files = ["schemas/vector.json"]
816validate_formats = false
817"#,
818        )?;
819
820        let config = find_and_load(&sub)?.expect("config should exist");
821        // Child override comes first, then parent
822        assert_eq!(config.overrides.len(), 2);
823        assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
824        assert_eq!(config.overrides[0].validate_formats, Some(false));
825        assert_eq!(config.overrides[1].files, vec!["schemas/**"]);
826        assert_eq!(config.overrides[1].validate_formats, Some(true));
827        Ok(())
828    }
829}