1#![warn(rust_2024_compatibility, clippy::all)]
2
3pub mod config;
4pub mod linter_output;
5
6use anyhow::Result;
7use camino::Utf8Path;
8use dictator_decree_abi::{BoxDecree, Diagnostic, Diagnostics};
9use std::collections::{HashMap, HashSet};
10
11pub use config::{DecreeSettings, DictateConfig};
12
13pub struct Source<'a> {
15 pub path: &'a Utf8Path,
16 pub text: &'a str,
17}
18
19pub struct Regime {
21 decrees: Vec<BoxDecree>,
22 rule_ignores: HashMap<String, HashMap<String, config::RuleIgnore>>,
23}
24
25impl Default for Regime {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl Regime {
32 #[must_use]
33 pub fn new() -> Self {
34 Self {
35 decrees: Vec::new(),
36 rule_ignores: HashMap::new(),
37 }
38 }
39
40 #[must_use]
41 pub fn with_decree(mut self, decree: BoxDecree) -> Self {
42 self.decrees.push(decree);
43 self
44 }
45
46 pub fn add_decree(&mut self, decree: BoxDecree) {
47 self.decrees.push(decree);
48 }
49
50 pub fn set_rule_ignores_from_config(&mut self, config: Option<&DictateConfig>) {
55 self.rule_ignores.clear();
56
57 let Some(cfg) = config else {
58 return;
59 };
60
61 for (decree_name, settings) in &cfg.decree {
62 if settings.ignore.is_empty() {
63 continue;
64 }
65 self.rule_ignores
66 .insert(decree_name.clone(), settings.ignore.clone());
67 }
68 }
69
70 #[must_use]
77 pub fn watched_extensions(&self) -> Option<HashSet<String>> {
78 let mut exts = HashSet::new();
79 for decree in &self.decrees {
80 let supported = &decree.metadata().supported_extensions;
81 if supported.is_empty() {
82 continue; }
84 for ext in supported {
85 exts.insert(ext.to_ascii_lowercase());
86 }
87 }
88
89 if exts.is_empty() { None } else { Some(exts) }
90 }
91
92 #[cfg(feature = "wasm-loader")]
99 pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<()> {
100 let decree = loader::load_decree(path.as_ref())?;
101 self.decrees.push(decree);
102 Ok(())
103 }
104
105 #[cfg(not(feature = "wasm-loader"))]
106 pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, _path: P) -> Result<()> {
107 anyhow::bail!("WASM loader disabled; enable the `wasm-loader` feature to load decrees");
108 }
109
110 pub fn enforce(&self, sources: &[Source<'_>]) -> Result<Diagnostics> {
122 let mut all = Diagnostics::new();
123 for src in sources {
124 let filename = src.path.file_name().unwrap_or("");
125
126 let is_supreme_shadowed = self.is_supreme_shadowed(src.path);
129
130 for decree in &self.decrees {
131 let meta = decree.metadata();
132
133 if meta.skip_filenames.iter().any(|s| s == filename) {
135 continue;
136 }
137
138 let matches = Self::decree_matches(src.path, &meta);
140 if !matches {
141 continue;
142 }
143
144 let is_universal =
146 meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty();
147 if is_supreme_shadowed && is_universal && decree.name() == "supreme" {
148 continue;
149 }
150
151 let diags = decree.lint(src.path.as_str(), src.text);
152 for diag in diags {
153 if self.is_rule_ignored_for_path(src.path, &diag) {
154 continue;
155 }
156 all.push(diag);
157 }
158 }
159 }
160 Ok(all)
161 }
162
163 fn decree_matches(path: &Utf8Path, meta: &dictator_decree_abi::DecreeMetadata) -> bool {
165 let filename = path.file_name().unwrap_or("");
166
167 if meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty() {
169 return true;
170 }
171
172 if meta.supported_filenames.iter().any(|s| s == filename) {
174 return true;
175 }
176
177 Self::extension_matches(path, &meta.supported_extensions)
179 }
180
181 fn extension_matches(path: &Utf8Path, supported: &[String]) -> bool {
183 path.extension()
184 .is_some_and(|ext| supported.iter().any(|s| s == ext))
185 }
186
187 fn is_supreme_shadowed(&self, path: &Utf8Path) -> bool {
188 const SHADOWERS: [&str; 5] = ["ruby", "typescript", "golang", "rust", "python"];
191
192 self.decrees.iter().any(|decree| {
193 let name = decree.name();
194 if !SHADOWERS.contains(&name) {
195 return false;
196 }
197
198 let meta = decree.metadata();
199
200 Self::decree_matches(path, &meta)
202 })
203 }
204
205 fn is_rule_ignored_for_path(&self, path: &Utf8Path, diag: &Diagnostic) -> bool {
206 if self.rule_ignores.is_empty() {
207 return false;
208 }
209
210 let Some((decree, rule_name)) = diag.rule.split_once('/') else {
211 return false;
212 };
213
214 let Some(rules) = self.rule_ignores.get(decree) else {
215 return false;
216 };
217 let Some(ignore) = rules.get(rule_name) else {
218 return false;
219 };
220
221 let filename = path.file_name().unwrap_or("");
222 if ignore.filenames.iter().any(|f| f == filename) {
223 return true;
224 }
225
226 let Some(ext) = path.extension() else {
227 return false;
228 };
229 ignore
230 .extensions
231 .iter()
232 .any(|e| e.eq_ignore_ascii_case(ext))
233 }
234}
235
236#[cfg(feature = "wasm-loader")]
237mod loader {
238 use anyhow::{Context, Result};
239 use dictator_decree_abi::{BoxDecree, Diagnostics, Span};
240 use libloading::Library;
241 use std::path::Path;
242 use std::sync::Mutex;
243 use wasmtime::component::{Component, Linker, ResourceTable};
244 use wasmtime::{Config, Engine, Store};
245 use wasmtime_wasi::p2::add_to_linker_sync;
246 use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
247
248 mod bindings {
249 wasmtime::component::bindgen!({ path: "wit/decree.wit", world: "decree" });
250 }
251
252 #[allow(unsafe_code)]
260 fn load_native(lib_path: &Path) -> Result<BoxDecree> {
261 use dictator_decree_abi::{ABI_VERSION, DECREE_FACTORY_EXPORT, DecreeFactory};
262
263 static LOADED_LIBRARIES: std::sync::OnceLock<std::sync::Mutex<Vec<Library>>> =
267 std::sync::OnceLock::new();
268
269 unsafe {
270 let lib = Library::new(lib_path)
271 .with_context(|| format!("failed to load native decree: {}", lib_path.display()))?;
272 let ctor: libloading::Symbol<DecreeFactory> =
273 lib.get(DECREE_FACTORY_EXPORT.as_bytes()).with_context(|| {
274 format!(
275 "missing symbol {} in {}",
276 DECREE_FACTORY_EXPORT,
277 lib_path.display()
278 )
279 })?;
280
281 let decree = ctor();
282
283 let metadata = decree.metadata();
285 metadata.validate_abi(ABI_VERSION).map_err(|e| {
286 anyhow::anyhow!(
287 "Decree '{}' from {}: {}",
288 decree.name(),
289 lib_path.display(),
290 e
291 )
292 })?;
293
294 tracing::info!(
295 "Loaded decree '{}' v{} (ABI {})",
296 decree.name(),
297 metadata.decree_version,
298 metadata.abi_version
299 );
300
301 LOADED_LIBRARIES
303 .get_or_init(std::sync::Mutex::default)
304 .lock()
305 .expect("loaded libraries mutex poisoned")
306 .push(lib);
307
308 Ok(decree)
309 }
310 }
311
312 use self::bindings::exports::dictator::decree::lints as guest;
313
314 struct HostState {
315 table: ResourceTable,
316 wasi: WasiCtx,
317 }
318
319 impl WasiView for HostState {
320 fn ctx(&mut self) -> WasiCtxView<'_> {
321 WasiCtxView {
322 ctx: &mut self.wasi,
323 table: &mut self.table,
324 }
325 }
326 }
327
328 struct WasmDecree {
329 name: String,
330 metadata: dictator_decree_abi::DecreeMetadata,
331 state: Mutex<WasmState>,
332 }
333
334 struct WasmState {
335 store: Store<HostState>,
336 plugin: bindings::Decree,
337 }
338
339 impl dictator_decree_abi::Decree for WasmDecree {
340 fn name(&self) -> &str {
341 &self.name
342 }
343
344 #[allow(clippy::significant_drop_tightening)]
345 fn lint(&self, path: &str, source: &str) -> Diagnostics {
346 let result = {
347 let mut guard = self.state.lock().expect("wasm store poisoned");
348 let WasmState { plugin, store } = &mut *guard;
349 plugin
350 .dictator_decree_lints()
351 .call_lint(store, path, source)
352 .unwrap_or_default()
353 };
354 result
355 .into_iter()
356 .map(|d| dictator_decree_abi::Diagnostic {
357 rule: d.rule,
358 message: d.message,
359 enforced: matches!(d.severity, guest::Severity::Info), span: Span {
361 start: d.span.start as usize,
362 end: d.span.end as usize,
363 },
364 })
365 .collect()
366 }
367
368 fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
369 self.metadata.clone()
370 }
371 }
372
373 fn load_wasm(lib_path: &Path) -> Result<BoxDecree> {
374 use dictator_decree_abi::ABI_VERSION;
375
376 let mut config = Config::new();
377 config.wasm_component_model(true);
378 let engine = Engine::new(&config)?;
379 let component = Component::from_file(&engine, lib_path)
380 .with_context(|| format!("failed to load wasm decree: {}", lib_path.display()))?;
381 let mut linker: Linker<HostState> = Linker::new(&engine);
382 add_to_linker_sync(&mut linker)?;
383 let host_state = HostState {
384 table: ResourceTable::new(),
385 wasi: WasiCtxBuilder::new().inherit_stdio().build(),
386 };
387 let mut store = Store::new(&engine, host_state);
388 let plugin = bindings::Decree::instantiate(&mut store, &component, &linker)?;
389 let guest = plugin.dictator_decree_lints();
390
391 let name = guest
392 .call_name(&mut store)
393 .unwrap_or_else(|_| "wasm-decree".to_string());
394
395 let wasm_meta = guest
397 .call_metadata(&mut store)
398 .context("failed to call metadata on wasm decree")?;
399
400 let metadata = dictator_decree_abi::DecreeMetadata {
401 abi_version: wasm_meta.abi_version,
402 decree_version: wasm_meta.decree_version,
403 description: wasm_meta.description,
404 dectauthors: wasm_meta.dectauthors,
405 supported_extensions: wasm_meta.supported_extensions,
406 supported_filenames: wasm_meta.supported_filenames,
407 skip_filenames: wasm_meta.skip_filenames,
408 capabilities: wasm_meta
409 .capabilities
410 .into_iter()
411 .map(|c| match c {
412 guest::Capability::Lint => dictator_decree_abi::Capability::Lint,
413 guest::Capability::AutoFix => dictator_decree_abi::Capability::AutoFix,
414 guest::Capability::Streaming => dictator_decree_abi::Capability::Streaming,
415 guest::Capability::RuntimeConfig => {
416 dictator_decree_abi::Capability::RuntimeConfig
417 }
418 guest::Capability::RichDiagnostics => {
419 dictator_decree_abi::Capability::RichDiagnostics
420 }
421 })
422 .collect(),
423 };
424
425 metadata
426 .validate_abi(ABI_VERSION)
427 .map_err(|e| anyhow::anyhow!("Decree '{}' from {}: {}", name, lib_path.display(), e))?;
428
429 tracing::info!(
430 "Loaded WASM decree '{}' v{} (ABI {})",
431 name,
432 metadata.decree_version,
433 metadata.abi_version
434 );
435
436 Ok(Box::new(WasmDecree {
437 name,
438 metadata,
439 state: Mutex::new(WasmState { store, plugin }),
440 }))
441 }
442
443 pub fn load_decree(path: &Path) -> Result<BoxDecree> {
444 match path.extension().and_then(|s| s.to_str()) {
445 Some("wasm") => load_wasm(path),
446 _ => load_native(path),
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use dictator_decree_abi::{Capability, Decree, DecreeMetadata, Diagnostics};
455 use dictator_decree_abi::{Diagnostic, Span};
456
457 struct MockDecree {
458 name: &'static str,
459 exts: Vec<String>,
460 filenames: Vec<String>,
461 skip: Vec<String>,
462 rule: &'static str,
463 }
464
465 impl MockDecree {
466 fn simple(name: &'static str, exts: Vec<String>, rule: &'static str) -> Self {
467 Self {
468 name,
469 exts,
470 filenames: vec![],
471 skip: vec![],
472 rule,
473 }
474 }
475 }
476
477 impl Decree for MockDecree {
478 fn name(&self) -> &str {
479 self.name
480 }
481
482 fn lint(&self, _path: &str, _source: &str) -> Diagnostics {
483 vec![Diagnostic {
484 rule: self.rule.to_string(),
485 message: format!("hit {}", self.name),
486 span: Span::new(0, 0),
487 enforced: false,
488 }]
489 }
490
491 fn metadata(&self) -> DecreeMetadata {
492 DecreeMetadata {
493 abi_version: "1".into(),
494 decree_version: "1".into(),
495 description: String::new(),
496 dectauthors: None,
497 supported_extensions: self.exts.clone(),
498 supported_filenames: self.filenames.clone(),
499 skip_filenames: self.skip.clone(),
500 capabilities: vec![Capability::Lint],
501 }
502 }
503 }
504
505 #[test]
506 fn watched_extensions_unites_declared_sets() {
507 let decree_a: BoxDecree = Box::new(MockDecree::simple(
508 "a",
509 vec!["rs".into(), "Rb".into()],
510 "a/hit",
511 ));
512 let decree_b: BoxDecree = Box::new(MockDecree::simple("b", vec!["ts".into()], "b/hit"));
513 let mut regime = Regime::new();
514 regime.add_decree(decree_a);
515 regime.add_decree(decree_b);
516
517 let exts = regime.watched_extensions().unwrap();
518 assert!(exts.contains("rs"));
519 assert!(exts.contains("rb"));
520 assert!(exts.contains("ts"));
521 assert_eq!(exts.len(), 3);
522 }
523
524 #[test]
525 fn watched_extensions_none_when_only_universal() {
526 let sup: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
527 let mut regime = Regime::new();
528 regime.add_decree(sup);
529
530 assert!(regime.watched_extensions().is_none());
531 }
532
533 #[test]
534 fn enforce_skips_supreme_when_language_specific_matches() {
535 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
536 let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
537
538 let mut regime = Regime::new();
539 regime.add_decree(supreme);
540 regime.add_decree(ruby);
541
542 let path = Utf8Path::new("test.rb");
543 let sources = [Source { path, text: "x" }];
544
545 let diags = regime.enforce(&sources).unwrap();
546 assert!(diags.iter().any(|d| d.rule == "ruby/hit"));
547 assert!(!diags.iter().any(|d| d.rule == "supreme/hit"));
548 }
549
550 #[test]
551 fn enforce_runs_supreme_when_language_specific_does_not_match() {
552 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
553 let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
554
555 let mut regime = Regime::new();
556 regime.add_decree(supreme);
557 regime.add_decree(ruby);
558
559 let path = Utf8Path::new("test.txt");
560 let sources = [Source { path, text: "x" }];
561
562 let diags = regime.enforce(&sources).unwrap();
563 assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
564 assert!(!diags.iter().any(|d| d.rule == "ruby/hit"));
565 }
566
567 #[test]
568 fn enforce_ignores_configured_rules_by_filename() {
569 let supreme: BoxDecree = Box::new(MockDecree::simple(
570 "supreme",
571 vec![],
572 "supreme/tab-character",
573 ));
574
575 let mut settings = DecreeSettings::default();
576 settings.ignore.insert(
577 "tab-character".to_string(),
578 crate::config::RuleIgnore {
579 filenames: vec!["Makefile".to_string()],
580 extensions: vec![],
581 },
582 );
583 let mut config = DictateConfig::default();
584 config.decree.insert("supreme".to_string(), settings);
585
586 let mut regime = Regime::new();
587 regime.set_rule_ignores_from_config(Some(&config));
588 regime.add_decree(supreme);
589
590 let path = Utf8Path::new("Makefile");
591 let sources = [Source { path, text: "x" }];
592 let diags = regime.enforce(&sources).unwrap();
593 assert!(diags.is_empty(), "rule should be ignored for Makefile");
594 }
595
596 #[test]
597 fn enforce_ignores_configured_rules_by_extension() {
598 let supreme: BoxDecree = Box::new(MockDecree::simple(
599 "supreme",
600 vec![],
601 "supreme/tab-character",
602 ));
603
604 let mut settings = DecreeSettings::default();
605 settings.ignore.insert(
606 "tab-character".to_string(),
607 crate::config::RuleIgnore {
608 filenames: vec![],
609 extensions: vec!["md".to_string(), "MDX".to_string()],
610 },
611 );
612 let mut config = DictateConfig::default();
613 config.decree.insert("supreme".to_string(), settings);
614
615 let mut regime = Regime::new();
616 regime.set_rule_ignores_from_config(Some(&config));
617 regime.add_decree(supreme);
618
619 let path = Utf8Path::new("README.md");
620 let sources = [Source { path, text: "x" }];
621 let diags = regime.enforce(&sources).unwrap();
622 assert!(diags.is_empty(), "rule should be ignored for .md");
623
624 let path = Utf8Path::new("doc.mdx");
625 let sources = [Source { path, text: "x" }];
626 let diags = regime.enforce(&sources).unwrap();
627 assert!(diags.is_empty(), "rule should be ignored for .mdx");
628 }
629
630 #[test]
631 fn enforce_does_not_ignore_unconfigured_rules() {
632 let supreme: BoxDecree = Box::new(MockDecree::simple(
633 "supreme",
634 vec![],
635 "supreme/trailing-whitespace",
636 ));
637
638 let mut settings = DecreeSettings::default();
639 settings.ignore.insert(
640 "tab-character".to_string(),
641 crate::config::RuleIgnore {
642 filenames: vec!["Makefile".to_string()],
643 extensions: vec!["md".to_string()],
644 },
645 );
646 let mut config = DictateConfig::default();
647 config.decree.insert("supreme".to_string(), settings);
648
649 let mut regime = Regime::new();
650 regime.set_rule_ignores_from_config(Some(&config));
651 regime.add_decree(supreme);
652
653 let path = Utf8Path::new("README.md");
654 let sources = [Source { path, text: "x" }];
655 let diags = regime.enforce(&sources).unwrap();
656 assert!(
657 diags
658 .iter()
659 .any(|d| d.rule == "supreme/trailing-whitespace"),
660 "unconfigured rules should still be reported"
661 );
662 }
663
664 #[test]
665 fn enforce_does_not_shadow_supreme_for_non_language_decree() {
666 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
667 let frontmatter: BoxDecree = Box::new(MockDecree::simple(
668 "frontmatter",
669 vec!["md".into()],
670 "frontmatter/hit",
671 ));
672
673 let mut regime = Regime::new();
674 regime.add_decree(supreme);
675 regime.add_decree(frontmatter);
676
677 let path = Utf8Path::new("README.md");
678 let sources = [Source { path, text: "x" }];
679
680 let diags = regime.enforce(&sources).unwrap();
681 assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
682 assert!(diags.iter().any(|d| d.rule == "frontmatter/hit"));
683 }
684
685 #[test]
686 fn enforce_golang_shadows_supreme_for_go_files() {
687 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
688 let golang: BoxDecree = Box::new(MockDecree::simple(
689 "golang",
690 vec!["go".into()],
691 "golang/hit",
692 ));
693
694 let mut regime = Regime::new();
695 regime.add_decree(supreme);
696 regime.add_decree(golang);
697
698 let path = Utf8Path::new("main.go");
699 let sources = [Source {
700 path,
701 text: "package main",
702 }];
703
704 let diags = regime.enforce(&sources).unwrap();
705 assert!(
706 diags.iter().any(|d| d.rule == "golang/hit"),
707 "golang should run on .go files"
708 );
709 assert!(
710 !diags.iter().any(|d| d.rule == "supreme/hit"),
711 "supreme should be shadowed by golang"
712 );
713 }
714
715 #[test]
716 fn enforce_supreme_runs_on_go_files_when_golang_not_loaded() {
717 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
718
719 let mut regime = Regime::new();
720 regime.add_decree(supreme);
721
722 let path = Utf8Path::new("main.go");
723 let sources = [Source {
724 path,
725 text: "package main",
726 }];
727
728 let diags = regime.enforce(&sources).unwrap();
729 assert!(
730 diags.iter().any(|d| d.rule == "supreme/hit"),
731 "supreme should run when no golang decree loaded"
732 );
733 }
734
735 #[test]
736 fn enforce_all_shadowers_work() {
737 for (name, ext, rule) in [
739 ("ruby", "rb", "ruby/hit"),
740 ("typescript", "ts", "typescript/hit"),
741 ("golang", "go", "golang/hit"),
742 ("rust", "rs", "rust/hit"),
743 ("python", "py", "python/hit"),
744 ] {
745 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
746 let lang: BoxDecree = Box::new(MockDecree::simple(name, vec![ext.into()], rule));
747
748 let mut regime = Regime::new();
749 regime.add_decree(supreme);
750 regime.add_decree(lang);
751
752 let path_str = format!("test.{ext}");
753 let path = Utf8Path::new(&path_str);
754 let sources = [Source { path, text: "x" }];
755
756 let diags = regime.enforce(&sources).unwrap();
757 assert!(
758 diags.iter().any(|d| d.rule == rule),
759 "{name} should run on .{ext} files"
760 );
761 assert!(
762 !diags.iter().any(|d| d.rule == "supreme/hit"),
763 "supreme should be shadowed by {name} on .{ext} files"
764 );
765 }
766 }
767
768 #[test]
771 fn enforce_matches_by_filename() {
772 let ruby: BoxDecree = Box::new(MockDecree {
773 name: "ruby",
774 exts: vec!["rb".into()],
775 filenames: vec!["Gemfile".into(), "Rakefile".into()],
776 skip: vec![],
777 rule: "ruby/hit",
778 });
779
780 let mut regime = Regime::new();
781 regime.add_decree(ruby);
782
783 let path = Utf8Path::new("Gemfile");
785 let sources = [Source { path, text: "x" }];
786 let diags = regime.enforce(&sources).unwrap();
787 assert!(
788 diags.iter().any(|d| d.rule == "ruby/hit"),
789 "ruby should match Gemfile by filename"
790 );
791 }
792
793 #[test]
794 fn enforce_skips_skip_filenames() {
795 let ruby: BoxDecree = Box::new(MockDecree {
796 name: "ruby",
797 exts: vec!["rb".into()],
798 filenames: vec!["Gemfile".into()],
799 skip: vec!["Gemfile.lock".into()],
800 rule: "ruby/hit",
801 });
802
803 let mut regime = Regime::new();
804 regime.add_decree(ruby);
805
806 let path = Utf8Path::new("Gemfile.lock");
808 let sources = [Source { path, text: "x" }];
809 let diags = regime.enforce(&sources).unwrap();
810 assert!(
811 diags.is_empty(),
812 "Gemfile.lock should be skipped (owned but not linted)"
813 );
814 }
815
816 #[test]
817 fn enforce_skip_filenames_prevents_supreme() {
818 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
819 let ruby: BoxDecree = Box::new(MockDecree {
820 name: "ruby",
821 exts: vec!["rb".into()],
822 filenames: vec!["Gemfile".into()],
823 skip: vec!["Gemfile.lock".into()],
824 rule: "ruby/hit",
825 });
826
827 let mut regime = Regime::new();
828 regime.add_decree(supreme);
829 regime.add_decree(ruby);
830
831 let path = Utf8Path::new("Gemfile.lock");
833 let sources = [Source { path, text: "x" }];
834 let diags = regime.enforce(&sources).unwrap();
835
836 assert!(
844 diags.iter().any(|d| d.rule == "supreme/hit"),
845 "supreme lints files not in its skip list"
846 );
847 }
848
849 #[test]
850 fn enforce_filename_shadows_supreme() {
851 let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
852 let golang: BoxDecree = Box::new(MockDecree {
853 name: "golang",
854 exts: vec!["go".into()],
855 filenames: vec!["go.mod".into()],
856 skip: vec!["go.sum".into()],
857 rule: "golang/hit",
858 });
859
860 let mut regime = Regime::new();
861 regime.add_decree(supreme);
862 regime.add_decree(golang);
863
864 let path = Utf8Path::new("go.mod");
866 let sources = [Source { path, text: "x" }];
867 let diags = regime.enforce(&sources).unwrap();
868 assert!(
869 diags.iter().any(|d| d.rule == "golang/hit"),
870 "golang should match go.mod"
871 );
872 assert!(
873 !diags.iter().any(|d| d.rule == "supreme/hit"),
874 "supreme should be shadowed by golang for go.mod"
875 );
876 }
877
878 #[test]
879 fn enforce_golang_skips_go_sum() {
880 let golang: BoxDecree = Box::new(MockDecree {
881 name: "golang",
882 exts: vec!["go".into()],
883 filenames: vec!["go.mod".into()],
884 skip: vec!["go.sum".into()],
885 rule: "golang/hit",
886 });
887
888 let mut regime = Regime::new();
889 regime.add_decree(golang);
890
891 let path = Utf8Path::new("go.sum");
893 let sources = [Source { path, text: "x" }];
894 let diags = regime.enforce(&sources).unwrap();
895 assert!(diags.is_empty(), "go.sum should be skipped by golang");
896 }
897}