1use camino::Utf8Path;
5use mollify_types::{Category, Finding, Severity};
6use rustc_hash::FxHashMap;
7
8#[derive(Debug, Clone)]
9pub struct Config {
10 pub severity: FxHashMap<String, Severity>,
13 pub ignore: Vec<String>,
15 pub exclude_dirs: Vec<String>,
19 pub max_cyclomatic: u32,
20 pub max_cognitive: u32,
21 pub dup_min_tokens: usize,
23 pub dup_min_lines: u32,
25 pub arch_preset: Option<String>,
27 pub arch_layers: Vec<String>,
30 pub policies: Vec<Policy>,
32 pub contracts: Contracts,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct Contracts {
39 pub forbidden: Vec<ForbiddenContract>,
41 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#[derive(Debug, Clone)]
54pub struct Policy {
55 pub id: String,
57 pub forbid_import: Option<String>,
59 pub forbid_call: Option<String>,
61 pub in_paths: Vec<String>,
63 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
86pub 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 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 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
229fn preset_layers(preset: &str) -> Vec<String> {
232 let names: &[&str] = match preset.to_ascii_lowercase().as_str() {
233 "layered" => &["presentation", "application", "domain", "infrastructure"],
235 "hexagonal" => &["adapters", "application", "domain"],
237 "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
266pub 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}