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 #[serde(default)]
17 pub files: Vec<String>,
18
19 #[serde(default)]
23 pub schemas: Vec<String>,
24
25 #[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 #[serde(default)]
37 pub root: bool,
38
39 #[serde(default)]
41 pub exclude: Vec<String>,
42
43 #[serde(default)]
49 pub schemas: HashMap<String, String>,
50
51 #[serde(default, rename = "no-default-catalog")]
54 pub no_default_catalog: bool,
55
56 #[serde(default)]
60 pub registries: Vec<String>,
61
62 #[serde(default)]
67 pub rewrite: HashMap<String, String>,
68
69 #[serde(default, rename = "override")]
71 pub overrides: Vec<Override>,
72}
73
74impl Config {
75 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 self.overrides.extend(parent.overrides);
96 }
97
98 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 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
141pub 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
162pub 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
172pub fn schema() -> Value {
178 serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
179}
180
181pub 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
197pub 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 let mut merged = configs.remove(0);
231 for parent in configs {
232 merged.merge_parent(parent);
233 }
234 Ok(Some(merged))
235}
236
237pub 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 #[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 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#)?;
335
336 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 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 fs::write(
356 tmp.path().join("lintel.toml"),
357 r#"
358exclude = ["parent/**"]
359
360[rewrite]
361"http://parent/" = "//parent/"
362"#,
363 )?;
364
365 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 assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
379 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 #[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 #[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 #[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 #[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 assert!(!config.should_validate_formats(
557 "some/file.toml",
558 &["https://json.schemastore.org/vector.json"]
559 ));
560 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 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 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
649 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, ..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 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
672 }
673
674 #[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 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}