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)]
53 pub registries: Vec<String>,
54
55 #[serde(default)]
60 pub rewrite: HashMap<String, String>,
61
62 #[serde(default, rename = "override")]
64 pub overrides: Vec<Override>,
65}
66
67impl Config {
68 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 self.overrides.extend(parent.overrides);
89 }
90
91 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 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
134pub 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
155pub 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
165pub fn schema() -> Value {
171 serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
172}
173
174pub 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
190pub 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 let mut merged = configs.remove(0);
224 for parent in configs {
225 merged.merge_parent(parent);
226 }
227 Ok(Some(merged))
228}
229
230pub 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 #[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 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#)?;
328
329 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 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 fs::write(
349 tmp.path().join("lintel.toml"),
350 r#"
351exclude = ["parent/**"]
352
353[rewrite]
354"http://parent/" = "//parent/"
355"#,
356 )?;
357
358 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 assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
372 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 #[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 #[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 #[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 #[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 assert!(!config.should_validate_formats(
550 "some/file.toml",
551 &["https://json.schemastore.org/vector.json"]
552 ));
553 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 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 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
642 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, ..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 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
665 }
666
667 #[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 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}