dictator_core/
lib.rs

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
13/// In-memory source file for the Regime to enforce.
14pub struct Source<'a> {
15    pub path: &'a Utf8Path,
16    pub text: &'a str,
17}
18
19/// The Regime: owns decree instances and enforces them over sources.
20pub 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    /// Configure per-rule ignores from a loaded `.dictate.toml`.
51    ///
52    /// Ignores are keyed by decree name (`decree.<name>`) and rule name (the
53    /// portion after `{decree}/` in diagnostic rule identifiers).
54    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    /// Return the union of supported extensions for all loaded decrees.
71    ///
72    /// - If at least one decree declares specific extensions, returns `Some(HashSet)` of
73    ///   those (lowercased) extensions.
74    /// - If no decree declares extensions (all empty lists), returns `None`, meaning
75    ///   "watch everything" (typical when only supreme is loaded).
76    #[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; // empty means "all" for enforcement, but we don't widen the watch set
83            }
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    /// Load a WASM decree from a file path.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the file cannot be loaded, if it's not a valid WASM/native decree,
97    /// or if the decree's ABI version is incompatible with the host.
98    pub fn add_wasm_decree<P: AsRef<std::path::Path>>(&mut self, path: P) -> Result<()> {
99        let decree = loader::load_decree(path.as_ref())?;
100        self.decrees.push(decree);
101        Ok(())
102    }
103
104    /// Enforce all decrees over provided sources.
105    ///
106    /// Matching priority:
107    /// 1. `skip_filenames` - decree owns file but returns empty diagnostics
108    /// 2. `supported_filenames` - exact filename match
109    /// 3. `supported_extensions` - extension match
110    /// 4. Universal decrees (empty lists) run on all files unless shadowed
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if any decree fails during linting.
115    pub fn enforce(&self, sources: &[Source<'_>]) -> Result<Diagnostics> {
116        let mut all = Diagnostics::new();
117        for src in sources {
118            let filename = src.path.file_name().unwrap_or("");
119
120            // CSS-style specificity: if a language-specific decree is present for this file
121            // type, do not run the catch-all decree.supreme on this file.
122            let is_supreme_shadowed = self.is_supreme_shadowed(src.path);
123
124            for decree in &self.decrees {
125                let meta = decree.metadata();
126
127                // Skip files in skip_filenames (owned but not linted)
128                if meta.skip_filenames.iter().any(|s| s == filename) {
129                    continue;
130                }
131
132                // Check if decree matches this file
133                let matches = Self::decree_matches(src.path, &meta);
134                if !matches {
135                    continue;
136                }
137
138                // Universal decrees shadowed by language-specific ones
139                let is_universal =
140                    meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty();
141                if is_supreme_shadowed && is_universal && decree.name() == "supreme" {
142                    continue;
143                }
144
145                let diags = decree.lint(src.path.as_str(), src.text);
146                for diag in diags {
147                    if self.is_rule_ignored_for_path(src.path, &diag) {
148                        continue;
149                    }
150                    all.push(diag);
151                }
152            }
153        }
154        Ok(all)
155    }
156
157    /// Check if a decree matches a file (by extension or filename).
158    fn decree_matches(path: &Utf8Path, meta: &dictator_decree_abi::DecreeMetadata) -> bool {
159        let filename = path.file_name().unwrap_or("");
160
161        // Universal decree (empty lists) matches everything
162        if meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty() {
163            return true;
164        }
165
166        // Check filename match
167        if meta.supported_filenames.iter().any(|s| s == filename) {
168            return true;
169        }
170
171        // Check extension match
172        Self::extension_matches(path, &meta.supported_extensions)
173    }
174
175    /// Check if a file's extension matches any in the supported list.
176    fn extension_matches(path: &Utf8Path, supported: &[String]) -> bool {
177        path.extension()
178            .is_some_and(|ext| supported.iter().any(|s| s == ext))
179    }
180
181    fn is_supreme_shadowed(&self, path: &Utf8Path) -> bool {
182        // Only language-specific decrees shadow decree.supreme. Other decrees (e.g. frontmatter
183        // or custom plugins) remain additive and run alongside supreme.
184        const SHADOWERS: [&str; 5] = ["ruby", "typescript", "golang", "rust", "python"];
185
186        self.decrees.iter().any(|decree| {
187            let name = decree.name();
188            if !SHADOWERS.contains(&name) {
189                return false;
190            }
191
192            let meta = decree.metadata();
193
194            // Check if this shadower handles this file
195            Self::decree_matches(path, &meta)
196        })
197    }
198
199    fn is_rule_ignored_for_path(&self, path: &Utf8Path, diag: &Diagnostic) -> bool {
200        if self.rule_ignores.is_empty() {
201            return false;
202        }
203
204        let Some((decree, rule_name)) = diag.rule.split_once('/') else {
205            return false;
206        };
207
208        let Some(rules) = self.rule_ignores.get(decree) else {
209            return false;
210        };
211        let Some(ignore) = rules.get(rule_name) else {
212            return false;
213        };
214
215        let filename = path.file_name().unwrap_or("");
216        if ignore.filenames.iter().any(|f| f == filename) {
217            return true;
218        }
219
220        let Some(ext) = path.extension() else {
221            return false;
222        };
223        ignore
224            .extensions
225            .iter()
226            .any(|e| e.eq_ignore_ascii_case(ext))
227    }
228}
229
230mod loader {
231    use anyhow::{Context, Result};
232    use dictator_decree_abi::{BoxDecree, Diagnostics, Span};
233    use libloading::Library;
234    use std::path::Path;
235    use std::sync::Mutex;
236    use wasmtime::component::{Component, Linker, ResourceTable};
237    use wasmtime::{Config, Engine, Store};
238    use wasmtime_wasi::p2::add_to_linker_sync;
239    use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
240
241    mod bindings {
242        wasmtime::component::bindgen!({ path: "wit/decree.wit", world: "decree" });
243    }
244
245    /// Load a decree compiled as a native dynamic library (.dylib/.so/.dll).
246    ///
247    /// # Safety
248    /// Loading dynamic libraries is inherently unsafe. The library must:
249    /// - Export a valid `dictator_create_decree` symbol
250    /// - Return a valid boxed Decree
251    /// - Not cause undefined behavior when called
252    #[allow(unsafe_code)]
253    fn load_native(lib_path: &Path) -> Result<BoxDecree> {
254        use dictator_decree_abi::{ABI_VERSION, DECREE_FACTORY_EXPORT, DecreeFactory};
255
256        // We must keep the library handle alive for the lifetime of the process; unloading
257        // invalidates function pointers held by the decree and triggers UB. We keep every
258        // successfully loaded Library in a global registry instead of letting it drop.
259        static LOADED_LIBRARIES: std::sync::OnceLock<std::sync::Mutex<Vec<Library>>> =
260            std::sync::OnceLock::new();
261
262        unsafe {
263            let lib = Library::new(lib_path)
264                .with_context(|| format!("failed to load native decree: {}", lib_path.display()))?;
265            let ctor: libloading::Symbol<DecreeFactory> =
266                lib.get(DECREE_FACTORY_EXPORT.as_bytes()).with_context(|| {
267                    format!(
268                        "missing symbol {} in {}",
269                        DECREE_FACTORY_EXPORT,
270                        lib_path.display()
271                    )
272                })?;
273
274            let decree = ctor();
275
276            // Validate ABI compatibility
277            let metadata = decree.metadata();
278            metadata.validate_abi(ABI_VERSION).map_err(|e| {
279                anyhow::anyhow!(
280                    "Decree '{}' from {}: {}",
281                    decree.name(),
282                    lib_path.display(),
283                    e
284                )
285            })?;
286
287            tracing::info!(
288                "Loaded decree '{}' v{} (ABI {})",
289                decree.name(),
290                metadata.decree_version,
291                metadata.abi_version
292            );
293
294            // Park the library handle so it is never dropped/unloaded.
295            LOADED_LIBRARIES
296                .get_or_init(std::sync::Mutex::default)
297                .lock()
298                .expect("loaded libraries mutex poisoned")
299                .push(lib);
300
301            Ok(decree)
302        }
303    }
304
305    use self::bindings::exports::dictator::decree::lints as guest;
306
307    struct HostState {
308        table: ResourceTable,
309        wasi: WasiCtx,
310    }
311
312    impl WasiView for HostState {
313        fn ctx(&mut self) -> WasiCtxView<'_> {
314            WasiCtxView {
315                ctx: &mut self.wasi,
316                table: &mut self.table,
317            }
318        }
319    }
320
321    struct WasmDecree {
322        name: String,
323        metadata: dictator_decree_abi::DecreeMetadata,
324        state: Mutex<WasmState>,
325    }
326
327    struct WasmState {
328        store: Store<HostState>,
329        plugin: bindings::Decree,
330    }
331
332    impl dictator_decree_abi::Decree for WasmDecree {
333        fn name(&self) -> &str {
334            &self.name
335        }
336
337        #[allow(clippy::significant_drop_tightening)]
338        fn lint(&self, path: &str, source: &str) -> Diagnostics {
339            let result = {
340                let mut guard = self.state.lock().expect("wasm store poisoned");
341                let WasmState { plugin, store } = &mut *guard;
342                plugin
343                    .dictator_decree_lints()
344                    .call_lint(store, path, source)
345                    .unwrap_or_default()
346            };
347            result
348                .into_iter()
349                .map(|d| dictator_decree_abi::Diagnostic {
350                    rule: d.rule,
351                    message: d.message,
352                    enforced: matches!(d.severity, guest::Severity::Info), // Info = auto-fixed
353                    span: Span {
354                        start: d.span.start as usize,
355                        end: d.span.end as usize,
356                    },
357                })
358                .collect()
359        }
360
361        fn metadata(&self) -> dictator_decree_abi::DecreeMetadata {
362            self.metadata.clone()
363        }
364    }
365
366    fn load_wasm(lib_path: &Path) -> Result<BoxDecree> {
367        use dictator_decree_abi::ABI_VERSION;
368
369        let mut config = Config::new();
370        config.wasm_component_model(true);
371        let engine = Engine::new(&config)?;
372        let component = Component::from_file(&engine, lib_path)
373            .with_context(|| format!("failed to load wasm decree: {}", lib_path.display()))?;
374        let mut linker: Linker<HostState> = Linker::new(&engine);
375        add_to_linker_sync(&mut linker)?;
376        let host_state = HostState {
377            table: ResourceTable::new(),
378            wasi: WasiCtxBuilder::new().inherit_stdio().build(),
379        };
380        let mut store = Store::new(&engine, host_state);
381        let plugin = bindings::Decree::instantiate(&mut store, &component, &linker)?;
382        let guest = plugin.dictator_decree_lints();
383
384        let name = guest
385            .call_name(&mut store)
386            .unwrap_or_else(|_| "wasm-decree".to_string());
387
388        // Get and validate metadata
389        let wasm_meta = guest
390            .call_metadata(&mut store)
391            .context("failed to call metadata on wasm decree")?;
392
393        let metadata = dictator_decree_abi::DecreeMetadata {
394            abi_version: wasm_meta.abi_version,
395            decree_version: wasm_meta.decree_version,
396            description: wasm_meta.description,
397            dectauthors: wasm_meta.dectauthors,
398            supported_extensions: wasm_meta.supported_extensions,
399            supported_filenames: wasm_meta.supported_filenames,
400            skip_filenames: wasm_meta.skip_filenames,
401            capabilities: wasm_meta
402                .capabilities
403                .into_iter()
404                .map(|c| match c {
405                    guest::Capability::Lint => dictator_decree_abi::Capability::Lint,
406                    guest::Capability::AutoFix => dictator_decree_abi::Capability::AutoFix,
407                    guest::Capability::Streaming => dictator_decree_abi::Capability::Streaming,
408                    guest::Capability::RuntimeConfig => {
409                        dictator_decree_abi::Capability::RuntimeConfig
410                    }
411                    guest::Capability::RichDiagnostics => {
412                        dictator_decree_abi::Capability::RichDiagnostics
413                    }
414                })
415                .collect(),
416        };
417
418        metadata
419            .validate_abi(ABI_VERSION)
420            .map_err(|e| anyhow::anyhow!("Decree '{}' from {}: {}", name, lib_path.display(), e))?;
421
422        tracing::info!(
423            "Loaded WASM decree '{}' v{} (ABI {})",
424            name,
425            metadata.decree_version,
426            metadata.abi_version
427        );
428
429        Ok(Box::new(WasmDecree {
430            name,
431            metadata,
432            state: Mutex::new(WasmState { store, plugin }),
433        }))
434    }
435
436    pub fn load_decree(path: &Path) -> Result<BoxDecree> {
437        match path.extension().and_then(|s| s.to_str()) {
438            Some("wasm") => load_wasm(path),
439            _ => load_native(path),
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use dictator_decree_abi::{Capability, Decree, DecreeMetadata, Diagnostics};
448    use dictator_decree_abi::{Diagnostic, Span};
449
450    struct MockDecree {
451        name: &'static str,
452        exts: Vec<String>,
453        filenames: Vec<String>,
454        skip: Vec<String>,
455        rule: &'static str,
456    }
457
458    impl MockDecree {
459        fn simple(name: &'static str, exts: Vec<String>, rule: &'static str) -> Self {
460            Self {
461                name,
462                exts,
463                filenames: vec![],
464                skip: vec![],
465                rule,
466            }
467        }
468    }
469
470    impl Decree for MockDecree {
471        fn name(&self) -> &str {
472            self.name
473        }
474
475        fn lint(&self, _path: &str, _source: &str) -> Diagnostics {
476            vec![Diagnostic {
477                rule: self.rule.to_string(),
478                message: format!("hit {}", self.name),
479                span: Span::new(0, 0),
480                enforced: false,
481            }]
482        }
483
484        fn metadata(&self) -> DecreeMetadata {
485            DecreeMetadata {
486                abi_version: "1".into(),
487                decree_version: "1".into(),
488                description: String::new(),
489                dectauthors: None,
490                supported_extensions: self.exts.clone(),
491                supported_filenames: self.filenames.clone(),
492                skip_filenames: self.skip.clone(),
493                capabilities: vec![Capability::Lint],
494            }
495        }
496    }
497
498    #[test]
499    fn watched_extensions_unites_declared_sets() {
500        let decree_a: BoxDecree = Box::new(MockDecree::simple(
501            "a",
502            vec!["rs".into(), "Rb".into()],
503            "a/hit",
504        ));
505        let decree_b: BoxDecree = Box::new(MockDecree::simple("b", vec!["ts".into()], "b/hit"));
506        let mut regime = Regime::new();
507        regime.add_decree(decree_a);
508        regime.add_decree(decree_b);
509
510        let exts = regime.watched_extensions().unwrap();
511        assert!(exts.contains("rs"));
512        assert!(exts.contains("rb"));
513        assert!(exts.contains("ts"));
514        assert_eq!(exts.len(), 3);
515    }
516
517    #[test]
518    fn watched_extensions_none_when_only_universal() {
519        let sup: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
520        let mut regime = Regime::new();
521        regime.add_decree(sup);
522
523        assert!(regime.watched_extensions().is_none());
524    }
525
526    #[test]
527    fn enforce_skips_supreme_when_language_specific_matches() {
528        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
529        let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
530
531        let mut regime = Regime::new();
532        regime.add_decree(supreme);
533        regime.add_decree(ruby);
534
535        let path = Utf8Path::new("test.rb");
536        let sources = [Source { path, text: "x" }];
537
538        let diags = regime.enforce(&sources).unwrap();
539        assert!(diags.iter().any(|d| d.rule == "ruby/hit"));
540        assert!(!diags.iter().any(|d| d.rule == "supreme/hit"));
541    }
542
543    #[test]
544    fn enforce_runs_supreme_when_language_specific_does_not_match() {
545        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
546        let ruby: BoxDecree = Box::new(MockDecree::simple("ruby", vec!["rb".into()], "ruby/hit"));
547
548        let mut regime = Regime::new();
549        regime.add_decree(supreme);
550        regime.add_decree(ruby);
551
552        let path = Utf8Path::new("test.txt");
553        let sources = [Source { path, text: "x" }];
554
555        let diags = regime.enforce(&sources).unwrap();
556        assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
557        assert!(!diags.iter().any(|d| d.rule == "ruby/hit"));
558    }
559
560    #[test]
561    fn enforce_ignores_configured_rules_by_filename() {
562        let supreme: BoxDecree = Box::new(MockDecree::simple(
563            "supreme",
564            vec![],
565            "supreme/tab-character",
566        ));
567
568        let mut settings = DecreeSettings::default();
569        settings.ignore.insert(
570            "tab-character".to_string(),
571            crate::config::RuleIgnore {
572                filenames: vec!["Makefile".to_string()],
573                extensions: vec![],
574            },
575        );
576        let mut config = DictateConfig::default();
577        config.decree.insert("supreme".to_string(), settings);
578
579        let mut regime = Regime::new();
580        regime.set_rule_ignores_from_config(Some(&config));
581        regime.add_decree(supreme);
582
583        let path = Utf8Path::new("Makefile");
584        let sources = [Source { path, text: "x" }];
585        let diags = regime.enforce(&sources).unwrap();
586        assert!(diags.is_empty(), "rule should be ignored for Makefile");
587    }
588
589    #[test]
590    fn enforce_ignores_configured_rules_by_extension() {
591        let supreme: BoxDecree = Box::new(MockDecree::simple(
592            "supreme",
593            vec![],
594            "supreme/tab-character",
595        ));
596
597        let mut settings = DecreeSettings::default();
598        settings.ignore.insert(
599            "tab-character".to_string(),
600            crate::config::RuleIgnore {
601                filenames: vec![],
602                extensions: vec!["md".to_string(), "MDX".to_string()],
603            },
604        );
605        let mut config = DictateConfig::default();
606        config.decree.insert("supreme".to_string(), settings);
607
608        let mut regime = Regime::new();
609        regime.set_rule_ignores_from_config(Some(&config));
610        regime.add_decree(supreme);
611
612        let path = Utf8Path::new("README.md");
613        let sources = [Source { path, text: "x" }];
614        let diags = regime.enforce(&sources).unwrap();
615        assert!(diags.is_empty(), "rule should be ignored for .md");
616
617        let path = Utf8Path::new("doc.mdx");
618        let sources = [Source { path, text: "x" }];
619        let diags = regime.enforce(&sources).unwrap();
620        assert!(diags.is_empty(), "rule should be ignored for .mdx");
621    }
622
623    #[test]
624    fn enforce_does_not_ignore_unconfigured_rules() {
625        let supreme: BoxDecree = Box::new(MockDecree::simple(
626            "supreme",
627            vec![],
628            "supreme/trailing-whitespace",
629        ));
630
631        let mut settings = DecreeSettings::default();
632        settings.ignore.insert(
633            "tab-character".to_string(),
634            crate::config::RuleIgnore {
635                filenames: vec!["Makefile".to_string()],
636                extensions: vec!["md".to_string()],
637            },
638        );
639        let mut config = DictateConfig::default();
640        config.decree.insert("supreme".to_string(), settings);
641
642        let mut regime = Regime::new();
643        regime.set_rule_ignores_from_config(Some(&config));
644        regime.add_decree(supreme);
645
646        let path = Utf8Path::new("README.md");
647        let sources = [Source { path, text: "x" }];
648        let diags = regime.enforce(&sources).unwrap();
649        assert!(
650            diags
651                .iter()
652                .any(|d| d.rule == "supreme/trailing-whitespace"),
653            "unconfigured rules should still be reported"
654        );
655    }
656
657    #[test]
658    fn enforce_does_not_shadow_supreme_for_non_language_decree() {
659        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
660        let frontmatter: BoxDecree = Box::new(MockDecree::simple(
661            "frontmatter",
662            vec!["md".into()],
663            "frontmatter/hit",
664        ));
665
666        let mut regime = Regime::new();
667        regime.add_decree(supreme);
668        regime.add_decree(frontmatter);
669
670        let path = Utf8Path::new("README.md");
671        let sources = [Source { path, text: "x" }];
672
673        let diags = regime.enforce(&sources).unwrap();
674        assert!(diags.iter().any(|d| d.rule == "supreme/hit"));
675        assert!(diags.iter().any(|d| d.rule == "frontmatter/hit"));
676    }
677
678    #[test]
679    fn enforce_golang_shadows_supreme_for_go_files() {
680        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
681        let golang: BoxDecree = Box::new(MockDecree::simple(
682            "golang",
683            vec!["go".into()],
684            "golang/hit",
685        ));
686
687        let mut regime = Regime::new();
688        regime.add_decree(supreme);
689        regime.add_decree(golang);
690
691        let path = Utf8Path::new("main.go");
692        let sources = [Source {
693            path,
694            text: "package main",
695        }];
696
697        let diags = regime.enforce(&sources).unwrap();
698        assert!(
699            diags.iter().any(|d| d.rule == "golang/hit"),
700            "golang should run on .go files"
701        );
702        assert!(
703            !diags.iter().any(|d| d.rule == "supreme/hit"),
704            "supreme should be shadowed by golang"
705        );
706    }
707
708    #[test]
709    fn enforce_supreme_runs_on_go_files_when_golang_not_loaded() {
710        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
711
712        let mut regime = Regime::new();
713        regime.add_decree(supreme);
714
715        let path = Utf8Path::new("main.go");
716        let sources = [Source {
717            path,
718            text: "package main",
719        }];
720
721        let diags = regime.enforce(&sources).unwrap();
722        assert!(
723            diags.iter().any(|d| d.rule == "supreme/hit"),
724            "supreme should run when no golang decree loaded"
725        );
726    }
727
728    #[test]
729    fn enforce_all_shadowers_work() {
730        // Test all language-specific decrees shadow supreme
731        for (name, ext, rule) in [
732            ("ruby", "rb", "ruby/hit"),
733            ("typescript", "ts", "typescript/hit"),
734            ("golang", "go", "golang/hit"),
735            ("rust", "rs", "rust/hit"),
736            ("python", "py", "python/hit"),
737        ] {
738            let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
739            let lang: BoxDecree = Box::new(MockDecree::simple(name, vec![ext.into()], rule));
740
741            let mut regime = Regime::new();
742            regime.add_decree(supreme);
743            regime.add_decree(lang);
744
745            let path_str = format!("test.{ext}");
746            let path = Utf8Path::new(&path_str);
747            let sources = [Source { path, text: "x" }];
748
749            let diags = regime.enforce(&sources).unwrap();
750            assert!(
751                diags.iter().any(|d| d.rule == rule),
752                "{name} should run on .{ext} files"
753            );
754            assert!(
755                !diags.iter().any(|d| d.rule == "supreme/hit"),
756                "supreme should be shadowed by {name} on .{ext} files"
757            );
758        }
759    }
760
761    // ========== Filename matching tests ==========
762
763    #[test]
764    fn enforce_matches_by_filename() {
765        let ruby: BoxDecree = Box::new(MockDecree {
766            name: "ruby",
767            exts: vec!["rb".into()],
768            filenames: vec!["Gemfile".into(), "Rakefile".into()],
769            skip: vec![],
770            rule: "ruby/hit",
771        });
772
773        let mut regime = Regime::new();
774        regime.add_decree(ruby);
775
776        // Test matching by filename
777        let path = Utf8Path::new("Gemfile");
778        let sources = [Source { path, text: "x" }];
779        let diags = regime.enforce(&sources).unwrap();
780        assert!(
781            diags.iter().any(|d| d.rule == "ruby/hit"),
782            "ruby should match Gemfile by filename"
783        );
784    }
785
786    #[test]
787    fn enforce_skips_skip_filenames() {
788        let ruby: BoxDecree = Box::new(MockDecree {
789            name: "ruby",
790            exts: vec!["rb".into()],
791            filenames: vec!["Gemfile".into()],
792            skip: vec!["Gemfile.lock".into()],
793            rule: "ruby/hit",
794        });
795
796        let mut regime = Regime::new();
797        regime.add_decree(ruby);
798
799        // Gemfile.lock should be owned but not linted
800        let path = Utf8Path::new("Gemfile.lock");
801        let sources = [Source { path, text: "x" }];
802        let diags = regime.enforce(&sources).unwrap();
803        assert!(
804            diags.is_empty(),
805            "Gemfile.lock should be skipped (owned but not linted)"
806        );
807    }
808
809    #[test]
810    fn enforce_skip_filenames_prevents_supreme() {
811        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
812        let ruby: BoxDecree = Box::new(MockDecree {
813            name: "ruby",
814            exts: vec!["rb".into()],
815            filenames: vec!["Gemfile".into()],
816            skip: vec!["Gemfile.lock".into()],
817            rule: "ruby/hit",
818        });
819
820        let mut regime = Regime::new();
821        regime.add_decree(supreme);
822        regime.add_decree(ruby);
823
824        // Gemfile.lock should not be linted by supreme either
825        let path = Utf8Path::new("Gemfile.lock");
826        let sources = [Source { path, text: "x" }];
827        let diags = regime.enforce(&sources).unwrap();
828
829        // Supreme doesn't have Gemfile.lock in skip, so it would lint it
830        // BUT the file doesn't match supreme's filename pattern (empty = all)
831        // Wait - empty lists mean universal, so supreme WOULD lint it...
832        // Actually the skip_filenames check happens per-decree, so supreme
833        // doesn't have Gemfile.lock in its skip list.
834        // This test validates current behavior - supreme still lints lock files.
835        // To prevent that, user should configure supreme to skip those.
836        assert!(
837            diags.iter().any(|d| d.rule == "supreme/hit"),
838            "supreme lints files not in its skip list"
839        );
840    }
841
842    #[test]
843    fn enforce_filename_shadows_supreme() {
844        let supreme: BoxDecree = Box::new(MockDecree::simple("supreme", vec![], "supreme/hit"));
845        let golang: BoxDecree = Box::new(MockDecree {
846            name: "golang",
847            exts: vec!["go".into()],
848            filenames: vec!["go.mod".into()],
849            skip: vec!["go.sum".into()],
850            rule: "golang/hit",
851        });
852
853        let mut regime = Regime::new();
854        regime.add_decree(supreme);
855        regime.add_decree(golang);
856
857        // go.mod should be handled by golang and shadow supreme
858        let path = Utf8Path::new("go.mod");
859        let sources = [Source { path, text: "x" }];
860        let diags = regime.enforce(&sources).unwrap();
861        assert!(
862            diags.iter().any(|d| d.rule == "golang/hit"),
863            "golang should match go.mod"
864        );
865        assert!(
866            !diags.iter().any(|d| d.rule == "supreme/hit"),
867            "supreme should be shadowed by golang for go.mod"
868        );
869    }
870
871    #[test]
872    fn enforce_golang_skips_go_sum() {
873        let golang: BoxDecree = Box::new(MockDecree {
874            name: "golang",
875            exts: vec!["go".into()],
876            filenames: vec!["go.mod".into()],
877            skip: vec!["go.sum".into()],
878            rule: "golang/hit",
879        });
880
881        let mut regime = Regime::new();
882        regime.add_decree(golang);
883
884        // go.sum should be skipped
885        let path = Utf8Path::new("go.sum");
886        let sources = [Source { path, text: "x" }];
887        let diags = regime.enforce(&sources).unwrap();
888        assert!(diags.is_empty(), "go.sum should be skipped by golang");
889    }
890}