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