Skip to main content

lintel_check/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use schemars::{schema_for, JsonSchema};
5use serde::Deserialize;
6use serde_json::Value;
7
8const CONFIG_FILENAME: &str = "lintel.toml";
9
10#[derive(Debug, Default, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct Override {
13    /// Glob patterns for instance file paths this override applies to.
14    #[serde(default)]
15    pub files: Vec<String>,
16
17    /// Glob patterns for schema URIs this override applies to.
18    /// Matched against the original URI (before rewrites) and the resolved
19    /// URI (after rewrites and `//` resolution).
20    #[serde(default)]
21    pub schemas: Vec<String>,
22
23    /// Whether to enable JSON Schema format validation for matched files.
24    /// When `None`, the override does not affect format validation.
25    #[serde(default)]
26    pub validate_formats: Option<bool>,
27}
28
29#[derive(Debug, Default, Deserialize, JsonSchema)]
30#[serde(deny_unknown_fields)]
31pub struct Config {
32    /// If true, stop walking up the directory tree. No parent `lintel.toml`
33    /// files will be merged.
34    #[serde(default)]
35    pub root: bool,
36
37    /// Glob patterns for files to exclude from validation.
38    #[serde(default)]
39    pub exclude: Vec<String>,
40
41    /// Custom schema mappings. Keys are glob patterns matching file paths;
42    /// values are schema URLs to use for those files.
43    ///
44    /// These take priority over catalog matching but are overridden by
45    /// inline `$schema` properties and YAML modeline comments.
46    #[serde(default)]
47    pub schemas: HashMap<String, String>,
48
49    /// Additional schema catalog URLs to fetch alongside SchemaStore.
50    /// Each URL should point to a JSON file with the same format as
51    /// the SchemaStore catalog (`{"schemas": [...]}`).
52    #[serde(default)]
53    pub registries: Vec<String>,
54
55    /// Schema URI rewrite rules. Keys are prefixes to match; values are
56    /// replacements. For example, `"http://localhost:8000/" = "//schemas/"`
57    /// rewrites any schema URI starting with `http://localhost:8000/` so that
58    /// prefix becomes `//schemas/`.
59    #[serde(default)]
60    pub rewrite: HashMap<String, String>,
61
62    /// Per-file overrides. Earlier entries (child configs) take priority.
63    #[serde(default, rename = "override")]
64    pub overrides: Vec<Override>,
65}
66
67impl Config {
68    /// Merge a parent config into this one.  Child values take priority:
69    /// - `exclude`: parent entries are appended (child entries come first)
70    /// - `schemas`: parent entries are added only if the key is not already present
71    /// - `registries`: parent entries are appended (deduped)
72    /// - `rewrite`: parent entries are added only if the key is not already present
73    /// - `root` is not inherited
74    fn merge_parent(&mut self, parent: Config) {
75        self.exclude.extend(parent.exclude);
76        for (k, v) in parent.schemas {
77            self.schemas.entry(k).or_insert(v);
78        }
79        for url in parent.registries {
80            if !self.registries.contains(&url) {
81                self.registries.push(url);
82            }
83        }
84        for (k, v) in parent.rewrite {
85            self.rewrite.entry(k).or_insert(v);
86        }
87        // Child overrides come first (higher priority), then parent overrides.
88        self.overrides.extend(parent.overrides);
89    }
90
91    /// Find a custom schema mapping for the given file path.
92    ///
93    /// Matches against the `[schemas]` table using glob patterns.
94    /// Returns the schema URL if a match is found.
95    pub fn find_schema_mapping(&self, path: &str, file_name: &str) -> Option<&str> {
96        let path = path.strip_prefix("./").unwrap_or(path);
97        for (pattern, url) in &self.schemas {
98            if glob_match::glob_match(pattern, path) || glob_match::glob_match(pattern, file_name) {
99                return Some(url);
100            }
101        }
102        None
103    }
104
105    /// Check whether format validation should be enabled for a given file.
106    ///
107    /// `path` is the instance file path.  `schema_uris` is a slice of schema
108    /// URIs to match against (typically the original URI before rewrites and
109    /// the resolved URI after rewrites + `//` resolution).
110    ///
111    /// Returns `false` if any matching `[[override]]` sets
112    /// `validate_formats = false`.  Defaults to `true` when no override matches.
113    pub fn should_validate_formats(&self, path: &str, schema_uris: &[&str]) -> bool {
114        let path = path.strip_prefix("./").unwrap_or(path);
115        for ov in &self.overrides {
116            let file_match = !ov.files.is_empty()
117                && ov.files.iter().any(|pat| glob_match::glob_match(pat, path));
118            let schema_match = !ov.schemas.is_empty()
119                && schema_uris.iter().any(|uri| {
120                    ov.schemas
121                        .iter()
122                        .any(|pat| glob_match::glob_match(pat, uri))
123                });
124            if file_match || schema_match {
125                if let Some(val) = ov.validate_formats {
126                    return val;
127                }
128            }
129        }
130        true
131    }
132}
133
134/// Apply rewrite rules to a schema URI. If the URI starts with any key in
135/// `rewrites`, that prefix is replaced with the corresponding value.
136/// The longest matching prefix wins.
137pub fn apply_rewrites(uri: &str, rewrites: &HashMap<String, String>) -> String {
138    let mut best_match: Option<(&str, &str)> = None;
139    for (from, to) in rewrites {
140        if uri.starts_with(from.as_str())
141            && best_match.is_none_or(|(prev_from, _)| from.len() > prev_from.len())
142        {
143            best_match = Some((from.as_str(), to.as_str()));
144        }
145    }
146    match best_match {
147        Some((from, to)) => format!("{to}{}", &uri[from.len()..]),
148        None => uri.to_string(),
149    }
150}
151
152/// Resolve a `//`-prefixed path relative to the given root directory (the
153/// directory containing `lintel.toml`). Non-`//` paths are returned unchanged.
154pub fn resolve_double_slash(uri: &str, config_dir: &Path) -> String {
155    if let Some(rest) = uri.strip_prefix("//") {
156        config_dir.join(rest).to_string_lossy().to_string()
157    } else {
158        uri.to_string()
159    }
160}
161
162/// Generate the JSON Schema for `lintel.toml` as a `serde_json::Value`.
163pub fn schema() -> Value {
164    serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
165}
166
167/// Find the nearest `lintel.toml` starting from `start_dir`, walking upward.
168/// Returns the path to `lintel.toml`, or `None` if not found.
169pub fn find_config_path(start_dir: &Path) -> Option<PathBuf> {
170    let mut dir = start_dir.to_path_buf();
171    loop {
172        let candidate = dir.join(CONFIG_FILENAME);
173        if candidate.is_file() {
174            return Some(candidate);
175        }
176        if !dir.pop() {
177            break;
178        }
179    }
180    None
181}
182
183/// Search for `lintel.toml` files starting from `start_dir`, walking up.
184/// Merges all configs found until one with `root = true` is hit (inclusive).
185/// Returns the merged config, or `None` if no config file was found.
186pub fn find_and_load(start_dir: &Path) -> Result<Option<Config>, anyhow::Error> {
187    let mut configs: Vec<Config> = Vec::new();
188    let mut dir = start_dir.to_path_buf();
189
190    loop {
191        let candidate = dir.join(CONFIG_FILENAME);
192        if candidate.is_file() {
193            let content = std::fs::read_to_string(&candidate)?;
194            let cfg: Config = toml::from_str(&content)
195                .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", candidate.display()))?;
196            let is_root = cfg.root;
197            configs.push(cfg);
198            if is_root {
199                break;
200            }
201        }
202        if !dir.pop() {
203            break;
204        }
205    }
206
207    if configs.is_empty() {
208        return Ok(None);
209    }
210
211    // configs[0] is the closest (child), last is the farthest (root-most parent)
212    let mut merged = configs.remove(0);
213    for parent in configs {
214        merged.merge_parent(parent);
215    }
216    Ok(Some(merged))
217}
218
219/// Load config from the current working directory (walking upward).
220pub fn load() -> Result<Config, anyhow::Error> {
221    let cwd = std::env::current_dir()?;
222    Ok(find_and_load(&cwd)?.unwrap_or_default())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::fs;
229
230    #[test]
231    fn loads_config_from_directory() {
232        let tmp = tempfile::tempdir().unwrap();
233        fs::write(
234            tmp.path().join("lintel.toml"),
235            r#"exclude = ["testdata/**"]"#,
236        )
237        .unwrap();
238
239        let config = find_and_load(tmp.path()).unwrap().unwrap();
240        assert_eq!(config.exclude, vec!["testdata/**"]);
241    }
242
243    #[test]
244    fn walks_up_to_find_config() {
245        let tmp = tempfile::tempdir().unwrap();
246        let sub = tmp.path().join("a/b/c");
247        fs::create_dir_all(&sub).unwrap();
248        fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["vendor/**"]"#).unwrap();
249
250        let config = find_and_load(&sub).unwrap().unwrap();
251        assert_eq!(config.exclude, vec!["vendor/**"]);
252    }
253
254    #[test]
255    fn returns_none_when_no_config() {
256        let tmp = tempfile::tempdir().unwrap();
257        let config = find_and_load(tmp.path()).unwrap();
258        assert!(config.is_none());
259    }
260
261    #[test]
262    fn empty_config_is_valid() {
263        let tmp = tempfile::tempdir().unwrap();
264        fs::write(tmp.path().join("lintel.toml"), "").unwrap();
265
266        let config = find_and_load(tmp.path()).unwrap().unwrap();
267        assert!(config.exclude.is_empty());
268        assert!(config.rewrite.is_empty());
269    }
270
271    #[test]
272    fn rejects_unknown_fields() {
273        let tmp = tempfile::tempdir().unwrap();
274        fs::write(tmp.path().join("lintel.toml"), "bogus = true").unwrap();
275
276        let result = find_and_load(tmp.path());
277        assert!(result.is_err());
278    }
279
280    #[test]
281    fn loads_rewrite_rules() {
282        let tmp = tempfile::tempdir().unwrap();
283        fs::write(
284            tmp.path().join("lintel.toml"),
285            r#"
286[rewrite]
287"http://localhost:8000/" = "//schemastore/src/"
288"#,
289        )
290        .unwrap();
291
292        let config = find_and_load(tmp.path()).unwrap().unwrap();
293        assert_eq!(
294            config.rewrite.get("http://localhost:8000/"),
295            Some(&"//schemastore/src/".to_string())
296        );
297    }
298
299    // --- root = true ---
300
301    #[test]
302    fn root_true_stops_walk() {
303        let tmp = tempfile::tempdir().unwrap();
304        let sub = tmp.path().join("child");
305        fs::create_dir_all(&sub).unwrap();
306
307        // Parent config
308        fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#).unwrap();
309
310        // Child config with root = true
311        fs::write(
312            sub.join("lintel.toml"),
313            "root = true\nexclude = [\"child/**\"]",
314        )
315        .unwrap();
316
317        let config = find_and_load(&sub).unwrap().unwrap();
318        assert_eq!(config.exclude, vec!["child/**"]);
319        // Parent's "parent/**" should NOT be included
320    }
321
322    #[test]
323    fn merges_parent_without_root() {
324        let tmp = tempfile::tempdir().unwrap();
325        let sub = tmp.path().join("child");
326        fs::create_dir_all(&sub).unwrap();
327
328        // Parent config
329        fs::write(
330            tmp.path().join("lintel.toml"),
331            r#"
332exclude = ["parent/**"]
333
334[rewrite]
335"http://parent/" = "//parent/"
336"#,
337        )
338        .unwrap();
339
340        // Child config (no root = true)
341        fs::write(
342            sub.join("lintel.toml"),
343            r#"
344exclude = ["child/**"]
345
346[rewrite]
347"http://child/" = "//child/"
348"#,
349        )
350        .unwrap();
351
352        let config = find_and_load(&sub).unwrap().unwrap();
353        // Child excludes come first, then parent
354        assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
355        // Both rewrite rules present
356        assert_eq!(
357            config.rewrite.get("http://child/"),
358            Some(&"//child/".to_string())
359        );
360        assert_eq!(
361            config.rewrite.get("http://parent/"),
362            Some(&"//parent/".to_string())
363        );
364    }
365
366    #[test]
367    fn child_rewrite_wins_on_conflict() {
368        let tmp = tempfile::tempdir().unwrap();
369        let sub = tmp.path().join("child");
370        fs::create_dir_all(&sub).unwrap();
371
372        fs::write(
373            tmp.path().join("lintel.toml"),
374            r#"
375[rewrite]
376"http://example/" = "//parent-value/"
377"#,
378        )
379        .unwrap();
380
381        fs::write(
382            sub.join("lintel.toml"),
383            r#"
384[rewrite]
385"http://example/" = "//child-value/"
386"#,
387        )
388        .unwrap();
389
390        let config = find_and_load(&sub).unwrap().unwrap();
391        assert_eq!(
392            config.rewrite.get("http://example/"),
393            Some(&"//child-value/".to_string())
394        );
395    }
396
397    // --- apply_rewrites ---
398
399    #[test]
400    fn rewrite_matching_prefix() {
401        let mut rewrites = HashMap::new();
402        rewrites.insert(
403            "http://localhost:8000/".to_string(),
404            "//schemastore/src/".to_string(),
405        );
406        let result = apply_rewrites("http://localhost:8000/schemas/foo.json", &rewrites);
407        assert_eq!(result, "//schemastore/src/schemas/foo.json");
408    }
409
410    #[test]
411    fn rewrite_no_match() {
412        let mut rewrites = HashMap::new();
413        rewrites.insert(
414            "http://localhost:8000/".to_string(),
415            "//schemastore/src/".to_string(),
416        );
417        let result = apply_rewrites("https://example.com/schema.json", &rewrites);
418        assert_eq!(result, "https://example.com/schema.json");
419    }
420
421    #[test]
422    fn rewrite_longest_prefix_wins() {
423        let mut rewrites = HashMap::new();
424        rewrites.insert("http://localhost/".to_string(), "//short/".to_string());
425        rewrites.insert(
426            "http://localhost/api/v2/".to_string(),
427            "//long/".to_string(),
428        );
429        let result = apply_rewrites("http://localhost/api/v2/schema.json", &rewrites);
430        assert_eq!(result, "//long/schema.json");
431    }
432
433    // --- resolve_double_slash ---
434
435    #[test]
436    fn resolve_double_slash_prefix() {
437        let config_dir = Path::new("/home/user/project");
438        let result = resolve_double_slash("//schemas/foo.json", config_dir);
439        assert_eq!(result, "/home/user/project/schemas/foo.json");
440    }
441
442    #[test]
443    fn resolve_double_slash_no_prefix() {
444        let config_dir = Path::new("/home/user/project");
445        let result = resolve_double_slash("https://example.com/s.json", config_dir);
446        assert_eq!(result, "https://example.com/s.json");
447    }
448
449    #[test]
450    fn resolve_double_slash_relative_path_unchanged() {
451        let config_dir = Path::new("/home/user/project");
452        let result = resolve_double_slash("./schemas/foo.json", config_dir);
453        assert_eq!(result, "./schemas/foo.json");
454    }
455
456    // --- Override parsing ---
457
458    #[test]
459    fn parses_override_blocks() {
460        let tmp = tempfile::tempdir().unwrap();
461        fs::write(
462            tmp.path().join("lintel.toml"),
463            r#"
464[[override]]
465files = ["schemas/vector.json"]
466validate_formats = false
467
468[[override]]
469files = ["schemas/other.json"]
470validate_formats = true
471"#,
472        )
473        .unwrap();
474
475        let config = find_and_load(tmp.path()).unwrap().unwrap();
476        assert_eq!(config.overrides.len(), 2);
477        assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
478        assert_eq!(config.overrides[0].validate_formats, Some(false));
479        assert_eq!(config.overrides[1].validate_formats, Some(true));
480    }
481
482    #[test]
483    fn override_validate_formats_defaults_to_none() {
484        let tmp = tempfile::tempdir().unwrap();
485        fs::write(
486            tmp.path().join("lintel.toml"),
487            r#"
488[[override]]
489files = ["schemas/vector.json"]
490"#,
491        )
492        .unwrap();
493
494        let config = find_and_load(tmp.path()).unwrap().unwrap();
495        assert_eq!(config.overrides.len(), 1);
496        assert_eq!(config.overrides[0].validate_formats, None);
497    }
498
499    // --- should_validate_formats ---
500
501    #[test]
502    fn should_validate_formats_default_true() {
503        let config = Config::default();
504        assert!(config.should_validate_formats("anything.json", &[]));
505    }
506
507    #[test]
508    fn should_validate_formats_matching_file_override() {
509        let config = Config {
510            overrides: vec![Override {
511                files: vec!["schemas/vector.json".to_string()],
512                validate_formats: Some(false),
513                ..Default::default()
514            }],
515            ..Default::default()
516        };
517        assert!(!config.should_validate_formats("schemas/vector.json", &[]));
518        assert!(config.should_validate_formats("schemas/other.json", &[]));
519    }
520
521    #[test]
522    fn should_validate_formats_matching_schema_override() {
523        let config = Config {
524            overrides: vec![Override {
525                schemas: vec!["https://json.schemastore.org/vector.json".to_string()],
526                validate_formats: Some(false),
527                ..Default::default()
528            }],
529            ..Default::default()
530        };
531        // Matches via schema URI
532        assert!(!config.should_validate_formats(
533            "some/file.toml",
534            &["https://json.schemastore.org/vector.json"]
535        ));
536        // No match
537        assert!(config.should_validate_formats(
538            "some/file.toml",
539            &["https://json.schemastore.org/other.json"]
540        ));
541    }
542
543    #[test]
544    fn should_validate_formats_schema_glob() {
545        let config = Config {
546            overrides: vec![Override {
547                schemas: vec!["https://json.schemastore.org/*.json".to_string()],
548                validate_formats: Some(false),
549                ..Default::default()
550            }],
551            ..Default::default()
552        };
553        assert!(!config
554            .should_validate_formats("any.toml", &["https://json.schemastore.org/vector.json"]));
555    }
556
557    #[test]
558    fn should_validate_formats_matches_resolved_uri() {
559        let config = Config {
560            overrides: vec![Override {
561                schemas: vec!["/local/schemas/vector.json".to_string()],
562                validate_formats: Some(false),
563                ..Default::default()
564            }],
565            ..Default::default()
566        };
567        // original doesn't match, but resolved does
568        assert!(!config.should_validate_formats(
569            "any.toml",
570            &[
571                "https://json.schemastore.org/vector.json",
572                "/local/schemas/vector.json"
573            ]
574        ));
575    }
576
577    #[test]
578    fn should_validate_formats_glob_pattern() {
579        let config = Config {
580            overrides: vec![Override {
581                files: vec!["schemas/**/*.json".to_string()],
582                validate_formats: Some(false),
583                ..Default::default()
584            }],
585            ..Default::default()
586        };
587        assert!(!config.should_validate_formats("schemas/deep/nested.json", &[]));
588        assert!(config.should_validate_formats("other/file.json", &[]));
589    }
590
591    #[test]
592    fn should_validate_formats_strips_dot_slash() {
593        let config = Config {
594            overrides: vec![Override {
595                files: vec!["schemas/vector.json".to_string()],
596                validate_formats: Some(false),
597                ..Default::default()
598            }],
599            ..Default::default()
600        };
601        assert!(!config.should_validate_formats("./schemas/vector.json", &[]));
602    }
603
604    #[test]
605    fn should_validate_formats_first_match_wins() {
606        let config = Config {
607            overrides: vec![
608                Override {
609                    files: vec!["schemas/vector.json".to_string()],
610                    validate_formats: Some(false),
611                    ..Default::default()
612                },
613                Override {
614                    files: vec!["schemas/**".to_string()],
615                    validate_formats: Some(true),
616                    ..Default::default()
617                },
618            ],
619            ..Default::default()
620        };
621        // First override matches, returns false
622        assert!(!config.should_validate_formats("schemas/vector.json", &[]));
623        // Second override matches for other files, returns true
624        assert!(config.should_validate_formats("schemas/other.json", &[]));
625    }
626
627    #[test]
628    fn should_validate_formats_skips_none_override() {
629        let config = Config {
630            overrides: vec![
631                Override {
632                    files: vec!["schemas/vector.json".to_string()],
633                    validate_formats: None, // no opinion
634                    ..Default::default()
635                },
636                Override {
637                    files: vec!["schemas/**".to_string()],
638                    validate_formats: Some(false),
639                    ..Default::default()
640                },
641            ],
642            ..Default::default()
643        };
644        // First override matches but has None, so falls through to second
645        assert!(!config.should_validate_formats("schemas/vector.json", &[]));
646    }
647
648    // --- Override merge behavior ---
649
650    #[test]
651    fn merge_overrides_child_first() {
652        let tmp = tempfile::tempdir().unwrap();
653        let sub = tmp.path().join("child");
654        fs::create_dir_all(&sub).unwrap();
655
656        fs::write(
657            tmp.path().join("lintel.toml"),
658            r#"
659[[override]]
660files = ["schemas/**"]
661validate_formats = true
662"#,
663        )
664        .unwrap();
665
666        fs::write(
667            sub.join("lintel.toml"),
668            r#"
669[[override]]
670files = ["schemas/vector.json"]
671validate_formats = false
672"#,
673        )
674        .unwrap();
675
676        let config = find_and_load(&sub).unwrap().unwrap();
677        // Child override comes first, then parent
678        assert_eq!(config.overrides.len(), 2);
679        assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
680        assert_eq!(config.overrides[0].validate_formats, Some(false));
681        assert_eq!(config.overrides[1].files, vec!["schemas/**"]);
682        assert_eq!(config.overrides[1].validate_formats, Some(true));
683    }
684}