Skip to main content

fallow_config/config/
mod.rs

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