Skip to main content

lintel_config/
lib.rs

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