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