Skip to main content

fallow_config/config/
rules.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Severity level for rules.
5///
6/// Controls whether an issue type causes CI failure (`error`), is reported
7/// without failing (`warn`), or is suppressed entirely (`off`).
8#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11    /// Report and fail CI (non-zero exit code).
12    #[default]
13    Error,
14    /// Report but don't fail CI.
15    Warn,
16    /// Don't detect or report.
17    Off,
18}
19
20impl Severity {
21    /// Default value for fields that should default to `Warn` instead of `Error`.
22    const fn default_warn() -> Self {
23        Self::Warn
24    }
25
26    /// Default value for fields that should default to `Off`.
27    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/// Per-issue-type severity configuration.
58///
59/// Controls which issue types cause CI failure, are reported as warnings,
60/// or are suppressed entirely. Most fields default to `Severity::Error`.
61///
62/// Rule names use kebab-case in config files (e.g., `"unused-files": "error"`).
63#[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}
128
129impl Default for RulesConfig {
130    fn default() -> Self {
131        Self {
132            unused_files: Severity::Error,
133            unused_exports: Severity::Error,
134            unused_types: Severity::Error,
135            private_type_leaks: Severity::Off,
136            unused_dependencies: Severity::Error,
137            unused_dev_dependencies: Severity::Warn,
138            unused_optional_dependencies: Severity::Warn,
139            unused_enum_members: Severity::Error,
140            unused_class_members: Severity::Error,
141            unresolved_imports: Severity::Error,
142            unlisted_dependencies: Severity::Error,
143            duplicate_exports: Severity::Error,
144            type_only_dependencies: Severity::Warn,
145            test_only_dependencies: Severity::Warn,
146            circular_dependencies: Severity::Error,
147            re_export_cycle: Severity::Warn,
148            boundary_violation: Severity::Error,
149            coverage_gaps: Severity::Off,
150            feature_flags: Severity::Off,
151            stale_suppressions: Severity::Warn,
152            unused_catalog_entries: Severity::Warn,
153            empty_catalog_groups: Severity::Warn,
154            unresolved_catalog_references: Severity::Error,
155            unused_dependency_overrides: Severity::Warn,
156            misconfigured_dependency_overrides: Severity::Error,
157        }
158    }
159}
160
161impl RulesConfig {
162    /// Apply a partial rules config on top. Only `Some` fields override.
163    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
164        if let Some(s) = partial.unused_files {
165            self.unused_files = s;
166        }
167        if let Some(s) = partial.unused_exports {
168            self.unused_exports = s;
169        }
170        if let Some(s) = partial.unused_types {
171            self.unused_types = s;
172        }
173        if let Some(s) = partial.private_type_leaks {
174            self.private_type_leaks = s;
175        }
176        if let Some(s) = partial.unused_dependencies {
177            self.unused_dependencies = s;
178        }
179        if let Some(s) = partial.unused_dev_dependencies {
180            self.unused_dev_dependencies = s;
181        }
182        if let Some(s) = partial.unused_optional_dependencies {
183            self.unused_optional_dependencies = s;
184        }
185        if let Some(s) = partial.unused_enum_members {
186            self.unused_enum_members = s;
187        }
188        if let Some(s) = partial.unused_class_members {
189            self.unused_class_members = s;
190        }
191        if let Some(s) = partial.unresolved_imports {
192            self.unresolved_imports = s;
193        }
194        if let Some(s) = partial.unlisted_dependencies {
195            self.unlisted_dependencies = s;
196        }
197        if let Some(s) = partial.duplicate_exports {
198            self.duplicate_exports = s;
199        }
200        if let Some(s) = partial.type_only_dependencies {
201            self.type_only_dependencies = s;
202        }
203        if let Some(s) = partial.test_only_dependencies {
204            self.test_only_dependencies = s;
205        }
206        if let Some(s) = partial.circular_dependencies {
207            self.circular_dependencies = s;
208        }
209        if let Some(s) = partial.re_export_cycle {
210            self.re_export_cycle = s;
211        }
212        if let Some(s) = partial.boundary_violation {
213            self.boundary_violation = s;
214        }
215        if let Some(s) = partial.coverage_gaps {
216            self.coverage_gaps = s;
217        }
218        if let Some(s) = partial.feature_flags {
219            self.feature_flags = s;
220        }
221        if let Some(s) = partial.stale_suppressions {
222            self.stale_suppressions = s;
223        }
224        if let Some(s) = partial.unused_catalog_entries {
225            self.unused_catalog_entries = s;
226        }
227        if let Some(s) = partial.empty_catalog_groups {
228            self.empty_catalog_groups = s;
229        }
230        if let Some(s) = partial.unresolved_catalog_references {
231            self.unresolved_catalog_references = s;
232        }
233        if let Some(s) = partial.unused_dependency_overrides {
234            self.unused_dependency_overrides = s;
235        }
236        if let Some(s) = partial.misconfigured_dependency_overrides {
237            self.misconfigured_dependency_overrides = s;
238        }
239    }
240}
241
242/// Partial per-issue-type severity for overrides. All fields optional.
243#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
244#[serde(rename_all = "kebab-case")]
245pub struct PartialRulesConfig {
246    #[serde(
247        default,
248        alias = "unused-file",
249        skip_serializing_if = "Option::is_none"
250    )]
251    pub unused_files: Option<Severity>,
252    #[serde(
253        default,
254        alias = "unused-export",
255        skip_serializing_if = "Option::is_none"
256    )]
257    pub unused_exports: Option<Severity>,
258    #[serde(
259        default,
260        alias = "unused-type",
261        skip_serializing_if = "Option::is_none"
262    )]
263    pub unused_types: Option<Severity>,
264    #[serde(
265        default,
266        alias = "private-type-leak",
267        skip_serializing_if = "Option::is_none"
268    )]
269    pub private_type_leaks: Option<Severity>,
270    #[serde(
271        default,
272        alias = "unused-dependency",
273        skip_serializing_if = "Option::is_none"
274    )]
275    pub unused_dependencies: Option<Severity>,
276    #[serde(
277        default,
278        alias = "unused-dev-dependency",
279        skip_serializing_if = "Option::is_none"
280    )]
281    pub unused_dev_dependencies: Option<Severity>,
282    #[serde(
283        default,
284        alias = "unused-optional-dependency",
285        skip_serializing_if = "Option::is_none"
286    )]
287    pub unused_optional_dependencies: Option<Severity>,
288    #[serde(
289        default,
290        alias = "unused-enum-member",
291        skip_serializing_if = "Option::is_none"
292    )]
293    pub unused_enum_members: Option<Severity>,
294    #[serde(
295        default,
296        alias = "unused-class-member",
297        skip_serializing_if = "Option::is_none"
298    )]
299    pub unused_class_members: Option<Severity>,
300    #[serde(
301        default,
302        alias = "unresolved-import",
303        skip_serializing_if = "Option::is_none"
304    )]
305    pub unresolved_imports: Option<Severity>,
306    #[serde(
307        default,
308        alias = "unlisted-dependency",
309        skip_serializing_if = "Option::is_none"
310    )]
311    pub unlisted_dependencies: Option<Severity>,
312    #[serde(
313        default,
314        alias = "duplicate-export",
315        skip_serializing_if = "Option::is_none"
316    )]
317    pub duplicate_exports: Option<Severity>,
318    #[serde(
319        default,
320        alias = "type-only-dependency",
321        skip_serializing_if = "Option::is_none"
322    )]
323    pub type_only_dependencies: Option<Severity>,
324    #[serde(
325        default,
326        alias = "test-only-dependency",
327        skip_serializing_if = "Option::is_none"
328    )]
329    pub test_only_dependencies: Option<Severity>,
330    #[serde(
331        default,
332        alias = "circular-dependency",
333        skip_serializing_if = "Option::is_none"
334    )]
335    pub circular_dependencies: Option<Severity>,
336    #[serde(
337        default,
338        alias = "re-export-cycles",
339        alias = "reexport-cycle",
340        alias = "reexport-cycles",
341        skip_serializing_if = "Option::is_none"
342    )]
343    pub re_export_cycle: Option<Severity>,
344    #[serde(
345        default,
346        alias = "boundary-violations",
347        skip_serializing_if = "Option::is_none"
348    )]
349    pub boundary_violation: Option<Severity>,
350    #[serde(
351        default,
352        alias = "coverage-gap",
353        skip_serializing_if = "Option::is_none"
354    )]
355    pub coverage_gaps: Option<Severity>,
356    #[serde(
357        default,
358        alias = "feature-flag",
359        skip_serializing_if = "Option::is_none"
360    )]
361    pub feature_flags: Option<Severity>,
362    #[serde(
363        default,
364        alias = "stale-suppression",
365        skip_serializing_if = "Option::is_none"
366    )]
367    pub stale_suppressions: Option<Severity>,
368    #[serde(
369        default,
370        alias = "unused-catalog-entry",
371        skip_serializing_if = "Option::is_none"
372    )]
373    pub unused_catalog_entries: Option<Severity>,
374    #[serde(
375        default,
376        alias = "empty-catalog-group",
377        skip_serializing_if = "Option::is_none"
378    )]
379    pub empty_catalog_groups: Option<Severity>,
380    #[serde(
381        default,
382        alias = "unresolved-catalog-reference",
383        skip_serializing_if = "Option::is_none"
384    )]
385    pub unresolved_catalog_references: Option<Severity>,
386    #[serde(
387        default,
388        alias = "unused-dependency-override",
389        skip_serializing_if = "Option::is_none"
390    )]
391    pub unused_dependency_overrides: Option<Severity>,
392    #[serde(
393        default,
394        alias = "misconfigured-dependency-override",
395        skip_serializing_if = "Option::is_none"
396    )]
397    pub misconfigured_dependency_overrides: Option<Severity>,
398}
399
400/// Every rule name accepted by `RulesConfig` deserialization, in kebab-case.
401///
402/// Includes both the canonical name produced by `#[serde(rename_all = "kebab-case")]`
403/// and every `#[serde(alias = ...)]` value. Used by
404/// [`find_unknown_rule_keys`] to detect typos in user-supplied configs and
405/// emit a `tracing::warn!` suggestion at config load time.
406///
407/// Keep in sync with the `#[serde]` attributes on `RulesConfig` and
408/// `PartialRulesConfig`; the `known_rule_names_count_matches_struct` test
409/// fails when the lists drift.
410pub const KNOWN_RULE_NAMES: &[&str] = &[
411    // canonical kebab-case names (rename_all = "kebab-case")
412    "unused-files",
413    "unused-exports",
414    "unused-types",
415    "private-type-leaks",
416    "unused-dependencies",
417    "unused-dev-dependencies",
418    "unused-optional-dependencies",
419    "unused-enum-members",
420    "unused-class-members",
421    "unresolved-imports",
422    "unlisted-dependencies",
423    "duplicate-exports",
424    "type-only-dependencies",
425    "test-only-dependencies",
426    "circular-dependencies",
427    "re-export-cycle",
428    "boundary-violation",
429    "coverage-gaps",
430    "feature-flags",
431    "stale-suppressions",
432    "unused-catalog-entries",
433    "empty-catalog-groups",
434    "unresolved-catalog-references",
435    "unused-dependency-overrides",
436    "misconfigured-dependency-overrides",
437    // serde aliases (singular forms, plus the `boundary-violations` legacy plural)
438    "unused-file",
439    "unused-export",
440    "unused-type",
441    "private-type-leak",
442    "unused-dependency",
443    "unused-dev-dependency",
444    "unused-optional-dependency",
445    "unused-enum-member",
446    "unused-class-member",
447    "unresolved-import",
448    "unlisted-dependency",
449    "duplicate-export",
450    "type-only-dependency",
451    "test-only-dependency",
452    "circular-dependency",
453    "re-export-cycles",
454    "reexport-cycle",
455    "reexport-cycles",
456    "boundary-violations",
457    "coverage-gap",
458    "feature-flag",
459    "stale-suppression",
460    "unused-catalog-entry",
461    "empty-catalog-group",
462    "unresolved-catalog-reference",
463    "unused-dependency-override",
464    "misconfigured-dependency-override",
465];
466
467/// Find the closest known rule name to `input` when it is plausibly a typo.
468///
469/// Thin wrapper over [`crate::levenshtein::closest_match`] that scopes the
470/// candidate set to [`KNOWN_RULE_NAMES`] and returns a `'static` reference so
471/// the suggestion can be embedded in tracing warnings without allocation.
472#[must_use]
473pub fn closest_known_rule_name(input: &str) -> Option<&'static str> {
474    let input_lower = input.to_ascii_lowercase();
475    let candidates = KNOWN_RULE_NAMES.iter().copied();
476    let suggestion = crate::levenshtein::closest_match(&input_lower, candidates)?;
477    KNOWN_RULE_NAMES.iter().copied().find(|&c| c == suggestion)
478}
479
480/// An unknown key found inside a `rules` (or `overrides[].rules`) object.
481///
482/// Surfaced by [`find_unknown_rule_keys`] so the caller (config loader) can
483/// emit one `tracing::warn!` per entry without coupling the detection logic
484/// to a tracing subscriber.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct UnknownRuleKey {
487    /// Human-readable source label, e.g. `"rules"` or `"overrides[2].rules"`.
488    pub context: String,
489    /// The unknown key as it appeared in the user's config.
490    pub key: String,
491    /// Closest known rule name when one is within plausible-typo distance.
492    pub suggestion: Option<&'static str>,
493}
494
495/// Collect every unknown key from a `rules`-shaped JSON object.
496///
497/// Returns an empty `Vec` when `value` is not an object or every key is
498/// recognized (canonical kebab-case or a documented alias). Called from
499/// [`crate::config::parsing`] after `extends` merge and before
500/// `serde_json::from_value::<FallowConfig>`, so the warning lists keys from
501/// the final merged config rather than per-file partials.
502#[must_use]
503pub fn find_unknown_rule_keys(value: &serde_json::Value, context: &str) -> Vec<UnknownRuleKey> {
504    let Some(map) = value.as_object() else {
505        return Vec::new();
506    };
507
508    map.keys()
509        .filter(|key| !KNOWN_RULE_NAMES.contains(&key.as_str()))
510        .map(|key| UnknownRuleKey {
511            context: context.to_owned(),
512            key: key.clone(),
513            suggestion: closest_known_rule_name(key),
514        })
515        .collect()
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn rules_default_severities() {
524        let rules = RulesConfig::default();
525        assert_eq!(rules.unused_files, Severity::Error);
526        assert_eq!(rules.unused_exports, Severity::Error);
527        assert_eq!(rules.unused_types, Severity::Error);
528        assert_eq!(rules.private_type_leaks, Severity::Off);
529        assert_eq!(rules.unused_dependencies, Severity::Error);
530        assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
531        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
532        assert_eq!(rules.unused_enum_members, Severity::Error);
533        assert_eq!(rules.unused_class_members, Severity::Error);
534        assert_eq!(rules.unresolved_imports, Severity::Error);
535        assert_eq!(rules.unlisted_dependencies, Severity::Error);
536        assert_eq!(rules.duplicate_exports, Severity::Error);
537        assert_eq!(rules.type_only_dependencies, Severity::Warn);
538        assert_eq!(rules.test_only_dependencies, Severity::Warn);
539        assert_eq!(rules.circular_dependencies, Severity::Error);
540        assert_eq!(rules.boundary_violation, Severity::Error);
541        assert_eq!(rules.coverage_gaps, Severity::Off);
542        assert_eq!(rules.feature_flags, Severity::Off);
543        assert_eq!(rules.stale_suppressions, Severity::Warn);
544        assert_eq!(rules.unused_catalog_entries, Severity::Warn);
545        assert_eq!(rules.empty_catalog_groups, Severity::Warn);
546        assert_eq!(rules.unresolved_catalog_references, Severity::Error);
547    }
548
549    #[test]
550    fn rules_deserialize_kebab_case() {
551        let json_str = r#"{
552            "unused-files": "error",
553            "unused-exports": "warn",
554            "unused-types": "off"
555        }"#;
556        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
557        assert_eq!(rules.unused_files, Severity::Error);
558        assert_eq!(rules.unused_exports, Severity::Warn);
559        assert_eq!(rules.unused_types, Severity::Off);
560        // Unset fields default to error
561        assert_eq!(rules.unresolved_imports, Severity::Error);
562    }
563
564    #[test]
565    fn rules_re_export_cycle_default_is_warn() {
566        let rules = RulesConfig::default();
567        assert_eq!(rules.re_export_cycle, Severity::Warn);
568    }
569
570    #[test]
571    fn rules_deserialize_re_export_cycle_aliases() {
572        // All four token forms (canonical + three aliases) must round-trip.
573        for token in [
574            "re-export-cycle",
575            "re-export-cycles",
576            "reexport-cycle",
577            "reexport-cycles",
578        ] {
579            let json_str = format!(r#"{{ "{token}": "error" }}"#);
580            let rules: RulesConfig = serde_json::from_str(&json_str)
581                .unwrap_or_else(|e| panic!("alias {token} did not deserialize: {e}"));
582            assert_eq!(
583                rules.re_export_cycle,
584                Severity::Error,
585                "alias {token} should set re_export_cycle"
586            );
587        }
588    }
589
590    #[test]
591    fn rules_deserialize_circular_dependency_alias() {
592        let json_str = r#"{
593            "circular-dependency": "off"
594        }"#;
595        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
596        assert_eq!(rules.circular_dependencies, Severity::Off);
597    }
598
599    #[test]
600    fn rules_deserialize_boundary_violations_alias() {
601        let json_str = r#"{
602            "boundary-violations": "off"
603        }"#;
604        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
605        assert_eq!(rules.boundary_violation, Severity::Off);
606
607        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
608        assert_eq!(partial.boundary_violation, Some(Severity::Off));
609    }
610
611    #[test]
612    fn rules_deserialize_singular_aliases_for_every_plural_rule() {
613        // Mirrors the LSP's per-diagnostic singular codes (e.g. "unused-export")
614        // so users who copy the form they see in IDE warnings into their config
615        // get their stated severity honored instead of silently dropped.
616        let json_str = r#"{
617            "unused-file": "off",
618            "unused-export": "off",
619            "unused-type": "off",
620            "private-type-leak": "warn",
621            "unused-dependency": "off",
622            "unused-dev-dependency": "off",
623            "unused-optional-dependency": "off",
624            "unused-enum-member": "off",
625            "unused-class-member": "off",
626            "unresolved-import": "off",
627            "unlisted-dependency": "off",
628            "duplicate-export": "off",
629            "type-only-dependency": "off",
630            "test-only-dependency": "off",
631            "coverage-gap": "warn",
632            "feature-flag": "warn",
633            "stale-suppression": "off",
634            "unused-catalog-entry": "error",
635            "empty-catalog-group": "error",
636            "unresolved-catalog-reference": "warn"
637        }"#;
638
639        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
640        assert_eq!(rules.unused_files, Severity::Off);
641        assert_eq!(rules.unused_exports, Severity::Off);
642        assert_eq!(rules.unused_types, Severity::Off);
643        assert_eq!(rules.private_type_leaks, Severity::Warn);
644        assert_eq!(rules.unused_dependencies, Severity::Off);
645        assert_eq!(rules.unused_dev_dependencies, Severity::Off);
646        assert_eq!(rules.unused_optional_dependencies, Severity::Off);
647        assert_eq!(rules.unused_enum_members, Severity::Off);
648        assert_eq!(rules.unused_class_members, Severity::Off);
649        assert_eq!(rules.unresolved_imports, Severity::Off);
650        assert_eq!(rules.unlisted_dependencies, Severity::Off);
651        assert_eq!(rules.duplicate_exports, Severity::Off);
652        assert_eq!(rules.type_only_dependencies, Severity::Off);
653        assert_eq!(rules.test_only_dependencies, Severity::Off);
654        assert_eq!(rules.coverage_gaps, Severity::Warn);
655        assert_eq!(rules.feature_flags, Severity::Warn);
656        assert_eq!(rules.stale_suppressions, Severity::Off);
657        assert_eq!(rules.unused_catalog_entries, Severity::Error);
658        assert_eq!(rules.empty_catalog_groups, Severity::Error);
659        assert_eq!(rules.unresolved_catalog_references, Severity::Warn);
660
661        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
662        assert_eq!(partial.unused_files, Some(Severity::Off));
663        assert_eq!(partial.unused_exports, Some(Severity::Off));
664        assert_eq!(partial.unused_types, Some(Severity::Off));
665        assert_eq!(partial.private_type_leaks, Some(Severity::Warn));
666        assert_eq!(partial.unused_dependencies, Some(Severity::Off));
667        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Off));
668        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
669        assert_eq!(partial.unused_enum_members, Some(Severity::Off));
670        assert_eq!(partial.unused_class_members, Some(Severity::Off));
671        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
672        assert_eq!(partial.unlisted_dependencies, Some(Severity::Off));
673        assert_eq!(partial.duplicate_exports, Some(Severity::Off));
674        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
675        assert_eq!(partial.test_only_dependencies, Some(Severity::Off));
676        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
677        assert_eq!(partial.feature_flags, Some(Severity::Warn));
678        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
679        assert_eq!(partial.unused_catalog_entries, Some(Severity::Error));
680        assert_eq!(partial.empty_catalog_groups, Some(Severity::Error));
681        assert_eq!(partial.unresolved_catalog_references, Some(Severity::Warn));
682    }
683
684    #[test]
685    fn severity_from_str() {
686        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
687        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
688        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
689        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
690        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
691        assert!("invalid".parse::<Severity>().is_err());
692    }
693
694    #[test]
695    fn apply_partial_only_some_fields() {
696        let mut rules = RulesConfig::default();
697        let partial = PartialRulesConfig {
698            unused_files: Some(Severity::Warn),
699            unused_exports: Some(Severity::Off),
700            ..Default::default()
701        };
702        rules.apply_partial(&partial);
703        assert_eq!(rules.unused_files, Severity::Warn);
704        assert_eq!(rules.unused_exports, Severity::Off);
705        // Unset fields unchanged
706        assert_eq!(rules.unused_types, Severity::Error);
707        assert_eq!(rules.unresolved_imports, Severity::Error);
708    }
709
710    #[test]
711    fn severity_display() {
712        assert_eq!(Severity::Error.to_string(), "error");
713        assert_eq!(Severity::Warn.to_string(), "warn");
714        assert_eq!(Severity::Off.to_string(), "off");
715    }
716
717    #[test]
718    fn apply_partial_all_none_changes_nothing() {
719        let mut rules = RulesConfig::default();
720        let original = rules.clone();
721        let partial = PartialRulesConfig::default(); // all None
722        rules.apply_partial(&partial);
723        assert_eq!(rules.unused_files, original.unused_files);
724        assert_eq!(rules.unused_exports, original.unused_exports);
725        assert_eq!(
726            rules.type_only_dependencies,
727            original.type_only_dependencies
728        );
729    }
730
731    #[test]
732    fn apply_partial_all_fields_set() {
733        let mut rules = RulesConfig::default();
734        let partial = PartialRulesConfig {
735            unused_files: Some(Severity::Off),
736            unused_exports: Some(Severity::Off),
737            unused_types: Some(Severity::Off),
738            private_type_leaks: Some(Severity::Off),
739            unused_dependencies: Some(Severity::Off),
740            unused_dev_dependencies: Some(Severity::Off),
741            unused_optional_dependencies: Some(Severity::Off),
742            unused_enum_members: Some(Severity::Off),
743            unused_class_members: Some(Severity::Off),
744            unresolved_imports: Some(Severity::Off),
745            unlisted_dependencies: Some(Severity::Off),
746            duplicate_exports: Some(Severity::Off),
747            type_only_dependencies: Some(Severity::Off),
748            test_only_dependencies: Some(Severity::Off),
749            circular_dependencies: Some(Severity::Off),
750            re_export_cycle: Some(Severity::Off),
751            boundary_violation: Some(Severity::Off),
752            coverage_gaps: Some(Severity::Off),
753            feature_flags: Some(Severity::Off),
754            stale_suppressions: Some(Severity::Off),
755            unused_catalog_entries: Some(Severity::Off),
756            empty_catalog_groups: Some(Severity::Off),
757            unresolved_catalog_references: Some(Severity::Off),
758            unused_dependency_overrides: Some(Severity::Off),
759            misconfigured_dependency_overrides: Some(Severity::Off),
760        };
761        rules.apply_partial(&partial);
762        assert_eq!(rules.unused_files, Severity::Off);
763        assert_eq!(rules.private_type_leaks, Severity::Off);
764        assert_eq!(rules.circular_dependencies, Severity::Off);
765        assert_eq!(rules.type_only_dependencies, Severity::Off);
766        assert_eq!(rules.test_only_dependencies, Severity::Off);
767        assert_eq!(rules.boundary_violation, Severity::Off);
768        assert_eq!(rules.coverage_gaps, Severity::Off);
769        assert_eq!(rules.feature_flags, Severity::Off);
770        assert_eq!(rules.stale_suppressions, Severity::Off);
771    }
772
773    #[test]
774    fn rules_config_defaults_include_optional_deps() {
775        let rules = RulesConfig::default();
776        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
777    }
778
779    #[test]
780    fn severity_from_str_case_insensitive() {
781        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
782        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
783        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
784        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
785        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
786    }
787
788    #[test]
789    fn severity_from_str_invalid_returns_error() {
790        let result = "critical".parse::<Severity>();
791        assert!(result.is_err());
792        let err = result.unwrap_err();
793        assert!(
794            err.contains("unknown severity"),
795            "Expected descriptive error, got: {err}"
796        );
797    }
798
799    // ── Unknown-rule-name detection (issue #467 phase 1) ─────────────
800
801    #[test]
802    fn known_rule_names_count_matches_struct() {
803        // Drift guard. Bump both numbers together when adding a rule.
804        // 24 canonical kebab-case names + 24 aliases = 48 entries.
805        assert_eq!(KNOWN_RULE_NAMES.len(), 52);
806    }
807
808    #[test]
809    fn known_rule_names_has_no_duplicates() {
810        let mut sorted: Vec<&str> = KNOWN_RULE_NAMES.to_vec();
811        sorted.sort_unstable();
812        let original_len = sorted.len();
813        sorted.dedup();
814        assert_eq!(
815            sorted.len(),
816            original_len,
817            "KNOWN_RULE_NAMES contains a duplicate"
818        );
819    }
820
821    #[test]
822    fn known_rule_names_covers_every_serde_alias_in_source() {
823        // Source-level drift guard: parse this file's text and extract every
824        // `alias = "<kebab>"` literal that appears on a `RulesConfig` /
825        // `PartialRulesConfig` field. Assert each one is in
826        // `KNOWN_RULE_NAMES`.
827        //
828        // Complements `known_rule_names_count_matches_struct` (catches new
829        // fields) and `every_known_rule_name_round_trips_through_partial`
830        // (catches stale or renamed entries). This one catches a new alias
831        // added to an existing field without a matching KNOWN_RULE_NAMES
832        // update; that's invisible to the count guard (count stays the
833        // same), invisible to the canonical-coverage walk (the canonical
834        // name is already present), and invisible to the roundtrip guard
835        // (the roundtrip walks KNOWN_RULE_NAMES, never discovering an
836        // alias that was added to the struct but not to the list).
837        let source = include_str!("rules.rs");
838
839        let mut aliases_found = Vec::new();
840        for line in source.lines() {
841            let trimmed = line.trim();
842            // Skip line comments (the test's own doc strings would otherwise
843            // pollute the count with placeholder examples).
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            // Real aliases are kebab-case ASCII; placeholder examples in any
855            // accidentally-included strings (`<kebab>`, `...`) get filtered.
856            if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
857                continue;
858            }
859            aliases_found.push(alias.to_owned());
860        }
861
862        // 27 alias attrs on RulesConfig + 27 on PartialRulesConfig = 54.
863        // (24 + 24 base + 3 new aliases per struct on `re_export_cycle`).
864        assert_eq!(
865            aliases_found.len(),
866            54,
867            "expected 54 source-level alias attrs (27 per struct); got {}: {:?}",
868            aliases_found.len(),
869            aliases_found
870        );
871
872        for alias in &aliases_found {
873            assert!(
874                KNOWN_RULE_NAMES.contains(&alias.as_str()),
875                "serde alias '{alias}' is in rules.rs source but missing from KNOWN_RULE_NAMES"
876            );
877        }
878    }
879
880    #[test]
881    fn re_export_cycle_aliases_all_round_trip_to_the_same_field() {
882        // Panel catch #10 (Persona 8): pin every alias spelling of the new
883        // `re-export-cycle` rule so a future rename / typo of any of the four
884        // alias literals would fail this test instead of silently making the
885        // alias unmatched.
886        for alias in [
887            "re-export-cycle",
888            "re-export-cycles",
889            "reexport-cycle",
890            "reexport-cycles",
891        ] {
892            let json = format!(r#"{{"{alias}": "warn"}}"#);
893            let partial: PartialRulesConfig = serde_json::from_str(&json)
894                .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
895            assert_eq!(
896                partial.re_export_cycle,
897                Some(Severity::Warn),
898                "'{alias}' should set re_export_cycle to Warn"
899            );
900            // None of the four aliases should accidentally populate any other field.
901            let serialized = serde_json::to_value(&partial).unwrap();
902            let map = serialized.as_object().unwrap();
903            assert_eq!(
904                map.len(),
905                1,
906                "'{alias}' should resolve to exactly one field, got: {map:?}"
907            );
908        }
909    }
910
911    #[test]
912    fn every_known_rule_name_round_trips_through_partial() {
913        // Stronger drift guard than the count + canonical-coverage tests:
914        // every entry in KNOWN_RULE_NAMES must deserialize successfully via
915        // `PartialRulesConfig` and resolve to exactly one field. Catches the
916        // case where a developer renames an alias on the struct but forgets
917        // to update KNOWN_RULE_NAMES (the count test still passes; this one
918        // fails).
919        for &name in KNOWN_RULE_NAMES {
920            let json = format!(r#"{{"{name}": "warn"}}"#);
921            let partial: PartialRulesConfig = serde_json::from_str(&json)
922                .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
923
924            let serialized = serde_json::to_value(&partial).unwrap();
925            let map = serialized.as_object().unwrap();
926            assert_eq!(
927                map.len(),
928                1,
929                "'{name}' should resolve to exactly one field, got: {map:?}"
930            );
931        }
932    }
933
934    #[test]
935    fn known_rule_names_covers_every_struct_field() {
936        // Every canonical rule must serialize to a name in KNOWN_RULE_NAMES.
937        // Iterates the serialized form of a default RulesConfig (which emits
938        // canonical kebab-case for every field) and asserts each appears in
939        // the const list. Drift guard for adding a new field but forgetting
940        // to update KNOWN_RULE_NAMES.
941        let json = serde_json::to_value(RulesConfig::default()).unwrap();
942        let obj = json.as_object().unwrap();
943        for key in obj.keys() {
944            assert!(
945                KNOWN_RULE_NAMES.contains(&key.as_str()),
946                "field '{key}' is serialized but missing from KNOWN_RULE_NAMES"
947            );
948        }
949    }
950
951    #[test]
952    fn closest_known_rule_name_suggests_for_obvious_typo() {
953        assert_eq!(
954            closest_known_rule_name("unsued-files"),
955            Some("unused-files")
956        );
957        assert_eq!(
958            closest_known_rule_name("circular-dependnecy"),
959            Some("circular-dependency")
960        );
961        assert_eq!(
962            closest_known_rule_name("unused-dep"),
963            None,
964            "too short for a confident suggestion"
965        );
966    }
967
968    #[test]
969    fn closest_known_rule_name_returns_none_for_novel_input() {
970        assert_eq!(closest_known_rule_name("totally-fabricated"), None);
971        assert_eq!(closest_known_rule_name("foo"), None);
972    }
973
974    #[test]
975    fn closest_known_rule_name_is_case_insensitive() {
976        assert_eq!(
977            closest_known_rule_name("UNSUED-FILES"),
978            Some("unused-files")
979        );
980    }
981
982    #[test]
983    fn closest_known_rule_name_returns_none_for_exact_match() {
984        // A match with distance 0 is not a typo, so no suggestion.
985        assert_eq!(closest_known_rule_name("unused-files"), None);
986    }
987
988    #[test]
989    fn find_unknown_rule_keys_flags_typo() {
990        let v = serde_json::json!({
991            "unsued-files": "warn",
992            "unused-exports": "off",
993        });
994        let unknown = find_unknown_rule_keys(&v, "rules");
995        assert_eq!(unknown.len(), 1);
996        assert_eq!(unknown[0].key, "unsued-files");
997        assert_eq!(unknown[0].context, "rules");
998        assert_eq!(unknown[0].suggestion, Some("unused-files"));
999    }
1000
1001    #[test]
1002    fn find_unknown_rule_keys_passes_aliases() {
1003        let v = serde_json::json!({
1004            "unused-file": "warn",
1005            "circular-dependency": "off",
1006            "boundary-violations": "warn",
1007        });
1008        let unknown = find_unknown_rule_keys(&v, "rules");
1009        assert!(
1010            unknown.is_empty(),
1011            "documented aliases must not flag as unknown: {unknown:?}"
1012        );
1013    }
1014
1015    #[test]
1016    fn find_unknown_rule_keys_returns_multiple_typos() {
1017        let v = serde_json::json!({
1018            "unsued-files": "warn",
1019            "circular-dependnecy": "off",
1020        });
1021        let unknown = find_unknown_rule_keys(&v, "rules");
1022        assert_eq!(unknown.len(), 2);
1023    }
1024
1025    #[test]
1026    fn find_unknown_rule_keys_carries_context() {
1027        let v = serde_json::json!({ "unsued-files": "warn" });
1028        let unknown = find_unknown_rule_keys(&v, "overrides[2].rules");
1029        assert_eq!(unknown[0].context, "overrides[2].rules");
1030    }
1031
1032    #[test]
1033    fn find_unknown_rule_keys_empty_when_not_object() {
1034        let v = serde_json::json!(null);
1035        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1036
1037        let v = serde_json::json!([1, 2, 3]);
1038        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1039    }
1040
1041    #[test]
1042    fn find_unknown_rule_keys_no_suggestion_for_novel_name() {
1043        let v = serde_json::json!({ "totally-fabricated-rule": "warn" });
1044        let unknown = find_unknown_rule_keys(&v, "rules");
1045        assert_eq!(unknown.len(), 1);
1046        assert_eq!(unknown[0].suggestion, None);
1047    }
1048
1049    // ── PartialRulesConfig deserialization ───────────────────────────
1050
1051    #[test]
1052    fn partial_rules_empty_json() {
1053        let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
1054        assert!(partial.unused_files.is_none());
1055        assert!(partial.unused_exports.is_none());
1056        assert!(partial.unused_types.is_none());
1057        assert!(partial.unused_dependencies.is_none());
1058        assert!(partial.circular_dependencies.is_none());
1059        assert!(partial.boundary_violation.is_none());
1060        assert!(partial.coverage_gaps.is_none());
1061        assert!(partial.feature_flags.is_none());
1062        assert!(partial.stale_suppressions.is_none());
1063    }
1064
1065    #[test]
1066    fn partial_rules_subset_json() {
1067        let json = r#"{
1068            "unused-files": "warn",
1069            "circular-dependencies": "off"
1070        }"#;
1071        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1072        assert_eq!(partial.unused_files, Some(Severity::Warn));
1073        assert_eq!(partial.circular_dependencies, Some(Severity::Off));
1074        assert!(partial.unused_exports.is_none());
1075    }
1076
1077    #[test]
1078    fn partial_rules_deserialize_circular_dependency_alias() {
1079        let json = r#"{
1080            "circular-dependency": "warn"
1081        }"#;
1082        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1083        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1084    }
1085
1086    #[test]
1087    fn partial_rules_all_fields_json() {
1088        let json = r#"{
1089            "unused-files": "error",
1090            "unused-exports": "warn",
1091            "unused-types": "off",
1092            "unused-dependencies": "error",
1093            "unused-dev-dependencies": "warn",
1094            "unused-optional-dependencies": "off",
1095            "unused-enum-members": "error",
1096            "unused-class-members": "warn",
1097            "unresolved-imports": "off",
1098            "unlisted-dependencies": "error",
1099            "duplicate-exports": "warn",
1100            "type-only-dependencies": "off",
1101            "test-only-dependencies": "error",
1102            "circular-dependencies": "warn",
1103            "boundary-violation": "off",
1104            "coverage-gaps": "warn",
1105            "feature-flags": "error",
1106            "stale-suppressions": "off"
1107        }"#;
1108        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1109        assert_eq!(partial.unused_files, Some(Severity::Error));
1110        assert_eq!(partial.unused_exports, Some(Severity::Warn));
1111        assert_eq!(partial.unused_types, Some(Severity::Off));
1112        assert_eq!(partial.unused_dependencies, Some(Severity::Error));
1113        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
1114        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1115        assert_eq!(partial.unused_enum_members, Some(Severity::Error));
1116        assert_eq!(partial.unused_class_members, Some(Severity::Warn));
1117        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1118        assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
1119        assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
1120        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1121        assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
1122        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1123        assert_eq!(partial.boundary_violation, Some(Severity::Off));
1124        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1125        assert_eq!(partial.feature_flags, Some(Severity::Error));
1126        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1127    }
1128
1129    // ── PartialRulesConfig serialization skip_serializing_if ────────
1130
1131    #[test]
1132    fn partial_rules_none_fields_not_serialized() {
1133        let partial = PartialRulesConfig::default();
1134        let json = serde_json::to_string(&partial).unwrap();
1135        assert_eq!(
1136            json, "{}",
1137            "all-None partial should serialize to empty object"
1138        );
1139    }
1140
1141    #[test]
1142    fn partial_rules_some_fields_serialized() {
1143        let partial = PartialRulesConfig {
1144            unused_files: Some(Severity::Warn),
1145            ..Default::default()
1146        };
1147        let json = serde_json::to_string(&partial).unwrap();
1148        assert!(json.contains("unused-files"));
1149        assert!(!json.contains("unused-exports"));
1150    }
1151
1152    // ── Severity JSON deserialization ────────────────────────────────
1153
1154    #[test]
1155    fn severity_json_deserialization() {
1156        let error: Severity = serde_json::from_str(r#""error""#).unwrap();
1157        assert_eq!(error, Severity::Error);
1158
1159        let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
1160        assert_eq!(warn, Severity::Warn);
1161
1162        let off: Severity = serde_json::from_str(r#""off""#).unwrap();
1163        assert_eq!(off, Severity::Off);
1164    }
1165
1166    #[test]
1167    fn severity_invalid_json_value_rejected() {
1168        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
1169        assert!(result.is_err());
1170    }
1171
1172    // ── Severity default ────────────────────────────────────────────
1173
1174    #[test]
1175    fn severity_default_is_error() {
1176        assert_eq!(Severity::default(), Severity::Error);
1177    }
1178
1179    // ── RulesConfig JSON serialize roundtrip ─────────────────────────
1180
1181    #[test]
1182    fn rules_config_json_roundtrip() {
1183        let rules = RulesConfig {
1184            unused_files: Severity::Warn,
1185            unused_exports: Severity::Off,
1186            type_only_dependencies: Severity::Error,
1187            ..RulesConfig::default()
1188        };
1189        let json = serde_json::to_string(&rules).unwrap();
1190        let restored: RulesConfig = serde_json::from_str(&json).unwrap();
1191        assert_eq!(restored.unused_files, Severity::Warn);
1192        assert_eq!(restored.unused_exports, Severity::Off);
1193        assert_eq!(restored.type_only_dependencies, Severity::Error);
1194        assert_eq!(restored.unused_dependencies, Severity::Error); // default
1195    }
1196
1197    // ── apply_partial preserves type_only/test_only defaults ────────
1198
1199    #[test]
1200    fn apply_partial_preserves_type_only_default() {
1201        let mut rules = RulesConfig::default();
1202        let partial = PartialRulesConfig {
1203            unused_files: Some(Severity::Off),
1204            ..Default::default()
1205        };
1206        rules.apply_partial(&partial);
1207        // type_only_dependencies defaults to Warn, should be preserved
1208        assert_eq!(rules.type_only_dependencies, Severity::Warn);
1209        assert_eq!(rules.test_only_dependencies, Severity::Warn);
1210    }
1211}