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