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, alias = "unused-file")]
67 pub unused_files: Severity,
68 #[serde(default, alias = "unused-export")]
69 pub unused_exports: Severity,
70 #[serde(default, alias = "unused-type")]
71 pub unused_types: Severity,
72 #[serde(default = "Severity::default_off", alias = "private-type-leak")]
73 pub private_type_leaks: Severity,
74 #[serde(default, alias = "unused-dependency")]
75 pub unused_dependencies: Severity,
76 #[serde(default = "Severity::default_warn", alias = "unused-dev-dependency")]
77 pub unused_dev_dependencies: Severity,
78 #[serde(
79 default = "Severity::default_warn",
80 alias = "unused-optional-dependency"
81 )]
82 pub unused_optional_dependencies: Severity,
83 #[serde(default, alias = "unused-enum-member")]
84 pub unused_enum_members: Severity,
85 #[serde(default, alias = "unused-class-member")]
86 pub unused_class_members: Severity,
87 #[serde(default, alias = "unresolved-import")]
88 pub unresolved_imports: Severity,
89 #[serde(default, alias = "unlisted-dependency")]
90 pub unlisted_dependencies: Severity,
91 #[serde(default, alias = "duplicate-export")]
92 pub duplicate_exports: Severity,
93 #[serde(default = "Severity::default_warn", alias = "type-only-dependency")]
94 pub type_only_dependencies: Severity,
95 #[serde(default = "Severity::default_warn", alias = "test-only-dependency")]
96 pub test_only_dependencies: Severity,
97 #[serde(default, alias = "circular-dependency")]
98 pub circular_dependencies: Severity,
99 #[serde(
100 default = "Severity::default_warn",
101 alias = "re-export-cycles",
102 alias = "reexport-cycle",
103 alias = "reexport-cycles"
104 )]
105 pub re_export_cycle: Severity,
106 #[serde(default, alias = "boundary-violations")]
107 pub boundary_violation: Severity,
108 #[serde(default, alias = "coverage-gap")]
109 pub coverage_gaps: Severity,
110 #[serde(default = "Severity::default_off", alias = "feature-flag")]
111 pub feature_flags: Severity,
112 #[serde(default = "Severity::default_warn", alias = "stale-suppression")]
113 pub stale_suppressions: Severity,
114 #[serde(default = "Severity::default_warn", alias = "unused-catalog-entry")]
115 pub unused_catalog_entries: Severity,
116 #[serde(default = "Severity::default_warn", alias = "empty-catalog-group")]
117 pub empty_catalog_groups: Severity,
118 #[serde(default, alias = "unresolved-catalog-reference")]
119 pub unresolved_catalog_references: Severity,
120 #[serde(
121 default = "Severity::default_warn",
122 alias = "unused-dependency-override"
123 )]
124 pub unused_dependency_overrides: Severity,
125 #[serde(default, alias = "misconfigured-dependency-override")]
126 pub misconfigured_dependency_overrides: Severity,
127 #[serde(default = "Severity::default_off")]
131 pub security_client_server_leak: Severity,
132 #[serde(default = "Severity::default_off")]
137 pub security_sink: Severity,
138}
139
140impl Default for RulesConfig {
141 fn default() -> Self {
142 Self {
143 unused_files: Severity::Error,
144 unused_exports: Severity::Error,
145 unused_types: Severity::Error,
146 private_type_leaks: Severity::Off,
147 unused_dependencies: Severity::Error,
148 unused_dev_dependencies: Severity::Warn,
149 unused_optional_dependencies: Severity::Warn,
150 unused_enum_members: Severity::Error,
151 unused_class_members: Severity::Error,
152 unresolved_imports: Severity::Error,
153 unlisted_dependencies: Severity::Error,
154 duplicate_exports: Severity::Error,
155 type_only_dependencies: Severity::Warn,
156 test_only_dependencies: Severity::Warn,
157 circular_dependencies: Severity::Error,
158 re_export_cycle: Severity::Warn,
159 boundary_violation: Severity::Error,
160 coverage_gaps: Severity::Off,
161 feature_flags: Severity::Off,
162 stale_suppressions: Severity::Warn,
163 unused_catalog_entries: Severity::Warn,
164 empty_catalog_groups: Severity::Warn,
165 unresolved_catalog_references: Severity::Error,
166 unused_dependency_overrides: Severity::Warn,
167 misconfigured_dependency_overrides: Severity::Error,
168 security_client_server_leak: Severity::Off,
169 security_sink: Severity::Off,
170 }
171 }
172}
173
174impl RulesConfig {
175 pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
177 if let Some(s) = partial.unused_files {
178 self.unused_files = s;
179 }
180 if let Some(s) = partial.unused_exports {
181 self.unused_exports = s;
182 }
183 if let Some(s) = partial.unused_types {
184 self.unused_types = s;
185 }
186 if let Some(s) = partial.private_type_leaks {
187 self.private_type_leaks = s;
188 }
189 if let Some(s) = partial.unused_dependencies {
190 self.unused_dependencies = s;
191 }
192 if let Some(s) = partial.unused_dev_dependencies {
193 self.unused_dev_dependencies = s;
194 }
195 if let Some(s) = partial.unused_optional_dependencies {
196 self.unused_optional_dependencies = s;
197 }
198 if let Some(s) = partial.unused_enum_members {
199 self.unused_enum_members = s;
200 }
201 if let Some(s) = partial.unused_class_members {
202 self.unused_class_members = s;
203 }
204 if let Some(s) = partial.unresolved_imports {
205 self.unresolved_imports = s;
206 }
207 if let Some(s) = partial.unlisted_dependencies {
208 self.unlisted_dependencies = s;
209 }
210 if let Some(s) = partial.duplicate_exports {
211 self.duplicate_exports = s;
212 }
213 if let Some(s) = partial.type_only_dependencies {
214 self.type_only_dependencies = s;
215 }
216 if let Some(s) = partial.test_only_dependencies {
217 self.test_only_dependencies = s;
218 }
219 if let Some(s) = partial.circular_dependencies {
220 self.circular_dependencies = s;
221 }
222 if let Some(s) = partial.re_export_cycle {
223 self.re_export_cycle = s;
224 }
225 if let Some(s) = partial.boundary_violation {
226 self.boundary_violation = s;
227 }
228 if let Some(s) = partial.coverage_gaps {
229 self.coverage_gaps = s;
230 }
231 if let Some(s) = partial.feature_flags {
232 self.feature_flags = s;
233 }
234 if let Some(s) = partial.stale_suppressions {
235 self.stale_suppressions = s;
236 }
237 if let Some(s) = partial.unused_catalog_entries {
238 self.unused_catalog_entries = s;
239 }
240 if let Some(s) = partial.empty_catalog_groups {
241 self.empty_catalog_groups = s;
242 }
243 if let Some(s) = partial.unresolved_catalog_references {
244 self.unresolved_catalog_references = s;
245 }
246 if let Some(s) = partial.unused_dependency_overrides {
247 self.unused_dependency_overrides = s;
248 }
249 if let Some(s) = partial.misconfigured_dependency_overrides {
250 self.misconfigured_dependency_overrides = s;
251 }
252 if let Some(s) = partial.security_client_server_leak {
253 self.security_client_server_leak = s;
254 }
255 if let Some(s) = partial.security_sink {
256 self.security_sink = s;
257 }
258 }
259}
260
261#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
263#[serde(rename_all = "kebab-case")]
264pub struct PartialRulesConfig {
265 #[serde(
266 default,
267 alias = "unused-file",
268 skip_serializing_if = "Option::is_none"
269 )]
270 pub unused_files: Option<Severity>,
271 #[serde(
272 default,
273 alias = "unused-export",
274 skip_serializing_if = "Option::is_none"
275 )]
276 pub unused_exports: Option<Severity>,
277 #[serde(
278 default,
279 alias = "unused-type",
280 skip_serializing_if = "Option::is_none"
281 )]
282 pub unused_types: Option<Severity>,
283 #[serde(
284 default,
285 alias = "private-type-leak",
286 skip_serializing_if = "Option::is_none"
287 )]
288 pub private_type_leaks: Option<Severity>,
289 #[serde(
290 default,
291 alias = "unused-dependency",
292 skip_serializing_if = "Option::is_none"
293 )]
294 pub unused_dependencies: Option<Severity>,
295 #[serde(
296 default,
297 alias = "unused-dev-dependency",
298 skip_serializing_if = "Option::is_none"
299 )]
300 pub unused_dev_dependencies: Option<Severity>,
301 #[serde(
302 default,
303 alias = "unused-optional-dependency",
304 skip_serializing_if = "Option::is_none"
305 )]
306 pub unused_optional_dependencies: Option<Severity>,
307 #[serde(
308 default,
309 alias = "unused-enum-member",
310 skip_serializing_if = "Option::is_none"
311 )]
312 pub unused_enum_members: Option<Severity>,
313 #[serde(
314 default,
315 alias = "unused-class-member",
316 skip_serializing_if = "Option::is_none"
317 )]
318 pub unused_class_members: Option<Severity>,
319 #[serde(
320 default,
321 alias = "unresolved-import",
322 skip_serializing_if = "Option::is_none"
323 )]
324 pub unresolved_imports: Option<Severity>,
325 #[serde(
326 default,
327 alias = "unlisted-dependency",
328 skip_serializing_if = "Option::is_none"
329 )]
330 pub unlisted_dependencies: Option<Severity>,
331 #[serde(
332 default,
333 alias = "duplicate-export",
334 skip_serializing_if = "Option::is_none"
335 )]
336 pub duplicate_exports: Option<Severity>,
337 #[serde(
338 default,
339 alias = "type-only-dependency",
340 skip_serializing_if = "Option::is_none"
341 )]
342 pub type_only_dependencies: Option<Severity>,
343 #[serde(
344 default,
345 alias = "test-only-dependency",
346 skip_serializing_if = "Option::is_none"
347 )]
348 pub test_only_dependencies: Option<Severity>,
349 #[serde(
350 default,
351 alias = "circular-dependency",
352 skip_serializing_if = "Option::is_none"
353 )]
354 pub circular_dependencies: Option<Severity>,
355 #[serde(
356 default,
357 alias = "re-export-cycles",
358 alias = "reexport-cycle",
359 alias = "reexport-cycles",
360 skip_serializing_if = "Option::is_none"
361 )]
362 pub re_export_cycle: Option<Severity>,
363 #[serde(
364 default,
365 alias = "boundary-violations",
366 skip_serializing_if = "Option::is_none"
367 )]
368 pub boundary_violation: Option<Severity>,
369 #[serde(
370 default,
371 alias = "coverage-gap",
372 skip_serializing_if = "Option::is_none"
373 )]
374 pub coverage_gaps: Option<Severity>,
375 #[serde(
376 default,
377 alias = "feature-flag",
378 skip_serializing_if = "Option::is_none"
379 )]
380 pub feature_flags: Option<Severity>,
381 #[serde(
382 default,
383 alias = "stale-suppression",
384 skip_serializing_if = "Option::is_none"
385 )]
386 pub stale_suppressions: Option<Severity>,
387 #[serde(
388 default,
389 alias = "unused-catalog-entry",
390 skip_serializing_if = "Option::is_none"
391 )]
392 pub unused_catalog_entries: Option<Severity>,
393 #[serde(
394 default,
395 alias = "empty-catalog-group",
396 skip_serializing_if = "Option::is_none"
397 )]
398 pub empty_catalog_groups: Option<Severity>,
399 #[serde(
400 default,
401 alias = "unresolved-catalog-reference",
402 skip_serializing_if = "Option::is_none"
403 )]
404 pub unresolved_catalog_references: Option<Severity>,
405 #[serde(
406 default,
407 alias = "unused-dependency-override",
408 skip_serializing_if = "Option::is_none"
409 )]
410 pub unused_dependency_overrides: Option<Severity>,
411 #[serde(
412 default,
413 alias = "misconfigured-dependency-override",
414 skip_serializing_if = "Option::is_none"
415 )]
416 pub misconfigured_dependency_overrides: Option<Severity>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub security_client_server_leak: Option<Severity>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub security_sink: Option<Severity>,
421}
422
423pub const KNOWN_RULE_NAMES: &[&str] = &[
434 "unused-files",
435 "unused-exports",
436 "unused-types",
437 "private-type-leaks",
438 "unused-dependencies",
439 "unused-dev-dependencies",
440 "unused-optional-dependencies",
441 "unused-enum-members",
442 "unused-class-members",
443 "unresolved-imports",
444 "unlisted-dependencies",
445 "duplicate-exports",
446 "type-only-dependencies",
447 "test-only-dependencies",
448 "circular-dependencies",
449 "re-export-cycle",
450 "boundary-violation",
451 "coverage-gaps",
452 "feature-flags",
453 "stale-suppressions",
454 "unused-catalog-entries",
455 "empty-catalog-groups",
456 "unresolved-catalog-references",
457 "unused-dependency-overrides",
458 "misconfigured-dependency-overrides",
459 "security-client-server-leak",
460 "security-sink",
461 "unused-file",
462 "unused-export",
463 "unused-type",
464 "private-type-leak",
465 "unused-dependency",
466 "unused-dev-dependency",
467 "unused-optional-dependency",
468 "unused-enum-member",
469 "unused-class-member",
470 "unresolved-import",
471 "unlisted-dependency",
472 "duplicate-export",
473 "type-only-dependency",
474 "test-only-dependency",
475 "circular-dependency",
476 "re-export-cycles",
477 "reexport-cycle",
478 "reexport-cycles",
479 "boundary-violations",
480 "coverage-gap",
481 "feature-flag",
482 "stale-suppression",
483 "unused-catalog-entry",
484 "empty-catalog-group",
485 "unresolved-catalog-reference",
486 "unused-dependency-override",
487 "misconfigured-dependency-override",
488];
489
490#[must_use]
496pub fn closest_known_rule_name(input: &str) -> Option<&'static str> {
497 let input_lower = input.to_ascii_lowercase();
498 let candidates = KNOWN_RULE_NAMES.iter().copied();
499 let suggestion = crate::levenshtein::closest_match(&input_lower, candidates)?;
500 KNOWN_RULE_NAMES.iter().copied().find(|&c| c == suggestion)
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct UnknownRuleKey {
510 pub context: String,
512 pub key: String,
514 pub suggestion: Option<&'static str>,
516}
517
518#[must_use]
526pub fn find_unknown_rule_keys(value: &serde_json::Value, context: &str) -> Vec<UnknownRuleKey> {
527 let Some(map) = value.as_object() else {
528 return Vec::new();
529 };
530
531 map.keys()
532 .filter(|key| !KNOWN_RULE_NAMES.contains(&key.as_str()))
533 .map(|key| UnknownRuleKey {
534 context: context.to_owned(),
535 key: key.clone(),
536 suggestion: closest_known_rule_name(key),
537 })
538 .collect()
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn rules_default_severities() {
547 let rules = RulesConfig::default();
548 assert_eq!(rules.unused_files, Severity::Error);
549 assert_eq!(rules.unused_exports, Severity::Error);
550 assert_eq!(rules.unused_types, Severity::Error);
551 assert_eq!(rules.private_type_leaks, Severity::Off);
552 assert_eq!(rules.unused_dependencies, Severity::Error);
553 assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
554 assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
555 assert_eq!(rules.unused_enum_members, Severity::Error);
556 assert_eq!(rules.unused_class_members, Severity::Error);
557 assert_eq!(rules.unresolved_imports, Severity::Error);
558 assert_eq!(rules.unlisted_dependencies, Severity::Error);
559 assert_eq!(rules.duplicate_exports, Severity::Error);
560 assert_eq!(rules.type_only_dependencies, Severity::Warn);
561 assert_eq!(rules.test_only_dependencies, Severity::Warn);
562 assert_eq!(rules.circular_dependencies, Severity::Error);
563 assert_eq!(rules.boundary_violation, Severity::Error);
564 assert_eq!(rules.coverage_gaps, Severity::Off);
565 assert_eq!(rules.feature_flags, Severity::Off);
566 assert_eq!(rules.stale_suppressions, Severity::Warn);
567 assert_eq!(rules.unused_catalog_entries, Severity::Warn);
568 assert_eq!(rules.empty_catalog_groups, Severity::Warn);
569 assert_eq!(rules.unresolved_catalog_references, Severity::Error);
570 }
571
572 #[test]
573 fn rules_deserialize_kebab_case() {
574 let json_str = r#"{
575 "unused-files": "error",
576 "unused-exports": "warn",
577 "unused-types": "off"
578 }"#;
579 let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
580 assert_eq!(rules.unused_files, Severity::Error);
581 assert_eq!(rules.unused_exports, Severity::Warn);
582 assert_eq!(rules.unused_types, Severity::Off);
583 assert_eq!(rules.unresolved_imports, Severity::Error);
584 }
585
586 #[test]
587 fn rules_re_export_cycle_default_is_warn() {
588 let rules = RulesConfig::default();
589 assert_eq!(rules.re_export_cycle, Severity::Warn);
590 }
591
592 #[test]
593 fn rules_deserialize_re_export_cycle_aliases() {
594 for token in [
595 "re-export-cycle",
596 "re-export-cycles",
597 "reexport-cycle",
598 "reexport-cycles",
599 ] {
600 let json_str = format!(r#"{{ "{token}": "error" }}"#);
601 let rules: RulesConfig = serde_json::from_str(&json_str)
602 .unwrap_or_else(|e| panic!("alias {token} did not deserialize: {e}"));
603 assert_eq!(
604 rules.re_export_cycle,
605 Severity::Error,
606 "alias {token} should set re_export_cycle"
607 );
608 }
609 }
610
611 #[test]
612 fn rules_deserialize_circular_dependency_alias() {
613 let json_str = r#"{
614 "circular-dependency": "off"
615 }"#;
616 let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
617 assert_eq!(rules.circular_dependencies, Severity::Off);
618 }
619
620 #[test]
621 fn rules_deserialize_boundary_violations_alias() {
622 let json_str = r#"{
623 "boundary-violations": "off"
624 }"#;
625 let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
626 assert_eq!(rules.boundary_violation, Severity::Off);
627
628 let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
629 assert_eq!(partial.boundary_violation, Some(Severity::Off));
630 }
631
632 #[test]
633 fn rules_deserialize_singular_aliases_for_every_plural_rule() {
634 let json_str = r#"{
635 "unused-file": "off",
636 "unused-export": "off",
637 "unused-type": "off",
638 "private-type-leak": "warn",
639 "unused-dependency": "off",
640 "unused-dev-dependency": "off",
641 "unused-optional-dependency": "off",
642 "unused-enum-member": "off",
643 "unused-class-member": "off",
644 "unresolved-import": "off",
645 "unlisted-dependency": "off",
646 "duplicate-export": "off",
647 "type-only-dependency": "off",
648 "test-only-dependency": "off",
649 "coverage-gap": "warn",
650 "feature-flag": "warn",
651 "stale-suppression": "off",
652 "unused-catalog-entry": "error",
653 "empty-catalog-group": "error",
654 "unresolved-catalog-reference": "warn"
655 }"#;
656
657 let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
658 assert_eq!(rules.unused_files, Severity::Off);
659 assert_eq!(rules.unused_exports, Severity::Off);
660 assert_eq!(rules.unused_types, Severity::Off);
661 assert_eq!(rules.private_type_leaks, Severity::Warn);
662 assert_eq!(rules.unused_dependencies, Severity::Off);
663 assert_eq!(rules.unused_dev_dependencies, Severity::Off);
664 assert_eq!(rules.unused_optional_dependencies, Severity::Off);
665 assert_eq!(rules.unused_enum_members, Severity::Off);
666 assert_eq!(rules.unused_class_members, Severity::Off);
667 assert_eq!(rules.unresolved_imports, Severity::Off);
668 assert_eq!(rules.unlisted_dependencies, Severity::Off);
669 assert_eq!(rules.duplicate_exports, Severity::Off);
670 assert_eq!(rules.type_only_dependencies, Severity::Off);
671 assert_eq!(rules.test_only_dependencies, Severity::Off);
672 assert_eq!(rules.coverage_gaps, Severity::Warn);
673 assert_eq!(rules.feature_flags, Severity::Warn);
674 assert_eq!(rules.stale_suppressions, Severity::Off);
675 assert_eq!(rules.unused_catalog_entries, Severity::Error);
676 assert_eq!(rules.empty_catalog_groups, Severity::Error);
677 assert_eq!(rules.unresolved_catalog_references, Severity::Warn);
678
679 let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
680 assert_eq!(partial.unused_files, Some(Severity::Off));
681 assert_eq!(partial.unused_exports, Some(Severity::Off));
682 assert_eq!(partial.unused_types, Some(Severity::Off));
683 assert_eq!(partial.private_type_leaks, Some(Severity::Warn));
684 assert_eq!(partial.unused_dependencies, Some(Severity::Off));
685 assert_eq!(partial.unused_dev_dependencies, Some(Severity::Off));
686 assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
687 assert_eq!(partial.unused_enum_members, Some(Severity::Off));
688 assert_eq!(partial.unused_class_members, Some(Severity::Off));
689 assert_eq!(partial.unresolved_imports, Some(Severity::Off));
690 assert_eq!(partial.unlisted_dependencies, Some(Severity::Off));
691 assert_eq!(partial.duplicate_exports, Some(Severity::Off));
692 assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
693 assert_eq!(partial.test_only_dependencies, Some(Severity::Off));
694 assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
695 assert_eq!(partial.feature_flags, Some(Severity::Warn));
696 assert_eq!(partial.stale_suppressions, Some(Severity::Off));
697 assert_eq!(partial.unused_catalog_entries, Some(Severity::Error));
698 assert_eq!(partial.empty_catalog_groups, Some(Severity::Error));
699 assert_eq!(partial.unresolved_catalog_references, Some(Severity::Warn));
700 }
701
702 #[test]
703 fn severity_from_str() {
704 assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
705 assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
706 assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
707 assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
708 assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
709 assert!("invalid".parse::<Severity>().is_err());
710 }
711
712 #[test]
713 fn apply_partial_only_some_fields() {
714 let mut rules = RulesConfig::default();
715 let partial = PartialRulesConfig {
716 unused_files: Some(Severity::Warn),
717 unused_exports: Some(Severity::Off),
718 ..Default::default()
719 };
720 rules.apply_partial(&partial);
721 assert_eq!(rules.unused_files, Severity::Warn);
722 assert_eq!(rules.unused_exports, Severity::Off);
723 assert_eq!(rules.unused_types, Severity::Error);
724 assert_eq!(rules.unresolved_imports, Severity::Error);
725 }
726
727 #[test]
728 fn severity_display() {
729 assert_eq!(Severity::Error.to_string(), "error");
730 assert_eq!(Severity::Warn.to_string(), "warn");
731 assert_eq!(Severity::Off.to_string(), "off");
732 }
733
734 #[test]
735 fn apply_partial_all_none_changes_nothing() {
736 let mut rules = RulesConfig::default();
737 let original = rules.clone();
738 let partial = PartialRulesConfig::default(); rules.apply_partial(&partial);
740 assert_eq!(rules.unused_files, original.unused_files);
741 assert_eq!(rules.unused_exports, original.unused_exports);
742 assert_eq!(
743 rules.type_only_dependencies,
744 original.type_only_dependencies
745 );
746 }
747
748 #[test]
749 fn apply_partial_all_fields_set() {
750 let mut rules = RulesConfig::default();
751 let partial = PartialRulesConfig {
752 unused_files: Some(Severity::Off),
753 unused_exports: Some(Severity::Off),
754 unused_types: Some(Severity::Off),
755 private_type_leaks: Some(Severity::Off),
756 unused_dependencies: Some(Severity::Off),
757 unused_dev_dependencies: Some(Severity::Off),
758 unused_optional_dependencies: Some(Severity::Off),
759 unused_enum_members: Some(Severity::Off),
760 unused_class_members: Some(Severity::Off),
761 unresolved_imports: Some(Severity::Off),
762 unlisted_dependencies: Some(Severity::Off),
763 duplicate_exports: Some(Severity::Off),
764 type_only_dependencies: Some(Severity::Off),
765 test_only_dependencies: Some(Severity::Off),
766 circular_dependencies: Some(Severity::Off),
767 re_export_cycle: Some(Severity::Off),
768 boundary_violation: Some(Severity::Off),
769 coverage_gaps: Some(Severity::Off),
770 feature_flags: Some(Severity::Off),
771 stale_suppressions: Some(Severity::Off),
772 unused_catalog_entries: Some(Severity::Off),
773 empty_catalog_groups: Some(Severity::Off),
774 unresolved_catalog_references: Some(Severity::Off),
775 unused_dependency_overrides: Some(Severity::Off),
776 misconfigured_dependency_overrides: Some(Severity::Off),
777 security_client_server_leak: Some(Severity::Off),
778 security_sink: Some(Severity::Off),
779 };
780 rules.apply_partial(&partial);
781 assert_eq!(rules.unused_files, Severity::Off);
782 assert_eq!(rules.private_type_leaks, Severity::Off);
783 assert_eq!(rules.circular_dependencies, Severity::Off);
784 assert_eq!(rules.type_only_dependencies, Severity::Off);
785 assert_eq!(rules.test_only_dependencies, Severity::Off);
786 assert_eq!(rules.boundary_violation, Severity::Off);
787 assert_eq!(rules.coverage_gaps, Severity::Off);
788 assert_eq!(rules.feature_flags, Severity::Off);
789 assert_eq!(rules.stale_suppressions, Severity::Off);
790 assert_eq!(rules.security_sink, Severity::Off);
791 }
792
793 #[test]
794 fn rules_config_defaults_include_optional_deps() {
795 let rules = RulesConfig::default();
796 assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
797 }
798
799 #[test]
800 fn severity_from_str_case_insensitive() {
801 assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
802 assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
803 assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
804 assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
805 assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
806 }
807
808 #[test]
809 fn severity_from_str_invalid_returns_error() {
810 let result = "critical".parse::<Severity>();
811 assert!(result.is_err());
812 let err = result.unwrap_err();
813 assert!(
814 err.contains("unknown severity"),
815 "Expected descriptive error, got: {err}"
816 );
817 }
818
819 #[test]
820 fn known_rule_names_count_matches_struct() {
821 assert_eq!(KNOWN_RULE_NAMES.len(), 54);
822 }
823
824 #[test]
825 fn known_rule_names_has_no_duplicates() {
826 let mut sorted: Vec<&str> = KNOWN_RULE_NAMES.to_vec();
827 sorted.sort_unstable();
828 let original_len = sorted.len();
829 sorted.dedup();
830 assert_eq!(
831 sorted.len(),
832 original_len,
833 "KNOWN_RULE_NAMES contains a duplicate"
834 );
835 }
836
837 #[test]
838 fn known_rule_names_covers_every_serde_alias_in_source() {
839 let source = include_str!("rules.rs");
840
841 let mut aliases_found = Vec::new();
842 for line in source.lines() {
843 let trimmed = line.trim();
844 if trimmed.starts_with("//") {
845 continue;
846 }
847 let Some(after) = trimmed.split("alias = \"").nth(1) else {
848 continue;
849 };
850 let Some(end) = after.find('"') else {
851 continue;
852 };
853 let alias = &after[..end];
854 if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
855 continue;
856 }
857 aliases_found.push(alias.to_owned());
858 }
859
860 assert_eq!(
861 aliases_found.len(),
862 54,
863 "expected 54 source-level alias attrs (27 per struct); got {}: {:?}",
864 aliases_found.len(),
865 aliases_found
866 );
867
868 for alias in &aliases_found {
869 assert!(
870 KNOWN_RULE_NAMES.contains(&alias.as_str()),
871 "serde alias '{alias}' is in rules.rs source but missing from KNOWN_RULE_NAMES"
872 );
873 }
874 }
875
876 #[test]
877 fn re_export_cycle_aliases_all_round_trip_to_the_same_field() {
878 for alias in [
879 "re-export-cycle",
880 "re-export-cycles",
881 "reexport-cycle",
882 "reexport-cycles",
883 ] {
884 let json = format!(r#"{{"{alias}": "warn"}}"#);
885 let partial: PartialRulesConfig = serde_json::from_str(&json)
886 .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
887 assert_eq!(
888 partial.re_export_cycle,
889 Some(Severity::Warn),
890 "'{alias}' should set re_export_cycle to Warn"
891 );
892 let serialized = serde_json::to_value(&partial).unwrap();
893 let map = serialized.as_object().unwrap();
894 assert_eq!(
895 map.len(),
896 1,
897 "'{alias}' should resolve to exactly one field, got: {map:?}"
898 );
899 }
900 }
901
902 #[test]
903 fn every_known_rule_name_round_trips_through_partial() {
904 for &name in KNOWN_RULE_NAMES {
905 let json = format!(r#"{{"{name}": "warn"}}"#);
906 let partial: PartialRulesConfig = serde_json::from_str(&json)
907 .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
908
909 let serialized = serde_json::to_value(&partial).unwrap();
910 let map = serialized.as_object().unwrap();
911 assert_eq!(
912 map.len(),
913 1,
914 "'{name}' should resolve to exactly one field, got: {map:?}"
915 );
916 }
917 }
918
919 #[test]
920 fn known_rule_names_covers_every_struct_field() {
921 let json = serde_json::to_value(RulesConfig::default()).unwrap();
922 let obj = json.as_object().unwrap();
923 for key in obj.keys() {
924 assert!(
925 KNOWN_RULE_NAMES.contains(&key.as_str()),
926 "field '{key}' is serialized but missing from KNOWN_RULE_NAMES"
927 );
928 }
929 }
930
931 #[test]
932 fn closest_known_rule_name_suggests_for_obvious_typo() {
933 assert_eq!(
934 closest_known_rule_name("unsued-files"),
935 Some("unused-files")
936 );
937 assert_eq!(
938 closest_known_rule_name("circular-dependnecy"),
939 Some("circular-dependency")
940 );
941 assert_eq!(
942 closest_known_rule_name("unused-dep"),
943 None,
944 "too short for a confident suggestion"
945 );
946 }
947
948 #[test]
949 fn closest_known_rule_name_returns_none_for_novel_input() {
950 assert_eq!(closest_known_rule_name("totally-fabricated"), None);
951 assert_eq!(closest_known_rule_name("foo"), None);
952 }
953
954 #[test]
955 fn closest_known_rule_name_is_case_insensitive() {
956 assert_eq!(
957 closest_known_rule_name("UNSUED-FILES"),
958 Some("unused-files")
959 );
960 }
961
962 #[test]
963 fn closest_known_rule_name_returns_none_for_exact_match() {
964 assert_eq!(closest_known_rule_name("unused-files"), None);
965 }
966
967 #[test]
968 fn find_unknown_rule_keys_flags_typo() {
969 let v = serde_json::json!({
970 "unsued-files": "warn",
971 "unused-exports": "off",
972 });
973 let unknown = find_unknown_rule_keys(&v, "rules");
974 assert_eq!(unknown.len(), 1);
975 assert_eq!(unknown[0].key, "unsued-files");
976 assert_eq!(unknown[0].context, "rules");
977 assert_eq!(unknown[0].suggestion, Some("unused-files"));
978 }
979
980 #[test]
981 fn find_unknown_rule_keys_passes_aliases() {
982 let v = serde_json::json!({
983 "unused-file": "warn",
984 "circular-dependency": "off",
985 "boundary-violations": "warn",
986 });
987 let unknown = find_unknown_rule_keys(&v, "rules");
988 assert!(
989 unknown.is_empty(),
990 "documented aliases must not flag as unknown: {unknown:?}"
991 );
992 }
993
994 #[test]
995 fn find_unknown_rule_keys_returns_multiple_typos() {
996 let v = serde_json::json!({
997 "unsued-files": "warn",
998 "circular-dependnecy": "off",
999 });
1000 let unknown = find_unknown_rule_keys(&v, "rules");
1001 assert_eq!(unknown.len(), 2);
1002 }
1003
1004 #[test]
1005 fn find_unknown_rule_keys_carries_context() {
1006 let v = serde_json::json!({ "unsued-files": "warn" });
1007 let unknown = find_unknown_rule_keys(&v, "overrides[2].rules");
1008 assert_eq!(unknown[0].context, "overrides[2].rules");
1009 }
1010
1011 #[test]
1012 fn find_unknown_rule_keys_empty_when_not_object() {
1013 let v = serde_json::json!(null);
1014 assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1015
1016 let v = serde_json::json!([1, 2, 3]);
1017 assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1018 }
1019
1020 #[test]
1021 fn find_unknown_rule_keys_no_suggestion_for_novel_name() {
1022 let v = serde_json::json!({ "totally-fabricated-rule": "warn" });
1023 let unknown = find_unknown_rule_keys(&v, "rules");
1024 assert_eq!(unknown.len(), 1);
1025 assert_eq!(unknown[0].suggestion, None);
1026 }
1027
1028 #[test]
1029 fn partial_rules_empty_json() {
1030 let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
1031 assert!(partial.unused_files.is_none());
1032 assert!(partial.unused_exports.is_none());
1033 assert!(partial.unused_types.is_none());
1034 assert!(partial.unused_dependencies.is_none());
1035 assert!(partial.circular_dependencies.is_none());
1036 assert!(partial.boundary_violation.is_none());
1037 assert!(partial.coverage_gaps.is_none());
1038 assert!(partial.feature_flags.is_none());
1039 assert!(partial.stale_suppressions.is_none());
1040 }
1041
1042 #[test]
1043 fn partial_rules_subset_json() {
1044 let json = r#"{
1045 "unused-files": "warn",
1046 "circular-dependencies": "off"
1047 }"#;
1048 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1049 assert_eq!(partial.unused_files, Some(Severity::Warn));
1050 assert_eq!(partial.circular_dependencies, Some(Severity::Off));
1051 assert!(partial.unused_exports.is_none());
1052 }
1053
1054 #[test]
1055 fn partial_rules_deserialize_circular_dependency_alias() {
1056 let json = r#"{
1057 "circular-dependency": "warn"
1058 }"#;
1059 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1060 assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1061 }
1062
1063 #[test]
1064 fn partial_rules_all_fields_json() {
1065 let json = r#"{
1066 "unused-files": "error",
1067 "unused-exports": "warn",
1068 "unused-types": "off",
1069 "unused-dependencies": "error",
1070 "unused-dev-dependencies": "warn",
1071 "unused-optional-dependencies": "off",
1072 "unused-enum-members": "error",
1073 "unused-class-members": "warn",
1074 "unresolved-imports": "off",
1075 "unlisted-dependencies": "error",
1076 "duplicate-exports": "warn",
1077 "type-only-dependencies": "off",
1078 "test-only-dependencies": "error",
1079 "circular-dependencies": "warn",
1080 "boundary-violation": "off",
1081 "coverage-gaps": "warn",
1082 "feature-flags": "error",
1083 "stale-suppressions": "off"
1084 }"#;
1085 let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1086 assert_eq!(partial.unused_files, Some(Severity::Error));
1087 assert_eq!(partial.unused_exports, Some(Severity::Warn));
1088 assert_eq!(partial.unused_types, Some(Severity::Off));
1089 assert_eq!(partial.unused_dependencies, Some(Severity::Error));
1090 assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
1091 assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1092 assert_eq!(partial.unused_enum_members, Some(Severity::Error));
1093 assert_eq!(partial.unused_class_members, Some(Severity::Warn));
1094 assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1095 assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
1096 assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
1097 assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1098 assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
1099 assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1100 assert_eq!(partial.boundary_violation, Some(Severity::Off));
1101 assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1102 assert_eq!(partial.feature_flags, Some(Severity::Error));
1103 assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1104 }
1105
1106 #[test]
1107 fn partial_rules_none_fields_not_serialized() {
1108 let partial = PartialRulesConfig::default();
1109 let json = serde_json::to_string(&partial).unwrap();
1110 assert_eq!(
1111 json, "{}",
1112 "all-None partial should serialize to empty object"
1113 );
1114 }
1115
1116 #[test]
1117 fn partial_rules_some_fields_serialized() {
1118 let partial = PartialRulesConfig {
1119 unused_files: Some(Severity::Warn),
1120 ..Default::default()
1121 };
1122 let json = serde_json::to_string(&partial).unwrap();
1123 assert!(json.contains("unused-files"));
1124 assert!(!json.contains("unused-exports"));
1125 }
1126
1127 #[test]
1128 fn severity_json_deserialization() {
1129 let error: Severity = serde_json::from_str(r#""error""#).unwrap();
1130 assert_eq!(error, Severity::Error);
1131
1132 let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
1133 assert_eq!(warn, Severity::Warn);
1134
1135 let off: Severity = serde_json::from_str(r#""off""#).unwrap();
1136 assert_eq!(off, Severity::Off);
1137 }
1138
1139 #[test]
1140 fn severity_invalid_json_value_rejected() {
1141 let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
1142 assert!(result.is_err());
1143 }
1144
1145 #[test]
1146 fn severity_default_is_error() {
1147 assert_eq!(Severity::default(), Severity::Error);
1148 }
1149
1150 #[test]
1151 fn rules_config_json_roundtrip() {
1152 let rules = RulesConfig {
1153 unused_files: Severity::Warn,
1154 unused_exports: Severity::Off,
1155 type_only_dependencies: Severity::Error,
1156 ..RulesConfig::default()
1157 };
1158 let json = serde_json::to_string(&rules).unwrap();
1159 let restored: RulesConfig = serde_json::from_str(&json).unwrap();
1160 assert_eq!(restored.unused_files, Severity::Warn);
1161 assert_eq!(restored.unused_exports, Severity::Off);
1162 assert_eq!(restored.type_only_dependencies, Severity::Error);
1163 assert_eq!(restored.unused_dependencies, Severity::Error); }
1165
1166 #[test]
1167 fn apply_partial_preserves_type_only_default() {
1168 let mut rules = RulesConfig::default();
1169 let partial = PartialRulesConfig {
1170 unused_files: Some(Severity::Off),
1171 ..Default::default()
1172 };
1173 rules.apply_partial(&partial);
1174 assert_eq!(rules.type_only_dependencies, Severity::Warn);
1175 assert_eq!(rules.test_only_dependencies, Severity::Warn);
1176 }
1177}