Skip to main content

fallow_config/config/
mod.rs

1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5mod health;
6mod parsing;
7mod resolution;
8mod resolve;
9mod rules;
10mod used_class_members;
11
12pub use boundaries::{
13    BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
14    ResolvedBoundaryRule, ResolvedZone,
15};
16pub use duplicates_config::{
17    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
18};
19pub use flags::{FlagsConfig, SdkPattern};
20pub use format::OutputFormat;
21pub use health::{EmailMode, HealthConfig, OwnershipConfig};
22pub use resolution::{
23    CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
24    CompiledIgnoreExportRule, ConfigOverride, IgnoreCatalogReferenceRule,
25    IgnoreDependencyOverrideRule, IgnoreExportRule, ResolvedConfig, ResolvedOverride,
26};
27pub use resolve::ResolveConfig;
28pub use rules::{PartialRulesConfig, RulesConfig, Severity};
29pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Deserializer, Serialize};
33use std::ops::Not;
34
35use crate::external_plugin::ExternalPluginDef;
36use crate::workspace::WorkspaceConfig;
37
38/// Controls whether exports referenced only inside their defining file are
39/// reported as unused exports.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
41#[serde(untagged, rename_all = "camelCase")]
42pub enum IgnoreExportsUsedInFileConfig {
43    /// `true` suppresses both value and type exports that are referenced in
44    /// their defining file. `false` preserves the default cross-file behavior.
45    Bool(bool),
46    /// Knip-compatible fine-grained form. Fallow groups type aliases and
47    /// interfaces under `unused_types`, so either field enables type-export
48    /// suppression for same-file references.
49    ByKind(IgnoreExportsUsedInFileByKind),
50}
51
52impl Default for IgnoreExportsUsedInFileConfig {
53    fn default() -> Self {
54        Self::Bool(false)
55    }
56}
57
58impl From<bool> for IgnoreExportsUsedInFileConfig {
59    fn from(value: bool) -> Self {
60        Self::Bool(value)
61    }
62}
63
64impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
65    fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
66        Self::ByKind(value)
67    }
68}
69
70impl IgnoreExportsUsedInFileConfig {
71    /// Whether this option can suppress at least one kind of export.
72    #[must_use]
73    pub const fn is_enabled(self) -> bool {
74        match self {
75            Self::Bool(value) => value,
76            Self::ByKind(kind) => kind.type_ || kind.interface,
77        }
78    }
79
80    /// Whether same-file references should suppress this export kind.
81    #[must_use]
82    pub const fn suppresses(self, is_type_only: bool) -> bool {
83        match self {
84            Self::Bool(value) => value,
85            Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
86        }
87    }
88}
89
90/// Knip-compatible `ignoreExportsUsedInFile` object form.
91#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
92#[serde(rename_all = "camelCase")]
93pub struct IgnoreExportsUsedInFileByKind {
94    /// Suppress same-file references for exported type aliases.
95    #[serde(default, rename = "type")]
96    pub type_: bool,
97    /// Suppress same-file references for exported interfaces.
98    #[serde(default)]
99    pub interface: bool,
100}
101
102/// User-facing configuration loaded from `.fallowrc.json`, `.fallowrc.jsonc`, `fallow.toml`, or `.fallow.toml`.
103///
104/// # Examples
105///
106/// ```
107/// use fallow_config::FallowConfig;
108///
109/// // Default config has sensible defaults
110/// let config = FallowConfig::default();
111/// assert!(config.entry.is_empty());
112/// assert!(!config.production);
113///
114/// // Deserialize from JSON
115/// let config: FallowConfig = serde_json::from_str(r#"{
116///     "entry": ["src/main.ts"],
117///     "production": true
118/// }"#).unwrap();
119/// assert_eq!(config.entry, vec!["src/main.ts"]);
120/// assert!(config.production);
121/// ```
122#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
123#[serde(deny_unknown_fields, rename_all = "camelCase")]
124pub struct FallowConfig {
125    /// JSON Schema reference (ignored during deserialization).
126    #[serde(rename = "$schema", default, skip_serializing)]
127    pub schema: Option<String>,
128
129    /// Base config files to extend from.
130    ///
131    /// Supports three resolution strategies:
132    /// - **Relative paths**: `"./base.json"` — resolved relative to the config file.
133    /// - **npm packages**: `"npm:@co/config"` — resolved by walking up `node_modules/`.
134    ///   Package resolution checks `package.json` `exports`/`main` first, then falls back
135    ///   to standard config file names. Subpaths are supported (e.g., `npm:@co/config/strict.json`).
136    /// - **HTTPS URLs**: `"https://example.com/fallow-base.json"` — fetched remotely.
137    ///   Only HTTPS is supported (no plain HTTP). URL-sourced configs may extend other
138    ///   URLs or `npm:` packages, but not relative paths. Only JSON/JSONC format is
139    ///   supported for remote configs. Timeout is configurable via
140    ///   `FALLOW_EXTENDS_TIMEOUT_SECS` (default: 5s).
141    ///
142    /// Base configs are loaded first, then this config's values override them.
143    /// Later entries in the array override earlier ones.
144    ///
145    /// **Note:** `npm:` resolution uses `node_modules/` directory walk-up and is
146    /// incompatible with Yarn Plug'n'Play (PnP), which has no `node_modules/`.
147    /// URL extends fetch on every run (no caching). For reliable CI, prefer `npm:`
148    /// for private or critical configs.
149    #[serde(default, skip_serializing)]
150    pub extends: Vec<String>,
151
152    /// Additional entry point glob patterns.
153    #[serde(default)]
154    pub entry: Vec<String>,
155
156    /// Glob patterns to ignore from analysis.
157    #[serde(default)]
158    pub ignore_patterns: Vec<String>,
159
160    /// Custom framework definitions (inline plugin definitions).
161    #[serde(default)]
162    pub framework: Vec<ExternalPluginDef>,
163
164    /// Workspace overrides.
165    #[serde(default)]
166    pub workspaces: Option<WorkspaceConfig>,
167
168    /// Dependencies to ignore (always considered used and always considered available).
169    ///
170    /// Listed dependencies are excluded from both unused dependency and unlisted
171    /// dependency detection. Useful for runtime-provided packages like `bun:sqlite`
172    /// or implicitly available dependencies.
173    #[serde(default)]
174    pub ignore_dependencies: Vec<String>,
175
176    /// Export ignore rules.
177    #[serde(default)]
178    pub ignore_exports: Vec<IgnoreExportRule>,
179
180    /// Rules for suppressing `unresolved-catalog-reference` findings.
181    ///
182    /// Each rule matches by package name, optionally scoped to a specific
183    /// catalog and/or consumer `package.json` glob. Useful for staged catalog
184    /// migrations where the catalog edit lands separately from the consumer
185    /// edit, and for library-internal placeholder packages whose target
186    /// catalog isn't ready yet.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
189
190    /// Rules for suppressing `unused-dependency-override` and
191    /// `misconfigured-dependency-override` findings.
192    ///
193    /// Each rule matches by override target package, optionally scoped to the
194    /// declaring source file (`pnpm-workspace.yaml` or `package.json`). Useful
195    /// for overrides targeting purely-transitive packages (CVE-fix pattern)
196    /// where the conservative static algorithm would otherwise cry wolf.
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
199
200    /// Suppress unused-export findings when the exported symbol is referenced
201    /// inside the file that declares it. This mirrors Knip's
202    /// `ignoreExportsUsedInFile` option while still reporting exports that have
203    /// no references at all.
204    #[serde(default)]
205    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
206
207    /// Class member method/property rules that should never be flagged as
208    /// unused. Supports plain member names for global suppression and scoped
209    /// objects with `extends` / `implements` constraints for framework-invoked
210    /// methods that should only be suppressed on matching classes.
211    #[serde(default)]
212    pub used_class_members: Vec<UsedClassMemberRule>,
213
214    /// Duplication detection settings.
215    #[serde(default)]
216    pub duplicates: DuplicatesConfig,
217
218    /// Complexity health metrics settings.
219    #[serde(default)]
220    pub health: HealthConfig,
221
222    /// Per-issue-type severity rules.
223    #[serde(default)]
224    pub rules: RulesConfig,
225
226    /// Architecture boundary enforcement configuration.
227    #[serde(default)]
228    pub boundaries: BoundaryConfig,
229
230    /// Feature flag detection configuration.
231    #[serde(default)]
232    pub flags: FlagsConfig,
233
234    /// Module resolver configuration (custom conditions, etc.).
235    #[serde(default)]
236    pub resolve: ResolveConfig,
237
238    /// Production mode: exclude test/dev files, only start/build scripts.
239    ///
240    /// Accepts the legacy boolean form (`true` applies to all analyses) or a
241    /// per-analysis object (`{ "deadCode": false, "health": true, "dupes": false }`).
242    #[serde(default)]
243    pub production: ProductionConfig,
244
245    /// Paths to external plugin files or directories containing plugin files.
246    ///
247    /// Supports TOML, JSON, and JSONC formats.
248    ///
249    /// In addition to these explicit paths, fallow automatically discovers:
250    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
251    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
252    #[serde(default)]
253    pub plugins: Vec<String>,
254
255    /// Glob patterns for files that are dynamically loaded at runtime
256    /// (plugin directories, locale files, etc.). These files are treated as
257    /// always-used and will never be flagged as unused.
258    #[serde(default)]
259    pub dynamically_loaded: Vec<String>,
260
261    /// Per-file rule overrides matching oxlint's overrides pattern.
262    #[serde(default)]
263    pub overrides: Vec<ConfigOverride>,
264
265    /// Path to a CODEOWNERS file for `--group-by owner`.
266    ///
267    /// When unset, fallow auto-probes `CODEOWNERS`, `.github/CODEOWNERS`,
268    /// `.gitlab/CODEOWNERS`, and `docs/CODEOWNERS`. Set this to use a
269    /// non-standard location.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub codeowners: Option<String>,
272
273    /// Workspace package name patterns that are public libraries.
274    /// Exported API surface from these packages is not flagged as unused.
275    #[serde(default)]
276    pub public_packages: Vec<String>,
277
278    /// Regression detection baseline embedded in config.
279    /// Stores issue counts from a known-good state for CI regression checks.
280    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub regression: Option<RegressionConfig>,
283
284    /// Audit command baseline paths (one per analysis: dead-code, health, dupes).
285    ///
286    /// `fallow audit` runs three analyses and each has its own baseline format.
287    /// Paths in this section are resolved relative to the project root. CLI flags
288    /// (`--dead-code-baseline`, `--health-baseline`, `--dupes-baseline`) override
289    /// these values when provided.
290    #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
291    pub audit: AuditConfig,
292
293    /// Mark this config as sealed: `extends` paths must be file-relative and
294    /// resolve within this config's own directory. `npm:` and `https:` extends
295    /// are rejected. Useful for library publishers and monorepo sub-packages
296    /// that want to guarantee their config is self-contained and not subject
297    /// to ancestor configs being injected via `extends`.
298    ///
299    /// Discovery is unaffected (first-match-wins already stops the directory
300    /// walk at the nearest config). This only constrains `extends`.
301    #[serde(default)]
302    pub sealed: bool,
303
304    /// Report unused exports in entry files instead of auto-marking them as
305    /// used. Catches typos in framework exports (e.g. `meatdata` instead of
306    /// `metadata`). The CLI flag `--include-entry-exports` (global) overrides
307    /// this when set; otherwise the config value is used.
308    #[serde(default)]
309    pub include_entry_exports: bool,
310}
311
312/// Analysis-specific production-mode selector.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ProductionAnalysis {
315    DeadCode,
316    Health,
317    Dupes,
318}
319
320/// Production-mode defaults.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
322#[serde(untagged)]
323pub enum ProductionConfig {
324    /// Legacy/global form: `production = true` or `"production": true`.
325    Global(bool),
326    /// Per-analysis form.
327    PerAnalysis(PerAnalysisProductionConfig),
328}
329
330impl<'de> Deserialize<'de> for ProductionConfig {
331    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
332    where
333        D: Deserializer<'de>,
334    {
335        struct ProductionConfigVisitor;
336
337        impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
338            type Value = ProductionConfig;
339
340            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341                formatter.write_str("a boolean or per-analysis production config object")
342            }
343
344            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
345            where
346                E: serde::de::Error,
347            {
348                Ok(ProductionConfig::Global(value))
349            }
350
351            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
352            where
353                A: serde::de::MapAccess<'de>,
354            {
355                PerAnalysisProductionConfig::deserialize(
356                    serde::de::value::MapAccessDeserializer::new(map),
357                )
358                .map(ProductionConfig::PerAnalysis)
359            }
360        }
361
362        deserializer.deserialize_any(ProductionConfigVisitor)
363    }
364}
365
366impl Default for ProductionConfig {
367    fn default() -> Self {
368        Self::Global(false)
369    }
370}
371
372impl From<bool> for ProductionConfig {
373    fn from(value: bool) -> Self {
374        Self::Global(value)
375    }
376}
377
378impl Not for ProductionConfig {
379    type Output = bool;
380
381    fn not(self) -> Self::Output {
382        !self.any_enabled()
383    }
384}
385
386impl ProductionConfig {
387    #[must_use]
388    pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
389        match self {
390            Self::Global(value) => value,
391            Self::PerAnalysis(config) => match analysis {
392                ProductionAnalysis::DeadCode => config.dead_code,
393                ProductionAnalysis::Health => config.health,
394                ProductionAnalysis::Dupes => config.dupes,
395            },
396        }
397    }
398
399    #[must_use]
400    pub const fn global(self) -> bool {
401        match self {
402            Self::Global(value) => value,
403            Self::PerAnalysis(_) => false,
404        }
405    }
406
407    #[must_use]
408    pub const fn any_enabled(self) -> bool {
409        match self {
410            Self::Global(value) => value,
411            Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
412        }
413    }
414}
415
416/// Per-analysis production-mode defaults.
417#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
418#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
419pub struct PerAnalysisProductionConfig {
420    /// Production mode for dead-code analysis.
421    pub dead_code: bool,
422    /// Production mode for health analysis.
423    pub health: bool,
424    /// Production mode for duplication analysis.
425    pub dupes: bool,
426}
427
428/// Per-analysis baseline paths for the `audit` command.
429///
430/// Each field points to a baseline file produced by the corresponding
431/// subcommand (`fallow dead-code --save-baseline`, `fallow health --save-baseline`,
432/// `fallow dupes --save-baseline`). `audit` passes each baseline through to its
433/// underlying analysis; baseline-matched issues are excluded from the verdict.
434#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
435#[serde(rename_all = "camelCase")]
436pub struct AuditConfig {
437    /// Which findings should make `fallow audit` fail.
438    #[serde(default, skip_serializing_if = "AuditGate::is_default")]
439    pub gate: AuditGate,
440
441    /// Path to the dead-code baseline (produced by `fallow dead-code --save-baseline`).
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub dead_code_baseline: Option<String>,
444
445    /// Path to the health baseline (produced by `fallow health --save-baseline`).
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub health_baseline: Option<String>,
448
449    /// Path to the duplication baseline (produced by `fallow dupes --save-baseline`).
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub dupes_baseline: Option<String>,
452}
453
454impl AuditConfig {
455    /// True when all baseline paths are unset.
456    #[must_use]
457    pub fn is_empty(&self) -> bool {
458        self.gate.is_default()
459            && self.dead_code_baseline.is_none()
460            && self.health_baseline.is_none()
461            && self.dupes_baseline.is_none()
462    }
463}
464
465/// Gating mode for `fallow audit`.
466#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
467#[serde(rename_all = "kebab-case")]
468pub enum AuditGate {
469    /// Only findings introduced by the current changeset affect the verdict.
470    #[default]
471    NewOnly,
472    /// All findings in changed files affect the verdict.
473    All,
474}
475
476impl AuditGate {
477    #[must_use]
478    pub const fn is_default(&self) -> bool {
479        matches!(self, Self::NewOnly)
480    }
481}
482
483/// Regression baseline counts, embedded in the config file.
484///
485/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
486/// fallow reads the baseline from this config section.
487/// When `--save-regression-baseline` is used without a path argument,
488/// fallow writes the baseline into the config file.
489#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
490#[serde(rename_all = "camelCase")]
491pub struct RegressionConfig {
492    /// Dead code issue counts baseline.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub baseline: Option<RegressionBaseline>,
495}
496
497/// Per-type issue counts for regression comparison.
498#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
499#[serde(rename_all = "camelCase")]
500pub struct RegressionBaseline {
501    #[serde(default)]
502    pub total_issues: usize,
503    #[serde(default)]
504    pub unused_files: usize,
505    #[serde(default)]
506    pub unused_exports: usize,
507    #[serde(default)]
508    pub unused_types: usize,
509    #[serde(default)]
510    pub unused_dependencies: usize,
511    #[serde(default)]
512    pub unused_dev_dependencies: usize,
513    #[serde(default)]
514    pub unused_optional_dependencies: usize,
515    #[serde(default)]
516    pub unused_enum_members: usize,
517    #[serde(default)]
518    pub unused_class_members: usize,
519    #[serde(default)]
520    pub unresolved_imports: usize,
521    #[serde(default)]
522    pub unlisted_dependencies: usize,
523    #[serde(default)]
524    pub duplicate_exports: usize,
525    #[serde(default)]
526    pub circular_dependencies: usize,
527    #[serde(default)]
528    pub type_only_dependencies: usize,
529    #[serde(default)]
530    pub test_only_dependencies: usize,
531    #[serde(default)]
532    pub boundary_violations: usize,
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    // ── Default trait ───────────────────────────────────────────────
540
541    #[test]
542    fn default_config_has_empty_collections() {
543        let config = FallowConfig::default();
544        assert!(config.schema.is_none());
545        assert!(config.extends.is_empty());
546        assert!(config.entry.is_empty());
547        assert!(config.ignore_patterns.is_empty());
548        assert!(config.framework.is_empty());
549        assert!(config.workspaces.is_none());
550        assert!(config.ignore_dependencies.is_empty());
551        assert!(config.ignore_exports.is_empty());
552        assert!(config.used_class_members.is_empty());
553        assert!(config.plugins.is_empty());
554        assert!(config.dynamically_loaded.is_empty());
555        assert!(config.overrides.is_empty());
556        assert!(config.public_packages.is_empty());
557        assert!(!config.production);
558    }
559
560    #[test]
561    fn default_config_rules_are_error() {
562        let config = FallowConfig::default();
563        assert_eq!(config.rules.unused_files, Severity::Error);
564        assert_eq!(config.rules.unused_exports, Severity::Error);
565        assert_eq!(config.rules.unused_dependencies, Severity::Error);
566    }
567
568    #[test]
569    fn default_config_duplicates_enabled() {
570        let config = FallowConfig::default();
571        assert!(config.duplicates.enabled);
572        assert_eq!(config.duplicates.min_tokens, 50);
573        assert_eq!(config.duplicates.min_lines, 5);
574    }
575
576    #[test]
577    fn default_config_health_thresholds() {
578        let config = FallowConfig::default();
579        assert_eq!(config.health.max_cyclomatic, 20);
580        assert_eq!(config.health.max_cognitive, 15);
581    }
582
583    // ── JSON deserialization ────────────────────────────────────────
584
585    #[test]
586    fn deserialize_empty_json_object() {
587        let config: FallowConfig = serde_json::from_str("{}").unwrap();
588        assert!(config.entry.is_empty());
589        assert!(!config.production);
590    }
591
592    #[test]
593    fn deserialize_json_with_all_top_level_fields() {
594        let json = r#"{
595            "$schema": "https://fallow.dev/schema.json",
596            "entry": ["src/main.ts"],
597            "ignorePatterns": ["generated/**"],
598            "ignoreDependencies": ["postcss"],
599            "production": true,
600            "plugins": ["custom-plugin.toml"],
601            "rules": {"unused-files": "warn"},
602            "duplicates": {"enabled": false},
603            "health": {"maxCyclomatic": 30}
604        }"#;
605        let config: FallowConfig = serde_json::from_str(json).unwrap();
606        assert_eq!(
607            config.schema.as_deref(),
608            Some("https://fallow.dev/schema.json")
609        );
610        assert_eq!(config.entry, vec!["src/main.ts"]);
611        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
612        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
613        assert!(config.production);
614        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
615        assert_eq!(config.rules.unused_files, Severity::Warn);
616        assert!(!config.duplicates.enabled);
617        assert_eq!(config.health.max_cyclomatic, 30);
618    }
619
620    #[test]
621    fn deserialize_json_deny_unknown_fields() {
622        let json = r#"{"unknownField": true}"#;
623        let result: Result<FallowConfig, _> = serde_json::from_str(json);
624        assert!(result.is_err(), "unknown fields should be rejected");
625    }
626
627    #[test]
628    fn deserialize_json_production_mode_default_false() {
629        let config: FallowConfig = serde_json::from_str("{}").unwrap();
630        assert!(!config.production);
631    }
632
633    #[test]
634    fn deserialize_json_production_mode_true() {
635        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
636        assert!(config.production);
637    }
638
639    #[test]
640    fn deserialize_json_per_analysis_production_mode() {
641        let config: FallowConfig = serde_json::from_str(
642            r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
643        )
644        .unwrap();
645        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
646        assert!(config.production.for_analysis(ProductionAnalysis::Health));
647        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
648    }
649
650    #[test]
651    fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
652        let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
653            .unwrap_err();
654        assert!(
655            err.to_string().contains("healthTypo"),
656            "error should name the unknown field: {err}"
657        );
658    }
659
660    #[test]
661    fn deserialize_json_dynamically_loaded() {
662        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
663        let config: FallowConfig = serde_json::from_str(json).unwrap();
664        assert_eq!(
665            config.dynamically_loaded,
666            vec!["plugins/**/*.ts", "locales/**/*.json"]
667        );
668    }
669
670    #[test]
671    fn deserialize_json_dynamically_loaded_defaults_empty() {
672        let config: FallowConfig = serde_json::from_str("{}").unwrap();
673        assert!(config.dynamically_loaded.is_empty());
674    }
675
676    #[test]
677    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
678        let json = r#"{
679            "usedClassMembers": [
680                "agInit",
681                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
682                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
683            ]
684        }"#;
685        let config: FallowConfig = serde_json::from_str(json).unwrap();
686        assert_eq!(
687            config.used_class_members,
688            vec![
689                UsedClassMemberRule::from("agInit"),
690                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
691                    extends: None,
692                    implements: Some("ICellRendererAngularComp".to_string()),
693                    members: vec!["refresh".to_string()],
694                }),
695                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
696                    extends: Some("BaseCommand".to_string()),
697                    implements: Some("CanActivate".to_string()),
698                    members: vec!["execute".to_string()],
699                }),
700            ]
701        );
702    }
703
704    // ── TOML deserialization ────────────────────────────────────────
705
706    #[test]
707    fn deserialize_toml_minimal() {
708        let toml_str = r#"
709entry = ["src/index.ts"]
710production = true
711"#;
712        let config: FallowConfig = toml::from_str(toml_str).unwrap();
713        assert_eq!(config.entry, vec!["src/index.ts"]);
714        assert!(config.production);
715    }
716
717    #[test]
718    fn deserialize_toml_per_analysis_production_mode() {
719        let toml_str = r"
720[production]
721deadCode = false
722health = true
723dupes = false
724";
725        let config: FallowConfig = toml::from_str(toml_str).unwrap();
726        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
727        assert!(config.production.for_analysis(ProductionAnalysis::Health));
728        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
729    }
730
731    #[test]
732    fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
733        let err = toml::from_str::<FallowConfig>(
734            r"
735[production]
736healthTypo = true
737",
738        )
739        .unwrap_err();
740        assert!(
741            err.to_string().contains("healthTypo"),
742            "error should name the unknown field: {err}"
743        );
744    }
745
746    #[test]
747    fn deserialize_toml_with_inline_framework() {
748        let toml_str = r#"
749[[framework]]
750name = "my-framework"
751enablers = ["my-framework-pkg"]
752entryPoints = ["src/routes/**/*.tsx"]
753"#;
754        let config: FallowConfig = toml::from_str(toml_str).unwrap();
755        assert_eq!(config.framework.len(), 1);
756        assert_eq!(config.framework[0].name, "my-framework");
757        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
758        assert_eq!(
759            config.framework[0].entry_points,
760            vec!["src/routes/**/*.tsx"]
761        );
762    }
763
764    #[test]
765    fn deserialize_toml_with_workspace_config() {
766        let toml_str = r#"
767[workspaces]
768patterns = ["packages/*", "apps/*"]
769"#;
770        let config: FallowConfig = toml::from_str(toml_str).unwrap();
771        assert!(config.workspaces.is_some());
772        let ws = config.workspaces.unwrap();
773        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
774    }
775
776    #[test]
777    fn deserialize_toml_with_ignore_exports() {
778        let toml_str = r#"
779[[ignoreExports]]
780file = "src/types/**/*.ts"
781exports = ["*"]
782"#;
783        let config: FallowConfig = toml::from_str(toml_str).unwrap();
784        assert_eq!(config.ignore_exports.len(), 1);
785        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
786        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
787    }
788
789    #[test]
790    fn deserialize_toml_used_class_members_supports_scoped_rules() {
791        let toml_str = r#"
792usedClassMembers = [
793  { implements = "ICellRendererAngularComp", members = ["refresh"] },
794  { extends = "BaseCommand", members = ["execute"] },
795]
796"#;
797        let config: FallowConfig = toml::from_str(toml_str).unwrap();
798        assert_eq!(
799            config.used_class_members,
800            vec![
801                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
802                    extends: None,
803                    implements: Some("ICellRendererAngularComp".to_string()),
804                    members: vec!["refresh".to_string()],
805                }),
806                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
807                    extends: Some("BaseCommand".to_string()),
808                    implements: None,
809                    members: vec!["execute".to_string()],
810                }),
811            ]
812        );
813    }
814
815    #[test]
816    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
817        let result = serde_json::from_str::<FallowConfig>(
818            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
819        );
820        assert!(
821            result.is_err(),
822            "unconstrained scoped rule should be rejected"
823        );
824    }
825
826    #[test]
827    fn deserialize_ignore_exports_used_in_file_bool() {
828        let config: FallowConfig =
829            serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
830
831        assert!(config.ignore_exports_used_in_file.suppresses(false));
832        assert!(config.ignore_exports_used_in_file.suppresses(true));
833    }
834
835    #[test]
836    fn deserialize_ignore_exports_used_in_file_kind_form() {
837        let config: FallowConfig =
838            serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
839
840        assert!(!config.ignore_exports_used_in_file.suppresses(false));
841        assert!(config.ignore_exports_used_in_file.suppresses(true));
842    }
843
844    #[test]
845    fn deserialize_toml_deny_unknown_fields() {
846        let toml_str = r"bogus_field = true";
847        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
848        assert!(result.is_err(), "unknown fields should be rejected");
849    }
850
851    // ── Serialization roundtrip ─────────────────────────────────────
852
853    #[test]
854    fn json_serialize_roundtrip() {
855        let config = FallowConfig {
856            entry: vec!["src/main.ts".to_string()],
857            production: true.into(),
858            ..FallowConfig::default()
859        };
860        let json = serde_json::to_string(&config).unwrap();
861        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
862        assert_eq!(restored.entry, vec!["src/main.ts"]);
863        assert!(restored.production);
864    }
865
866    #[test]
867    fn schema_field_not_serialized() {
868        let config = FallowConfig {
869            schema: Some("https://example.com/schema.json".to_string()),
870            ..FallowConfig::default()
871        };
872        let json = serde_json::to_string(&config).unwrap();
873        // $schema has skip_serializing, should not appear in output
874        assert!(
875            !json.contains("$schema"),
876            "schema field should be skipped in serialization"
877        );
878    }
879
880    #[test]
881    fn extends_field_not_serialized() {
882        let config = FallowConfig {
883            extends: vec!["base.json".to_string()],
884            ..FallowConfig::default()
885        };
886        let json = serde_json::to_string(&config).unwrap();
887        assert!(
888            !json.contains("extends"),
889            "extends field should be skipped in serialization"
890        );
891    }
892
893    // ── RegressionConfig / RegressionBaseline ──────────────────────
894
895    #[test]
896    fn regression_config_deserialize_json() {
897        let json = r#"{
898            "regression": {
899                "baseline": {
900                    "totalIssues": 42,
901                    "unusedFiles": 10,
902                    "unusedExports": 5,
903                    "circularDependencies": 2
904                }
905            }
906        }"#;
907        let config: FallowConfig = serde_json::from_str(json).unwrap();
908        let regression = config.regression.unwrap();
909        let baseline = regression.baseline.unwrap();
910        assert_eq!(baseline.total_issues, 42);
911        assert_eq!(baseline.unused_files, 10);
912        assert_eq!(baseline.unused_exports, 5);
913        assert_eq!(baseline.circular_dependencies, 2);
914        // Unset fields default to 0
915        assert_eq!(baseline.unused_types, 0);
916        assert_eq!(baseline.boundary_violations, 0);
917    }
918
919    #[test]
920    fn regression_config_defaults_to_none() {
921        let config: FallowConfig = serde_json::from_str("{}").unwrap();
922        assert!(config.regression.is_none());
923    }
924
925    #[test]
926    fn regression_baseline_all_zeros_by_default() {
927        let baseline = RegressionBaseline::default();
928        assert_eq!(baseline.total_issues, 0);
929        assert_eq!(baseline.unused_files, 0);
930        assert_eq!(baseline.unused_exports, 0);
931        assert_eq!(baseline.unused_types, 0);
932        assert_eq!(baseline.unused_dependencies, 0);
933        assert_eq!(baseline.unused_dev_dependencies, 0);
934        assert_eq!(baseline.unused_optional_dependencies, 0);
935        assert_eq!(baseline.unused_enum_members, 0);
936        assert_eq!(baseline.unused_class_members, 0);
937        assert_eq!(baseline.unresolved_imports, 0);
938        assert_eq!(baseline.unlisted_dependencies, 0);
939        assert_eq!(baseline.duplicate_exports, 0);
940        assert_eq!(baseline.circular_dependencies, 0);
941        assert_eq!(baseline.type_only_dependencies, 0);
942        assert_eq!(baseline.test_only_dependencies, 0);
943        assert_eq!(baseline.boundary_violations, 0);
944    }
945
946    #[test]
947    fn regression_config_serialize_roundtrip() {
948        let baseline = RegressionBaseline {
949            total_issues: 100,
950            unused_files: 20,
951            unused_exports: 30,
952            ..RegressionBaseline::default()
953        };
954        let regression = RegressionConfig {
955            baseline: Some(baseline),
956        };
957        let config = FallowConfig {
958            regression: Some(regression),
959            ..FallowConfig::default()
960        };
961        let json = serde_json::to_string(&config).unwrap();
962        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
963        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
964        assert_eq!(restored_baseline.total_issues, 100);
965        assert_eq!(restored_baseline.unused_files, 20);
966        assert_eq!(restored_baseline.unused_exports, 30);
967        assert_eq!(restored_baseline.unused_types, 0);
968    }
969
970    #[test]
971    fn regression_config_empty_baseline_deserialize() {
972        let json = r#"{"regression": {}}"#;
973        let config: FallowConfig = serde_json::from_str(json).unwrap();
974        let regression = config.regression.unwrap();
975        assert!(regression.baseline.is_none());
976    }
977
978    #[test]
979    fn regression_baseline_not_serialized_when_none() {
980        let config = FallowConfig {
981            regression: None,
982            ..FallowConfig::default()
983        };
984        let json = serde_json::to_string(&config).unwrap();
985        assert!(
986            !json.contains("regression"),
987            "regression should be skipped when None"
988        );
989    }
990
991    // ── JSON config with overrides and boundaries ──────────────────
992
993    #[test]
994    fn deserialize_json_with_overrides() {
995        let json = r#"{
996            "overrides": [
997                {
998                    "files": ["*.test.ts", "*.spec.ts"],
999                    "rules": {
1000                        "unused-exports": "off",
1001                        "unused-files": "warn"
1002                    }
1003                }
1004            ]
1005        }"#;
1006        let config: FallowConfig = serde_json::from_str(json).unwrap();
1007        assert_eq!(config.overrides.len(), 1);
1008        assert_eq!(config.overrides[0].files.len(), 2);
1009        assert_eq!(
1010            config.overrides[0].rules.unused_exports,
1011            Some(Severity::Off)
1012        );
1013        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1014    }
1015
1016    #[test]
1017    fn deserialize_json_with_boundaries() {
1018        let json = r#"{
1019            "boundaries": {
1020                "preset": "layered"
1021            }
1022        }"#;
1023        let config: FallowConfig = serde_json::from_str(json).unwrap();
1024        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1025    }
1026
1027    // ── TOML with regression config ────────────────────────────────
1028
1029    #[test]
1030    fn deserialize_toml_with_regression_baseline() {
1031        let toml_str = r"
1032[regression.baseline]
1033totalIssues = 50
1034unusedFiles = 10
1035unusedExports = 15
1036";
1037        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1038        let baseline = config.regression.unwrap().baseline.unwrap();
1039        assert_eq!(baseline.total_issues, 50);
1040        assert_eq!(baseline.unused_files, 10);
1041        assert_eq!(baseline.unused_exports, 15);
1042    }
1043
1044    // ── TOML with multiple overrides ───────────────────────────────
1045
1046    #[test]
1047    fn deserialize_toml_with_overrides() {
1048        let toml_str = r#"
1049[[overrides]]
1050files = ["*.test.ts"]
1051
1052[overrides.rules]
1053unused-exports = "off"
1054
1055[[overrides]]
1056files = ["*.stories.tsx"]
1057
1058[overrides.rules]
1059unused-files = "off"
1060"#;
1061        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1062        assert_eq!(config.overrides.len(), 2);
1063        assert_eq!(
1064            config.overrides[0].rules.unused_exports,
1065            Some(Severity::Off)
1066        );
1067        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1068    }
1069
1070    // ── Default regression config ──────────────────────────────────
1071
1072    #[test]
1073    fn regression_config_default_is_none_baseline() {
1074        let config = RegressionConfig::default();
1075        assert!(config.baseline.is_none());
1076    }
1077
1078    // ── Config with multiple ignore export rules ───────────────────
1079
1080    #[test]
1081    fn deserialize_json_multiple_ignore_export_rules() {
1082        let json = r#"{
1083            "ignoreExports": [
1084                {"file": "src/types/**/*.ts", "exports": ["*"]},
1085                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1086                {"file": "src/index.ts", "exports": ["default"]}
1087            ]
1088        }"#;
1089        let config: FallowConfig = serde_json::from_str(json).unwrap();
1090        assert_eq!(config.ignore_exports.len(), 3);
1091        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1092    }
1093
1094    // ── Public packages ───────────────────────────────────────────
1095
1096    #[test]
1097    fn deserialize_json_public_packages_camel_case() {
1098        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1099        let config: FallowConfig = serde_json::from_str(json).unwrap();
1100        assert_eq!(
1101            config.public_packages,
1102            vec!["@myorg/shared-lib", "@myorg/utils"]
1103        );
1104    }
1105
1106    #[test]
1107    fn deserialize_json_public_packages_rejects_snake_case() {
1108        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1109        let result: Result<FallowConfig, _> = serde_json::from_str(json);
1110        assert!(
1111            result.is_err(),
1112            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1113        );
1114    }
1115
1116    #[test]
1117    fn deserialize_json_public_packages_empty() {
1118        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1119        assert!(config.public_packages.is_empty());
1120    }
1121
1122    #[test]
1123    fn deserialize_toml_public_packages() {
1124        let toml_str = r#"
1125publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1126"#;
1127        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1128        assert_eq!(
1129            config.public_packages,
1130            vec!["@myorg/shared-lib", "@myorg/ui"]
1131        );
1132    }
1133
1134    #[test]
1135    fn public_packages_serialize_roundtrip() {
1136        let config = FallowConfig {
1137            public_packages: vec!["@myorg/shared-lib".to_string()],
1138            ..FallowConfig::default()
1139        };
1140        let json = serde_json::to_string(&config).unwrap();
1141        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1142        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1143    }
1144}