Skip to main content

fallow_config/config/
mod.rs

1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5pub mod glob_validation;
6mod health;
7mod parsing;
8mod resolution;
9mod resolve;
10mod rules;
11mod used_class_members;
12
13#[expect(
14    clippy::redundant_pub_crate,
15    reason = "this module is glob re-exported from lib.rs, so `pub` would leak the helper into the public API; pub(crate) keeps it internal to the crate"
16)]
17pub(crate) use boundaries::wildcard_placement_error;
18pub use boundaries::{
19    AuthoredRule, BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryPreset,
20    BoundaryRule, BoundaryZone, ForbiddenCallRule, ForbiddenCallee, InvalidForbiddenCallee,
21    LogicalGroup, LogicalGroupStatus, RedundantRootPrefix, ResolvedBoundaryConfig,
22    ResolvedBoundaryCoverageConfig, ResolvedBoundaryRule, ResolvedZone, UnknownZoneRef,
23    ZoneReferenceKind, ZoneValidationError,
24};
25pub use duplicates_config::{
26    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
27};
28pub use flags::{FlagsConfig, SdkPattern};
29pub use format::OutputFormat;
30pub use health::{EmailMode, HealthConfig, HealthThresholdOverride, OwnershipConfig};
31pub use resolution::{
32    CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
33    CompiledIgnoreExportRule, ConfigOverride, DEFAULT_MAX_FILE_SIZE_BYTES,
34    DEFAULT_MAX_FILE_SIZE_MB, IgnoreCatalogReferenceRule, IgnoreDependencyOverrideRule,
35    IgnoreExportRule, ResolvedConfig, ResolvedOverride, resolve_max_file_size_bytes,
36};
37pub use resolve::ResolveConfig;
38pub use rules::{PartialRulesConfig, RulesConfig, Severity};
39pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
40
41use schemars::JsonSchema;
42use serde::{Deserialize, Deserializer, Serialize};
43use std::ops::Not;
44use std::path::PathBuf;
45
46use crate::external_plugin::ExternalPluginDef;
47use crate::workspace::WorkspaceConfig;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
50#[serde(untagged, rename_all = "camelCase")]
51pub enum IgnoreExportsUsedInFileConfig {
52    Bool(bool),
53    ByKind(IgnoreExportsUsedInFileByKind),
54}
55
56impl Default for IgnoreExportsUsedInFileConfig {
57    fn default() -> Self {
58        Self::Bool(false)
59    }
60}
61
62impl From<bool> for IgnoreExportsUsedInFileConfig {
63    fn from(value: bool) -> Self {
64        Self::Bool(value)
65    }
66}
67
68impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
69    fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
70        Self::ByKind(value)
71    }
72}
73
74impl IgnoreExportsUsedInFileConfig {
75    #[must_use]
76    pub const fn is_enabled(self) -> bool {
77        match self {
78            Self::Bool(value) => value,
79            Self::ByKind(kind) => kind.type_ || kind.interface,
80        }
81    }
82
83    #[must_use]
84    pub const fn suppresses(self, is_type_only: bool) -> bool {
85        match self {
86            Self::Bool(value) => value,
87            Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
88        }
89    }
90}
91
92#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
93#[serde(rename_all = "camelCase")]
94pub struct IgnoreExportsUsedInFileByKind {
95    #[serde(default, rename = "type")]
96    pub type_: bool,
97    #[serde(default)]
98    pub interface: bool,
99}
100
101/// Options for the `unused-component-props` rule.
102///
103/// Lets a project exempt component props whose local destructure binding name
104/// matches a regex from `unused-component-props`, honoring the
105/// "accepted-but-intentionally-unused" leading-underscore convention (Svelte 5
106/// `$props()`, React destructure) that mirrors TypeScript `noUnusedParameters`
107/// and ESLint `@typescript-eslint/no-unused-vars` `varsIgnorePattern` /
108/// `argsIgnorePattern`. Opt-in; an unset `ignorePattern` leaves the rule's
109/// behavior unchanged.
110#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
111#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
112pub struct UnusedComponentPropsConfig {
113    /// Regex matched against each declared prop's LOCAL destructure binding name
114    /// (e.g. `_stage` in `let { stage: _stage } = $props()`), which falls back
115    /// to the public prop name when there is no alias. A prop whose local name
116    /// matches is treated as intentionally unused and never reported as
117    /// `unused-component-props`. Matching is unanchored (substring), like
118    /// ESLint's `RegExp.test`, so anchor with `^_` to match a leading
119    /// underscore. Compiled and validated at config load (an invalid regex fails
120    /// load). Applies to Vue, Svelte, Astro, and React/Preact props.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub ignore_pattern: Option<String>,
123}
124
125impl UnusedComponentPropsConfig {
126    #[must_use]
127    pub fn is_default(&self) -> bool {
128        self.ignore_pattern.is_none()
129    }
130}
131
132#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
133#[serde(rename_all = "camelCase")]
134pub struct FixConfig {
135    #[serde(default)]
136    pub catalog: CatalogFixConfig,
137}
138
139#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
140#[serde(rename_all = "camelCase")]
141pub struct CatalogFixConfig {
142    #[serde(default)]
143    pub delete_preceding_comments: CatalogPrecedingCommentPolicy,
144}
145
146#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
147#[serde(rename_all = "lowercase")]
148pub enum CatalogPrecedingCommentPolicy {
149    #[default]
150    Auto,
151    Always,
152    Never,
153}
154
155#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
156#[serde(deny_unknown_fields, rename_all = "camelCase")]
157pub struct FallowConfig {
158    #[serde(rename = "$schema", default, skip_serializing)]
159    pub schema: Option<String>,
160
161    #[serde(default, skip_serializing)]
162    pub extends: Vec<String>,
163
164    #[serde(default)]
165    pub entry: Vec<String>,
166
167    #[serde(default)]
168    pub ignore_patterns: Vec<String>,
169
170    #[serde(default)]
171    pub framework: Vec<ExternalPluginDef>,
172
173    #[serde(default)]
174    pub workspaces: Option<WorkspaceConfig>,
175
176    #[serde(default)]
177    pub ignore_dependencies: Vec<String>,
178
179    #[serde(default)]
180    pub ignore_unresolved_imports: Vec<String>,
181
182    #[serde(default)]
183    pub ignore_exports: Vec<IgnoreExportRule>,
184
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
187
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
190
191    #[serde(default)]
192    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
193
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub ignore_decorators: Vec<String>,
196
197    #[serde(default)]
198    pub used_class_members: Vec<UsedClassMemberRule>,
199
200    #[serde(default)]
201    pub duplicates: DuplicatesConfig,
202
203    #[serde(default)]
204    pub health: HealthConfig,
205
206    #[serde(default)]
207    pub rules: RulesConfig,
208
209    #[serde(
210        default,
211        skip_serializing_if = "UnusedComponentPropsConfig::is_default"
212    )]
213    pub unused_component_props: UnusedComponentPropsConfig,
214
215    #[serde(default)]
216    pub boundaries: BoundaryConfig,
217
218    #[serde(default)]
219    pub flags: FlagsConfig,
220
221    #[serde(default)]
222    pub security: SecurityConfig,
223
224    #[serde(default)]
225    pub fix: FixConfig,
226
227    #[serde(default)]
228    pub resolve: ResolveConfig,
229
230    #[serde(default)]
231    pub production: ProductionConfig,
232
233    #[serde(default)]
234    pub plugins: Vec<String>,
235
236    /// Paths to declarative rule-pack files (JSON or JSONC), relative to the
237    /// project root. Each pack declares `banned-call`, `banned-import`, or
238    /// `banned-effect` rules that report as `policy-violation` findings. Packs
239    /// are pure data: no project code is executed. Invalid or missing packs
240    /// fail config load.
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub rule_packs: Vec<String>,
243
244    #[serde(default)]
245    pub dynamically_loaded: Vec<String>,
246
247    #[serde(default)]
248    pub overrides: Vec<ConfigOverride>,
249
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub codeowners: Option<String>,
252
253    #[serde(default)]
254    pub public_packages: Vec<String>,
255
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub regression: Option<RegressionConfig>,
258
259    #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
260    pub audit: AuditConfig,
261
262    #[serde(default)]
263    pub sealed: bool,
264
265    #[serde(default)]
266    pub include_entry_exports: bool,
267
268    #[serde(default)]
269    pub auto_imports: bool,
270
271    #[serde(default, skip_serializing_if = "CacheConfig::is_default")]
272    pub cache: CacheConfig,
273}
274
275/// Scopes `fallow security` catalogue behavior. An absent category block admits
276/// every catalogue category. `hardcoded-secret` is include-required and only
277/// runs when explicitly listed in `security.categories.include`.
278#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
279#[serde(deny_unknown_fields, rename_all = "camelCase")]
280pub struct SecurityConfig {
281    /// Include/exclude filter over category ids (e.g. `dangerous-html`).
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub categories: Option<SecurityCategories>,
284    /// Additional project-local names for HTTP request objects. These names
285    /// extend the built-in receiver allowlist for `*.query`, `*.params`, and
286    /// `*.body` source patterns. They do not replace the built-ins and do not
287    /// gate `*.searchParams`, which intentionally stays ungated.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub request_receivers: Vec<String>,
290}
291
292impl SecurityConfig {
293    #[must_use]
294    pub fn normalized_request_receivers(&self) -> Vec<String> {
295        let mut receivers = Vec::new();
296        for receiver in &self.request_receivers {
297            let normalized = receiver.trim().to_ascii_lowercase();
298            if !normalized.is_empty() && !receivers.contains(&normalized) {
299                receivers.push(normalized);
300            }
301        }
302        receivers
303    }
304
305    #[must_use]
306    pub fn request_receivers_are_valid(&self) -> bool {
307        self.request_receivers
308            .iter()
309            .all(|receiver| !receiver.trim().is_empty())
310    }
311}
312
313/// Include/exclude lists scoping the active security categories. When `include`
314/// is set, only those categories are active; `exclude` removes categories from
315/// the admitted set. Both unset admits catalogue categories. `hardcoded-secret`
316/// still requires explicit inclusion.
317#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
318#[serde(deny_unknown_fields, rename_all = "camelCase")]
319pub struct SecurityCategories {
320    /// Catalogue category ids to admit. When set, all others are excluded.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub include: Option<Vec<String>>,
323    /// Catalogue category ids to remove from the admitted set.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub exclude: Option<Vec<String>>,
326}
327
328#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
329#[serde(deny_unknown_fields, rename_all = "camelCase")]
330pub struct CacheConfig {
331    /// Directory for fallow's persistent analysis cache. Relative paths resolve
332    /// from the project root.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub dir: Option<PathBuf>,
335    /// Maximum size of the persistent extraction cache, in megabytes.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub max_size_mb: Option<u32>,
338}
339
340impl CacheConfig {
341    #[must_use]
342    pub fn is_default(&self) -> bool {
343        self.dir.is_none() && self.max_size_mb.is_none()
344    }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum ProductionAnalysis {
349    DeadCode,
350    Health,
351    Dupes,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
355#[serde(untagged)]
356pub enum ProductionConfig {
357    Global(bool),
358    PerAnalysis(PerAnalysisProductionConfig),
359}
360
361impl<'de> Deserialize<'de> for ProductionConfig {
362    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363    where
364        D: Deserializer<'de>,
365    {
366        struct ProductionConfigVisitor;
367
368        impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
369            type Value = ProductionConfig;
370
371            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372                formatter.write_str("a boolean or per-analysis production config object")
373            }
374
375            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
376            where
377                E: serde::de::Error,
378            {
379                Ok(ProductionConfig::Global(value))
380            }
381
382            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
383            where
384                A: serde::de::MapAccess<'de>,
385            {
386                PerAnalysisProductionConfig::deserialize(
387                    serde::de::value::MapAccessDeserializer::new(map),
388                )
389                .map(ProductionConfig::PerAnalysis)
390            }
391        }
392
393        deserializer.deserialize_any(ProductionConfigVisitor)
394    }
395}
396
397impl Default for ProductionConfig {
398    fn default() -> Self {
399        Self::Global(false)
400    }
401}
402
403impl From<bool> for ProductionConfig {
404    fn from(value: bool) -> Self {
405        Self::Global(value)
406    }
407}
408
409impl Not for ProductionConfig {
410    type Output = bool;
411
412    fn not(self) -> Self::Output {
413        !self.any_enabled()
414    }
415}
416
417impl ProductionConfig {
418    #[must_use]
419    pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
420        match self {
421            Self::Global(value) => value,
422            Self::PerAnalysis(config) => match analysis {
423                ProductionAnalysis::DeadCode => config.dead_code,
424                ProductionAnalysis::Health => config.health,
425                ProductionAnalysis::Dupes => config.dupes,
426            },
427        }
428    }
429
430    #[must_use]
431    pub const fn global(self) -> bool {
432        match self {
433            Self::Global(value) => value,
434            Self::PerAnalysis(_) => false,
435        }
436    }
437
438    #[must_use]
439    pub const fn any_enabled(self) -> bool {
440        match self {
441            Self::Global(value) => value,
442            Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
443        }
444    }
445}
446
447#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
448#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
449pub struct PerAnalysisProductionConfig {
450    pub dead_code: bool,
451    pub health: bool,
452    pub dupes: bool,
453}
454
455#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
456#[serde(rename_all = "camelCase")]
457pub struct AuditConfig {
458    #[serde(default, skip_serializing_if = "AuditGate::is_default")]
459    pub gate: AuditGate,
460
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub css: Option<bool>,
463
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub css_deep: Option<bool>,
466
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub dead_code_baseline: Option<String>,
469
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub health_baseline: Option<String>,
472
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub dupes_baseline: Option<String>,
475
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub cache_max_age_days: Option<u32>,
478}
479
480impl AuditConfig {
481    #[must_use]
482    pub fn is_empty(&self) -> bool {
483        self.gate.is_default()
484            && self.css.is_none()
485            && self.css_deep.is_none()
486            && self.dead_code_baseline.is_none()
487            && self.health_baseline.is_none()
488            && self.dupes_baseline.is_none()
489            && self.cache_max_age_days.is_none()
490    }
491}
492
493#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
494#[serde(rename_all = "kebab-case")]
495pub enum AuditGate {
496    #[default]
497    NewOnly,
498    All,
499}
500
501impl AuditGate {
502    #[must_use]
503    pub const fn is_default(&self) -> bool {
504        matches!(self, Self::NewOnly)
505    }
506}
507
508#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
509#[serde(rename_all = "camelCase")]
510pub struct RegressionConfig {
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub baseline: Option<RegressionBaseline>,
513}
514
515#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
516#[serde(rename_all = "camelCase")]
517pub struct RegressionBaseline {
518    #[serde(default)]
519    pub total_issues: usize,
520    #[serde(default)]
521    pub unused_files: usize,
522    #[serde(default)]
523    pub unused_exports: usize,
524    #[serde(default)]
525    pub unused_types: usize,
526    #[serde(default)]
527    pub unused_dependencies: usize,
528    #[serde(default)]
529    pub unused_dev_dependencies: usize,
530    #[serde(default)]
531    pub unused_optional_dependencies: usize,
532    #[serde(default)]
533    pub unused_enum_members: usize,
534    #[serde(default)]
535    pub unused_class_members: usize,
536    #[serde(default)]
537    pub unresolved_imports: usize,
538    #[serde(default)]
539    pub unlisted_dependencies: usize,
540    #[serde(default)]
541    pub duplicate_exports: usize,
542    #[serde(default)]
543    pub circular_dependencies: usize,
544    #[serde(default)]
545    pub re_export_cycles: usize,
546    #[serde(default)]
547    pub type_only_dependencies: usize,
548    #[serde(default)]
549    pub test_only_dependencies: usize,
550    #[serde(default)]
551    pub dev_dependencies_in_production: usize,
552    #[serde(default)]
553    pub boundary_violations: usize,
554    #[serde(default)]
555    pub boundary_coverage_violations: usize,
556    #[serde(default)]
557    pub boundary_call_violations: usize,
558    #[serde(default)]
559    pub policy_violations: usize,
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn default_config_has_empty_collections() {
568        let config = FallowConfig::default();
569        assert!(config.schema.is_none());
570        assert!(config.extends.is_empty());
571        assert!(config.entry.is_empty());
572        assert!(config.ignore_patterns.is_empty());
573        assert!(config.framework.is_empty());
574        assert!(config.workspaces.is_none());
575        assert!(config.ignore_dependencies.is_empty());
576        assert!(config.ignore_exports.is_empty());
577        assert!(config.used_class_members.is_empty());
578        assert!(config.plugins.is_empty());
579        assert!(config.dynamically_loaded.is_empty());
580        assert!(config.overrides.is_empty());
581        assert!(config.public_packages.is_empty());
582        assert_eq!(
583            config.fix.catalog.delete_preceding_comments,
584            CatalogPrecedingCommentPolicy::Auto
585        );
586        assert!(!config.production);
587    }
588
589    #[test]
590    fn default_config_rules_are_error() {
591        let config = FallowConfig::default();
592        assert_eq!(config.rules.unused_files, Severity::Error);
593        assert_eq!(config.rules.unused_exports, Severity::Error);
594        assert_eq!(config.rules.unused_dependencies, Severity::Error);
595    }
596
597    #[test]
598    fn default_config_duplicates_enabled() {
599        let config = FallowConfig::default();
600        assert!(config.duplicates.enabled);
601        assert_eq!(config.duplicates.min_tokens, 50);
602        assert_eq!(config.duplicates.min_lines, 5);
603    }
604
605    #[test]
606    fn default_config_health_thresholds() {
607        let config = FallowConfig::default();
608        assert_eq!(config.health.max_cyclomatic, 20);
609        assert_eq!(config.health.max_cognitive, 15);
610    }
611
612    #[test]
613    fn deserialize_empty_json_object() {
614        let config: FallowConfig = serde_json::from_str("{}").unwrap();
615        assert!(config.entry.is_empty());
616        assert!(!config.production);
617    }
618
619    #[test]
620    fn deserialize_json_with_all_top_level_fields() {
621        let json = r#"{
622            "$schema": "https://fallow.dev/schema.json",
623            "entry": ["src/main.ts"],
624            "ignorePatterns": ["generated/**"],
625            "ignoreDependencies": ["postcss"],
626            "production": true,
627            "plugins": ["custom-plugin.toml"],
628            "rules": {"unused-files": "warn"},
629            "duplicates": {"enabled": false},
630            "health": {"maxCyclomatic": 30}
631        }"#;
632        let config: FallowConfig = serde_json::from_str(json).unwrap();
633        assert_eq!(
634            config.schema.as_deref(),
635            Some("https://fallow.dev/schema.json")
636        );
637        assert_eq!(config.entry, vec!["src/main.ts"]);
638        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
639        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
640        assert!(config.production);
641        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
642        assert_eq!(config.rules.unused_files, Severity::Warn);
643        assert!(!config.duplicates.enabled);
644        assert_eq!(config.health.max_cyclomatic, 30);
645    }
646
647    #[test]
648    fn deserialize_json_deny_unknown_fields() {
649        let json = r#"{"unknownField": true}"#;
650        let result: Result<FallowConfig, _> = serde_json::from_str(json);
651        assert!(result.is_err(), "unknown fields should be rejected");
652    }
653
654    #[test]
655    fn deserialize_json_production_mode_default_false() {
656        let config: FallowConfig = serde_json::from_str("{}").unwrap();
657        assert!(!config.production);
658    }
659
660    #[test]
661    fn deserialize_json_production_mode_true() {
662        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
663        assert!(config.production);
664    }
665
666    #[test]
667    fn deserialize_json_per_analysis_production_mode() {
668        let config: FallowConfig = serde_json::from_str(
669            r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
670        )
671        .unwrap();
672        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
673        assert!(config.production.for_analysis(ProductionAnalysis::Health));
674        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
675    }
676
677    #[test]
678    fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
679        let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
680            .unwrap_err();
681        assert!(
682            err.to_string().contains("healthTypo"),
683            "error should name the unknown field: {err}"
684        );
685    }
686
687    #[test]
688    fn deserialize_json_dynamically_loaded() {
689        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
690        let config: FallowConfig = serde_json::from_str(json).unwrap();
691        assert_eq!(
692            config.dynamically_loaded,
693            vec!["plugins/**/*.ts", "locales/**/*.json"]
694        );
695    }
696
697    #[test]
698    fn deserialize_json_dynamically_loaded_defaults_empty() {
699        let config: FallowConfig = serde_json::from_str("{}").unwrap();
700        assert!(config.dynamically_loaded.is_empty());
701    }
702
703    #[test]
704    fn deserialize_json_fix_catalog_delete_preceding_comments() {
705        let config: FallowConfig =
706            serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
707                .unwrap();
708        assert_eq!(
709            config.fix.catalog.delete_preceding_comments,
710            CatalogPrecedingCommentPolicy::Always
711        );
712    }
713
714    #[test]
715    fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
716        let err = serde_json::from_str::<FallowConfig>(
717            r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
718        )
719        .unwrap_err();
720        assert!(
721            err.to_string().contains("sometimes"),
722            "error should name the bad policy: {err}"
723        );
724    }
725
726    #[test]
727    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
728        let json = r#"{
729            "usedClassMembers": [
730                "agInit",
731                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
732                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
733            ]
734        }"#;
735        let config: FallowConfig = serde_json::from_str(json).unwrap();
736        assert_eq!(
737            config.used_class_members,
738            vec![
739                UsedClassMemberRule::from("agInit"),
740                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
741                    extends: None,
742                    implements: Some("ICellRendererAngularComp".to_string()),
743                    members: vec!["refresh".to_string()],
744                }),
745                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
746                    extends: Some("BaseCommand".to_string()),
747                    implements: Some("CanActivate".to_string()),
748                    members: vec!["execute".to_string()],
749                }),
750            ]
751        );
752    }
753
754    #[test]
755    fn deserialize_toml_minimal() {
756        let toml_str = r#"
757entry = ["src/index.ts"]
758production = true
759"#;
760        let config: FallowConfig = toml::from_str(toml_str).unwrap();
761        assert_eq!(config.entry, vec!["src/index.ts"]);
762        assert!(config.production);
763    }
764
765    #[test]
766    fn deserialize_toml_per_analysis_production_mode() {
767        let toml_str = r"
768[production]
769deadCode = false
770health = true
771dupes = false
772";
773        let config: FallowConfig = toml::from_str(toml_str).unwrap();
774        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
775        assert!(config.production.for_analysis(ProductionAnalysis::Health));
776        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
777    }
778
779    #[test]
780    fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
781        let err = toml::from_str::<FallowConfig>(
782            r"
783[production]
784healthTypo = true
785",
786        )
787        .unwrap_err();
788        assert!(
789            err.to_string().contains("healthTypo"),
790            "error should name the unknown field: {err}"
791        );
792    }
793
794    #[test]
795    fn deserialize_toml_with_inline_framework() {
796        let toml_str = r#"
797[[framework]]
798name = "my-framework"
799enablers = ["my-framework-pkg"]
800entryPoints = ["src/routes/**/*.tsx"]
801"#;
802        let config: FallowConfig = toml::from_str(toml_str).unwrap();
803        assert_eq!(config.framework.len(), 1);
804        assert_eq!(config.framework[0].name, "my-framework");
805        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
806        assert_eq!(
807            config.framework[0].entry_points,
808            vec!["src/routes/**/*.tsx"]
809        );
810    }
811
812    #[test]
813    fn deserialize_toml_fix_catalog_delete_preceding_comments() {
814        let toml_str = r#"
815[fix.catalog]
816deletePrecedingComments = "never"
817"#;
818        let config: FallowConfig = toml::from_str(toml_str).unwrap();
819        assert_eq!(
820            config.fix.catalog.delete_preceding_comments,
821            CatalogPrecedingCommentPolicy::Never
822        );
823    }
824
825    #[test]
826    fn deserialize_toml_with_workspace_config() {
827        let toml_str = r#"
828[workspaces]
829patterns = ["packages/*", "apps/*"]
830"#;
831        let config: FallowConfig = toml::from_str(toml_str).unwrap();
832        assert!(config.workspaces.is_some());
833        let ws = config.workspaces.unwrap();
834        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
835    }
836
837    #[test]
838    fn deserialize_toml_with_ignore_exports() {
839        let toml_str = r#"
840[[ignoreExports]]
841file = "src/types/**/*.ts"
842exports = ["*"]
843"#;
844        let config: FallowConfig = toml::from_str(toml_str).unwrap();
845        assert_eq!(config.ignore_exports.len(), 1);
846        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
847        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
848    }
849
850    #[test]
851    fn deserialize_toml_used_class_members_supports_scoped_rules() {
852        let toml_str = r#"
853usedClassMembers = [
854  { implements = "ICellRendererAngularComp", members = ["refresh"] },
855  { extends = "BaseCommand", members = ["execute"] },
856]
857"#;
858        let config: FallowConfig = toml::from_str(toml_str).unwrap();
859        assert_eq!(
860            config.used_class_members,
861            vec![
862                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
863                    extends: None,
864                    implements: Some("ICellRendererAngularComp".to_string()),
865                    members: vec!["refresh".to_string()],
866                }),
867                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
868                    extends: Some("BaseCommand".to_string()),
869                    implements: None,
870                    members: vec!["execute".to_string()],
871                }),
872            ]
873        );
874    }
875
876    #[test]
877    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
878        let result = serde_json::from_str::<FallowConfig>(
879            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
880        );
881        assert!(
882            result.is_err(),
883            "unconstrained scoped rule should be rejected"
884        );
885    }
886
887    #[test]
888    fn deserialize_ignore_exports_used_in_file_bool() {
889        let config: FallowConfig =
890            serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
891
892        assert!(config.ignore_exports_used_in_file.suppresses(false));
893        assert!(config.ignore_exports_used_in_file.suppresses(true));
894    }
895
896    #[test]
897    fn deserialize_ignore_exports_used_in_file_kind_form() {
898        let config: FallowConfig =
899            serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
900
901        assert!(!config.ignore_exports_used_in_file.suppresses(false));
902        assert!(config.ignore_exports_used_in_file.suppresses(true));
903    }
904
905    #[test]
906    fn deserialize_toml_deny_unknown_fields() {
907        let toml_str = r"bogus_field = true";
908        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
909        assert!(result.is_err(), "unknown fields should be rejected");
910    }
911
912    #[test]
913    fn json_serialize_roundtrip() {
914        let config = FallowConfig {
915            entry: vec!["src/main.ts".to_string()],
916            production: true.into(),
917            ..FallowConfig::default()
918        };
919        let json = serde_json::to_string(&config).unwrap();
920        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
921        assert_eq!(restored.entry, vec!["src/main.ts"]);
922        assert!(restored.production);
923    }
924
925    #[test]
926    fn schema_field_not_serialized() {
927        let config = FallowConfig {
928            schema: Some("https://example.com/schema.json".to_string()),
929            ..FallowConfig::default()
930        };
931        let json = serde_json::to_string(&config).unwrap();
932        assert!(
933            !json.contains("$schema"),
934            "schema field should be skipped in serialization"
935        );
936    }
937
938    #[test]
939    fn extends_field_not_serialized() {
940        let config = FallowConfig {
941            extends: vec!["base.json".to_string()],
942            ..FallowConfig::default()
943        };
944        let json = serde_json::to_string(&config).unwrap();
945        assert!(
946            !json.contains("extends"),
947            "extends field should be skipped in serialization"
948        );
949    }
950
951    #[test]
952    fn regression_config_deserialize_json() {
953        let json = r#"{
954            "regression": {
955                "baseline": {
956                    "totalIssues": 42,
957                    "unusedFiles": 10,
958                    "unusedExports": 5,
959                    "circularDependencies": 2
960                }
961            }
962        }"#;
963        let config: FallowConfig = serde_json::from_str(json).unwrap();
964        let regression = config.regression.unwrap();
965        let baseline = regression.baseline.unwrap();
966        assert_eq!(baseline.total_issues, 42);
967        assert_eq!(baseline.unused_files, 10);
968        assert_eq!(baseline.unused_exports, 5);
969        assert_eq!(baseline.circular_dependencies, 2);
970        assert_eq!(baseline.unused_types, 0);
971        assert_eq!(baseline.boundary_violations, 0);
972    }
973
974    #[test]
975    fn regression_config_defaults_to_none() {
976        let config: FallowConfig = serde_json::from_str("{}").unwrap();
977        assert!(config.regression.is_none());
978    }
979
980    #[test]
981    fn regression_baseline_all_zeros_by_default() {
982        let baseline = RegressionBaseline::default();
983        assert_eq!(baseline.total_issues, 0);
984        assert_eq!(baseline.unused_files, 0);
985        assert_eq!(baseline.unused_exports, 0);
986        assert_eq!(baseline.unused_types, 0);
987        assert_eq!(baseline.unused_dependencies, 0);
988        assert_eq!(baseline.unused_dev_dependencies, 0);
989        assert_eq!(baseline.unused_optional_dependencies, 0);
990        assert_eq!(baseline.unused_enum_members, 0);
991        assert_eq!(baseline.unused_class_members, 0);
992        assert_eq!(baseline.unresolved_imports, 0);
993        assert_eq!(baseline.unlisted_dependencies, 0);
994        assert_eq!(baseline.duplicate_exports, 0);
995        assert_eq!(baseline.circular_dependencies, 0);
996        assert_eq!(baseline.type_only_dependencies, 0);
997        assert_eq!(baseline.test_only_dependencies, 0);
998        assert_eq!(baseline.boundary_violations, 0);
999    }
1000
1001    #[test]
1002    fn regression_config_serialize_roundtrip() {
1003        let baseline = RegressionBaseline {
1004            total_issues: 100,
1005            unused_files: 20,
1006            unused_exports: 30,
1007            ..RegressionBaseline::default()
1008        };
1009        let regression = RegressionConfig {
1010            baseline: Some(baseline),
1011        };
1012        let config = FallowConfig {
1013            regression: Some(regression),
1014            ..FallowConfig::default()
1015        };
1016        let json = serde_json::to_string(&config).unwrap();
1017        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1018        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1019        assert_eq!(restored_baseline.total_issues, 100);
1020        assert_eq!(restored_baseline.unused_files, 20);
1021        assert_eq!(restored_baseline.unused_exports, 30);
1022        assert_eq!(restored_baseline.unused_types, 0);
1023    }
1024
1025    #[test]
1026    fn regression_config_empty_baseline_deserialize() {
1027        let json = r#"{"regression": {}}"#;
1028        let config: FallowConfig = serde_json::from_str(json).unwrap();
1029        let regression = config.regression.unwrap();
1030        assert!(regression.baseline.is_none());
1031    }
1032
1033    #[test]
1034    fn regression_baseline_not_serialized_when_none() {
1035        let config = FallowConfig {
1036            regression: None,
1037            ..FallowConfig::default()
1038        };
1039        let json = serde_json::to_string(&config).unwrap();
1040        assert!(
1041            !json.contains("regression"),
1042            "regression should be skipped when None"
1043        );
1044    }
1045
1046    #[test]
1047    fn deserialize_json_with_overrides() {
1048        let json = r#"{
1049            "overrides": [
1050                {
1051                    "files": ["*.test.ts", "*.spec.ts"],
1052                    "rules": {
1053                        "unused-exports": "off",
1054                        "unused-files": "warn"
1055                    }
1056                }
1057            ]
1058        }"#;
1059        let config: FallowConfig = serde_json::from_str(json).unwrap();
1060        assert_eq!(config.overrides.len(), 1);
1061        assert_eq!(config.overrides[0].files.len(), 2);
1062        assert_eq!(
1063            config.overrides[0].rules.unused_exports,
1064            Some(Severity::Off)
1065        );
1066        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1067    }
1068
1069    #[test]
1070    fn deserialize_json_with_boundaries() {
1071        let json = r#"{
1072            "boundaries": {
1073                "preset": "layered"
1074            }
1075        }"#;
1076        let config: FallowConfig = serde_json::from_str(json).unwrap();
1077        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1078    }
1079
1080    #[test]
1081    fn deserialize_toml_with_regression_baseline() {
1082        let toml_str = r"
1083[regression.baseline]
1084totalIssues = 50
1085unusedFiles = 10
1086unusedExports = 15
1087";
1088        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1089        let baseline = config.regression.unwrap().baseline.unwrap();
1090        assert_eq!(baseline.total_issues, 50);
1091        assert_eq!(baseline.unused_files, 10);
1092        assert_eq!(baseline.unused_exports, 15);
1093    }
1094
1095    #[test]
1096    fn deserialize_toml_with_overrides() {
1097        let toml_str = r#"
1098[[overrides]]
1099files = ["*.test.ts"]
1100
1101[overrides.rules]
1102unused-exports = "off"
1103
1104[[overrides]]
1105files = ["*.stories.tsx"]
1106
1107[overrides.rules]
1108unused-files = "off"
1109"#;
1110        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1111        assert_eq!(config.overrides.len(), 2);
1112        assert_eq!(
1113            config.overrides[0].rules.unused_exports,
1114            Some(Severity::Off)
1115        );
1116        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1117    }
1118
1119    #[test]
1120    fn regression_config_default_is_none_baseline() {
1121        let config = RegressionConfig::default();
1122        assert!(config.baseline.is_none());
1123    }
1124
1125    #[test]
1126    fn deserialize_json_multiple_ignore_export_rules() {
1127        let json = r#"{
1128            "ignoreExports": [
1129                {"file": "src/types/**/*.ts", "exports": ["*"]},
1130                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1131                {"file": "src/index.ts", "exports": ["default"]}
1132            ]
1133        }"#;
1134        let config: FallowConfig = serde_json::from_str(json).unwrap();
1135        assert_eq!(config.ignore_exports.len(), 3);
1136        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1137    }
1138
1139    #[test]
1140    fn deserialize_json_public_packages_camel_case() {
1141        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1142        let config: FallowConfig = serde_json::from_str(json).unwrap();
1143        assert_eq!(
1144            config.public_packages,
1145            vec!["@myorg/shared-lib", "@myorg/utils"]
1146        );
1147    }
1148
1149    #[test]
1150    fn deserialize_json_public_packages_rejects_snake_case() {
1151        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1152        let result: Result<FallowConfig, _> = serde_json::from_str(json);
1153        assert!(
1154            result.is_err(),
1155            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1156        );
1157    }
1158
1159    #[test]
1160    fn deserialize_json_public_packages_empty() {
1161        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1162        assert!(config.public_packages.is_empty());
1163    }
1164
1165    #[test]
1166    fn deserialize_toml_public_packages() {
1167        let toml_str = r#"
1168publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1169"#;
1170        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1171        assert_eq!(
1172            config.public_packages,
1173            vec!["@myorg/shared-lib", "@myorg/ui"]
1174        );
1175    }
1176
1177    #[test]
1178    fn public_packages_serialize_roundtrip() {
1179        let config = FallowConfig {
1180            public_packages: vec!["@myorg/shared-lib".to_string()],
1181            ..FallowConfig::default()
1182        };
1183        let json = serde_json::to_string(&config).unwrap();
1184        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1185        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1186    }
1187}