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 #[serde(default)]
15 pub files: Vec<String>,
16
17 #[serde(default)]
21 pub schemas: Vec<String>,
22
23 #[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 #[serde(default)]
35 pub root: bool,
36
37 #[serde(default)]
39 pub exclude: Vec<String>,
40
41 #[serde(default)]
47 pub schemas: HashMap<String, String>,
48
49 #[serde(default, rename = "no-default-catalog")]
52 pub no_default_catalog: bool,
53
54 #[serde(default)]
58 pub registries: Vec<String>,
59
60 #[serde(default)]
65 pub rewrite: HashMap<String, String>,
66
67 #[serde(default, rename = "override")]
69 pub overrides: Vec<Override>,
70}
71
72impl Config {
73 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 self.overrides.extend(parent.overrides);
94 }
95
96 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 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
139pub 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
160pub 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
170pub fn schema() -> Value {
176 serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
177}
178
179pub 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
195pub 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 let mut merged = configs.remove(0);
229 for parent in configs {
230 merged.merge_parent(parent);
231 }
232 Ok(Some(merged))
233}
234
235pub 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 #[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 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#)?;
333
334 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 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 fs::write(
354 tmp.path().join("lintel.toml"),
355 r#"
356exclude = ["parent/**"]
357
358[rewrite]
359"http://parent/" = "//parent/"
360"#,
361 )?;
362
363 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 assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
377 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 #[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 #[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 #[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 #[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 assert!(!config.should_validate_formats(
555 "some/file.toml",
556 &["https://json.schemastore.org/vector.json"]
557 ));
558 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 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 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
647 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, ..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 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
670 }
671
672 #[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 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}