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