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