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