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