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, 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 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(
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 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 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(); 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 #[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 #[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 #[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 #[test]
530 fn severity_default_is_error() {
531 assert_eq!(Severity::default(), Severity::Error);
532 }
533
534 #[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); }
551
552 #[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 assert_eq!(rules.type_only_dependencies, Severity::Warn);
564 assert_eq!(rules.test_only_dependencies, Severity::Warn);
565 }
566}