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