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 = "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)]
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 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#[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(default, skip_serializing_if = "Option::is_none")]
228 pub circular_dependencies: Option<Severity>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub boundary_violation: Option<Severity>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub coverage_gaps: Option<Severity>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub feature_flags: Option<Severity>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub stale_suppressions: Option<Severity>,
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn rules_default_severities() {
245 let rules = RulesConfig::default();
246 assert_eq!(rules.unused_files, Severity::Error);
247 assert_eq!(rules.unused_exports, Severity::Error);
248 assert_eq!(rules.unused_types, Severity::Error);
249 assert_eq!(rules.private_type_leaks, Severity::Off);
250 assert_eq!(rules.unused_dependencies, Severity::Error);
251 assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
252 assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
253 assert_eq!(rules.unused_enum_members, Severity::Error);
254 assert_eq!(rules.unused_class_members, Severity::Error);
255 assert_eq!(rules.unresolved_imports, Severity::Error);
256 assert_eq!(rules.unlisted_dependencies, Severity::Error);
257 assert_eq!(rules.duplicate_exports, Severity::Error);
258 assert_eq!(rules.type_only_dependencies, Severity::Warn);
259 assert_eq!(rules.test_only_dependencies, Severity::Warn);
260 assert_eq!(rules.circular_dependencies, Severity::Error);
261 assert_eq!(rules.boundary_violation, Severity::Error);
262 assert_eq!(rules.coverage_gaps, Severity::Off);
263 assert_eq!(rules.feature_flags, Severity::Off);
264 assert_eq!(rules.stale_suppressions, Severity::Warn);
265 }
266
267 #[test]
268 fn rules_deserialize_kebab_case() {
269 let json_str = r#"{
270 "unused-files": "error",
271 "unused-exports": "warn",
272 "unused-types": "off"
273 }"#;
274 let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
275 assert_eq!(rules.unused_files, Severity::Error);
276 assert_eq!(rules.unused_exports, Severity::Warn);
277 assert_eq!(rules.unused_types, Severity::Off);
278 assert_eq!(rules.unresolved_imports, Severity::Error);
280 }
281
282 #[test]
283 fn severity_from_str() {
284 assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
285 assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
286 assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
287 assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
288 assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
289 assert!("invalid".parse::<Severity>().is_err());
290 }
291
292 #[test]
293 fn apply_partial_only_some_fields() {
294 let mut rules = RulesConfig::default();
295 let partial = PartialRulesConfig {
296 unused_files: Some(Severity::Warn),
297 unused_exports: Some(Severity::Off),
298 ..Default::default()
299 };
300 rules.apply_partial(&partial);
301 assert_eq!(rules.unused_files, Severity::Warn);
302 assert_eq!(rules.unused_exports, Severity::Off);
303 assert_eq!(rules.unused_types, Severity::Error);
305 assert_eq!(rules.unresolved_imports, Severity::Error);
306 }
307
308 #[test]
309 fn severity_display() {
310 assert_eq!(Severity::Error.to_string(), "error");
311 assert_eq!(Severity::Warn.to_string(), "warn");
312 assert_eq!(Severity::Off.to_string(), "off");
313 }
314
315 #[test]
316 fn apply_partial_all_none_changes_nothing() {
317 let mut rules = RulesConfig::default();
318 let original = rules.clone();
319 let partial = PartialRulesConfig::default(); rules.apply_partial(&partial);
321 assert_eq!(rules.unused_files, original.unused_files);
322 assert_eq!(rules.unused_exports, original.unused_exports);
323 assert_eq!(
324 rules.type_only_dependencies,
325 original.type_only_dependencies
326 );
327 }
328
329 #[test]
330 fn apply_partial_all_fields_set() {
331 let mut rules = RulesConfig::default();
332 let partial = PartialRulesConfig {
333 unused_files: Some(Severity::Off),
334 unused_exports: Some(Severity::Off),
335 unused_types: Some(Severity::Off),
336 private_type_leaks: Some(Severity::Off),
337 unused_dependencies: Some(Severity::Off),
338 unused_dev_dependencies: Some(Severity::Off),
339 unused_optional_dependencies: Some(Severity::Off),
340 unused_enum_members: Some(Severity::Off),
341 unused_class_members: Some(Severity::Off),
342 unresolved_imports: Some(Severity::Off),
343 unlisted_dependencies: Some(Severity::Off),
344 duplicate_exports: Some(Severity::Off),
345 type_only_dependencies: Some(Severity::Off),
346 test_only_dependencies: Some(Severity::Off),
347 circular_dependencies: Some(Severity::Off),
348 boundary_violation: Some(Severity::Off),
349 coverage_gaps: Some(Severity::Off),
350 feature_flags: Some(Severity::Off),
351 stale_suppressions: Some(Severity::Off),
352 };
353 rules.apply_partial(&partial);
354 assert_eq!(rules.unused_files, Severity::Off);
355 assert_eq!(rules.private_type_leaks, Severity::Off);
356 assert_eq!(rules.circular_dependencies, Severity::Off);
357 assert_eq!(rules.type_only_dependencies, Severity::Off);
358 assert_eq!(rules.test_only_dependencies, Severity::Off);
359 assert_eq!(rules.boundary_violation, Severity::Off);
360 assert_eq!(rules.coverage_gaps, Severity::Off);
361 assert_eq!(rules.feature_flags, Severity::Off);
362 assert_eq!(rules.stale_suppressions, Severity::Off);
363 }
364
365 #[test]
366 fn rules_config_defaults_include_optional_deps() {
367 let rules = RulesConfig::default();
368 assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
369 }
370
371 #[test]
372 fn severity_from_str_case_insensitive() {
373 assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
374 assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
375 assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
376 assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
377 assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
378 }
379
380 #[test]
381 fn severity_from_str_invalid_returns_error() {
382 let result = "critical".parse::<Severity>();
383 assert!(result.is_err());
384 let err = result.unwrap_err();
385 assert!(
386 err.contains("unknown severity"),
387 "Expected descriptive error, got: {err}"
388 );
389 }
390
391 #[test]
394 fn partial_rules_empty_json() {
395 let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
396 assert!(partial.unused_files.is_none());
397 assert!(partial.unused_exports.is_none());
398 assert!(partial.unused_types.is_none());
399 assert!(partial.unused_dependencies.is_none());
400 assert!(partial.circular_dependencies.is_none());
401 assert!(partial.boundary_violation.is_none());
402 assert!(partial.coverage_gaps.is_none());
403 assert!(partial.feature_flags.is_none());
404 assert!(partial.stale_suppressions.is_none());
405 }
406
407 #[test]
408 fn partial_rules_subset_json() {
409 let json = r#"{
410 "unused-files": "warn",
411 "circular-dependencies": "off"
412 }"#;
413 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
414 assert_eq!(partial.unused_files, Some(Severity::Warn));
415 assert_eq!(partial.circular_dependencies, Some(Severity::Off));
416 assert!(partial.unused_exports.is_none());
417 }
418
419 #[test]
420 fn partial_rules_all_fields_json() {
421 let json = r#"{
422 "unused-files": "error",
423 "unused-exports": "warn",
424 "unused-types": "off",
425 "unused-dependencies": "error",
426 "unused-dev-dependencies": "warn",
427 "unused-optional-dependencies": "off",
428 "unused-enum-members": "error",
429 "unused-class-members": "warn",
430 "unresolved-imports": "off",
431 "unlisted-dependencies": "error",
432 "duplicate-exports": "warn",
433 "type-only-dependencies": "off",
434 "test-only-dependencies": "error",
435 "circular-dependencies": "warn",
436 "boundary-violation": "off",
437 "coverage-gaps": "warn",
438 "feature-flags": "error",
439 "stale-suppressions": "off"
440 }"#;
441 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
442 assert_eq!(partial.unused_files, Some(Severity::Error));
443 assert_eq!(partial.unused_exports, Some(Severity::Warn));
444 assert_eq!(partial.unused_types, Some(Severity::Off));
445 assert_eq!(partial.unused_dependencies, Some(Severity::Error));
446 assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
447 assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
448 assert_eq!(partial.unused_enum_members, Some(Severity::Error));
449 assert_eq!(partial.unused_class_members, Some(Severity::Warn));
450 assert_eq!(partial.unresolved_imports, Some(Severity::Off));
451 assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
452 assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
453 assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
454 assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
455 assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
456 assert_eq!(partial.boundary_violation, Some(Severity::Off));
457 assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
458 assert_eq!(partial.feature_flags, Some(Severity::Error));
459 assert_eq!(partial.stale_suppressions, Some(Severity::Off));
460 }
461
462 #[test]
465 fn partial_rules_none_fields_not_serialized() {
466 let partial = PartialRulesConfig::default();
467 let json = serde_json::to_string(&partial).unwrap();
468 assert_eq!(
469 json, "{}",
470 "all-None partial should serialize to empty object"
471 );
472 }
473
474 #[test]
475 fn partial_rules_some_fields_serialized() {
476 let partial = PartialRulesConfig {
477 unused_files: Some(Severity::Warn),
478 ..Default::default()
479 };
480 let json = serde_json::to_string(&partial).unwrap();
481 assert!(json.contains("unused-files"));
482 assert!(!json.contains("unused-exports"));
483 }
484
485 #[test]
488 fn severity_json_deserialization() {
489 let error: Severity = serde_json::from_str(r#""error""#).unwrap();
490 assert_eq!(error, Severity::Error);
491
492 let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
493 assert_eq!(warn, Severity::Warn);
494
495 let off: Severity = serde_json::from_str(r#""off""#).unwrap();
496 assert_eq!(off, Severity::Off);
497 }
498
499 #[test]
500 fn severity_invalid_json_value_rejected() {
501 let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
502 assert!(result.is_err());
503 }
504
505 #[test]
508 fn severity_default_is_error() {
509 assert_eq!(Severity::default(), Severity::Error);
510 }
511
512 #[test]
515 fn rules_config_json_roundtrip() {
516 let rules = RulesConfig {
517 unused_files: Severity::Warn,
518 unused_exports: Severity::Off,
519 type_only_dependencies: Severity::Error,
520 ..RulesConfig::default()
521 };
522 let json = serde_json::to_string(&rules).unwrap();
523 let restored: RulesConfig = serde_json::from_str(&json).unwrap();
524 assert_eq!(restored.unused_files, Severity::Warn);
525 assert_eq!(restored.unused_exports, Severity::Off);
526 assert_eq!(restored.type_only_dependencies, Severity::Error);
527 assert_eq!(restored.unused_dependencies, Severity::Error); }
529
530 #[test]
533 fn apply_partial_preserves_type_only_default() {
534 let mut rules = RulesConfig::default();
535 let partial = PartialRulesConfig {
536 unused_files: Some(Severity::Off),
537 ..Default::default()
538 };
539 rules.apply_partial(&partial);
540 assert_eq!(rules.type_only_dependencies, Severity::Warn);
542 assert_eq!(rules.test_only_dependencies, Severity::Warn);
543 }
544}