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 max_cyclomatic: u32,
16 pub max_cognitive: u32,
17 pub dup_min_tokens: usize,
19 pub dup_min_lines: u32,
21 pub arch_preset: Option<String>,
23 pub arch_layers: Vec<String>,
26 pub policies: Vec<Policy>,
28 pub contracts: Contracts,
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct Contracts {
35 pub forbidden: Vec<ForbiddenContract>,
37 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#[derive(Debug, Clone)]
50pub struct Policy {
51 pub id: String,
53 pub forbid_import: Option<String>,
55 pub forbid_call: Option<String>,
57 pub in_paths: Vec<String>,
59 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
81pub 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 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 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
218fn preset_layers(preset: &str) -> Vec<String> {
221 let names: &[&str] = match preset.to_ascii_lowercase().as_str() {
222 "layered" => &["presentation", "application", "domain", "infrastructure"],
224 "hexagonal" => &["adapters", "application", "domain"],
226 "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
255pub 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}