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