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