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