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