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)]
70    pub unused_dev_dependencies: Severity,
71    #[serde(default)]
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}
90
91impl Default for RulesConfig {
92    fn default() -> Self {
93        Self {
94            unused_files: Severity::Error,
95            unused_exports: Severity::Error,
96            unused_types: Severity::Error,
97            unused_dependencies: Severity::Error,
98            unused_dev_dependencies: Severity::Error,
99            unused_optional_dependencies: Severity::Error,
100            unused_enum_members: Severity::Error,
101            unused_class_members: Severity::Error,
102            unresolved_imports: Severity::Error,
103            unlisted_dependencies: Severity::Error,
104            duplicate_exports: Severity::Error,
105            type_only_dependencies: Severity::Warn,
106            test_only_dependencies: Severity::Warn,
107            circular_dependencies: Severity::Error,
108        }
109    }
110}
111
112impl RulesConfig {
113    /// Apply a partial rules config on top. Only `Some` fields override.
114    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
115        if let Some(s) = partial.unused_files {
116            self.unused_files = s;
117        }
118        if let Some(s) = partial.unused_exports {
119            self.unused_exports = s;
120        }
121        if let Some(s) = partial.unused_types {
122            self.unused_types = s;
123        }
124        if let Some(s) = partial.unused_dependencies {
125            self.unused_dependencies = s;
126        }
127        if let Some(s) = partial.unused_dev_dependencies {
128            self.unused_dev_dependencies = s;
129        }
130        if let Some(s) = partial.unused_optional_dependencies {
131            self.unused_optional_dependencies = s;
132        }
133        if let Some(s) = partial.unused_enum_members {
134            self.unused_enum_members = s;
135        }
136        if let Some(s) = partial.unused_class_members {
137            self.unused_class_members = s;
138        }
139        if let Some(s) = partial.unresolved_imports {
140            self.unresolved_imports = s;
141        }
142        if let Some(s) = partial.unlisted_dependencies {
143            self.unlisted_dependencies = s;
144        }
145        if let Some(s) = partial.duplicate_exports {
146            self.duplicate_exports = s;
147        }
148        if let Some(s) = partial.type_only_dependencies {
149            self.type_only_dependencies = s;
150        }
151        if let Some(s) = partial.test_only_dependencies {
152            self.test_only_dependencies = s;
153        }
154        if let Some(s) = partial.circular_dependencies {
155            self.circular_dependencies = s;
156        }
157    }
158}
159
160/// Partial per-issue-type severity for overrides. All fields optional.
161#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
162#[serde(rename_all = "kebab-case")]
163pub struct PartialRulesConfig {
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub unused_files: Option<Severity>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub unused_exports: Option<Severity>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub unused_types: Option<Severity>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub unused_dependencies: Option<Severity>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub unused_dev_dependencies: Option<Severity>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub unused_optional_dependencies: Option<Severity>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub unused_enum_members: Option<Severity>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub unused_class_members: Option<Severity>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub unresolved_imports: Option<Severity>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub unlisted_dependencies: Option<Severity>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub duplicate_exports: Option<Severity>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub type_only_dependencies: Option<Severity>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub test_only_dependencies: Option<Severity>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub circular_dependencies: Option<Severity>,
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn rules_default_all_error_except_type_only() {
200        let rules = RulesConfig::default();
201        assert_eq!(rules.unused_files, Severity::Error);
202        assert_eq!(rules.unused_exports, Severity::Error);
203        assert_eq!(rules.unused_types, Severity::Error);
204        assert_eq!(rules.unused_dependencies, Severity::Error);
205        assert_eq!(rules.unused_dev_dependencies, Severity::Error);
206        assert_eq!(rules.unused_enum_members, Severity::Error);
207        assert_eq!(rules.unused_class_members, Severity::Error);
208        assert_eq!(rules.unresolved_imports, Severity::Error);
209        assert_eq!(rules.unlisted_dependencies, Severity::Error);
210        assert_eq!(rules.duplicate_exports, Severity::Error);
211        assert_eq!(rules.type_only_dependencies, Severity::Warn);
212        assert_eq!(rules.test_only_dependencies, Severity::Warn);
213        assert_eq!(rules.circular_dependencies, Severity::Error);
214    }
215
216    #[test]
217    fn rules_deserialize_kebab_case() {
218        let json_str = r#"{
219            "unused-files": "error",
220            "unused-exports": "warn",
221            "unused-types": "off"
222        }"#;
223        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
224        assert_eq!(rules.unused_files, Severity::Error);
225        assert_eq!(rules.unused_exports, Severity::Warn);
226        assert_eq!(rules.unused_types, Severity::Off);
227        // Unset fields default to error
228        assert_eq!(rules.unresolved_imports, Severity::Error);
229    }
230
231    #[test]
232    fn severity_from_str() {
233        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
234        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
235        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
236        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
237        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
238        assert!("invalid".parse::<Severity>().is_err());
239    }
240
241    #[test]
242    fn apply_partial_only_some_fields() {
243        let mut rules = RulesConfig::default();
244        let partial = PartialRulesConfig {
245            unused_files: Some(Severity::Warn),
246            unused_exports: Some(Severity::Off),
247            ..Default::default()
248        };
249        rules.apply_partial(&partial);
250        assert_eq!(rules.unused_files, Severity::Warn);
251        assert_eq!(rules.unused_exports, Severity::Off);
252        // Unset fields unchanged
253        assert_eq!(rules.unused_types, Severity::Error);
254        assert_eq!(rules.unresolved_imports, Severity::Error);
255    }
256
257    #[test]
258    fn severity_display() {
259        assert_eq!(Severity::Error.to_string(), "error");
260        assert_eq!(Severity::Warn.to_string(), "warn");
261        assert_eq!(Severity::Off.to_string(), "off");
262    }
263
264    #[test]
265    fn apply_partial_all_none_changes_nothing() {
266        let mut rules = RulesConfig::default();
267        let original = rules.clone();
268        let partial = PartialRulesConfig::default(); // all None
269        rules.apply_partial(&partial);
270        assert_eq!(rules.unused_files, original.unused_files);
271        assert_eq!(rules.unused_exports, original.unused_exports);
272        assert_eq!(
273            rules.type_only_dependencies,
274            original.type_only_dependencies
275        );
276    }
277
278    #[test]
279    fn apply_partial_all_fields_set() {
280        let mut rules = RulesConfig::default();
281        let partial = PartialRulesConfig {
282            unused_files: Some(Severity::Off),
283            unused_exports: Some(Severity::Off),
284            unused_types: Some(Severity::Off),
285            unused_dependencies: Some(Severity::Off),
286            unused_dev_dependencies: Some(Severity::Off),
287            unused_optional_dependencies: Some(Severity::Off),
288            unused_enum_members: Some(Severity::Off),
289            unused_class_members: Some(Severity::Off),
290            unresolved_imports: Some(Severity::Off),
291            unlisted_dependencies: Some(Severity::Off),
292            duplicate_exports: Some(Severity::Off),
293            type_only_dependencies: Some(Severity::Off),
294            test_only_dependencies: Some(Severity::Off),
295            circular_dependencies: Some(Severity::Off),
296        };
297        rules.apply_partial(&partial);
298        assert_eq!(rules.unused_files, Severity::Off);
299        assert_eq!(rules.circular_dependencies, Severity::Off);
300        assert_eq!(rules.type_only_dependencies, Severity::Off);
301        assert_eq!(rules.test_only_dependencies, Severity::Off);
302    }
303
304    #[test]
305    fn rules_config_defaults_include_optional_deps() {
306        let rules = RulesConfig::default();
307        assert_eq!(rules.unused_optional_dependencies, Severity::Error);
308    }
309
310    #[test]
311    fn severity_from_str_case_insensitive() {
312        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
313        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
314        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
315        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
316        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
317    }
318
319    #[test]
320    fn severity_from_str_invalid_returns_error() {
321        let result = "critical".parse::<Severity>();
322        assert!(result.is_err());
323        let err = result.unwrap_err();
324        assert!(
325            err.contains("unknown severity"),
326            "Expected descriptive error, got: {err}"
327        );
328    }
329}