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