Skip to main content

fallow_config/config/
rules.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Severity level for rules.
5///
6/// Controls whether an issue type causes CI failure (`error`), is reported
7/// without failing (`warn`), or is suppressed entirely (`off`).
8#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11    /// Report and fail CI (non-zero exit code).
12    #[default]
13    Error,
14    /// Report but don't fail CI.
15    Warn,
16    /// Don't detect or report.
17    Off,
18}
19
20impl Severity {
21    /// Default value for fields that should default to `Warn` instead of `Error`.
22    const fn default_warn() -> Self {
23        Self::Warn
24    }
25}
26
27impl std::fmt::Display for Severity {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::Error => write!(f, "error"),
31            Self::Warn => write!(f, "warn"),
32            Self::Off => write!(f, "off"),
33        }
34    }
35}
36
37impl std::str::FromStr for Severity {
38    type Err = String;
39
40    fn from_str(s: &str) -> Result<Self, Self::Err> {
41        match s.to_lowercase().as_str() {
42            "error" => Ok(Self::Error),
43            "warn" | "warning" => Ok(Self::Warn),
44            "off" | "none" => Ok(Self::Off),
45            other => Err(format!(
46                "unknown severity: '{other}' (expected error, warn, or off)"
47            )),
48        }
49    }
50}
51
52/// Per-issue-type severity configuration.
53///
54/// Controls which issue types cause CI failure, are reported as warnings,
55/// or are suppressed entirely. All fields default to `Severity::Error`.
56///
57/// Rule names use kebab-case in config files (e.g., `"unused-files": "error"`).
58#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
59#[serde(rename_all = "kebab-case")]
60pub struct RulesConfig {
61    #[serde(default)]
62    pub unused_files: Severity,
63    #[serde(default)]
64    pub unused_exports: Severity,
65    #[serde(default)]
66    pub unused_types: Severity,
67    #[serde(default)]
68    pub unused_dependencies: Severity,
69    #[serde(default = "Severity::default_warn")]
70    pub unused_dev_dependencies: Severity,
71    #[serde(default = "Severity::default_warn")]
72    pub unused_optional_dependencies: Severity,
73    #[serde(default)]
74    pub unused_enum_members: Severity,
75    #[serde(default)]
76    pub unused_class_members: Severity,
77    #[serde(default)]
78    pub unresolved_imports: Severity,
79    #[serde(default)]
80    pub unlisted_dependencies: Severity,
81    #[serde(default)]
82    pub duplicate_exports: Severity,
83    #[serde(default = "Severity::default_warn")]
84    pub type_only_dependencies: Severity,
85    #[serde(default = "Severity::default_warn")]
86    pub test_only_dependencies: Severity,
87    #[serde(default)]
88    pub circular_dependencies: Severity,
89    #[serde(default)]
90    pub boundary_violation: Severity,
91    #[serde(default)]
92    pub coverage_gaps: Severity,
93}
94
95impl Default for RulesConfig {
96    fn default() -> Self {
97        Self {
98            unused_files: Severity::Error,
99            unused_exports: Severity::Error,
100            unused_types: Severity::Error,
101            unused_dependencies: Severity::Error,
102            unused_dev_dependencies: Severity::Warn,
103            unused_optional_dependencies: Severity::Warn,
104            unused_enum_members: Severity::Error,
105            unused_class_members: Severity::Error,
106            unresolved_imports: Severity::Error,
107            unlisted_dependencies: Severity::Error,
108            duplicate_exports: Severity::Error,
109            type_only_dependencies: Severity::Warn,
110            test_only_dependencies: Severity::Warn,
111            circular_dependencies: Severity::Error,
112            boundary_violation: Severity::Error,
113            coverage_gaps: Severity::Off,
114        }
115    }
116}
117
118impl RulesConfig {
119    /// Apply a partial rules config on top. Only `Some` fields override.
120    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
121        if let Some(s) = partial.unused_files {
122            self.unused_files = s;
123        }
124        if let Some(s) = partial.unused_exports {
125            self.unused_exports = s;
126        }
127        if let Some(s) = partial.unused_types {
128            self.unused_types = s;
129        }
130        if let Some(s) = partial.unused_dependencies {
131            self.unused_dependencies = s;
132        }
133        if let Some(s) = partial.unused_dev_dependencies {
134            self.unused_dev_dependencies = s;
135        }
136        if let Some(s) = partial.unused_optional_dependencies {
137            self.unused_optional_dependencies = s;
138        }
139        if let Some(s) = partial.unused_enum_members {
140            self.unused_enum_members = s;
141        }
142        if let Some(s) = partial.unused_class_members {
143            self.unused_class_members = s;
144        }
145        if let Some(s) = partial.unresolved_imports {
146            self.unresolved_imports = s;
147        }
148        if let Some(s) = partial.unlisted_dependencies {
149            self.unlisted_dependencies = s;
150        }
151        if let Some(s) = partial.duplicate_exports {
152            self.duplicate_exports = s;
153        }
154        if let Some(s) = partial.type_only_dependencies {
155            self.type_only_dependencies = s;
156        }
157        if let Some(s) = partial.test_only_dependencies {
158            self.test_only_dependencies = s;
159        }
160        if let Some(s) = partial.circular_dependencies {
161            self.circular_dependencies = s;
162        }
163        if let Some(s) = partial.boundary_violation {
164            self.boundary_violation = s;
165        }
166        if let Some(s) = partial.coverage_gaps {
167            self.coverage_gaps = s;
168        }
169    }
170}
171
172/// Partial per-issue-type severity for overrides. All fields optional.
173#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
174#[serde(rename_all = "kebab-case")]
175pub struct PartialRulesConfig {
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub unused_files: Option<Severity>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub unused_exports: Option<Severity>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub unused_types: Option<Severity>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub unused_dependencies: Option<Severity>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub unused_dev_dependencies: Option<Severity>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub unused_optional_dependencies: Option<Severity>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub unused_enum_members: Option<Severity>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub unused_class_members: Option<Severity>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub unresolved_imports: Option<Severity>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub unlisted_dependencies: Option<Severity>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub duplicate_exports: Option<Severity>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub type_only_dependencies: Option<Severity>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub test_only_dependencies: Option<Severity>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub circular_dependencies: Option<Severity>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub boundary_violation: Option<Severity>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub coverage_gaps: Option<Severity>,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn rules_default_all_error_except_type_only() {
216        let rules = RulesConfig::default();
217        assert_eq!(rules.unused_files, Severity::Error);
218        assert_eq!(rules.unused_exports, Severity::Error);
219        assert_eq!(rules.unused_types, Severity::Error);
220        assert_eq!(rules.unused_dependencies, Severity::Error);
221        assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
222        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
223        assert_eq!(rules.unused_enum_members, Severity::Error);
224        assert_eq!(rules.unused_class_members, Severity::Error);
225        assert_eq!(rules.unresolved_imports, Severity::Error);
226        assert_eq!(rules.unlisted_dependencies, Severity::Error);
227        assert_eq!(rules.duplicate_exports, Severity::Error);
228        assert_eq!(rules.type_only_dependencies, Severity::Warn);
229        assert_eq!(rules.test_only_dependencies, Severity::Warn);
230        assert_eq!(rules.circular_dependencies, Severity::Error);
231        assert_eq!(rules.boundary_violation, Severity::Error);
232        assert_eq!(rules.coverage_gaps, Severity::Off);
233    }
234
235    #[test]
236    fn rules_deserialize_kebab_case() {
237        let json_str = r#"{
238            "unused-files": "error",
239            "unused-exports": "warn",
240            "unused-types": "off"
241        }"#;
242        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
243        assert_eq!(rules.unused_files, Severity::Error);
244        assert_eq!(rules.unused_exports, Severity::Warn);
245        assert_eq!(rules.unused_types, Severity::Off);
246        // Unset fields default to error
247        assert_eq!(rules.unresolved_imports, Severity::Error);
248    }
249
250    #[test]
251    fn severity_from_str() {
252        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
253        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
254        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
255        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
256        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
257        assert!("invalid".parse::<Severity>().is_err());
258    }
259
260    #[test]
261    fn apply_partial_only_some_fields() {
262        let mut rules = RulesConfig::default();
263        let partial = PartialRulesConfig {
264            unused_files: Some(Severity::Warn),
265            unused_exports: Some(Severity::Off),
266            ..Default::default()
267        };
268        rules.apply_partial(&partial);
269        assert_eq!(rules.unused_files, Severity::Warn);
270        assert_eq!(rules.unused_exports, Severity::Off);
271        // Unset fields unchanged
272        assert_eq!(rules.unused_types, Severity::Error);
273        assert_eq!(rules.unresolved_imports, Severity::Error);
274    }
275
276    #[test]
277    fn severity_display() {
278        assert_eq!(Severity::Error.to_string(), "error");
279        assert_eq!(Severity::Warn.to_string(), "warn");
280        assert_eq!(Severity::Off.to_string(), "off");
281    }
282
283    #[test]
284    fn apply_partial_all_none_changes_nothing() {
285        let mut rules = RulesConfig::default();
286        let original = rules.clone();
287        let partial = PartialRulesConfig::default(); // all None
288        rules.apply_partial(&partial);
289        assert_eq!(rules.unused_files, original.unused_files);
290        assert_eq!(rules.unused_exports, original.unused_exports);
291        assert_eq!(
292            rules.type_only_dependencies,
293            original.type_only_dependencies
294        );
295    }
296
297    #[test]
298    fn apply_partial_all_fields_set() {
299        let mut rules = RulesConfig::default();
300        let partial = PartialRulesConfig {
301            unused_files: Some(Severity::Off),
302            unused_exports: Some(Severity::Off),
303            unused_types: Some(Severity::Off),
304            unused_dependencies: Some(Severity::Off),
305            unused_dev_dependencies: Some(Severity::Off),
306            unused_optional_dependencies: Some(Severity::Off),
307            unused_enum_members: Some(Severity::Off),
308            unused_class_members: Some(Severity::Off),
309            unresolved_imports: Some(Severity::Off),
310            unlisted_dependencies: Some(Severity::Off),
311            duplicate_exports: Some(Severity::Off),
312            type_only_dependencies: Some(Severity::Off),
313            test_only_dependencies: Some(Severity::Off),
314            circular_dependencies: Some(Severity::Off),
315            boundary_violation: Some(Severity::Off),
316            coverage_gaps: Some(Severity::Off),
317        };
318        rules.apply_partial(&partial);
319        assert_eq!(rules.unused_files, Severity::Off);
320        assert_eq!(rules.circular_dependencies, Severity::Off);
321        assert_eq!(rules.type_only_dependencies, Severity::Off);
322        assert_eq!(rules.test_only_dependencies, Severity::Off);
323        assert_eq!(rules.boundary_violation, Severity::Off);
324        assert_eq!(rules.coverage_gaps, Severity::Off);
325    }
326
327    #[test]
328    fn rules_config_defaults_include_optional_deps() {
329        let rules = RulesConfig::default();
330        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
331    }
332
333    #[test]
334    fn severity_from_str_case_insensitive() {
335        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
336        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
337        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
338        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
339        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
340    }
341
342    #[test]
343    fn severity_from_str_invalid_returns_error() {
344        let result = "critical".parse::<Severity>();
345        assert!(result.is_err());
346        let err = result.unwrap_err();
347        assert!(
348            err.contains("unknown severity"),
349            "Expected descriptive error, got: {err}"
350        );
351    }
352
353    // ── PartialRulesConfig deserialization ───────────────────────────
354
355    #[test]
356    fn partial_rules_empty_json() {
357        let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
358        assert!(partial.unused_files.is_none());
359        assert!(partial.unused_exports.is_none());
360        assert!(partial.unused_types.is_none());
361        assert!(partial.unused_dependencies.is_none());
362        assert!(partial.circular_dependencies.is_none());
363        assert!(partial.boundary_violation.is_none());
364        assert!(partial.coverage_gaps.is_none());
365    }
366
367    #[test]
368    fn partial_rules_subset_json() {
369        let json = r#"{
370            "unused-files": "warn",
371            "circular-dependencies": "off"
372        }"#;
373        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
374        assert_eq!(partial.unused_files, Some(Severity::Warn));
375        assert_eq!(partial.circular_dependencies, Some(Severity::Off));
376        assert!(partial.unused_exports.is_none());
377    }
378
379    #[test]
380    fn partial_rules_all_fields_json() {
381        let json = r#"{
382            "unused-files": "error",
383            "unused-exports": "warn",
384            "unused-types": "off",
385            "unused-dependencies": "error",
386            "unused-dev-dependencies": "warn",
387            "unused-optional-dependencies": "off",
388            "unused-enum-members": "error",
389            "unused-class-members": "warn",
390            "unresolved-imports": "off",
391            "unlisted-dependencies": "error",
392            "duplicate-exports": "warn",
393            "type-only-dependencies": "off",
394            "test-only-dependencies": "error",
395            "circular-dependencies": "warn",
396            "boundary-violation": "off",
397            "coverage-gaps": "warn"
398        }"#;
399        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
400        assert_eq!(partial.unused_files, Some(Severity::Error));
401        assert_eq!(partial.unused_exports, Some(Severity::Warn));
402        assert_eq!(partial.unused_types, Some(Severity::Off));
403        assert_eq!(partial.unused_dependencies, Some(Severity::Error));
404        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
405        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
406        assert_eq!(partial.unused_enum_members, Some(Severity::Error));
407        assert_eq!(partial.unused_class_members, Some(Severity::Warn));
408        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
409        assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
410        assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
411        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
412        assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
413        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
414        assert_eq!(partial.boundary_violation, Some(Severity::Off));
415        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
416    }
417
418    // ── PartialRulesConfig serialization skip_serializing_if ────────
419
420    #[test]
421    fn partial_rules_none_fields_not_serialized() {
422        let partial = PartialRulesConfig::default();
423        let json = serde_json::to_string(&partial).unwrap();
424        assert_eq!(
425            json, "{}",
426            "all-None partial should serialize to empty object"
427        );
428    }
429
430    #[test]
431    fn partial_rules_some_fields_serialized() {
432        let partial = PartialRulesConfig {
433            unused_files: Some(Severity::Warn),
434            ..Default::default()
435        };
436        let json = serde_json::to_string(&partial).unwrap();
437        assert!(json.contains("unused-files"));
438        assert!(!json.contains("unused-exports"));
439    }
440
441    // ── Severity JSON deserialization ────────────────────────────────
442
443    #[test]
444    fn severity_json_deserialization() {
445        let error: Severity = serde_json::from_str(r#""error""#).unwrap();
446        assert_eq!(error, Severity::Error);
447
448        let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
449        assert_eq!(warn, Severity::Warn);
450
451        let off: Severity = serde_json::from_str(r#""off""#).unwrap();
452        assert_eq!(off, Severity::Off);
453    }
454
455    #[test]
456    fn severity_invalid_json_value_rejected() {
457        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
458        assert!(result.is_err());
459    }
460
461    // ── Severity default ────────────────────────────────────────────
462
463    #[test]
464    fn severity_default_is_error() {
465        assert_eq!(Severity::default(), Severity::Error);
466    }
467
468    // ── RulesConfig JSON serialize roundtrip ─────────────────────────
469
470    #[test]
471    fn rules_config_json_roundtrip() {
472        let rules = RulesConfig {
473            unused_files: Severity::Warn,
474            unused_exports: Severity::Off,
475            type_only_dependencies: Severity::Error,
476            ..RulesConfig::default()
477        };
478        let json = serde_json::to_string(&rules).unwrap();
479        let restored: RulesConfig = serde_json::from_str(&json).unwrap();
480        assert_eq!(restored.unused_files, Severity::Warn);
481        assert_eq!(restored.unused_exports, Severity::Off);
482        assert_eq!(restored.type_only_dependencies, Severity::Error);
483        assert_eq!(restored.unused_dependencies, Severity::Error); // default
484    }
485
486    // ── apply_partial preserves type_only/test_only defaults ────────
487
488    #[test]
489    fn apply_partial_preserves_type_only_default() {
490        let mut rules = RulesConfig::default();
491        let partial = PartialRulesConfig {
492            unused_files: Some(Severity::Off),
493            ..Default::default()
494        };
495        rules.apply_partial(&partial);
496        // type_only_dependencies defaults to Warn, should be preserved
497        assert_eq!(rules.type_only_dependencies, Severity::Warn);
498        assert_eq!(rules.test_only_dependencies, Severity::Warn);
499    }
500}