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    /// Path to the dead-code baseline (produced by `fallow dead-code --save-baseline`).
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub dead_code_baseline: Option<String>,
409
410    /// Path to the health baseline (produced by `fallow health --save-baseline`).
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub health_baseline: Option<String>,
413
414    /// Path to the duplication baseline (produced by `fallow dupes --save-baseline`).
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub dupes_baseline: Option<String>,
417}
418
419impl AuditConfig {
420    /// True when all baseline paths are unset.
421    #[must_use]
422    pub fn is_empty(&self) -> bool {
423        self.dead_code_baseline.is_none()
424            && self.health_baseline.is_none()
425            && self.dupes_baseline.is_none()
426    }
427}
428
429/// Regression baseline counts, embedded in the config file.
430///
431/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
432/// fallow reads the baseline from this config section.
433/// When `--save-regression-baseline` is used without a path argument,
434/// fallow writes the baseline into the config file.
435#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
436#[serde(rename_all = "camelCase")]
437pub struct RegressionConfig {
438    /// Dead code issue counts baseline.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub baseline: Option<RegressionBaseline>,
441}
442
443/// Per-type issue counts for regression comparison.
444#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
445#[serde(rename_all = "camelCase")]
446pub struct RegressionBaseline {
447    #[serde(default)]
448    pub total_issues: usize,
449    #[serde(default)]
450    pub unused_files: usize,
451    #[serde(default)]
452    pub unused_exports: usize,
453    #[serde(default)]
454    pub unused_types: usize,
455    #[serde(default)]
456    pub unused_dependencies: usize,
457    #[serde(default)]
458    pub unused_dev_dependencies: usize,
459    #[serde(default)]
460    pub unused_optional_dependencies: usize,
461    #[serde(default)]
462    pub unused_enum_members: usize,
463    #[serde(default)]
464    pub unused_class_members: usize,
465    #[serde(default)]
466    pub unresolved_imports: usize,
467    #[serde(default)]
468    pub unlisted_dependencies: usize,
469    #[serde(default)]
470    pub duplicate_exports: usize,
471    #[serde(default)]
472    pub circular_dependencies: usize,
473    #[serde(default)]
474    pub type_only_dependencies: usize,
475    #[serde(default)]
476    pub test_only_dependencies: usize,
477    #[serde(default)]
478    pub boundary_violations: usize,
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    // ── Default trait ───────────────────────────────────────────────
486
487    #[test]
488    fn default_config_has_empty_collections() {
489        let config = FallowConfig::default();
490        assert!(config.schema.is_none());
491        assert!(config.extends.is_empty());
492        assert!(config.entry.is_empty());
493        assert!(config.ignore_patterns.is_empty());
494        assert!(config.framework.is_empty());
495        assert!(config.workspaces.is_none());
496        assert!(config.ignore_dependencies.is_empty());
497        assert!(config.ignore_exports.is_empty());
498        assert!(config.used_class_members.is_empty());
499        assert!(config.plugins.is_empty());
500        assert!(config.dynamically_loaded.is_empty());
501        assert!(config.overrides.is_empty());
502        assert!(config.public_packages.is_empty());
503        assert!(!config.production);
504    }
505
506    #[test]
507    fn default_config_rules_are_error() {
508        let config = FallowConfig::default();
509        assert_eq!(config.rules.unused_files, Severity::Error);
510        assert_eq!(config.rules.unused_exports, Severity::Error);
511        assert_eq!(config.rules.unused_dependencies, Severity::Error);
512    }
513
514    #[test]
515    fn default_config_duplicates_enabled() {
516        let config = FallowConfig::default();
517        assert!(config.duplicates.enabled);
518        assert_eq!(config.duplicates.min_tokens, 50);
519        assert_eq!(config.duplicates.min_lines, 5);
520    }
521
522    #[test]
523    fn default_config_health_thresholds() {
524        let config = FallowConfig::default();
525        assert_eq!(config.health.max_cyclomatic, 20);
526        assert_eq!(config.health.max_cognitive, 15);
527    }
528
529    // ── JSON deserialization ────────────────────────────────────────
530
531    #[test]
532    fn deserialize_empty_json_object() {
533        let config: FallowConfig = serde_json::from_str("{}").unwrap();
534        assert!(config.entry.is_empty());
535        assert!(!config.production);
536    }
537
538    #[test]
539    fn deserialize_json_with_all_top_level_fields() {
540        let json = r#"{
541            "$schema": "https://fallow.dev/schema.json",
542            "entry": ["src/main.ts"],
543            "ignorePatterns": ["generated/**"],
544            "ignoreDependencies": ["postcss"],
545            "production": true,
546            "plugins": ["custom-plugin.toml"],
547            "rules": {"unused-files": "warn"},
548            "duplicates": {"enabled": false},
549            "health": {"maxCyclomatic": 30}
550        }"#;
551        let config: FallowConfig = serde_json::from_str(json).unwrap();
552        assert_eq!(
553            config.schema.as_deref(),
554            Some("https://fallow.dev/schema.json")
555        );
556        assert_eq!(config.entry, vec!["src/main.ts"]);
557        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
558        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
559        assert!(config.production);
560        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
561        assert_eq!(config.rules.unused_files, Severity::Warn);
562        assert!(!config.duplicates.enabled);
563        assert_eq!(config.health.max_cyclomatic, 30);
564    }
565
566    #[test]
567    fn deserialize_json_deny_unknown_fields() {
568        let json = r#"{"unknownField": true}"#;
569        let result: Result<FallowConfig, _> = serde_json::from_str(json);
570        assert!(result.is_err(), "unknown fields should be rejected");
571    }
572
573    #[test]
574    fn deserialize_json_production_mode_default_false() {
575        let config: FallowConfig = serde_json::from_str("{}").unwrap();
576        assert!(!config.production);
577    }
578
579    #[test]
580    fn deserialize_json_production_mode_true() {
581        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
582        assert!(config.production);
583    }
584
585    #[test]
586    fn deserialize_json_per_analysis_production_mode() {
587        let config: FallowConfig = serde_json::from_str(
588            r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
589        )
590        .unwrap();
591        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
592        assert!(config.production.for_analysis(ProductionAnalysis::Health));
593        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
594    }
595
596    #[test]
597    fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
598        let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
599            .unwrap_err();
600        assert!(
601            err.to_string().contains("healthTypo"),
602            "error should name the unknown field: {err}"
603        );
604    }
605
606    #[test]
607    fn deserialize_json_dynamically_loaded() {
608        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
609        let config: FallowConfig = serde_json::from_str(json).unwrap();
610        assert_eq!(
611            config.dynamically_loaded,
612            vec!["plugins/**/*.ts", "locales/**/*.json"]
613        );
614    }
615
616    #[test]
617    fn deserialize_json_dynamically_loaded_defaults_empty() {
618        let config: FallowConfig = serde_json::from_str("{}").unwrap();
619        assert!(config.dynamically_loaded.is_empty());
620    }
621
622    #[test]
623    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
624        let json = r#"{
625            "usedClassMembers": [
626                "agInit",
627                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
628                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
629            ]
630        }"#;
631        let config: FallowConfig = serde_json::from_str(json).unwrap();
632        assert_eq!(
633            config.used_class_members,
634            vec![
635                UsedClassMemberRule::from("agInit"),
636                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
637                    extends: None,
638                    implements: Some("ICellRendererAngularComp".to_string()),
639                    members: vec!["refresh".to_string()],
640                }),
641                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
642                    extends: Some("BaseCommand".to_string()),
643                    implements: Some("CanActivate".to_string()),
644                    members: vec!["execute".to_string()],
645                }),
646            ]
647        );
648    }
649
650    // ── TOML deserialization ────────────────────────────────────────
651
652    #[test]
653    fn deserialize_toml_minimal() {
654        let toml_str = r#"
655entry = ["src/index.ts"]
656production = true
657"#;
658        let config: FallowConfig = toml::from_str(toml_str).unwrap();
659        assert_eq!(config.entry, vec!["src/index.ts"]);
660        assert!(config.production);
661    }
662
663    #[test]
664    fn deserialize_toml_per_analysis_production_mode() {
665        let toml_str = r"
666[production]
667deadCode = false
668health = true
669dupes = false
670";
671        let config: FallowConfig = toml::from_str(toml_str).unwrap();
672        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
673        assert!(config.production.for_analysis(ProductionAnalysis::Health));
674        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
675    }
676
677    #[test]
678    fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
679        let err = toml::from_str::<FallowConfig>(
680            r"
681[production]
682healthTypo = true
683",
684        )
685        .unwrap_err();
686        assert!(
687            err.to_string().contains("healthTypo"),
688            "error should name the unknown field: {err}"
689        );
690    }
691
692    #[test]
693    fn deserialize_toml_with_inline_framework() {
694        let toml_str = r#"
695[[framework]]
696name = "my-framework"
697enablers = ["my-framework-pkg"]
698entryPoints = ["src/routes/**/*.tsx"]
699"#;
700        let config: FallowConfig = toml::from_str(toml_str).unwrap();
701        assert_eq!(config.framework.len(), 1);
702        assert_eq!(config.framework[0].name, "my-framework");
703        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
704        assert_eq!(
705            config.framework[0].entry_points,
706            vec!["src/routes/**/*.tsx"]
707        );
708    }
709
710    #[test]
711    fn deserialize_toml_with_workspace_config() {
712        let toml_str = r#"
713[workspaces]
714patterns = ["packages/*", "apps/*"]
715"#;
716        let config: FallowConfig = toml::from_str(toml_str).unwrap();
717        assert!(config.workspaces.is_some());
718        let ws = config.workspaces.unwrap();
719        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
720    }
721
722    #[test]
723    fn deserialize_toml_with_ignore_exports() {
724        let toml_str = r#"
725[[ignoreExports]]
726file = "src/types/**/*.ts"
727exports = ["*"]
728"#;
729        let config: FallowConfig = toml::from_str(toml_str).unwrap();
730        assert_eq!(config.ignore_exports.len(), 1);
731        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
732        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
733    }
734
735    #[test]
736    fn deserialize_toml_used_class_members_supports_scoped_rules() {
737        let toml_str = r#"
738usedClassMembers = [
739  { implements = "ICellRendererAngularComp", members = ["refresh"] },
740  { extends = "BaseCommand", members = ["execute"] },
741]
742"#;
743        let config: FallowConfig = toml::from_str(toml_str).unwrap();
744        assert_eq!(
745            config.used_class_members,
746            vec![
747                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
748                    extends: None,
749                    implements: Some("ICellRendererAngularComp".to_string()),
750                    members: vec!["refresh".to_string()],
751                }),
752                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
753                    extends: Some("BaseCommand".to_string()),
754                    implements: None,
755                    members: vec!["execute".to_string()],
756                }),
757            ]
758        );
759    }
760
761    #[test]
762    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
763        let result = serde_json::from_str::<FallowConfig>(
764            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
765        );
766        assert!(
767            result.is_err(),
768            "unconstrained scoped rule should be rejected"
769        );
770    }
771
772    #[test]
773    fn deserialize_ignore_exports_used_in_file_bool() {
774        let config: FallowConfig =
775            serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
776
777        assert!(config.ignore_exports_used_in_file.suppresses(false));
778        assert!(config.ignore_exports_used_in_file.suppresses(true));
779    }
780
781    #[test]
782    fn deserialize_ignore_exports_used_in_file_kind_form() {
783        let config: FallowConfig =
784            serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
785
786        assert!(!config.ignore_exports_used_in_file.suppresses(false));
787        assert!(config.ignore_exports_used_in_file.suppresses(true));
788    }
789
790    #[test]
791    fn deserialize_toml_deny_unknown_fields() {
792        let toml_str = r"bogus_field = true";
793        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
794        assert!(result.is_err(), "unknown fields should be rejected");
795    }
796
797    // ── Serialization roundtrip ─────────────────────────────────────
798
799    #[test]
800    fn json_serialize_roundtrip() {
801        let config = FallowConfig {
802            entry: vec!["src/main.ts".to_string()],
803            production: true.into(),
804            ..FallowConfig::default()
805        };
806        let json = serde_json::to_string(&config).unwrap();
807        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
808        assert_eq!(restored.entry, vec!["src/main.ts"]);
809        assert!(restored.production);
810    }
811
812    #[test]
813    fn schema_field_not_serialized() {
814        let config = FallowConfig {
815            schema: Some("https://example.com/schema.json".to_string()),
816            ..FallowConfig::default()
817        };
818        let json = serde_json::to_string(&config).unwrap();
819        // $schema has skip_serializing, should not appear in output
820        assert!(
821            !json.contains("$schema"),
822            "schema field should be skipped in serialization"
823        );
824    }
825
826    #[test]
827    fn extends_field_not_serialized() {
828        let config = FallowConfig {
829            extends: vec!["base.json".to_string()],
830            ..FallowConfig::default()
831        };
832        let json = serde_json::to_string(&config).unwrap();
833        assert!(
834            !json.contains("extends"),
835            "extends field should be skipped in serialization"
836        );
837    }
838
839    // ── RegressionConfig / RegressionBaseline ──────────────────────
840
841    #[test]
842    fn regression_config_deserialize_json() {
843        let json = r#"{
844            "regression": {
845                "baseline": {
846                    "totalIssues": 42,
847                    "unusedFiles": 10,
848                    "unusedExports": 5,
849                    "circularDependencies": 2
850                }
851            }
852        }"#;
853        let config: FallowConfig = serde_json::from_str(json).unwrap();
854        let regression = config.regression.unwrap();
855        let baseline = regression.baseline.unwrap();
856        assert_eq!(baseline.total_issues, 42);
857        assert_eq!(baseline.unused_files, 10);
858        assert_eq!(baseline.unused_exports, 5);
859        assert_eq!(baseline.circular_dependencies, 2);
860        // Unset fields default to 0
861        assert_eq!(baseline.unused_types, 0);
862        assert_eq!(baseline.boundary_violations, 0);
863    }
864
865    #[test]
866    fn regression_config_defaults_to_none() {
867        let config: FallowConfig = serde_json::from_str("{}").unwrap();
868        assert!(config.regression.is_none());
869    }
870
871    #[test]
872    fn regression_baseline_all_zeros_by_default() {
873        let baseline = RegressionBaseline::default();
874        assert_eq!(baseline.total_issues, 0);
875        assert_eq!(baseline.unused_files, 0);
876        assert_eq!(baseline.unused_exports, 0);
877        assert_eq!(baseline.unused_types, 0);
878        assert_eq!(baseline.unused_dependencies, 0);
879        assert_eq!(baseline.unused_dev_dependencies, 0);
880        assert_eq!(baseline.unused_optional_dependencies, 0);
881        assert_eq!(baseline.unused_enum_members, 0);
882        assert_eq!(baseline.unused_class_members, 0);
883        assert_eq!(baseline.unresolved_imports, 0);
884        assert_eq!(baseline.unlisted_dependencies, 0);
885        assert_eq!(baseline.duplicate_exports, 0);
886        assert_eq!(baseline.circular_dependencies, 0);
887        assert_eq!(baseline.type_only_dependencies, 0);
888        assert_eq!(baseline.test_only_dependencies, 0);
889        assert_eq!(baseline.boundary_violations, 0);
890    }
891
892    #[test]
893    fn regression_config_serialize_roundtrip() {
894        let baseline = RegressionBaseline {
895            total_issues: 100,
896            unused_files: 20,
897            unused_exports: 30,
898            ..RegressionBaseline::default()
899        };
900        let regression = RegressionConfig {
901            baseline: Some(baseline),
902        };
903        let config = FallowConfig {
904            regression: Some(regression),
905            ..FallowConfig::default()
906        };
907        let json = serde_json::to_string(&config).unwrap();
908        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
909        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
910        assert_eq!(restored_baseline.total_issues, 100);
911        assert_eq!(restored_baseline.unused_files, 20);
912        assert_eq!(restored_baseline.unused_exports, 30);
913        assert_eq!(restored_baseline.unused_types, 0);
914    }
915
916    #[test]
917    fn regression_config_empty_baseline_deserialize() {
918        let json = r#"{"regression": {}}"#;
919        let config: FallowConfig = serde_json::from_str(json).unwrap();
920        let regression = config.regression.unwrap();
921        assert!(regression.baseline.is_none());
922    }
923
924    #[test]
925    fn regression_baseline_not_serialized_when_none() {
926        let config = FallowConfig {
927            regression: None,
928            ..FallowConfig::default()
929        };
930        let json = serde_json::to_string(&config).unwrap();
931        assert!(
932            !json.contains("regression"),
933            "regression should be skipped when None"
934        );
935    }
936
937    // ── JSON config with overrides and boundaries ──────────────────
938
939    #[test]
940    fn deserialize_json_with_overrides() {
941        let json = r#"{
942            "overrides": [
943                {
944                    "files": ["*.test.ts", "*.spec.ts"],
945                    "rules": {
946                        "unused-exports": "off",
947                        "unused-files": "warn"
948                    }
949                }
950            ]
951        }"#;
952        let config: FallowConfig = serde_json::from_str(json).unwrap();
953        assert_eq!(config.overrides.len(), 1);
954        assert_eq!(config.overrides[0].files.len(), 2);
955        assert_eq!(
956            config.overrides[0].rules.unused_exports,
957            Some(Severity::Off)
958        );
959        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
960    }
961
962    #[test]
963    fn deserialize_json_with_boundaries() {
964        let json = r#"{
965            "boundaries": {
966                "preset": "layered"
967            }
968        }"#;
969        let config: FallowConfig = serde_json::from_str(json).unwrap();
970        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
971    }
972
973    // ── TOML with regression config ────────────────────────────────
974
975    #[test]
976    fn deserialize_toml_with_regression_baseline() {
977        let toml_str = r"
978[regression.baseline]
979totalIssues = 50
980unusedFiles = 10
981unusedExports = 15
982";
983        let config: FallowConfig = toml::from_str(toml_str).unwrap();
984        let baseline = config.regression.unwrap().baseline.unwrap();
985        assert_eq!(baseline.total_issues, 50);
986        assert_eq!(baseline.unused_files, 10);
987        assert_eq!(baseline.unused_exports, 15);
988    }
989
990    // ── TOML with multiple overrides ───────────────────────────────
991
992    #[test]
993    fn deserialize_toml_with_overrides() {
994        let toml_str = r#"
995[[overrides]]
996files = ["*.test.ts"]
997
998[overrides.rules]
999unused-exports = "off"
1000
1001[[overrides]]
1002files = ["*.stories.tsx"]
1003
1004[overrides.rules]
1005unused-files = "off"
1006"#;
1007        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1008        assert_eq!(config.overrides.len(), 2);
1009        assert_eq!(
1010            config.overrides[0].rules.unused_exports,
1011            Some(Severity::Off)
1012        );
1013        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1014    }
1015
1016    // ── Default regression config ──────────────────────────────────
1017
1018    #[test]
1019    fn regression_config_default_is_none_baseline() {
1020        let config = RegressionConfig::default();
1021        assert!(config.baseline.is_none());
1022    }
1023
1024    // ── Config with multiple ignore export rules ───────────────────
1025
1026    #[test]
1027    fn deserialize_json_multiple_ignore_export_rules() {
1028        let json = r#"{
1029            "ignoreExports": [
1030                {"file": "src/types/**/*.ts", "exports": ["*"]},
1031                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1032                {"file": "src/index.ts", "exports": ["default"]}
1033            ]
1034        }"#;
1035        let config: FallowConfig = serde_json::from_str(json).unwrap();
1036        assert_eq!(config.ignore_exports.len(), 3);
1037        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1038    }
1039
1040    // ── Public packages ───────────────────────────────────────────
1041
1042    #[test]
1043    fn deserialize_json_public_packages_camel_case() {
1044        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1045        let config: FallowConfig = serde_json::from_str(json).unwrap();
1046        assert_eq!(
1047            config.public_packages,
1048            vec!["@myorg/shared-lib", "@myorg/utils"]
1049        );
1050    }
1051
1052    #[test]
1053    fn deserialize_json_public_packages_rejects_snake_case() {
1054        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1055        let result: Result<FallowConfig, _> = serde_json::from_str(json);
1056        assert!(
1057            result.is_err(),
1058            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1059        );
1060    }
1061
1062    #[test]
1063    fn deserialize_json_public_packages_empty() {
1064        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1065        assert!(config.public_packages.is_empty());
1066    }
1067
1068    #[test]
1069    fn deserialize_toml_public_packages() {
1070        let toml_str = r#"
1071publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1072"#;
1073        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1074        assert_eq!(
1075            config.public_packages,
1076            vec!["@myorg/shared-lib", "@myorg/ui"]
1077        );
1078    }
1079
1080    #[test]
1081    fn public_packages_serialize_roundtrip() {
1082        let config = FallowConfig {
1083            public_packages: vec!["@myorg/shared-lib".to_string()],
1084            ..FallowConfig::default()
1085        };
1086        let json = serde_json::to_string(&config).unwrap();
1087        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1088        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1089    }
1090}