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