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)]
50#[serde(deny_unknown_fields)]
51#[schemars(title = "Format")]
52pub struct Format {
53 pub dprint: Option<dprint_config::DprintConfig>,
59}
60
61#[derive(Debug, Default, Deserialize, JsonSchema)]
71#[serde(deny_unknown_fields)]
72#[schemars(title = "Override Rule")]
73pub struct Override {
74 #[schemars(
81 title = "File Patterns",
82 example = example_file_pattern(),
83 example = example_file_glob(),
84 example = example_file_config(),
85 )]
86 #[serde(default)]
87 pub files: Vec<String>,
88
89 #[schemars(
95 title = "Schema Patterns",
96 example = example_schema_url(),
97 example = example_schema_glob(),
98 )]
99 #[serde(default)]
100 pub schemas: Vec<String>,
101
102 #[schemars(title = "Validate Formats")]
111 #[serde(default)]
112 pub validate_formats: Option<bool>,
113}
114
115#[derive(Debug, Default, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126#[schemars(title = "lintel.toml")]
127pub struct Config {
128 #[serde(default)]
135 pub root: bool,
136
137 #[schemars(title = "Exclude Patterns", example = example_exclude())]
143 #[serde(default)]
144 pub exclude: Vec<String>,
145
146 #[schemars(title = "Schema Mappings")]
160 #[serde(default)]
161 pub schemas: HashMap<String, String>,
162
163 #[schemars(title = "No Default Catalog")]
169 #[serde(default, rename = "no-default-catalog")]
170 pub no_default_catalog: bool,
171
172 #[schemars(title = "Additional Registries", example = example_registry())]
181 #[serde(default)]
182 pub registries: Vec<String>,
183
184 #[schemars(title = "Rewrite Rules")]
199 #[serde(default)]
200 pub rewrite: HashMap<String, String>,
201
202 #[serde(default, rename = "override")]
208 pub overrides: Vec<Override>,
209
210 #[schemars(title = "Format")]
212 #[serde(default)]
213 pub format: Option<Format>,
214}
215
216impl Config {
217 fn merge_parent(&mut self, parent: Config) {
224 self.exclude.extend(parent.exclude);
225 for (k, v) in parent.schemas {
226 self.schemas.entry(k).or_insert(v);
227 }
228 for url in parent.registries {
229 if !self.registries.contains(&url) {
230 self.registries.push(url);
231 }
232 }
233 for (k, v) in parent.rewrite {
234 self.rewrite.entry(k).or_insert(v);
235 }
236 self.overrides.extend(parent.overrides);
238 if self.format.is_none() {
240 self.format = parent.format;
241 }
242 }
243
244 pub fn find_schema_mapping(&self, path: &str, file_name: &str) -> Option<&str> {
249 let path = path.strip_prefix("./").unwrap_or(path);
250 for (pattern, url) in &self.schemas {
251 if glob_match::glob_match(pattern, path) || glob_match::glob_match(pattern, file_name) {
252 return Some(url);
253 }
254 }
255 None
256 }
257
258 pub fn should_validate_formats(&self, path: &str, schema_uris: &[&str]) -> bool {
267 let path = path.strip_prefix("./").unwrap_or(path);
268 for ov in &self.overrides {
269 let file_match = !ov.files.is_empty()
270 && ov.files.iter().any(|pat| glob_match::glob_match(pat, path));
271 let schema_match = !ov.schemas.is_empty()
272 && schema_uris.iter().any(|uri| {
273 ov.schemas
274 .iter()
275 .any(|pat| glob_match::glob_match(pat, uri))
276 });
277 if (file_match || schema_match)
278 && let Some(val) = ov.validate_formats
279 {
280 return val;
281 }
282 }
283 true
284 }
285}
286
287pub fn apply_rewrites<S: ::core::hash::BuildHasher>(
291 uri: &str,
292 rewrites: &HashMap<String, String, S>,
293) -> String {
294 let mut best_match: Option<(&str, &str)> = None;
295 for (from, to) in rewrites {
296 if uri.starts_with(from.as_str())
297 && best_match.is_none_or(|(prev_from, _)| from.len() > prev_from.len())
298 {
299 best_match = Some((from.as_str(), to.as_str()));
300 }
301 }
302 match best_match {
303 Some((from, to)) => format!("{to}{}", &uri[from.len()..]),
304 None => uri.to_string(),
305 }
306}
307
308pub fn resolve_double_slash(uri: &str, config_dir: &Path) -> String {
311 if let Some(rest) = uri.strip_prefix("//") {
312 config_dir.join(rest).to_string_lossy().to_string()
313 } else {
314 uri.to_string()
315 }
316}
317
318pub fn schema() -> Value {
324 serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
325}
326
327pub fn find_config_path(start_dir: &Path) -> Option<PathBuf> {
330 let mut dir = start_dir.to_path_buf();
331 loop {
332 let candidate = dir.join(CONFIG_FILENAME);
333 if candidate.is_file() {
334 return Some(candidate);
335 }
336 if !dir.pop() {
337 break;
338 }
339 }
340 None
341}
342
343pub fn find_and_load(start_dir: &Path) -> Result<Option<Config>, anyhow::Error> {
351 let mut configs: Vec<Config> = Vec::new();
352 let mut dir = start_dir.to_path_buf();
353
354 loop {
355 let candidate = dir.join(CONFIG_FILENAME);
356 if candidate.is_file() {
357 let content = std::fs::read_to_string(&candidate)?;
358 let cfg: Config = toml::from_str(&content)
359 .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", candidate.display()))?;
360 let is_root = cfg.root;
361 configs.push(cfg);
362 if is_root {
363 break;
364 }
365 }
366 if !dir.pop() {
367 break;
368 }
369 }
370
371 if configs.is_empty() {
372 return Ok(None);
373 }
374
375 let mut merged = configs.remove(0);
377 for parent in configs {
378 merged.merge_parent(parent);
379 }
380 Ok(Some(merged))
381}
382
383pub fn load() -> Result<Config, anyhow::Error> {
389 let cwd = std::env::current_dir()?;
390 Ok(find_and_load(&cwd)?.unwrap_or_default())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use std::fs;
397
398 #[test]
399 fn loads_config_from_directory() -> anyhow::Result<()> {
400 let tmp = tempfile::tempdir()?;
401 fs::write(
402 tmp.path().join("lintel.toml"),
403 r#"exclude = ["testdata/**"]"#,
404 )?;
405
406 let config = find_and_load(tmp.path())?.expect("config should exist");
407 assert_eq!(config.exclude, vec!["testdata/**"]);
408 Ok(())
409 }
410
411 #[test]
412 fn walks_up_to_find_config() -> anyhow::Result<()> {
413 let tmp = tempfile::tempdir()?;
414 let sub = tmp.path().join("a/b/c");
415 fs::create_dir_all(&sub)?;
416 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["vendor/**"]"#)?;
417
418 let config = find_and_load(&sub)?.expect("config should exist");
419 assert_eq!(config.exclude, vec!["vendor/**"]);
420 Ok(())
421 }
422
423 #[test]
424 fn returns_none_when_no_config() -> anyhow::Result<()> {
425 let tmp = tempfile::tempdir()?;
426 let config = find_and_load(tmp.path())?;
427 assert!(config.is_none());
428 Ok(())
429 }
430
431 #[test]
432 fn empty_config_is_valid() -> anyhow::Result<()> {
433 let tmp = tempfile::tempdir()?;
434 fs::write(tmp.path().join("lintel.toml"), "")?;
435
436 let config = find_and_load(tmp.path())?.expect("config should exist");
437 assert!(config.exclude.is_empty());
438 assert!(config.rewrite.is_empty());
439 Ok(())
440 }
441
442 #[test]
443 fn rejects_unknown_fields() -> anyhow::Result<()> {
444 let tmp = tempfile::tempdir()?;
445 fs::write(tmp.path().join("lintel.toml"), "bogus = true")?;
446
447 let result = find_and_load(tmp.path());
448 assert!(result.is_err());
449 Ok(())
450 }
451
452 #[test]
453 fn loads_rewrite_rules() -> anyhow::Result<()> {
454 let tmp = tempfile::tempdir()?;
455 fs::write(
456 tmp.path().join("lintel.toml"),
457 r#"
458[rewrite]
459"http://localhost:8000/" = "//schemastore/src/"
460"#,
461 )?;
462
463 let config = find_and_load(tmp.path())?.expect("config should exist");
464 assert_eq!(
465 config.rewrite.get("http://localhost:8000/"),
466 Some(&"//schemastore/src/".to_string())
467 );
468 Ok(())
469 }
470
471 #[test]
474 fn root_true_stops_walk() -> anyhow::Result<()> {
475 let tmp = tempfile::tempdir()?;
476 let sub = tmp.path().join("child");
477 fs::create_dir_all(&sub)?;
478
479 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#)?;
481
482 fs::write(
484 sub.join("lintel.toml"),
485 "root = true\nexclude = [\"child/**\"]",
486 )?;
487
488 let config = find_and_load(&sub)?.expect("config should exist");
489 assert_eq!(config.exclude, vec!["child/**"]);
490 Ok(())
492 }
493
494 #[test]
495 fn merges_parent_without_root() -> anyhow::Result<()> {
496 let tmp = tempfile::tempdir()?;
497 let sub = tmp.path().join("child");
498 fs::create_dir_all(&sub)?;
499
500 fs::write(
502 tmp.path().join("lintel.toml"),
503 r#"
504exclude = ["parent/**"]
505
506[rewrite]
507"http://parent/" = "//parent/"
508"#,
509 )?;
510
511 fs::write(
513 sub.join("lintel.toml"),
514 r#"
515exclude = ["child/**"]
516
517[rewrite]
518"http://child/" = "//child/"
519"#,
520 )?;
521
522 let config = find_and_load(&sub)?.expect("config should exist");
523 assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
525 assert_eq!(
527 config.rewrite.get("http://child/"),
528 Some(&"//child/".to_string())
529 );
530 assert_eq!(
531 config.rewrite.get("http://parent/"),
532 Some(&"//parent/".to_string())
533 );
534 Ok(())
535 }
536
537 #[test]
538 fn child_rewrite_wins_on_conflict() -> anyhow::Result<()> {
539 let tmp = tempfile::tempdir()?;
540 let sub = tmp.path().join("child");
541 fs::create_dir_all(&sub)?;
542
543 fs::write(
544 tmp.path().join("lintel.toml"),
545 r#"
546[rewrite]
547"http://example/" = "//parent-value/"
548"#,
549 )?;
550
551 fs::write(
552 sub.join("lintel.toml"),
553 r#"
554[rewrite]
555"http://example/" = "//child-value/"
556"#,
557 )?;
558
559 let config = find_and_load(&sub)?.expect("config should exist");
560 assert_eq!(
561 config.rewrite.get("http://example/"),
562 Some(&"//child-value/".to_string())
563 );
564 Ok(())
565 }
566
567 #[test]
570 fn rewrite_matching_prefix() {
571 let mut rewrites = HashMap::new();
572 rewrites.insert(
573 "http://localhost:8000/".to_string(),
574 "//schemastore/src/".to_string(),
575 );
576 let result = apply_rewrites("http://localhost:8000/schemas/foo.json", &rewrites);
577 assert_eq!(result, "//schemastore/src/schemas/foo.json");
578 }
579
580 #[test]
581 fn rewrite_no_match() {
582 let mut rewrites = HashMap::new();
583 rewrites.insert(
584 "http://localhost:8000/".to_string(),
585 "//schemastore/src/".to_string(),
586 );
587 let result = apply_rewrites("https://example.com/schema.json", &rewrites);
588 assert_eq!(result, "https://example.com/schema.json");
589 }
590
591 #[test]
592 fn rewrite_longest_prefix_wins() {
593 let mut rewrites = HashMap::new();
594 rewrites.insert("http://localhost/".to_string(), "//short/".to_string());
595 rewrites.insert(
596 "http://localhost/api/v2/".to_string(),
597 "//long/".to_string(),
598 );
599 let result = apply_rewrites("http://localhost/api/v2/schema.json", &rewrites);
600 assert_eq!(result, "//long/schema.json");
601 }
602
603 #[test]
606 fn resolve_double_slash_prefix() {
607 let config_dir = Path::new("/home/user/project");
608 let result = resolve_double_slash("//schemas/foo.json", config_dir);
609 assert_eq!(result, "/home/user/project/schemas/foo.json");
610 }
611
612 #[test]
613 fn resolve_double_slash_no_prefix() {
614 let config_dir = Path::new("/home/user/project");
615 let result = resolve_double_slash("https://example.com/s.json", config_dir);
616 assert_eq!(result, "https://example.com/s.json");
617 }
618
619 #[test]
620 fn resolve_double_slash_relative_path_unchanged() {
621 let config_dir = Path::new("/home/user/project");
622 let result = resolve_double_slash("./schemas/foo.json", config_dir);
623 assert_eq!(result, "./schemas/foo.json");
624 }
625
626 #[test]
629 fn parses_override_blocks() -> anyhow::Result<()> {
630 let tmp = tempfile::tempdir()?;
631 fs::write(
632 tmp.path().join("lintel.toml"),
633 r#"
634[[override]]
635files = ["schemas/vector.json"]
636validate_formats = false
637
638[[override]]
639files = ["schemas/other.json"]
640validate_formats = true
641"#,
642 )?;
643
644 let config = find_and_load(tmp.path())?.expect("config should exist");
645 assert_eq!(config.overrides.len(), 2);
646 assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
647 assert_eq!(config.overrides[0].validate_formats, Some(false));
648 assert_eq!(config.overrides[1].validate_formats, Some(true));
649 Ok(())
650 }
651
652 #[test]
653 fn override_validate_formats_defaults_to_none() -> anyhow::Result<()> {
654 let tmp = tempfile::tempdir()?;
655 fs::write(
656 tmp.path().join("lintel.toml"),
657 r#"
658[[override]]
659files = ["schemas/vector.json"]
660"#,
661 )?;
662
663 let config = find_and_load(tmp.path())?.expect("config should exist");
664 assert_eq!(config.overrides.len(), 1);
665 assert_eq!(config.overrides[0].validate_formats, None);
666 Ok(())
667 }
668
669 #[test]
672 fn should_validate_formats_default_true() {
673 let config = Config::default();
674 assert!(config.should_validate_formats("anything.json", &[]));
675 }
676
677 #[test]
678 fn should_validate_formats_matching_file_override() {
679 let config = Config {
680 overrides: vec![Override {
681 files: vec!["schemas/vector.json".to_string()],
682 validate_formats: Some(false),
683 ..Default::default()
684 }],
685 ..Default::default()
686 };
687 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
688 assert!(config.should_validate_formats("schemas/other.json", &[]));
689 }
690
691 #[test]
692 fn should_validate_formats_matching_schema_override() {
693 let config = Config {
694 overrides: vec![Override {
695 schemas: vec!["https://json.schemastore.org/vector.json".to_string()],
696 validate_formats: Some(false),
697 ..Default::default()
698 }],
699 ..Default::default()
700 };
701 assert!(!config.should_validate_formats(
703 "some/file.toml",
704 &["https://json.schemastore.org/vector.json"]
705 ));
706 assert!(config.should_validate_formats(
708 "some/file.toml",
709 &["https://json.schemastore.org/other.json"]
710 ));
711 }
712
713 #[test]
714 fn should_validate_formats_schema_glob() {
715 let config = Config {
716 overrides: vec![Override {
717 schemas: vec!["https://json.schemastore.org/*.json".to_string()],
718 validate_formats: Some(false),
719 ..Default::default()
720 }],
721 ..Default::default()
722 };
723 assert!(
724 !config
725 .should_validate_formats("any.toml", &["https://json.schemastore.org/vector.json"])
726 );
727 }
728
729 #[test]
730 fn should_validate_formats_matches_resolved_uri() {
731 let config = Config {
732 overrides: vec![Override {
733 schemas: vec!["/local/schemas/vector.json".to_string()],
734 validate_formats: Some(false),
735 ..Default::default()
736 }],
737 ..Default::default()
738 };
739 assert!(!config.should_validate_formats(
741 "any.toml",
742 &[
743 "https://json.schemastore.org/vector.json",
744 "/local/schemas/vector.json"
745 ]
746 ));
747 }
748
749 #[test]
750 fn should_validate_formats_glob_pattern() {
751 let config = Config {
752 overrides: vec![Override {
753 files: vec!["schemas/**/*.json".to_string()],
754 validate_formats: Some(false),
755 ..Default::default()
756 }],
757 ..Default::default()
758 };
759 assert!(!config.should_validate_formats("schemas/deep/nested.json", &[]));
760 assert!(config.should_validate_formats("other/file.json", &[]));
761 }
762
763 #[test]
764 fn should_validate_formats_strips_dot_slash() {
765 let config = Config {
766 overrides: vec![Override {
767 files: vec!["schemas/vector.json".to_string()],
768 validate_formats: Some(false),
769 ..Default::default()
770 }],
771 ..Default::default()
772 };
773 assert!(!config.should_validate_formats("./schemas/vector.json", &[]));
774 }
775
776 #[test]
777 fn should_validate_formats_first_match_wins() {
778 let config = Config {
779 overrides: vec![
780 Override {
781 files: vec!["schemas/vector.json".to_string()],
782 validate_formats: Some(false),
783 ..Default::default()
784 },
785 Override {
786 files: vec!["schemas/**".to_string()],
787 validate_formats: Some(true),
788 ..Default::default()
789 },
790 ],
791 ..Default::default()
792 };
793 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
795 assert!(config.should_validate_formats("schemas/other.json", &[]));
797 }
798
799 #[test]
802 fn parses_format_dprint_config_kebab_case() -> anyhow::Result<()> {
803 let tmp = tempfile::tempdir()?;
804 fs::write(
805 tmp.path().join("lintel.toml"),
806 r#"
807root = true
808
809[format.dprint]
810line-width = 100
811
812[format.dprint.toml]
813"cargo.applyConventions" = false
814
815[format.dprint.json]
816indent-width = 4
817trailing-commas = "never"
818"#,
819 )?;
820
821 let config = find_and_load(tmp.path())?.expect("config should exist");
822 let fmt = config.format.expect("format section should exist");
823 let dprint = fmt.dprint.expect("dprint section should exist");
824 assert_eq!(dprint.line_width, Some(100));
825 let toml_cfg = dprint.toml.expect("toml section should exist");
826 assert_eq!(toml_cfg.cargo_apply_conventions, Some(false));
827 let json_cfg = dprint.json.expect("json section should exist");
828 assert_eq!(json_cfg.indent_width, Some(4));
829 Ok(())
830 }
831
832 #[test]
833 fn parses_format_dprint_config_camel_case() -> anyhow::Result<()> {
834 let tmp = tempfile::tempdir()?;
835 fs::write(
836 tmp.path().join("lintel.toml"),
837 r#"
838root = true
839
840[format.dprint]
841lineWidth = 100
842
843[format.dprint.toml]
844"cargo.applyConventions" = false
845
846[format.dprint.json]
847indentWidth = 4
848trailingCommas = "never"
849"#,
850 )?;
851
852 let config = find_and_load(tmp.path())?.expect("config should exist");
853 let fmt = config.format.expect("format section should exist");
854 let dprint = fmt.dprint.expect("dprint section should exist");
855 assert_eq!(dprint.line_width, Some(100));
856 let toml_cfg = dprint.toml.expect("toml section should exist");
857 assert_eq!(toml_cfg.cargo_apply_conventions, Some(false));
858 let json_cfg = dprint.json.expect("json section should exist");
859 assert_eq!(json_cfg.indent_width, Some(4));
860 Ok(())
861 }
862
863 #[test]
864 fn format_section_child_takes_priority() -> anyhow::Result<()> {
865 let tmp = tempfile::tempdir()?;
866 let sub = tmp.path().join("child");
867 fs::create_dir_all(&sub)?;
868
869 fs::write(
870 tmp.path().join("lintel.toml"),
871 r"
872[format.dprint]
873lineWidth = 80
874",
875 )?;
876
877 fs::write(
878 sub.join("lintel.toml"),
879 r"
880[format.dprint]
881lineWidth = 120
882",
883 )?;
884
885 let config = find_and_load(&sub)?.expect("config should exist");
886 let dprint = config.format.expect("format").dprint.expect("dprint");
887 assert_eq!(dprint.line_width, Some(120));
888 Ok(())
889 }
890
891 #[test]
892 fn format_section_inherits_from_parent() -> anyhow::Result<()> {
893 let tmp = tempfile::tempdir()?;
894 let sub = tmp.path().join("child");
895 fs::create_dir_all(&sub)?;
896
897 fs::write(
898 tmp.path().join("lintel.toml"),
899 r"
900[format.dprint]
901lineWidth = 80
902",
903 )?;
904
905 fs::write(sub.join("lintel.toml"), "exclude = [\"test/**\"]\n")?;
907
908 let config = find_and_load(&sub)?.expect("config should exist");
909 let dprint = config.format.expect("format").dprint.expect("dprint");
910 assert_eq!(dprint.line_width, Some(80));
911 Ok(())
912 }
913
914 #[test]
915 fn should_validate_formats_skips_none_override() {
916 let config = Config {
917 overrides: vec![
918 Override {
919 files: vec!["schemas/vector.json".to_string()],
920 validate_formats: None, ..Default::default()
922 },
923 Override {
924 files: vec!["schemas/**".to_string()],
925 validate_formats: Some(false),
926 ..Default::default()
927 },
928 ],
929 ..Default::default()
930 };
931 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
933 }
934
935 #[test]
938 fn merge_overrides_child_first() -> anyhow::Result<()> {
939 let tmp = tempfile::tempdir()?;
940 let sub = tmp.path().join("child");
941 fs::create_dir_all(&sub)?;
942
943 fs::write(
944 tmp.path().join("lintel.toml"),
945 r#"
946[[override]]
947files = ["schemas/**"]
948validate_formats = true
949"#,
950 )?;
951
952 fs::write(
953 sub.join("lintel.toml"),
954 r#"
955[[override]]
956files = ["schemas/vector.json"]
957validate_formats = false
958"#,
959 )?;
960
961 let config = find_and_load(&sub)?.expect("config should exist");
962 assert_eq!(config.overrides.len(), 2);
964 assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
965 assert_eq!(config.overrides[0].validate_formats, Some(false));
966 assert_eq!(config.overrides[1].files, vec!["schemas/**"]);
967 assert_eq!(config.overrides[1].validate_formats, Some(true));
968 Ok(())
969 }
970}