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