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