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