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    #[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    /// Enforce all decrees over provided sources.
111    ///
112    /// Matching priority:
113    /// 1. `skip_filenames` - decree owns file but returns empty diagnostics
114    /// 2. `supported_filenames` - exact filename match
115    /// 3. `supported_extensions` - extension match
116    /// 4. Universal decrees (empty lists) run on all files unless shadowed
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if any decree fails during linting.
121    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            // CSS-style specificity: if a language-specific decree is present for this file
127            // type, do not run the catch-all decree.supreme on this file.
128            let is_supreme_shadowed = self.is_supreme_shadowed(src.path);
129
130            for decree in &self.decrees {
131                let meta = decree.metadata();
132
133                // Skip files in skip_filenames (owned but not linted)
134                if meta.skip_filenames.iter().any(|s| s == filename) {
135                    continue;
136                }
137
138                // Check if decree matches this file
139                let matches = Self::decree_matches(src.path, &meta);
140                if !matches {
141                    continue;
142                }
143
144                // Universal decrees shadowed by language-specific ones
145                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    /// Check if a decree matches a file (by extension or filename).
164    fn decree_matches(path: &Utf8Path, meta: &dictator_decree_abi::DecreeMetadata) -> bool {
165        let filename = path.file_name().unwrap_or("");
166
167        // Universal decree (empty lists) matches everything
168        if meta.supported_extensions.is_empty() && meta.supported_filenames.is_empty() {
169            return true;
170        }
171
172        // Check filename match
173        if meta.supported_filenames.iter().any(|s| s == filename) {
174            return true;
175        }
176
177        // Check extension match
178        Self::extension_matches(path, &meta.supported_extensions)
179    }
180
181    /// Check if a file's extension matches any in the supported list.
182    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        // Only language-specific decrees shadow decree.supreme. Other decrees (e.g. frontmatter
189        // or custom plugins) remain additive and run alongside supreme.
190        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            // Check if this shadower handles this file
201            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    /// Load a decree compiled as a native dynamic library (.dylib/.so/.dll).
253    ///
254    /// # Safety
255    /// Loading dynamic libraries is inherently unsafe. The library must:
256    /// - Export a valid `dictator_create_decree` symbol
257    /// - Return a valid boxed Decree
258    /// - Not cause undefined behavior when called
259    #[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        // We must keep the library handle alive for the lifetime of the process; unloading
264        // invalidates function pointers held by the decree and triggers UB. We keep every
265        // successfully loaded Library in a global registry instead of letting it drop.
266        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            // Validate ABI compatibility
284            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            // Park the library handle so it is never dropped/unloaded.
302            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), // Info = auto-fixed
360                    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        // Get and validate metadata
396        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        // Test all language-specific decrees shadow supreme
738        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    // ========== Filename matching tests ==========
769
770    #[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        // Test matching by filename
784        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        // Gemfile.lock should be owned but not linted
807        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        // Gemfile.lock should not be linted by supreme either
832        let path = Utf8Path::new("Gemfile.lock");
833        let sources = [Source { path, text: "x" }];
834        let diags = regime.enforce(&sources).unwrap();
835
836        // Supreme doesn't have Gemfile.lock in skip, so it would lint it
837        // BUT the file doesn't match supreme's filename pattern (empty = all)
838        // Wait - empty lists mean universal, so supreme WOULD lint it...
839        // Actually the skip_filenames check happens per-decree, so supreme
840        // doesn't have Gemfile.lock in its skip list.
841        // This test validates current behavior - supreme still lints lock files.
842        // To prevent that, user should configure supreme to skip those.
843        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        // go.mod should be handled by golang and shadow supreme
865        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        // go.sum should be skipped
892        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}