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    /// Report unused exports in entry files instead of auto-marking them as
281    /// used. Catches typos in framework exports (e.g. `meatdata` instead of
282    /// `metadata`). The CLI flag `--include-entry-exports` (global) overrides
283    /// this when set; otherwise the config value is used.
284    #[serde(default)]
285    pub include_entry_exports: bool,
286}
287
288/// Analysis-specific production-mode selector.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum ProductionAnalysis {
291    DeadCode,
292    Health,
293    Dupes,
294}
295
296/// Production-mode defaults.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
298#[serde(untagged)]
299pub enum ProductionConfig {
300    /// Legacy/global form: `production = true` or `"production": true`.
301    Global(bool),
302    /// Per-analysis form.
303    PerAnalysis(PerAnalysisProductionConfig),
304}
305
306impl<'de> Deserialize<'de> for ProductionConfig {
307    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
308    where
309        D: Deserializer<'de>,
310    {
311        struct ProductionConfigVisitor;
312
313        impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
314            type Value = ProductionConfig;
315
316            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317                formatter.write_str("a boolean or per-analysis production config object")
318            }
319
320            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
321            where
322                E: serde::de::Error,
323            {
324                Ok(ProductionConfig::Global(value))
325            }
326
327            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
328            where
329                A: serde::de::MapAccess<'de>,
330            {
331                PerAnalysisProductionConfig::deserialize(
332                    serde::de::value::MapAccessDeserializer::new(map),
333                )
334                .map(ProductionConfig::PerAnalysis)
335            }
336        }
337
338        deserializer.deserialize_any(ProductionConfigVisitor)
339    }
340}
341
342impl Default for ProductionConfig {
343    fn default() -> Self {
344        Self::Global(false)
345    }
346}
347
348impl From<bool> for ProductionConfig {
349    fn from(value: bool) -> Self {
350        Self::Global(value)
351    }
352}
353
354impl Not for ProductionConfig {
355    type Output = bool;
356
357    fn not(self) -> Self::Output {
358        !self.any_enabled()
359    }
360}
361
362impl ProductionConfig {
363    #[must_use]
364    pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
365        match self {
366            Self::Global(value) => value,
367            Self::PerAnalysis(config) => match analysis {
368                ProductionAnalysis::DeadCode => config.dead_code,
369                ProductionAnalysis::Health => config.health,
370                ProductionAnalysis::Dupes => config.dupes,
371            },
372        }
373    }
374
375    #[must_use]
376    pub const fn global(self) -> bool {
377        match self {
378            Self::Global(value) => value,
379            Self::PerAnalysis(_) => false,
380        }
381    }
382
383    #[must_use]
384    pub const fn any_enabled(self) -> bool {
385        match self {
386            Self::Global(value) => value,
387            Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
388        }
389    }
390}
391
392/// Per-analysis production-mode defaults.
393#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
394#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
395pub struct PerAnalysisProductionConfig {
396    /// Production mode for dead-code analysis.
397    pub dead_code: bool,
398    /// Production mode for health analysis.
399    pub health: bool,
400    /// Production mode for duplication analysis.
401    pub dupes: bool,
402}
403
404/// Per-analysis baseline paths for the `audit` command.
405///
406/// Each field points to a baseline file produced by the corresponding
407/// subcommand (`fallow dead-code --save-baseline`, `fallow health --save-baseline`,
408/// `fallow dupes --save-baseline`). `audit` passes each baseline through to its
409/// underlying analysis; baseline-matched issues are excluded from the verdict.
410#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
411#[serde(rename_all = "camelCase")]
412pub struct AuditConfig {
413    /// Which findings should make `fallow audit` fail.
414    #[serde(default, skip_serializing_if = "AuditGate::is_default")]
415    pub gate: AuditGate,
416
417    /// Path to the dead-code baseline (produced by `fallow dead-code --save-baseline`).
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub dead_code_baseline: Option<String>,
420
421    /// Path to the health baseline (produced by `fallow health --save-baseline`).
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub health_baseline: Option<String>,
424
425    /// Path to the duplication baseline (produced by `fallow dupes --save-baseline`).
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub dupes_baseline: Option<String>,
428}
429
430impl AuditConfig {
431    /// True when all baseline paths are unset.
432    #[must_use]
433    pub fn is_empty(&self) -> bool {
434        self.gate.is_default()
435            && self.dead_code_baseline.is_none()
436            && self.health_baseline.is_none()
437            && self.dupes_baseline.is_none()
438    }
439}
440
441/// Gating mode for `fallow audit`.
442#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
443#[serde(rename_all = "kebab-case")]
444pub enum AuditGate {
445    /// Only findings introduced by the current changeset affect the verdict.
446    #[default]
447    NewOnly,
448    /// All findings in changed files affect the verdict.
449    All,
450}
451
452impl AuditGate {
453    #[must_use]
454    pub const fn is_default(&self) -> bool {
455        matches!(self, Self::NewOnly)
456    }
457}
458
459/// Regression baseline counts, embedded in the config file.
460///
461/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
462/// fallow reads the baseline from this config section.
463/// When `--save-regression-baseline` is used without a path argument,
464/// fallow writes the baseline into the config file.
465#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
466#[serde(rename_all = "camelCase")]
467pub struct RegressionConfig {
468    /// Dead code issue counts baseline.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub baseline: Option<RegressionBaseline>,
471}
472
473/// Per-type issue counts for regression comparison.
474#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
475#[serde(rename_all = "camelCase")]
476pub struct RegressionBaseline {
477    #[serde(default)]
478    pub total_issues: usize,
479    #[serde(default)]
480    pub unused_files: usize,
481    #[serde(default)]
482    pub unused_exports: usize,
483    #[serde(default)]
484    pub unused_types: usize,
485    #[serde(default)]
486    pub unused_dependencies: usize,
487    #[serde(default)]
488    pub unused_dev_dependencies: usize,
489    #[serde(default)]
490    pub unused_optional_dependencies: usize,
491    #[serde(default)]
492    pub unused_enum_members: usize,
493    #[serde(default)]
494    pub unused_class_members: usize,
495    #[serde(default)]
496    pub unresolved_imports: usize,
497    #[serde(default)]
498    pub unlisted_dependencies: usize,
499    #[serde(default)]
500    pub duplicate_exports: usize,
501    #[serde(default)]
502    pub circular_dependencies: usize,
503    #[serde(default)]
504    pub type_only_dependencies: usize,
505    #[serde(default)]
506    pub test_only_dependencies: usize,
507    #[serde(default)]
508    pub boundary_violations: usize,
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    // ── Default trait ───────────────────────────────────────────────
516
517    #[test]
518    fn default_config_has_empty_collections() {
519        let config = FallowConfig::default();
520        assert!(config.schema.is_none());
521        assert!(config.extends.is_empty());
522        assert!(config.entry.is_empty());
523        assert!(config.ignore_patterns.is_empty());
524        assert!(config.framework.is_empty());
525        assert!(config.workspaces.is_none());
526        assert!(config.ignore_dependencies.is_empty());
527        assert!(config.ignore_exports.is_empty());
528        assert!(config.used_class_members.is_empty());
529        assert!(config.plugins.is_empty());
530        assert!(config.dynamically_loaded.is_empty());
531        assert!(config.overrides.is_empty());
532        assert!(config.public_packages.is_empty());
533        assert!(!config.production);
534    }
535
536    #[test]
537    fn default_config_rules_are_error() {
538        let config = FallowConfig::default();
539        assert_eq!(config.rules.unused_files, Severity::Error);
540        assert_eq!(config.rules.unused_exports, Severity::Error);
541        assert_eq!(config.rules.unused_dependencies, Severity::Error);
542    }
543
544    #[test]
545    fn default_config_duplicates_enabled() {
546        let config = FallowConfig::default();
547        assert!(config.duplicates.enabled);
548        assert_eq!(config.duplicates.min_tokens, 50);
549        assert_eq!(config.duplicates.min_lines, 5);
550    }
551
552    #[test]
553    fn default_config_health_thresholds() {
554        let config = FallowConfig::default();
555        assert_eq!(config.health.max_cyclomatic, 20);
556        assert_eq!(config.health.max_cognitive, 15);
557    }
558
559    // ── JSON deserialization ────────────────────────────────────────
560
561    #[test]
562    fn deserialize_empty_json_object() {
563        let config: FallowConfig = serde_json::from_str("{}").unwrap();
564        assert!(config.entry.is_empty());
565        assert!(!config.production);
566    }
567
568    #[test]
569    fn deserialize_json_with_all_top_level_fields() {
570        let json = r#"{
571            "$schema": "https://fallow.dev/schema.json",
572            "entry": ["src/main.ts"],
573            "ignorePatterns": ["generated/**"],
574            "ignoreDependencies": ["postcss"],
575            "production": true,
576            "plugins": ["custom-plugin.toml"],
577            "rules": {"unused-files": "warn"},
578            "duplicates": {"enabled": false},
579            "health": {"maxCyclomatic": 30}
580        }"#;
581        let config: FallowConfig = serde_json::from_str(json).unwrap();
582        assert_eq!(
583            config.schema.as_deref(),
584            Some("https://fallow.dev/schema.json")
585        );
586        assert_eq!(config.entry, vec!["src/main.ts"]);
587        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
588        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
589        assert!(config.production);
590        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
591        assert_eq!(config.rules.unused_files, Severity::Warn);
592        assert!(!config.duplicates.enabled);
593        assert_eq!(config.health.max_cyclomatic, 30);
594    }
595
596    #[test]
597    fn deserialize_json_deny_unknown_fields() {
598        let json = r#"{"unknownField": true}"#;
599        let result: Result<FallowConfig, _> = serde_json::from_str(json);
600        assert!(result.is_err(), "unknown fields should be rejected");
601    }
602
603    #[test]
604    fn deserialize_json_production_mode_default_false() {
605        let config: FallowConfig = serde_json::from_str("{}").unwrap();
606        assert!(!config.production);
607    }
608
609    #[test]
610    fn deserialize_json_production_mode_true() {
611        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
612        assert!(config.production);
613    }
614
615    #[test]
616    fn deserialize_json_per_analysis_production_mode() {
617        let config: FallowConfig = serde_json::from_str(
618            r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
619        )
620        .unwrap();
621        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
622        assert!(config.production.for_analysis(ProductionAnalysis::Health));
623        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
624    }
625
626    #[test]
627    fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
628        let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
629            .unwrap_err();
630        assert!(
631            err.to_string().contains("healthTypo"),
632            "error should name the unknown field: {err}"
633        );
634    }
635
636    #[test]
637    fn deserialize_json_dynamically_loaded() {
638        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
639        let config: FallowConfig = serde_json::from_str(json).unwrap();
640        assert_eq!(
641            config.dynamically_loaded,
642            vec!["plugins/**/*.ts", "locales/**/*.json"]
643        );
644    }
645
646    #[test]
647    fn deserialize_json_dynamically_loaded_defaults_empty() {
648        let config: FallowConfig = serde_json::from_str("{}").unwrap();
649        assert!(config.dynamically_loaded.is_empty());
650    }
651
652    #[test]
653    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
654        let json = r#"{
655            "usedClassMembers": [
656                "agInit",
657                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
658                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
659            ]
660        }"#;
661        let config: FallowConfig = serde_json::from_str(json).unwrap();
662        assert_eq!(
663            config.used_class_members,
664            vec![
665                UsedClassMemberRule::from("agInit"),
666                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
667                    extends: None,
668                    implements: Some("ICellRendererAngularComp".to_string()),
669                    members: vec!["refresh".to_string()],
670                }),
671                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
672                    extends: Some("BaseCommand".to_string()),
673                    implements: Some("CanActivate".to_string()),
674                    members: vec!["execute".to_string()],
675                }),
676            ]
677        );
678    }
679
680    // ── TOML deserialization ────────────────────────────────────────
681
682    #[test]
683    fn deserialize_toml_minimal() {
684        let toml_str = r#"
685entry = ["src/index.ts"]
686production = true
687"#;
688        let config: FallowConfig = toml::from_str(toml_str).unwrap();
689        assert_eq!(config.entry, vec!["src/index.ts"]);
690        assert!(config.production);
691    }
692
693    #[test]
694    fn deserialize_toml_per_analysis_production_mode() {
695        let toml_str = r"
696[production]
697deadCode = false
698health = true
699dupes = false
700";
701        let config: FallowConfig = toml::from_str(toml_str).unwrap();
702        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
703        assert!(config.production.for_analysis(ProductionAnalysis::Health));
704        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
705    }
706
707    #[test]
708    fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
709        let err = toml::from_str::<FallowConfig>(
710            r"
711[production]
712healthTypo = true
713",
714        )
715        .unwrap_err();
716        assert!(
717            err.to_string().contains("healthTypo"),
718            "error should name the unknown field: {err}"
719        );
720    }
721
722    #[test]
723    fn deserialize_toml_with_inline_framework() {
724        let toml_str = r#"
725[[framework]]
726name = "my-framework"
727enablers = ["my-framework-pkg"]
728entryPoints = ["src/routes/**/*.tsx"]
729"#;
730        let config: FallowConfig = toml::from_str(toml_str).unwrap();
731        assert_eq!(config.framework.len(), 1);
732        assert_eq!(config.framework[0].name, "my-framework");
733        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
734        assert_eq!(
735            config.framework[0].entry_points,
736            vec!["src/routes/**/*.tsx"]
737        );
738    }
739
740    #[test]
741    fn deserialize_toml_with_workspace_config() {
742        let toml_str = r#"
743[workspaces]
744patterns = ["packages/*", "apps/*"]
745"#;
746        let config: FallowConfig = toml::from_str(toml_str).unwrap();
747        assert!(config.workspaces.is_some());
748        let ws = config.workspaces.unwrap();
749        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
750    }
751
752    #[test]
753    fn deserialize_toml_with_ignore_exports() {
754        let toml_str = r#"
755[[ignoreExports]]
756file = "src/types/**/*.ts"
757exports = ["*"]
758"#;
759        let config: FallowConfig = toml::from_str(toml_str).unwrap();
760        assert_eq!(config.ignore_exports.len(), 1);
761        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
762        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
763    }
764
765    #[test]
766    fn deserialize_toml_used_class_members_supports_scoped_rules() {
767        let toml_str = r#"
768usedClassMembers = [
769  { implements = "ICellRendererAngularComp", members = ["refresh"] },
770  { extends = "BaseCommand", members = ["execute"] },
771]
772"#;
773        let config: FallowConfig = toml::from_str(toml_str).unwrap();
774        assert_eq!(
775            config.used_class_members,
776            vec![
777                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
778                    extends: None,
779                    implements: Some("ICellRendererAngularComp".to_string()),
780                    members: vec!["refresh".to_string()],
781                }),
782                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
783                    extends: Some("BaseCommand".to_string()),
784                    implements: None,
785                    members: vec!["execute".to_string()],
786                }),
787            ]
788        );
789    }
790
791    #[test]
792    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
793        let result = serde_json::from_str::<FallowConfig>(
794            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
795        );
796        assert!(
797            result.is_err(),
798            "unconstrained scoped rule should be rejected"
799        );
800    }
801
802    #[test]
803    fn deserialize_ignore_exports_used_in_file_bool() {
804        let config: FallowConfig =
805            serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
806
807        assert!(config.ignore_exports_used_in_file.suppresses(false));
808        assert!(config.ignore_exports_used_in_file.suppresses(true));
809    }
810
811    #[test]
812    fn deserialize_ignore_exports_used_in_file_kind_form() {
813        let config: FallowConfig =
814            serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
815
816        assert!(!config.ignore_exports_used_in_file.suppresses(false));
817        assert!(config.ignore_exports_used_in_file.suppresses(true));
818    }
819
820    #[test]
821    fn deserialize_toml_deny_unknown_fields() {
822        let toml_str = r"bogus_field = true";
823        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
824        assert!(result.is_err(), "unknown fields should be rejected");
825    }
826
827    // ── Serialization roundtrip ─────────────────────────────────────
828
829    #[test]
830    fn json_serialize_roundtrip() {
831        let config = FallowConfig {
832            entry: vec!["src/main.ts".to_string()],
833            production: true.into(),
834            ..FallowConfig::default()
835        };
836        let json = serde_json::to_string(&config).unwrap();
837        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
838        assert_eq!(restored.entry, vec!["src/main.ts"]);
839        assert!(restored.production);
840    }
841
842    #[test]
843    fn schema_field_not_serialized() {
844        let config = FallowConfig {
845            schema: Some("https://example.com/schema.json".to_string()),
846            ..FallowConfig::default()
847        };
848        let json = serde_json::to_string(&config).unwrap();
849        // $schema has skip_serializing, should not appear in output
850        assert!(
851            !json.contains("$schema"),
852            "schema field should be skipped in serialization"
853        );
854    }
855
856    #[test]
857    fn extends_field_not_serialized() {
858        let config = FallowConfig {
859            extends: vec!["base.json".to_string()],
860            ..FallowConfig::default()
861        };
862        let json = serde_json::to_string(&config).unwrap();
863        assert!(
864            !json.contains("extends"),
865            "extends field should be skipped in serialization"
866        );
867    }
868
869    // ── RegressionConfig / RegressionBaseline ──────────────────────
870
871    #[test]
872    fn regression_config_deserialize_json() {
873        let json = r#"{
874            "regression": {
875                "baseline": {
876                    "totalIssues": 42,
877                    "unusedFiles": 10,
878                    "unusedExports": 5,
879                    "circularDependencies": 2
880                }
881            }
882        }"#;
883        let config: FallowConfig = serde_json::from_str(json).unwrap();
884        let regression = config.regression.unwrap();
885        let baseline = regression.baseline.unwrap();
886        assert_eq!(baseline.total_issues, 42);
887        assert_eq!(baseline.unused_files, 10);
888        assert_eq!(baseline.unused_exports, 5);
889        assert_eq!(baseline.circular_dependencies, 2);
890        // Unset fields default to 0
891        assert_eq!(baseline.unused_types, 0);
892        assert_eq!(baseline.boundary_violations, 0);
893    }
894
895    #[test]
896    fn regression_config_defaults_to_none() {
897        let config: FallowConfig = serde_json::from_str("{}").unwrap();
898        assert!(config.regression.is_none());
899    }
900
901    #[test]
902    fn regression_baseline_all_zeros_by_default() {
903        let baseline = RegressionBaseline::default();
904        assert_eq!(baseline.total_issues, 0);
905        assert_eq!(baseline.unused_files, 0);
906        assert_eq!(baseline.unused_exports, 0);
907        assert_eq!(baseline.unused_types, 0);
908        assert_eq!(baseline.unused_dependencies, 0);
909        assert_eq!(baseline.unused_dev_dependencies, 0);
910        assert_eq!(baseline.unused_optional_dependencies, 0);
911        assert_eq!(baseline.unused_enum_members, 0);
912        assert_eq!(baseline.unused_class_members, 0);
913        assert_eq!(baseline.unresolved_imports, 0);
914        assert_eq!(baseline.unlisted_dependencies, 0);
915        assert_eq!(baseline.duplicate_exports, 0);
916        assert_eq!(baseline.circular_dependencies, 0);
917        assert_eq!(baseline.type_only_dependencies, 0);
918        assert_eq!(baseline.test_only_dependencies, 0);
919        assert_eq!(baseline.boundary_violations, 0);
920    }
921
922    #[test]
923    fn regression_config_serialize_roundtrip() {
924        let baseline = RegressionBaseline {
925            total_issues: 100,
926            unused_files: 20,
927            unused_exports: 30,
928            ..RegressionBaseline::default()
929        };
930        let regression = RegressionConfig {
931            baseline: Some(baseline),
932        };
933        let config = FallowConfig {
934            regression: Some(regression),
935            ..FallowConfig::default()
936        };
937        let json = serde_json::to_string(&config).unwrap();
938        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
939        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
940        assert_eq!(restored_baseline.total_issues, 100);
941        assert_eq!(restored_baseline.unused_files, 20);
942        assert_eq!(restored_baseline.unused_exports, 30);
943        assert_eq!(restored_baseline.unused_types, 0);
944    }
945
946    #[test]
947    fn regression_config_empty_baseline_deserialize() {
948        let json = r#"{"regression": {}}"#;
949        let config: FallowConfig = serde_json::from_str(json).unwrap();
950        let regression = config.regression.unwrap();
951        assert!(regression.baseline.is_none());
952    }
953
954    #[test]
955    fn regression_baseline_not_serialized_when_none() {
956        let config = FallowConfig {
957            regression: None,
958            ..FallowConfig::default()
959        };
960        let json = serde_json::to_string(&config).unwrap();
961        assert!(
962            !json.contains("regression"),
963            "regression should be skipped when None"
964        );
965    }
966
967    // ── JSON config with overrides and boundaries ──────────────────
968
969    #[test]
970    fn deserialize_json_with_overrides() {
971        let json = r#"{
972            "overrides": [
973                {
974                    "files": ["*.test.ts", "*.spec.ts"],
975                    "rules": {
976                        "unused-exports": "off",
977                        "unused-files": "warn"
978                    }
979                }
980            ]
981        }"#;
982        let config: FallowConfig = serde_json::from_str(json).unwrap();
983        assert_eq!(config.overrides.len(), 1);
984        assert_eq!(config.overrides[0].files.len(), 2);
985        assert_eq!(
986            config.overrides[0].rules.unused_exports,
987            Some(Severity::Off)
988        );
989        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
990    }
991
992    #[test]
993    fn deserialize_json_with_boundaries() {
994        let json = r#"{
995            "boundaries": {
996                "preset": "layered"
997            }
998        }"#;
999        let config: FallowConfig = serde_json::from_str(json).unwrap();
1000        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1001    }
1002
1003    // ── TOML with regression config ────────────────────────────────
1004
1005    #[test]
1006    fn deserialize_toml_with_regression_baseline() {
1007        let toml_str = r"
1008[regression.baseline]
1009totalIssues = 50
1010unusedFiles = 10
1011unusedExports = 15
1012";
1013        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1014        let baseline = config.regression.unwrap().baseline.unwrap();
1015        assert_eq!(baseline.total_issues, 50);
1016        assert_eq!(baseline.unused_files, 10);
1017        assert_eq!(baseline.unused_exports, 15);
1018    }
1019
1020    // ── TOML with multiple overrides ───────────────────────────────
1021
1022    #[test]
1023    fn deserialize_toml_with_overrides() {
1024        let toml_str = r#"
1025[[overrides]]
1026files = ["*.test.ts"]
1027
1028[overrides.rules]
1029unused-exports = "off"
1030
1031[[overrides]]
1032files = ["*.stories.tsx"]
1033
1034[overrides.rules]
1035unused-files = "off"
1036"#;
1037        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1038        assert_eq!(config.overrides.len(), 2);
1039        assert_eq!(
1040            config.overrides[0].rules.unused_exports,
1041            Some(Severity::Off)
1042        );
1043        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1044    }
1045
1046    // ── Default regression config ──────────────────────────────────
1047
1048    #[test]
1049    fn regression_config_default_is_none_baseline() {
1050        let config = RegressionConfig::default();
1051        assert!(config.baseline.is_none());
1052    }
1053
1054    // ── Config with multiple ignore export rules ───────────────────
1055
1056    #[test]
1057    fn deserialize_json_multiple_ignore_export_rules() {
1058        let json = r#"{
1059            "ignoreExports": [
1060                {"file": "src/types/**/*.ts", "exports": ["*"]},
1061                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1062                {"file": "src/index.ts", "exports": ["default"]}
1063            ]
1064        }"#;
1065        let config: FallowConfig = serde_json::from_str(json).unwrap();
1066        assert_eq!(config.ignore_exports.len(), 3);
1067        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1068    }
1069
1070    // ── Public packages ───────────────────────────────────────────
1071
1072    #[test]
1073    fn deserialize_json_public_packages_camel_case() {
1074        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1075        let config: FallowConfig = serde_json::from_str(json).unwrap();
1076        assert_eq!(
1077            config.public_packages,
1078            vec!["@myorg/shared-lib", "@myorg/utils"]
1079        );
1080    }
1081
1082    #[test]
1083    fn deserialize_json_public_packages_rejects_snake_case() {
1084        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1085        let result: Result<FallowConfig, _> = serde_json::from_str(json);
1086        assert!(
1087            result.is_err(),
1088            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1089        );
1090    }
1091
1092    #[test]
1093    fn deserialize_json_public_packages_empty() {
1094        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1095        assert!(config.public_packages.is_empty());
1096    }
1097
1098    #[test]
1099    fn deserialize_toml_public_packages() {
1100        let toml_str = r#"
1101publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1102"#;
1103        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1104        assert_eq!(
1105            config.public_packages,
1106            vec!["@myorg/shared-lib", "@myorg/ui"]
1107        );
1108    }
1109
1110    #[test]
1111    fn public_packages_serialize_roundtrip() {
1112        let config = FallowConfig {
1113            public_packages: vec!["@myorg/shared-lib".to_string()],
1114            ..FallowConfig::default()
1115        };
1116        let json = serde_json::to_string(&config).unwrap();
1117        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1118        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1119    }
1120}