1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use schemars::{schema_for, JsonSchema};
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 if let Some(val) = ov.validate_formats {
126 return val;
127 }
128 }
129 }
130 true
131 }
132}
133
134pub fn apply_rewrites(uri: &str, rewrites: &HashMap<String, String>) -> String {
138 let mut best_match: Option<(&str, &str)> = None;
139 for (from, to) in rewrites {
140 if uri.starts_with(from.as_str())
141 && best_match.is_none_or(|(prev_from, _)| from.len() > prev_from.len())
142 {
143 best_match = Some((from.as_str(), to.as_str()));
144 }
145 }
146 match best_match {
147 Some((from, to)) => format!("{to}{}", &uri[from.len()..]),
148 None => uri.to_string(),
149 }
150}
151
152pub fn resolve_double_slash(uri: &str, config_dir: &Path) -> String {
155 if let Some(rest) = uri.strip_prefix("//") {
156 config_dir.join(rest).to_string_lossy().to_string()
157 } else {
158 uri.to_string()
159 }
160}
161
162pub fn schema() -> Value {
164 serde_json::to_value(schema_for!(Config)).expect("schema serialization cannot fail")
165}
166
167pub fn find_config_path(start_dir: &Path) -> Option<PathBuf> {
170 let mut dir = start_dir.to_path_buf();
171 loop {
172 let candidate = dir.join(CONFIG_FILENAME);
173 if candidate.is_file() {
174 return Some(candidate);
175 }
176 if !dir.pop() {
177 break;
178 }
179 }
180 None
181}
182
183pub fn find_and_load(start_dir: &Path) -> Result<Option<Config>, anyhow::Error> {
187 let mut configs: Vec<Config> = Vec::new();
188 let mut dir = start_dir.to_path_buf();
189
190 loop {
191 let candidate = dir.join(CONFIG_FILENAME);
192 if candidate.is_file() {
193 let content = std::fs::read_to_string(&candidate)?;
194 let cfg: Config = toml::from_str(&content)
195 .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", candidate.display()))?;
196 let is_root = cfg.root;
197 configs.push(cfg);
198 if is_root {
199 break;
200 }
201 }
202 if !dir.pop() {
203 break;
204 }
205 }
206
207 if configs.is_empty() {
208 return Ok(None);
209 }
210
211 let mut merged = configs.remove(0);
213 for parent in configs {
214 merged.merge_parent(parent);
215 }
216 Ok(Some(merged))
217}
218
219pub fn load() -> Result<Config, anyhow::Error> {
221 let cwd = std::env::current_dir()?;
222 Ok(find_and_load(&cwd)?.unwrap_or_default())
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::fs;
229
230 #[test]
231 fn loads_config_from_directory() {
232 let tmp = tempfile::tempdir().unwrap();
233 fs::write(
234 tmp.path().join("lintel.toml"),
235 r#"exclude = ["testdata/**"]"#,
236 )
237 .unwrap();
238
239 let config = find_and_load(tmp.path()).unwrap().unwrap();
240 assert_eq!(config.exclude, vec!["testdata/**"]);
241 }
242
243 #[test]
244 fn walks_up_to_find_config() {
245 let tmp = tempfile::tempdir().unwrap();
246 let sub = tmp.path().join("a/b/c");
247 fs::create_dir_all(&sub).unwrap();
248 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["vendor/**"]"#).unwrap();
249
250 let config = find_and_load(&sub).unwrap().unwrap();
251 assert_eq!(config.exclude, vec!["vendor/**"]);
252 }
253
254 #[test]
255 fn returns_none_when_no_config() {
256 let tmp = tempfile::tempdir().unwrap();
257 let config = find_and_load(tmp.path()).unwrap();
258 assert!(config.is_none());
259 }
260
261 #[test]
262 fn empty_config_is_valid() {
263 let tmp = tempfile::tempdir().unwrap();
264 fs::write(tmp.path().join("lintel.toml"), "").unwrap();
265
266 let config = find_and_load(tmp.path()).unwrap().unwrap();
267 assert!(config.exclude.is_empty());
268 assert!(config.rewrite.is_empty());
269 }
270
271 #[test]
272 fn rejects_unknown_fields() {
273 let tmp = tempfile::tempdir().unwrap();
274 fs::write(tmp.path().join("lintel.toml"), "bogus = true").unwrap();
275
276 let result = find_and_load(tmp.path());
277 assert!(result.is_err());
278 }
279
280 #[test]
281 fn loads_rewrite_rules() {
282 let tmp = tempfile::tempdir().unwrap();
283 fs::write(
284 tmp.path().join("lintel.toml"),
285 r#"
286[rewrite]
287"http://localhost:8000/" = "//schemastore/src/"
288"#,
289 )
290 .unwrap();
291
292 let config = find_and_load(tmp.path()).unwrap().unwrap();
293 assert_eq!(
294 config.rewrite.get("http://localhost:8000/"),
295 Some(&"//schemastore/src/".to_string())
296 );
297 }
298
299 #[test]
302 fn root_true_stops_walk() {
303 let tmp = tempfile::tempdir().unwrap();
304 let sub = tmp.path().join("child");
305 fs::create_dir_all(&sub).unwrap();
306
307 fs::write(tmp.path().join("lintel.toml"), r#"exclude = ["parent/**"]"#).unwrap();
309
310 fs::write(
312 sub.join("lintel.toml"),
313 "root = true\nexclude = [\"child/**\"]",
314 )
315 .unwrap();
316
317 let config = find_and_load(&sub).unwrap().unwrap();
318 assert_eq!(config.exclude, vec!["child/**"]);
319 }
321
322 #[test]
323 fn merges_parent_without_root() {
324 let tmp = tempfile::tempdir().unwrap();
325 let sub = tmp.path().join("child");
326 fs::create_dir_all(&sub).unwrap();
327
328 fs::write(
330 tmp.path().join("lintel.toml"),
331 r#"
332exclude = ["parent/**"]
333
334[rewrite]
335"http://parent/" = "//parent/"
336"#,
337 )
338 .unwrap();
339
340 fs::write(
342 sub.join("lintel.toml"),
343 r#"
344exclude = ["child/**"]
345
346[rewrite]
347"http://child/" = "//child/"
348"#,
349 )
350 .unwrap();
351
352 let config = find_and_load(&sub).unwrap().unwrap();
353 assert_eq!(config.exclude, vec!["child/**", "parent/**"]);
355 assert_eq!(
357 config.rewrite.get("http://child/"),
358 Some(&"//child/".to_string())
359 );
360 assert_eq!(
361 config.rewrite.get("http://parent/"),
362 Some(&"//parent/".to_string())
363 );
364 }
365
366 #[test]
367 fn child_rewrite_wins_on_conflict() {
368 let tmp = tempfile::tempdir().unwrap();
369 let sub = tmp.path().join("child");
370 fs::create_dir_all(&sub).unwrap();
371
372 fs::write(
373 tmp.path().join("lintel.toml"),
374 r#"
375[rewrite]
376"http://example/" = "//parent-value/"
377"#,
378 )
379 .unwrap();
380
381 fs::write(
382 sub.join("lintel.toml"),
383 r#"
384[rewrite]
385"http://example/" = "//child-value/"
386"#,
387 )
388 .unwrap();
389
390 let config = find_and_load(&sub).unwrap().unwrap();
391 assert_eq!(
392 config.rewrite.get("http://example/"),
393 Some(&"//child-value/".to_string())
394 );
395 }
396
397 #[test]
400 fn rewrite_matching_prefix() {
401 let mut rewrites = HashMap::new();
402 rewrites.insert(
403 "http://localhost:8000/".to_string(),
404 "//schemastore/src/".to_string(),
405 );
406 let result = apply_rewrites("http://localhost:8000/schemas/foo.json", &rewrites);
407 assert_eq!(result, "//schemastore/src/schemas/foo.json");
408 }
409
410 #[test]
411 fn rewrite_no_match() {
412 let mut rewrites = HashMap::new();
413 rewrites.insert(
414 "http://localhost:8000/".to_string(),
415 "//schemastore/src/".to_string(),
416 );
417 let result = apply_rewrites("https://example.com/schema.json", &rewrites);
418 assert_eq!(result, "https://example.com/schema.json");
419 }
420
421 #[test]
422 fn rewrite_longest_prefix_wins() {
423 let mut rewrites = HashMap::new();
424 rewrites.insert("http://localhost/".to_string(), "//short/".to_string());
425 rewrites.insert(
426 "http://localhost/api/v2/".to_string(),
427 "//long/".to_string(),
428 );
429 let result = apply_rewrites("http://localhost/api/v2/schema.json", &rewrites);
430 assert_eq!(result, "//long/schema.json");
431 }
432
433 #[test]
436 fn resolve_double_slash_prefix() {
437 let config_dir = Path::new("/home/user/project");
438 let result = resolve_double_slash("//schemas/foo.json", config_dir);
439 assert_eq!(result, "/home/user/project/schemas/foo.json");
440 }
441
442 #[test]
443 fn resolve_double_slash_no_prefix() {
444 let config_dir = Path::new("/home/user/project");
445 let result = resolve_double_slash("https://example.com/s.json", config_dir);
446 assert_eq!(result, "https://example.com/s.json");
447 }
448
449 #[test]
450 fn resolve_double_slash_relative_path_unchanged() {
451 let config_dir = Path::new("/home/user/project");
452 let result = resolve_double_slash("./schemas/foo.json", config_dir);
453 assert_eq!(result, "./schemas/foo.json");
454 }
455
456 #[test]
459 fn parses_override_blocks() {
460 let tmp = tempfile::tempdir().unwrap();
461 fs::write(
462 tmp.path().join("lintel.toml"),
463 r#"
464[[override]]
465files = ["schemas/vector.json"]
466validate_formats = false
467
468[[override]]
469files = ["schemas/other.json"]
470validate_formats = true
471"#,
472 )
473 .unwrap();
474
475 let config = find_and_load(tmp.path()).unwrap().unwrap();
476 assert_eq!(config.overrides.len(), 2);
477 assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
478 assert_eq!(config.overrides[0].validate_formats, Some(false));
479 assert_eq!(config.overrides[1].validate_formats, Some(true));
480 }
481
482 #[test]
483 fn override_validate_formats_defaults_to_none() {
484 let tmp = tempfile::tempdir().unwrap();
485 fs::write(
486 tmp.path().join("lintel.toml"),
487 r#"
488[[override]]
489files = ["schemas/vector.json"]
490"#,
491 )
492 .unwrap();
493
494 let config = find_and_load(tmp.path()).unwrap().unwrap();
495 assert_eq!(config.overrides.len(), 1);
496 assert_eq!(config.overrides[0].validate_formats, None);
497 }
498
499 #[test]
502 fn should_validate_formats_default_true() {
503 let config = Config::default();
504 assert!(config.should_validate_formats("anything.json", &[]));
505 }
506
507 #[test]
508 fn should_validate_formats_matching_file_override() {
509 let config = Config {
510 overrides: vec![Override {
511 files: vec!["schemas/vector.json".to_string()],
512 validate_formats: Some(false),
513 ..Default::default()
514 }],
515 ..Default::default()
516 };
517 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
518 assert!(config.should_validate_formats("schemas/other.json", &[]));
519 }
520
521 #[test]
522 fn should_validate_formats_matching_schema_override() {
523 let config = Config {
524 overrides: vec![Override {
525 schemas: vec!["https://json.schemastore.org/vector.json".to_string()],
526 validate_formats: Some(false),
527 ..Default::default()
528 }],
529 ..Default::default()
530 };
531 assert!(!config.should_validate_formats(
533 "some/file.toml",
534 &["https://json.schemastore.org/vector.json"]
535 ));
536 assert!(config.should_validate_formats(
538 "some/file.toml",
539 &["https://json.schemastore.org/other.json"]
540 ));
541 }
542
543 #[test]
544 fn should_validate_formats_schema_glob() {
545 let config = Config {
546 overrides: vec![Override {
547 schemas: vec!["https://json.schemastore.org/*.json".to_string()],
548 validate_formats: Some(false),
549 ..Default::default()
550 }],
551 ..Default::default()
552 };
553 assert!(!config
554 .should_validate_formats("any.toml", &["https://json.schemastore.org/vector.json"]));
555 }
556
557 #[test]
558 fn should_validate_formats_matches_resolved_uri() {
559 let config = Config {
560 overrides: vec![Override {
561 schemas: vec!["/local/schemas/vector.json".to_string()],
562 validate_formats: Some(false),
563 ..Default::default()
564 }],
565 ..Default::default()
566 };
567 assert!(!config.should_validate_formats(
569 "any.toml",
570 &[
571 "https://json.schemastore.org/vector.json",
572 "/local/schemas/vector.json"
573 ]
574 ));
575 }
576
577 #[test]
578 fn should_validate_formats_glob_pattern() {
579 let config = Config {
580 overrides: vec![Override {
581 files: vec!["schemas/**/*.json".to_string()],
582 validate_formats: Some(false),
583 ..Default::default()
584 }],
585 ..Default::default()
586 };
587 assert!(!config.should_validate_formats("schemas/deep/nested.json", &[]));
588 assert!(config.should_validate_formats("other/file.json", &[]));
589 }
590
591 #[test]
592 fn should_validate_formats_strips_dot_slash() {
593 let config = Config {
594 overrides: vec![Override {
595 files: vec!["schemas/vector.json".to_string()],
596 validate_formats: Some(false),
597 ..Default::default()
598 }],
599 ..Default::default()
600 };
601 assert!(!config.should_validate_formats("./schemas/vector.json", &[]));
602 }
603
604 #[test]
605 fn should_validate_formats_first_match_wins() {
606 let config = Config {
607 overrides: vec![
608 Override {
609 files: vec!["schemas/vector.json".to_string()],
610 validate_formats: Some(false),
611 ..Default::default()
612 },
613 Override {
614 files: vec!["schemas/**".to_string()],
615 validate_formats: Some(true),
616 ..Default::default()
617 },
618 ],
619 ..Default::default()
620 };
621 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
623 assert!(config.should_validate_formats("schemas/other.json", &[]));
625 }
626
627 #[test]
628 fn should_validate_formats_skips_none_override() {
629 let config = Config {
630 overrides: vec![
631 Override {
632 files: vec!["schemas/vector.json".to_string()],
633 validate_formats: None, ..Default::default()
635 },
636 Override {
637 files: vec!["schemas/**".to_string()],
638 validate_formats: Some(false),
639 ..Default::default()
640 },
641 ],
642 ..Default::default()
643 };
644 assert!(!config.should_validate_formats("schemas/vector.json", &[]));
646 }
647
648 #[test]
651 fn merge_overrides_child_first() {
652 let tmp = tempfile::tempdir().unwrap();
653 let sub = tmp.path().join("child");
654 fs::create_dir_all(&sub).unwrap();
655
656 fs::write(
657 tmp.path().join("lintel.toml"),
658 r#"
659[[override]]
660files = ["schemas/**"]
661validate_formats = true
662"#,
663 )
664 .unwrap();
665
666 fs::write(
667 sub.join("lintel.toml"),
668 r#"
669[[override]]
670files = ["schemas/vector.json"]
671validate_formats = false
672"#,
673 )
674 .unwrap();
675
676 let config = find_and_load(&sub).unwrap().unwrap();
677 assert_eq!(config.overrides.len(), 2);
679 assert_eq!(config.overrides[0].files, vec!["schemas/vector.json"]);
680 assert_eq!(config.overrides[0].validate_formats, Some(false));
681 assert_eq!(config.overrides[1].files, vec!["schemas/**"]);
682 assert_eq!(config.overrides[1].validate_formats, Some(true));
683 }
684}