Skip to main content

fallow_config/config/
mod.rs

1mod boundaries;
2mod duplicates_config;
3mod format;
4mod health;
5mod parsing;
6mod resolution;
7mod rules;
8
9pub use boundaries::{
10    BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
11    ResolvedBoundaryRule, ResolvedZone,
12};
13pub use duplicates_config::{
14    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
15};
16pub use format::OutputFormat;
17pub use health::HealthConfig;
18pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
19pub use rules::{PartialRulesConfig, RulesConfig, Severity};
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::external_plugin::ExternalPluginDef;
25use crate::workspace::WorkspaceConfig;
26
27/// User-facing configuration loaded from `.fallowrc.json` or `fallow.toml`.
28///
29/// # Examples
30///
31/// ```
32/// use fallow_config::FallowConfig;
33///
34/// // Default config has sensible defaults
35/// let config = FallowConfig::default();
36/// assert!(config.entry.is_empty());
37/// assert!(!config.production);
38///
39/// // Deserialize from JSON
40/// let config: FallowConfig = serde_json::from_str(r#"{
41///     "entry": ["src/main.ts"],
42///     "production": true
43/// }"#).unwrap();
44/// assert_eq!(config.entry, vec!["src/main.ts"]);
45/// assert!(config.production);
46/// ```
47#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
48#[serde(deny_unknown_fields, rename_all = "camelCase")]
49pub struct FallowConfig {
50    /// JSON Schema reference (ignored during deserialization).
51    #[serde(rename = "$schema", default, skip_serializing)]
52    #[schemars(skip)]
53    pub schema: Option<String>,
54
55    /// Paths to base config files to extend from.
56    /// Paths are resolved relative to the config file containing the `extends`.
57    /// Base configs are loaded first, then this config's values override them.
58    /// Later entries in the array override earlier ones.
59    #[serde(default, skip_serializing)]
60    pub extends: Vec<String>,
61
62    /// Additional entry point glob patterns.
63    #[serde(default)]
64    pub entry: Vec<String>,
65
66    /// Glob patterns to ignore from analysis.
67    #[serde(default)]
68    pub ignore_patterns: Vec<String>,
69
70    /// Custom framework definitions (inline plugin definitions).
71    #[serde(default)]
72    pub framework: Vec<ExternalPluginDef>,
73
74    /// Workspace overrides.
75    #[serde(default)]
76    pub workspaces: Option<WorkspaceConfig>,
77
78    /// Dependencies to ignore (always considered used).
79    #[serde(default)]
80    pub ignore_dependencies: Vec<String>,
81
82    /// Export ignore rules.
83    #[serde(default)]
84    pub ignore_exports: Vec<IgnoreExportRule>,
85
86    /// Duplication detection settings.
87    #[serde(default)]
88    pub duplicates: DuplicatesConfig,
89
90    /// Complexity health metrics settings.
91    #[serde(default)]
92    pub health: HealthConfig,
93
94    /// Per-issue-type severity rules.
95    #[serde(default)]
96    pub rules: RulesConfig,
97
98    /// Architecture boundary enforcement configuration.
99    #[serde(default)]
100    pub boundaries: BoundaryConfig,
101
102    /// Production mode: exclude test/dev files, only start/build scripts.
103    #[serde(default)]
104    pub production: bool,
105
106    /// Paths to external plugin files or directories containing plugin files.
107    ///
108    /// Supports TOML, JSON, and JSONC formats.
109    ///
110    /// In addition to these explicit paths, fallow automatically discovers:
111    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
112    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
113    #[serde(default)]
114    pub plugins: Vec<String>,
115
116    /// Per-file rule overrides matching oxlint's overrides pattern.
117    #[serde(default)]
118    pub overrides: Vec<ConfigOverride>,
119
120    /// Regression detection baseline embedded in config.
121    /// Stores issue counts from a known-good state for CI regression checks.
122    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub regression: Option<RegressionConfig>,
125}
126
127/// Regression baseline counts, embedded in the config file.
128///
129/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
130/// fallow reads the baseline from this config section.
131/// When `--save-regression-baseline` is used without a path argument,
132/// fallow writes the baseline into the config file.
133#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
134#[serde(rename_all = "camelCase")]
135pub struct RegressionConfig {
136    /// Dead code issue counts baseline.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub baseline: Option<RegressionBaseline>,
139}
140
141/// Per-type issue counts for regression comparison.
142#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
143#[serde(rename_all = "camelCase")]
144pub struct RegressionBaseline {
145    #[serde(default)]
146    pub total_issues: usize,
147    #[serde(default)]
148    pub unused_files: usize,
149    #[serde(default)]
150    pub unused_exports: usize,
151    #[serde(default)]
152    pub unused_types: usize,
153    #[serde(default)]
154    pub unused_dependencies: usize,
155    #[serde(default)]
156    pub unused_dev_dependencies: usize,
157    #[serde(default)]
158    pub unused_optional_dependencies: usize,
159    #[serde(default)]
160    pub unused_enum_members: usize,
161    #[serde(default)]
162    pub unused_class_members: usize,
163    #[serde(default)]
164    pub unresolved_imports: usize,
165    #[serde(default)]
166    pub unlisted_dependencies: usize,
167    #[serde(default)]
168    pub duplicate_exports: usize,
169    #[serde(default)]
170    pub circular_dependencies: usize,
171    #[serde(default)]
172    pub type_only_dependencies: usize,
173    #[serde(default)]
174    pub test_only_dependencies: usize,
175    #[serde(default)]
176    pub boundary_violations: usize,
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    // ── Default trait ───────────────────────────────────────────────
184
185    #[test]
186    fn default_config_has_empty_collections() {
187        let config = FallowConfig::default();
188        assert!(config.schema.is_none());
189        assert!(config.extends.is_empty());
190        assert!(config.entry.is_empty());
191        assert!(config.ignore_patterns.is_empty());
192        assert!(config.framework.is_empty());
193        assert!(config.workspaces.is_none());
194        assert!(config.ignore_dependencies.is_empty());
195        assert!(config.ignore_exports.is_empty());
196        assert!(config.plugins.is_empty());
197        assert!(config.overrides.is_empty());
198        assert!(!config.production);
199    }
200
201    #[test]
202    fn default_config_rules_are_error() {
203        let config = FallowConfig::default();
204        assert_eq!(config.rules.unused_files, Severity::Error);
205        assert_eq!(config.rules.unused_exports, Severity::Error);
206        assert_eq!(config.rules.unused_dependencies, Severity::Error);
207    }
208
209    #[test]
210    fn default_config_duplicates_enabled() {
211        let config = FallowConfig::default();
212        assert!(config.duplicates.enabled);
213        assert_eq!(config.duplicates.min_tokens, 50);
214        assert_eq!(config.duplicates.min_lines, 5);
215    }
216
217    #[test]
218    fn default_config_health_thresholds() {
219        let config = FallowConfig::default();
220        assert_eq!(config.health.max_cyclomatic, 20);
221        assert_eq!(config.health.max_cognitive, 15);
222    }
223
224    // ── JSON deserialization ────────────────────────────────────────
225
226    #[test]
227    fn deserialize_empty_json_object() {
228        let config: FallowConfig = serde_json::from_str("{}").unwrap();
229        assert!(config.entry.is_empty());
230        assert!(!config.production);
231    }
232
233    #[test]
234    fn deserialize_json_with_all_top_level_fields() {
235        let json = r#"{
236            "$schema": "https://fallow.dev/schema.json",
237            "entry": ["src/main.ts"],
238            "ignorePatterns": ["generated/**"],
239            "ignoreDependencies": ["postcss"],
240            "production": true,
241            "plugins": ["custom-plugin.toml"],
242            "rules": {"unused-files": "warn"},
243            "duplicates": {"enabled": false},
244            "health": {"maxCyclomatic": 30}
245        }"#;
246        let config: FallowConfig = serde_json::from_str(json).unwrap();
247        assert_eq!(
248            config.schema.as_deref(),
249            Some("https://fallow.dev/schema.json")
250        );
251        assert_eq!(config.entry, vec!["src/main.ts"]);
252        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
253        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
254        assert!(config.production);
255        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
256        assert_eq!(config.rules.unused_files, Severity::Warn);
257        assert!(!config.duplicates.enabled);
258        assert_eq!(config.health.max_cyclomatic, 30);
259    }
260
261    #[test]
262    fn deserialize_json_deny_unknown_fields() {
263        let json = r#"{"unknownField": true}"#;
264        let result: Result<FallowConfig, _> = serde_json::from_str(json);
265        assert!(result.is_err(), "unknown fields should be rejected");
266    }
267
268    #[test]
269    fn deserialize_json_production_mode_default_false() {
270        let config: FallowConfig = serde_json::from_str("{}").unwrap();
271        assert!(!config.production);
272    }
273
274    #[test]
275    fn deserialize_json_production_mode_true() {
276        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
277        assert!(config.production);
278    }
279
280    // ── TOML deserialization ────────────────────────────────────────
281
282    #[test]
283    fn deserialize_toml_minimal() {
284        let toml_str = r#"
285entry = ["src/index.ts"]
286production = true
287"#;
288        let config: FallowConfig = toml::from_str(toml_str).unwrap();
289        assert_eq!(config.entry, vec!["src/index.ts"]);
290        assert!(config.production);
291    }
292
293    #[test]
294    fn deserialize_toml_with_inline_framework() {
295        let toml_str = r#"
296[[framework]]
297name = "my-framework"
298enablers = ["my-framework-pkg"]
299entryPoints = ["src/routes/**/*.tsx"]
300"#;
301        let config: FallowConfig = toml::from_str(toml_str).unwrap();
302        assert_eq!(config.framework.len(), 1);
303        assert_eq!(config.framework[0].name, "my-framework");
304        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
305        assert_eq!(
306            config.framework[0].entry_points,
307            vec!["src/routes/**/*.tsx"]
308        );
309    }
310
311    #[test]
312    fn deserialize_toml_with_workspace_config() {
313        let toml_str = r#"
314[workspaces]
315patterns = ["packages/*", "apps/*"]
316"#;
317        let config: FallowConfig = toml::from_str(toml_str).unwrap();
318        assert!(config.workspaces.is_some());
319        let ws = config.workspaces.unwrap();
320        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
321    }
322
323    #[test]
324    fn deserialize_toml_with_ignore_exports() {
325        let toml_str = r#"
326[[ignoreExports]]
327file = "src/types/**/*.ts"
328exports = ["*"]
329"#;
330        let config: FallowConfig = toml::from_str(toml_str).unwrap();
331        assert_eq!(config.ignore_exports.len(), 1);
332        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
333        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
334    }
335
336    #[test]
337    fn deserialize_toml_deny_unknown_fields() {
338        let toml_str = r"bogus_field = true";
339        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
340        assert!(result.is_err(), "unknown fields should be rejected");
341    }
342
343    // ── Serialization roundtrip ─────────────────────────────────────
344
345    #[test]
346    fn json_serialize_roundtrip() {
347        let config = FallowConfig {
348            entry: vec!["src/main.ts".to_string()],
349            production: true,
350            ..FallowConfig::default()
351        };
352        let json = serde_json::to_string(&config).unwrap();
353        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
354        assert_eq!(restored.entry, vec!["src/main.ts"]);
355        assert!(restored.production);
356    }
357
358    #[test]
359    fn schema_field_not_serialized() {
360        let config = FallowConfig {
361            schema: Some("https://example.com/schema.json".to_string()),
362            ..FallowConfig::default()
363        };
364        let json = serde_json::to_string(&config).unwrap();
365        // $schema has skip_serializing, should not appear in output
366        assert!(
367            !json.contains("$schema"),
368            "schema field should be skipped in serialization"
369        );
370    }
371
372    #[test]
373    fn extends_field_not_serialized() {
374        let config = FallowConfig {
375            extends: vec!["base.json".to_string()],
376            ..FallowConfig::default()
377        };
378        let json = serde_json::to_string(&config).unwrap();
379        assert!(
380            !json.contains("extends"),
381            "extends field should be skipped in serialization"
382        );
383    }
384
385    // ── RegressionConfig / RegressionBaseline ──────────────────────
386
387    #[test]
388    fn regression_config_deserialize_json() {
389        let json = r#"{
390            "regression": {
391                "baseline": {
392                    "totalIssues": 42,
393                    "unusedFiles": 10,
394                    "unusedExports": 5,
395                    "circularDependencies": 2
396                }
397            }
398        }"#;
399        let config: FallowConfig = serde_json::from_str(json).unwrap();
400        let regression = config.regression.unwrap();
401        let baseline = regression.baseline.unwrap();
402        assert_eq!(baseline.total_issues, 42);
403        assert_eq!(baseline.unused_files, 10);
404        assert_eq!(baseline.unused_exports, 5);
405        assert_eq!(baseline.circular_dependencies, 2);
406        // Unset fields default to 0
407        assert_eq!(baseline.unused_types, 0);
408        assert_eq!(baseline.boundary_violations, 0);
409    }
410
411    #[test]
412    fn regression_config_defaults_to_none() {
413        let config: FallowConfig = serde_json::from_str("{}").unwrap();
414        assert!(config.regression.is_none());
415    }
416
417    #[test]
418    fn regression_baseline_all_zeros_by_default() {
419        let baseline = RegressionBaseline::default();
420        assert_eq!(baseline.total_issues, 0);
421        assert_eq!(baseline.unused_files, 0);
422        assert_eq!(baseline.unused_exports, 0);
423        assert_eq!(baseline.unused_types, 0);
424        assert_eq!(baseline.unused_dependencies, 0);
425        assert_eq!(baseline.unused_dev_dependencies, 0);
426        assert_eq!(baseline.unused_optional_dependencies, 0);
427        assert_eq!(baseline.unused_enum_members, 0);
428        assert_eq!(baseline.unused_class_members, 0);
429        assert_eq!(baseline.unresolved_imports, 0);
430        assert_eq!(baseline.unlisted_dependencies, 0);
431        assert_eq!(baseline.duplicate_exports, 0);
432        assert_eq!(baseline.circular_dependencies, 0);
433        assert_eq!(baseline.type_only_dependencies, 0);
434        assert_eq!(baseline.test_only_dependencies, 0);
435        assert_eq!(baseline.boundary_violations, 0);
436    }
437
438    #[test]
439    fn regression_config_serialize_roundtrip() {
440        let baseline = RegressionBaseline {
441            total_issues: 100,
442            unused_files: 20,
443            unused_exports: 30,
444            ..RegressionBaseline::default()
445        };
446        let regression = RegressionConfig {
447            baseline: Some(baseline),
448        };
449        let config = FallowConfig {
450            regression: Some(regression),
451            ..FallowConfig::default()
452        };
453        let json = serde_json::to_string(&config).unwrap();
454        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
455        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
456        assert_eq!(restored_baseline.total_issues, 100);
457        assert_eq!(restored_baseline.unused_files, 20);
458        assert_eq!(restored_baseline.unused_exports, 30);
459        assert_eq!(restored_baseline.unused_types, 0);
460    }
461
462    #[test]
463    fn regression_config_empty_baseline_deserialize() {
464        let json = r#"{"regression": {}}"#;
465        let config: FallowConfig = serde_json::from_str(json).unwrap();
466        let regression = config.regression.unwrap();
467        assert!(regression.baseline.is_none());
468    }
469
470    #[test]
471    fn regression_baseline_not_serialized_when_none() {
472        let config = FallowConfig {
473            regression: None,
474            ..FallowConfig::default()
475        };
476        let json = serde_json::to_string(&config).unwrap();
477        assert!(
478            !json.contains("regression"),
479            "regression should be skipped when None"
480        );
481    }
482
483    // ── JSON config with overrides and boundaries ──────────────────
484
485    #[test]
486    fn deserialize_json_with_overrides() {
487        let json = r#"{
488            "overrides": [
489                {
490                    "files": ["*.test.ts", "*.spec.ts"],
491                    "rules": {
492                        "unused-exports": "off",
493                        "unused-files": "warn"
494                    }
495                }
496            ]
497        }"#;
498        let config: FallowConfig = serde_json::from_str(json).unwrap();
499        assert_eq!(config.overrides.len(), 1);
500        assert_eq!(config.overrides[0].files.len(), 2);
501        assert_eq!(
502            config.overrides[0].rules.unused_exports,
503            Some(Severity::Off)
504        );
505        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
506    }
507
508    #[test]
509    fn deserialize_json_with_boundaries() {
510        let json = r#"{
511            "boundaries": {
512                "preset": "layered"
513            }
514        }"#;
515        let config: FallowConfig = serde_json::from_str(json).unwrap();
516        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
517    }
518
519    // ── TOML with regression config ────────────────────────────────
520
521    #[test]
522    fn deserialize_toml_with_regression_baseline() {
523        let toml_str = r"
524[regression.baseline]
525totalIssues = 50
526unusedFiles = 10
527unusedExports = 15
528";
529        let config: FallowConfig = toml::from_str(toml_str).unwrap();
530        let baseline = config.regression.unwrap().baseline.unwrap();
531        assert_eq!(baseline.total_issues, 50);
532        assert_eq!(baseline.unused_files, 10);
533        assert_eq!(baseline.unused_exports, 15);
534    }
535
536    // ── TOML with multiple overrides ───────────────────────────────
537
538    #[test]
539    fn deserialize_toml_with_overrides() {
540        let toml_str = r#"
541[[overrides]]
542files = ["*.test.ts"]
543
544[overrides.rules]
545unused-exports = "off"
546
547[[overrides]]
548files = ["*.stories.tsx"]
549
550[overrides.rules]
551unused-files = "off"
552"#;
553        let config: FallowConfig = toml::from_str(toml_str).unwrap();
554        assert_eq!(config.overrides.len(), 2);
555        assert_eq!(
556            config.overrides[0].rules.unused_exports,
557            Some(Severity::Off)
558        );
559        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
560    }
561
562    // ── Default regression config ──────────────────────────────────
563
564    #[test]
565    fn regression_config_default_is_none_baseline() {
566        let config = RegressionConfig::default();
567        assert!(config.baseline.is_none());
568    }
569
570    // ── Config with multiple ignore export rules ───────────────────
571
572    #[test]
573    fn deserialize_json_multiple_ignore_export_rules() {
574        let json = r#"{
575            "ignoreExports": [
576                {"file": "src/types/**/*.ts", "exports": ["*"]},
577                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
578                {"file": "src/index.ts", "exports": ["default"]}
579            ]
580        }"#;
581        let config: FallowConfig = serde_json::from_str(json).unwrap();
582        assert_eq!(config.ignore_exports.len(), 3);
583        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
584    }
585}