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, 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    /// Opt-in (default off): a `"use client"` file that transitively imports a
128    /// module reading a non-public `process.env` secret. Surfaced only by
129    /// `fallow security`; never under bare `fallow` or the `audit` gate.
130    #[serde(default = "Severity::default_off")]
131    pub security_client_server_leak: Severity,
132    /// Opt-in (default off): a syntactic tainted-sink candidate matched against
133    /// the data-driven catalogue (`security_matchers.toml`). ONE knob gates ALL
134    /// catalogue categories. Surfaced only by `fallow security`; never under
135    /// bare `fallow` or the `audit` gate.
136    #[serde(default = "Severity::default_off")]
137    pub security_sink: Severity,
138    /// Master severity for rule-pack findings (`rulePacks` config). Defaults
139    /// to `warn` so enabling a brand-new policy pack never hard-fails CI on
140    /// its first run; individual pack rules opt up via `"severity": "error"`.
141    /// `off` is a kill switch that disables the whole evaluator (per-rule
142    /// severity cannot resurrect it).
143    #[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    /// Apply a partial rules config on top. Only `Some` fields override.
184    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/// Partial per-issue-type severity for overrides. All fields optional.
273#[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
440/// Every rule name accepted by `RulesConfig` deserialization, in kebab-case.
441///
442/// Includes both the canonical name produced by `#[serde(rename_all = "kebab-case")]`
443/// and every `#[serde(alias = ...)]` value. Used by
444/// [`find_unknown_rule_keys`] to detect typos in user-supplied configs and
445/// emit a `tracing::warn!` suggestion at config load time.
446///
447/// Keep in sync with the `#[serde]` attributes on `RulesConfig` and
448/// `PartialRulesConfig`; the `known_rule_names_count_matches_struct` test
449/// fails when the lists drift.
450pub 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/// Find the closest known rule name to `input` when it is plausibly a typo.
510///
511/// Thin wrapper over [`crate::levenshtein::closest_match`] that scopes the
512/// candidate set to [`KNOWN_RULE_NAMES`] and returns a `'static` reference so
513/// the suggestion can be embedded in tracing warnings without allocation.
514#[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/// An unknown key found inside a `rules` (or `overrides[].rules`) object.
523///
524/// Surfaced by [`find_unknown_rule_keys`] so the caller (config loader) can
525/// emit one `tracing::warn!` per entry without coupling the detection logic
526/// to a tracing subscriber.
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct UnknownRuleKey {
529    /// Human-readable source label, e.g. `"rules"` or `"overrides[2].rules"`.
530    pub context: String,
531    /// The unknown key as it appeared in the user's config.
532    pub key: String,
533    /// Closest known rule name when one is within plausible-typo distance.
534    pub suggestion: Option<&'static str>,
535}
536
537/// Collect every unknown key from a `rules`-shaped JSON object.
538///
539/// Returns an empty `Vec` when `value` is not an object or every key is
540/// recognized (canonical kebab-case or a documented alias). Called from
541/// [`crate::config::parsing`] after `extends` merge and before
542/// `serde_json::from_value::<FallowConfig>`, so the warning lists keys from
543/// the final merged config rather than per-file partials.
544#[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(); // all None
758        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); // default
1198    }
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}