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