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