Skip to main content

mollify_core/
config.rs

1//! `.mollifyrc.json` configuration: severity overrides (per rule or category),
2//! ignore globs, and complexity thresholds. Absent config → sensible defaults.
3
4use camino::Utf8Path;
5use mollify_types::{Category, Finding, Severity};
6use rustc_hash::FxHashMap;
7
8#[derive(Debug, Clone)]
9pub struct Config {
10    /// Override severity by rule id (e.g. "unused-export") or category
11    /// ("dead-code"). Rule id wins over category.
12    pub severity: FxHashMap<String, Severity>,
13    /// Path substrings to ignore (simple contains-match; globs later).
14    pub ignore: Vec<String>,
15    pub max_cyclomatic: u32,
16    pub max_cognitive: u32,
17    /// Minimum normalized-token window for a duplication clone (default 40).
18    pub dup_min_tokens: usize,
19    /// Minimum line span for a duplication clone (default 5).
20    pub dup_min_lines: u32,
21    /// Architecture preset name (informational): layered | hexagonal | feature-sliced | bulletproof.
22    pub arch_preset: Option<String>,
23    /// Ordered layer names, top (most dependent) → bottom. A layer may import
24    /// same/lower layers; importing a higher layer is a `layer-violation`.
25    pub arch_layers: Vec<String>,
26    /// Declarative rule packs: banned imports / calls, optionally path-scoped.
27    pub policies: Vec<Policy>,
28    /// Declarative import contracts (import-linter / tach style).
29    pub contracts: Contracts,
30}
31
32/// Module-boundary contracts checked against the import graph.
33#[derive(Debug, Clone, Default)]
34pub struct Contracts {
35    /// `from` module(s) must not import any `to` module (by dotted prefix).
36    pub forbidden: Vec<ForbiddenContract>,
37    /// Each group is a set of modules that must not import one another.
38    pub independent: Vec<Vec<String>>,
39}
40
41#[derive(Debug, Clone)]
42pub struct ForbiddenContract {
43    pub from: String,
44    pub to: Vec<String>,
45}
46
47/// One declarative policy ("rule pack" entry): forbid an import and/or a call,
48/// optionally only within certain path substrings.
49#[derive(Debug, Clone)]
50pub struct Policy {
51    /// Stable rule id surfaced on findings (e.g. `no-requests-in-domain`).
52    pub id: String,
53    /// Forbidden import module prefix (e.g. `requests`, `django.db`).
54    pub forbid_import: Option<String>,
55    /// Forbidden call callee (e.g. `print`, `os.system`, `subprocess`).
56    pub forbid_call: Option<String>,
57    /// Path substrings this policy applies to; empty = whole project.
58    pub in_paths: Vec<String>,
59    /// Human explanation shown in the finding reason.
60    pub message: Option<String>,
61    pub severity: Severity,
62}
63
64impl Default for Config {
65    fn default() -> Self {
66        Config {
67            severity: FxHashMap::default(),
68            ignore: Vec::new(),
69            max_cyclomatic: crate::complexity::DEFAULT_CYCLOMATIC,
70            max_cognitive: crate::complexity::DEFAULT_COGNITIVE,
71            dup_min_tokens: crate::dupes::MIN_TOKENS,
72            dup_min_lines: crate::dupes::MIN_LINES,
73            arch_preset: None,
74            arch_layers: Vec::new(),
75            policies: Vec::new(),
76            contracts: Contracts::default(),
77        }
78    }
79}
80
81/// Load `.mollifyrc.json` from `root` (or defaults if missing/invalid).
82pub fn load(root: &Utf8Path) -> Config {
83    let mut cfg = Config::default();
84    let path = root.join(".mollifyrc.json");
85    let Ok(text) = std::fs::read_to_string(&path) else {
86        return cfg;
87    };
88    let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
89        return cfg;
90    };
91    if let Some(sev) = v.get("severity").and_then(|s| s.as_object()) {
92        for (k, val) in sev {
93            if let Some(s) = val.as_str().and_then(parse_severity) {
94                cfg.severity.insert(k.clone(), s);
95            }
96        }
97    }
98    if let Some(ig) = v.get("ignore").and_then(|i| i.as_array()) {
99        cfg.ignore = ig
100            .iter()
101            .filter_map(|x| x.as_str().map(String::from))
102            .collect();
103    }
104    if let Some(c) = v.get("max_cyclomatic").and_then(|n| n.as_u64()) {
105        cfg.max_cyclomatic = c as u32;
106    }
107    if let Some(c) = v.get("max_cognitive").and_then(|n| n.as_u64()) {
108        cfg.max_cognitive = c as u32;
109    }
110    if let Some(dup) = v.get("duplication").and_then(|d| d.as_object()) {
111        if let Some(n) = dup.get("min_tokens").and_then(|x| x.as_u64()) {
112            cfg.dup_min_tokens = n as usize;
113        }
114        if let Some(n) = dup.get("min_lines").and_then(|x| x.as_u64()) {
115            cfg.dup_min_lines = n as u32;
116        }
117    }
118    if let Some(arch) = v.get("architecture").and_then(|a| a.as_object()) {
119        cfg.arch_preset = arch
120            .get("preset")
121            .and_then(|p| p.as_str())
122            .map(String::from);
123        if let Some(layers) = arch.get("layers").and_then(|l| l.as_array()) {
124            cfg.arch_layers = layers
125                .iter()
126                .filter_map(|x| x.as_str().map(String::from))
127                .collect();
128        }
129        // An explicit `layers` list always wins; otherwise a known `preset`
130        // expands to a conventional ordering so users can opt in with one key.
131        if cfg.arch_layers.is_empty() {
132            if let Some(preset) = cfg.arch_preset.as_deref() {
133                cfg.arch_layers = preset_layers(preset);
134            }
135        }
136    }
137    if let Some(contracts) = v.get("contracts").and_then(|c| c.as_object()) {
138        if let Some(arr) = contracts.get("forbidden").and_then(|f| f.as_array()) {
139            for c in arr {
140                let Some(from) = c.get("from").and_then(|x| x.as_str()) else {
141                    continue;
142                };
143                let to: Vec<String> = c
144                    .get("to")
145                    .and_then(|t| t.as_array())
146                    .map(|a| {
147                        a.iter()
148                            .filter_map(|x| x.as_str().map(String::from))
149                            .collect()
150                    })
151                    .unwrap_or_default();
152                if !to.is_empty() {
153                    cfg.contracts.forbidden.push(ForbiddenContract {
154                        from: from.to_string(),
155                        to,
156                    });
157                }
158            }
159        }
160        if let Some(arr) = contracts.get("independent").and_then(|i| i.as_array()) {
161            for group in arr {
162                if let Some(members) = group.as_array() {
163                    let g: Vec<String> = members
164                        .iter()
165                        .filter_map(|x| x.as_str().map(String::from))
166                        .collect();
167                    if g.len() >= 2 {
168                        cfg.contracts.independent.push(g);
169                    }
170                }
171            }
172        }
173    }
174    if let Some(pols) = v.get("policies").and_then(|p| p.as_array()) {
175        for (i, p) in pols.iter().enumerate() {
176            let Some(obj) = p.as_object() else { continue };
177            let forbid_import = obj.get("forbid_import").and_then(|x| x.as_str());
178            let forbid_call = obj.get("forbid_call").and_then(|x| x.as_str());
179            // A policy with neither lever is inert; skip it.
180            if forbid_import.is_none() && forbid_call.is_none() {
181                continue;
182            }
183            let id = obj
184                .get("id")
185                .and_then(|x| x.as_str())
186                .map(String::from)
187                .unwrap_or_else(|| format!("policy-{i}"));
188            let in_paths = obj
189                .get("in_paths")
190                .and_then(|x| x.as_array())
191                .map(|a| {
192                    a.iter()
193                        .filter_map(|s| s.as_str().map(String::from))
194                        .collect()
195                })
196                .unwrap_or_default();
197            let severity = obj
198                .get("severity")
199                .and_then(|s| s.as_str())
200                .and_then(parse_severity)
201                .unwrap_or(Severity::Warn);
202            cfg.policies.push(Policy {
203                id,
204                forbid_import: forbid_import.map(String::from),
205                forbid_call: forbid_call.map(String::from),
206                in_paths,
207                message: obj
208                    .get("message")
209                    .and_then(|x| x.as_str())
210                    .map(String::from),
211                severity,
212            });
213        }
214    }
215    cfg
216}
217
218/// Default ordered layer names (top/most-dependent → bottom) for a named preset.
219/// Unknown presets yield an empty list (the layer engine then does nothing).
220fn preset_layers(preset: &str) -> Vec<String> {
221    let names: &[&str] = match preset.to_ascii_lowercase().as_str() {
222        // Classic n-tier: presentation depends on application depends on domain…
223        "layered" => &["presentation", "application", "domain", "infrastructure"],
224        // Ports-and-adapters: adapters/app may depend on domain, never the reverse.
225        "hexagonal" => &["adapters", "application", "domain"],
226        // Bulletproof-style: features → entities → shared.
227        "feature-sliced" | "bulletproof" => &["app", "features", "entities", "shared"],
228        _ => &[],
229    };
230    names.iter().map(|s| s.to_string()).collect()
231}
232
233fn parse_severity(s: &str) -> Option<Severity> {
234    match s.to_ascii_lowercase().as_str() {
235        "error" => Some(Severity::Error),
236        "warn" | "warning" => Some(Severity::Warn),
237        "off" | "ignore" => Some(Severity::Off),
238        _ => None,
239    }
240}
241
242fn category_key(c: Category) -> &'static str {
243    match c {
244        Category::DeadCode => "dead-code",
245        Category::Duplication => "duplication",
246        Category::CircularDependency => "circular-dependency",
247        Category::Complexity => "complexity",
248        Category::Architecture => "architecture",
249        Category::DependencyHygiene => "dependency-hygiene",
250        Category::TypeHealth => "type-health",
251        Category::Security => "security",
252    }
253}
254
255/// Apply config to findings: drop ignored paths and `off` findings, and override
256/// severities (rule id first, then category).
257pub fn apply(cfg: &Config, findings: &mut Vec<Finding>) {
258    for f in findings.iter_mut() {
259        if let Some(s) = cfg
260            .severity
261            .get(&f.rule)
262            .or_else(|| cfg.severity.get(category_key(f.category)))
263        {
264            f.severity = *s;
265        }
266    }
267    findings.retain(|f| {
268        if f.severity == Severity::Off {
269            return false;
270        }
271        let p = f.location.path.as_str();
272        !cfg.ignore.iter().any(|ig| p.contains(ig.as_str()))
273    });
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use mollify_types::{Category, Location};
280
281    fn finding(rule: &str, path: &str) -> Finding {
282        Finding {
283            fingerprint: "x".into(),
284            rule: rule.into(),
285            category: Category::DeadCode,
286            severity: Severity::Warn,
287            confidence: mollify_types::Confidence::Likely,
288            attribution: None,
289            reason: "r".into(),
290            location: Location {
291                path: path.into(),
292                line: 1,
293                column: 0,
294                end_line: None,
295            },
296            actions: vec![],
297        }
298    }
299
300    #[test]
301    fn severity_override_and_ignore() {
302        let mut cfg = Config::default();
303        cfg.severity.insert("unused-export".into(), Severity::Error);
304        cfg.ignore.push("tests/".into());
305        let mut f = vec![
306            finding("unused-export", "src/a.py"),
307            finding("unused-export", "tests/b.py"),
308        ];
309        apply(&cfg, &mut f);
310        assert_eq!(f.len(), 1);
311        assert_eq!(f[0].location.path, "src/a.py");
312        assert_eq!(f[0].severity, Severity::Error);
313    }
314
315    #[test]
316    fn preset_expands_to_default_layers() {
317        assert_eq!(preset_layers("hexagonal").len(), 3);
318        assert_eq!(preset_layers("layered")[0], "presentation");
319        assert!(preset_layers("nonsense").is_empty());
320    }
321
322    #[test]
323    fn off_drops_finding() {
324        let mut cfg = Config::default();
325        cfg.severity.insert("dead-code".into(), Severity::Off);
326        let mut f = vec![finding("unused-export", "a.py")];
327        apply(&cfg, &mut f);
328        assert!(f.is_empty());
329    }
330}