Skip to main content

fallow_config/config/
mod.rs

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