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