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
12fn example_file_pattern() -> Vec<String> {
13 vec!["schemas/vector.json".into()]
14}
15
16fn example_file_glob() -> Vec<String> {
17 vec!["schemas/**/*.json".into()]
18}
19
20fn example_file_config() -> Vec<String> {
21 vec!["config/*.yaml".into()]
22}
23
24fn example_schema_url() -> Vec<String> {
25 vec!["https://json.schemastore.org/vector.json".into()]
26}
27
28fn example_schema_glob() -> Vec<String> {
29 vec!["https://json.schemastore.org/*.json".into()]
30}
31
32fn example_exclude() -> Vec<String> {
33 vec![
34 "vendor/**".into(),
35 "testdata/**".into(),
36 "*.generated.json".into(),
37 ]
38}
39
40fn example_registry() -> Vec<String> {
41 vec!["https://example.com/custom-catalog.json".into()]
42}
43
44#[derive(Debug, Default, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55#[schemars(title = "Override Rule")]
56pub struct Override {
57 #[schemars(
64 title = "File Patterns",
65 example = example_file_pattern(),
66 example = example_file_glob(),
67 example = example_file_config(),
68 )]
69 #[serde(default)]
70 pub files: Vec<String>,
71
72 #[schemars(
78 title = "Schema Patterns",
79 example = example_schema_url(),
80 example = example_schema_glob(),
81 )]
82 #[serde(default)]
83 pub schemas: Vec<String>,
84
85 #[schemars(title = "Validate Formats")]
94 #[serde(default)]
95 pub validate_formats: Option<bool>,
96}
97
98#[derive(Debug, Default, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109#[schemars(title = "lintel.toml")]
110pub struct Config {
111 #[serde(default)]
118 pub root: bool,
119
120 #[schemars(title = "Exclude Patterns", example = example_exclude())]
126 #[serde(default)]
127 pub exclude: Vec<String>,
128
129 #[schemars(title = "Schema Mappings")]
143 #[serde(default)]
144 pub schemas: HashMap<String, String>,
145
146 #[schemars(title = "No Default Catalog")]
152 #[serde(default, rename = "no-default-catalog")]
153 pub no_default_catalog: bool,
154
155 #[schemars(title = "Additional Registries", example = example_registry())]
164 #[serde(default)]
165 pub registries: Vec<String>,
166
167 #[schemars(title = "Rewrite Rules")]
182 #[serde(default)]
183 pub rewrite: HashMap<String, String>,
184
185 #[serde(default, rename = "override")]
191 pub overrides: Vec<Override>,
192}
193
194impl Config {
195 fn merge_parent(&mut self, parent: Config) {
202 self.exclude.extend(parent.exclude);
203 for (k, v) in parent.schemas {
204 self.schemas.entry(k).or_insert(v);
205 }
206 for url in parent.registries {
207 if !self.registries.contains(&url) {
208 self.registries.push(url);
209 }
210 }
211 for (k, v) in parent.rewrite {
212 self.rewrite.entry(k).or_insert(v);
213 }
214 self.overrides.extend(parent.overrides);
216 }
217
218 pub fn find_schema_mapping(&self, path: &str, file_name: &str) -> Option<&str> {
223 let path = path.strip_prefix("./").unwrap_or(path);
224 for (pattern, url) in &self.schemas {
225 if glob_match::glob_match(pattern, path) || glob_match::glob_match(pattern, file_name) {
226 return Some(url);
227 }
228 }
229 None
230 }
231
232 pub fn should_validate_formats(&self, path: &str, schema_uris: &[&str]) -> bool {
241 let path = path.strip_prefix("./").unwrap_or(path);
242 for ov in &self.overrides {
243 let file_match = !ov.files.is_empty()
244 && ov.files.iter().any(|pat| glob_match::glob_match(pat, path));
245 let schema_match = !ov.schemas.is_empty()
246 && schema_uris.iter().any(|uri| {
247 ov.schemas
248 .iter()
249 .any(|pat| glob_match::glob_match(pat, uri))
250 });
251 if (file_match || schema_match)
252 && let Some(val) = ov.validate_formats
253 {
254 return val;
255 }
256 }
257 true
258 }
259}
260
261pub fn apply_rewrites<S: ::core::hash::BuildHasher>(
265 uri: &str,
266 rewrites: &HashMap<String, String, S>,
267) -> String {
268 let mut best_match: Option<(&str, &str)> = None;
269 for (from, to) in rewrites {
270 if uri.starts_with(from.as_str())
271 && best_match.is_none_or(|(prev_from, _)| from.len() > prev_from.len())
272 {
273 best_match = Some((from.as_str(), to.as_str()));
274 }
275 }
276 match best_match {
277 Some((from, to)) => format!("{to}{}", &uri[from.len()..]),
278 None => uri.to_string(),
279 }
280}
281
282pub fn resolve_double_slash(uri: &str, config_dir: &Path) -> String {
285 if let Some(rest) = uri.strip_prefix("//") {
286 config_dir.join(rest).to_string_lossy().to_string()
287 } else {
288 uri.to_string()
289 }
290}
291
292pub fn schema() -> Value {
298 serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
299}
300
301pub fn find_config_path(start_dir: &Path) -> Option<PathBuf> {
304 let mut dir = start_dir.to_path_buf();
305 loop {
306 let candidate = dir.join(CONFIG_FILENAME);
307 if candidate.is_file() {
308 return Some(candidate);
309 }
310 if !dir.pop() {
311 break;
312 }
313 }
314 None
315}
316
317pub fn find_and_load(start_dir: &Path) -> Result<Option<Config>, anyhow::Error> {
325 let mut configs: Vec<Config> = Vec::new();
326 let mut dir = start_dir.to_path_buf();
327
328 loop {
329 let candidate = dir.join(CONFIG_FILENAME);
330 if candidate.is_file() {
331 let content = std::fs::read_to_string(&candidate)?;
332 let cfg: Config = toml::from_str(&content)
333 .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", candidate.display()))?;
334 let is_root = cfg.root;
335 configs.push(cfg);
336 if is_root {
337 break;
338 }
339 }
340 if !dir.pop() {
341 break;
342 }
343 }
344
345 if configs.is_empty() {
346 return Ok(None);
347 }
348
349 let mut merged = configs.remove(0);
351 for parent in configs {
352 merged.merge_parent(parent);
353 }
354 Ok(Some(merged))
355}
356
357pub fn load() -> Result<Config, anyhow::Error> {
363 let cwd = std::env::current_dir()?;
364 Ok(find_and_load(&cwd)?.unwrap_or_default())
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use std::fs;
371
372 #[test]
373 fn loads_config_from_directory() -> anyhow::Result<()> {
374 let tmp = tempfile::tempdir()?;
375 fs::write(
376 tmp.path().join("lintel.toml"),
377 r#"exclude = ["testdata/**"]"#,
378 )?;
379
380 let config = find_and_load(tmp.path())?.expect("config should exist");
381 assert_eq!(config.exclude, vec!["testdata/**"]);
382 Ok(())
383 }
384
385 #[test]
386 fn walks_up_to_find_config() -> anyhow::Result<()> {
387 let tmp = tempfile::tempdir()?;
388 let sub = tmp.path().join("a/b/c");
389 fs::create_dir_all(&sub)?;
390 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["vendor/**"]"#)?;
391
392 let config = find_and_load(&sub)?.expect("config should exist");
393 assert_eq!(config.exclude, vec!["vendor/**"]);
394 Ok(())
395 }
396
397 #[test]
398 fn returns_none_when_no_config() -> anyhow::Result<()> {
399 let tmp = tempfile::tempdir()?;
400 let config = find_and_load(tmp.path())?;
401 assert!(config.is_none());
402 Ok(())
403 }
404
405 #[test]
406 fn empty_config_is_valid() -> anyhow::Result<()> {
407 let tmp = tempfile::tempdir()?;
408 fs::write(tmp.path().join("lintel.toml"), "")?;
409
410 let config = find_and_load(tmp.path())?.expect("config should exist");
411 assert!(config.exclude.is_empty());
412 assert!(config.rewrite.is_empty());
413 Ok(())
414 }
415
416 #[test]
417 fn rejects_unknown_fields() -> anyhow::Result<()> {
418 let tmp = tempfile::tempdir()?;
419 fs::write(tmp.path().join("lintel.toml"), "bogus = true")?;
420
421 let result = find_and_load(tmp.path());
422 assert!(result.is_err());
423 Ok(())
424 }
425
426 #[test]
427 fn loads_rewrite_rules() -> anyhow::Result<()> {
428 let tmp = tempfile::tempdir()?;
429 fs::write(
430 tmp.path().join("lintel.toml"),
431 r#"
432[rewrite]
433"http://localhost:8000/" = "//schemastore/src/"
434"#,
435 )?;
436
437 let config = find_and_load(tmp.path())?.expect("config should exist");
438 assert_eq!(
439 config.rewrite.get("http://localhost:8000/"),
440 Some(&"//schemastore/src/".to_string())
441 );
442 Ok(())
443 }
444
445 #[test]
448 fn root_true_stops_walk() -> anyhow::Result<()> {
449 let tmp = tempfile::tempdir()?;
450 let sub = tmp.path().join("child");
451 fs::create_dir_all(&sub)?;
452
453 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#)?;
455
456 fs::write(
458 sub.join("lintel.toml"),
459 "root = true\nexclude = [\"child/**\"]",
460 )?;
461
462 let config = find_and_load(&sub)?.expect("config should exist");
463 assert_eq!(config.exclude, vec!["child/**"]);
464 Ok(())
466 }
467
468 #[test]
469 fn merges_parent_without_root() -> anyhow::Result<()> {
470 let tmp = tempfile::tempdir()?;
471 let sub = tmp.path().join("child");
472 fs::create_dir_all(&sub)?;
473
474 fs::write(
476 tmp.path().join("lintel.toml"),
477 r#"
478exclude = ["parent/**"]
479
480[rewrite]
481"http://parent/" = "//parent/"
482"#,
483 )?;
484
485 fs::write(
487 sub.join("lintel.toml"),
488 r#"
489exclude = ["child/**"]
490
491[rewrite]
492"http://child/" = "//child/"
493"#,
494 )?;
495
496 let config = find_and_load(&sub)?.expect("config should exist");
497 assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
499 assert_eq!(
501 config.rewrite.get("http://child/"),
502 Some(&"//child/".to_string())
503 );
504 assert_eq!(
505 config.rewrite.get("http://parent/"),
506 Some(&"//parent/".to_string())
507 );
508 Ok(())
509 }
510
511 #[test]
512 fn child_rewrite_wins_on_conflict() -> anyhow::Result<()> {
513 let tmp = tempfile::tempdir()?;
514 let sub = tmp.path().join("child");
515 fs::create_dir_all(&sub)?;
516
517 fs::write(
518 tmp.path().join("lintel.toml"),
519 r#"
520[rewrite]
521"http://example/" = "//parent-value/"
522"#,
523 )?;
524
525 fs::write(
526 sub.join("lintel.toml"),
527 r#"
528[rewrite]
529"http://example/" = "//child-value/"
530"#,
531 )?;
532
533 let config = find_and_load(&sub)?.expect("config should exist");
534 assert_eq!(
535 config.rewrite.get("http://example/"),
536 Some(&"//child-value/".to_string())
537 );
538 Ok(())
539 }
540
541 #[test]
544 fn rewrite_matching_prefix() {
545 let mut rewrites = HashMap::new();
546 rewrites.insert(
547 "http://localhost:8000/".to_string(),
548 "//schemastore/src/".to_string(),
549 );
550 let result = apply_rewrites("http://localhost:8000/schemas/foo.json", &rewrites);
551 assert_eq!(result, "//schemastore/src/schemas/foo.json");
552 }
553
554 #[test]
555 fn rewrite_no_match() {
556 let mut rewrites = HashMap::new();
557 rewrites.insert(
558 "http://localhost:8000/".to_string(),
559 "//schemastore/src/".to_string(),
560 );
561 let result = apply_rewrites("https://example.com/schema.json", &rewrites);
562 assert_eq!(result, "https://example.com/schema.json");
563 }
564
565 #[test]
566 fn rewrite_longest_prefix_wins() {
567 let mut rewrites = HashMap::new();
568 rewrites.insert("http://localhost/".to_string(), "//short/".to_string());
569 rewrites.insert(
570 "http://localhost/api/v2/".to_string(),
571 "//long/".to_string(),
572 );
573 let result = apply_rewrites("http://localhost/api/v2/schema.json", &rewrites);
574 assert_eq!(result, "//long/schema.json");
575 }
576
577 #[test]
580 fn resolve_double_slash_prefix() {
581 let config_dir = Path::new("/home/user/project");
582 let result = resolve_double_slash("//schemas/foo.json", config_dir);
583 assert_eq!(result, "/home/user/project/schemas/foo.json");
584 }
585
586 #[test]
587 fn resolve_double_slash_no_prefix() {
588 let config_dir = Path::new("/home/user/project");
589 let result = resolve_double_slash("https://example.com/s.json", config_dir);
590 assert_eq!(result, "https://example.com/s.json");
591 }
592
593 #[test]
594 fn resolve_double_slash_relative_path_unchanged() {
595 let config_dir = Path::new("/home/user/project");
596 let result = resolve_double_slash("./schemas/foo.json", config_dir);
597 assert_eq!(result, "./schemas/foo.json");
598 }
599
600 #[test]
603 fn parses_override_blocks() -> anyhow::Result<()> {
604 let tmp = tempfile::tempdir()?;
605 fs::write(
606 tmp.path().join("lintel.toml"),
607 r#"
608[[override]]
609files = ["schemas/vector.json"]
610validate_formats = false
611
612[[override]]
613files = ["schemas/other.json"]
614validate_formats = true
615"#,
616 )?;
617
618 let config = find_and_load(tmp.path())?.expect("config should exist");
619 assert_eq!(config.overrides.len(), 2);
620 assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
621 assert_eq!(config.overrides[0].validate_formats, Some(false));
622 assert_eq!(config.overrides[1].validate_formats, Some(true));
623 Ok(())
624 }
625
626 #[test]
627 fn override_validate_formats_defaults_to_none() -> anyhow::Result<()> {
628 let tmp = tempfile::tempdir()?;
629 fs::write(
630 tmp.path().join("lintel.toml"),
631 r#"
632[[override]]
633files = ["schemas/vector.json"]
634"#,
635 )?;
636
637 let config = find_and_load(tmp.path())?.expect("config should exist");
638 assert_eq!(config.overrides.len(), 1);
639 assert_eq!(config.overrides[0].validate_formats, None);
640 Ok(())
641 }
642
643 #[test]
646 fn should_validate_formats_default_true() {
647 let config = Config::default();
648 assert!(config.should_validate_formats("anything.json", &[]));
649 }
650
651 #[test]
652 fn should_validate_formats_matching_file_override() {
653 let config = Config {
654 overrides: vec![Override {
655 files: vec!["schemas/vector.json".to_string()],
656 validate_formats: Some(false),
657 ..Default::default()
658 }],
659 ..Default::default()
660 };
661 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
662 assert!(config.should_validate_formats("schemas/other.json", &[]));
663 }
664
665 #[test]
666 fn should_validate_formats_matching_schema_override() {
667 let config = Config {
668 overrides: vec![Override {
669 schemas: vec!["https://json.schemastore.org/vector.json".to_string()],
670 validate_formats: Some(false),
671 ..Default::default()
672 }],
673 ..Default::default()
674 };
675 assert!(!config.should_validate_formats(
677 "some/file.toml",
678 &["https://json.schemastore.org/vector.json"]
679 ));
680 assert!(config.should_validate_formats(
682 "some/file.toml",
683 &["https://json.schemastore.org/other.json"]
684 ));
685 }
686
687 #[test]
688 fn should_validate_formats_schema_glob() {
689 let config = Config {
690 overrides: vec![Override {
691 schemas: vec!["https://json.schemastore.org/*.json".to_string()],
692 validate_formats: Some(false),
693 ..Default::default()
694 }],
695 ..Default::default()
696 };
697 assert!(
698 !config
699 .should_validate_formats("any.toml", &["https://json.schemastore.org/vector.json"])
700 );
701 }
702
703 #[test]
704 fn should_validate_formats_matches_resolved_uri() {
705 let config = Config {
706 overrides: vec![Override {
707 schemas: vec!["/local/schemas/vector.json".to_string()],
708 validate_formats: Some(false),
709 ..Default::default()
710 }],
711 ..Default::default()
712 };
713 assert!(!config.should_validate_formats(
715 "any.toml",
716 &[
717 "https://json.schemastore.org/vector.json",
718 "/local/schemas/vector.json"
719 ]
720 ));
721 }
722
723 #[test]
724 fn should_validate_formats_glob_pattern() {
725 let config = Config {
726 overrides: vec![Override {
727 files: vec!["schemas/**/*.json".to_string()],
728 validate_formats: Some(false),
729 ..Default::default()
730 }],
731 ..Default::default()
732 };
733 assert!(!config.should_validate_formats("schemas/deep/nested.json", &[]));
734 assert!(config.should_validate_formats("other/file.json", &[]));
735 }
736
737 #[test]
738 fn should_validate_formats_strips_dot_slash() {
739 let config = Config {
740 overrides: vec![Override {
741 files: vec!["schemas/vector.json".to_string()],
742 validate_formats: Some(false),
743 ..Default::default()
744 }],
745 ..Default::default()
746 };
747 assert!(!config.should_validate_formats("./schemas/vector.json", &[]));
748 }
749
750 #[test]
751 fn should_validate_formats_first_match_wins() {
752 let config = Config {
753 overrides: vec![
754 Override {
755 files: vec!["schemas/vector.json".to_string()],
756 validate_formats: Some(false),
757 ..Default::default()
758 },
759 Override {
760 files: vec!["schemas/**".to_string()],
761 validate_formats: Some(true),
762 ..Default::default()
763 },
764 ],
765 ..Default::default()
766 };
767 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
769 assert!(config.should_validate_formats("schemas/other.json", &[]));
771 }
772
773 #[test]
774 fn should_validate_formats_skips_none_override() {
775 let config = Config {
776 overrides: vec![
777 Override {
778 files: vec!["schemas/vector.json".to_string()],
779 validate_formats: None, ..Default::default()
781 },
782 Override {
783 files: vec!["schemas/**".to_string()],
784 validate_formats: Some(false),
785 ..Default::default()
786 },
787 ],
788 ..Default::default()
789 };
790 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
792 }
793
794 #[test]
797 fn merge_overrides_child_first() -> anyhow::Result<()> {
798 let tmp = tempfile::tempdir()?;
799 let sub = tmp.path().join("child");
800 fs::create_dir_all(&sub)?;
801
802 fs::write(
803 tmp.path().join("lintel.toml"),
804 r#"
805[[override]]
806files = ["schemas/**"]
807validate_formats = true
808"#,
809 )?;
810
811 fs::write(
812 sub.join("lintel.toml"),
813 r#"
814[[override]]
815files = ["schemas/vector.json"]
816validate_formats = false
817"#,
818 )?;
819
820 let config = find_and_load(&sub)?.expect("config should exist");
821 assert_eq!(config.overrides.len(), 2);
823 assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
824 assert_eq!(config.overrides[0].validate_formats, Some(false));
825 assert_eq!(config.overrides[1].files, vec!["schemas/**"]);
826 assert_eq!(config.overrides[1].validate_formats, Some(true));
827 Ok(())
828 }
829}