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