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    /// Path to a CODEOWNERS file for `--group-by owner`.
137    ///
138    /// When unset, fallow auto-probes `CODEOWNERS`, `.github/CODEOWNERS`,
139    /// `.gitlab/CODEOWNERS`, and `docs/CODEOWNERS`. Set this to use a
140    /// non-standard location.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub codeowners: Option<String>,
143
144    /// Regression detection baseline embedded in config.
145    /// Stores issue counts from a known-good state for CI regression checks.
146    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub regression: Option<RegressionConfig>,
149}
150
151/// Regression baseline counts, embedded in the config file.
152///
153/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
154/// fallow reads the baseline from this config section.
155/// When `--save-regression-baseline` is used without a path argument,
156/// fallow writes the baseline into the config file.
157#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
158#[serde(rename_all = "camelCase")]
159pub struct RegressionConfig {
160    /// Dead code issue counts baseline.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub baseline: Option<RegressionBaseline>,
163}
164
165/// Per-type issue counts for regression comparison.
166#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
167#[serde(rename_all = "camelCase")]
168pub struct RegressionBaseline {
169    #[serde(default)]
170    pub total_issues: usize,
171    #[serde(default)]
172    pub unused_files: usize,
173    #[serde(default)]
174    pub unused_exports: usize,
175    #[serde(default)]
176    pub unused_types: usize,
177    #[serde(default)]
178    pub unused_dependencies: usize,
179    #[serde(default)]
180    pub unused_dev_dependencies: usize,
181    #[serde(default)]
182    pub unused_optional_dependencies: usize,
183    #[serde(default)]
184    pub unused_enum_members: usize,
185    #[serde(default)]
186    pub unused_class_members: usize,
187    #[serde(default)]
188    pub unresolved_imports: usize,
189    #[serde(default)]
190    pub unlisted_dependencies: usize,
191    #[serde(default)]
192    pub duplicate_exports: usize,
193    #[serde(default)]
194    pub circular_dependencies: usize,
195    #[serde(default)]
196    pub type_only_dependencies: usize,
197    #[serde(default)]
198    pub test_only_dependencies: usize,
199    #[serde(default)]
200    pub boundary_violations: usize,
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    // ── Default trait ───────────────────────────────────────────────
208
209    #[test]
210    fn default_config_has_empty_collections() {
211        let config = FallowConfig::default();
212        assert!(config.schema.is_none());
213        assert!(config.extends.is_empty());
214        assert!(config.entry.is_empty());
215        assert!(config.ignore_patterns.is_empty());
216        assert!(config.framework.is_empty());
217        assert!(config.workspaces.is_none());
218        assert!(config.ignore_dependencies.is_empty());
219        assert!(config.ignore_exports.is_empty());
220        assert!(config.plugins.is_empty());
221        assert!(config.overrides.is_empty());
222        assert!(!config.production);
223    }
224
225    #[test]
226    fn default_config_rules_are_error() {
227        let config = FallowConfig::default();
228        assert_eq!(config.rules.unused_files, Severity::Error);
229        assert_eq!(config.rules.unused_exports, Severity::Error);
230        assert_eq!(config.rules.unused_dependencies, Severity::Error);
231    }
232
233    #[test]
234    fn default_config_duplicates_enabled() {
235        let config = FallowConfig::default();
236        assert!(config.duplicates.enabled);
237        assert_eq!(config.duplicates.min_tokens, 50);
238        assert_eq!(config.duplicates.min_lines, 5);
239    }
240
241    #[test]
242    fn default_config_health_thresholds() {
243        let config = FallowConfig::default();
244        assert_eq!(config.health.max_cyclomatic, 20);
245        assert_eq!(config.health.max_cognitive, 15);
246    }
247
248    // ── JSON deserialization ────────────────────────────────────────
249
250    #[test]
251    fn deserialize_empty_json_object() {
252        let config: FallowConfig = serde_json::from_str("{}").unwrap();
253        assert!(config.entry.is_empty());
254        assert!(!config.production);
255    }
256
257    #[test]
258    fn deserialize_json_with_all_top_level_fields() {
259        let json = r#"{
260            "$schema": "https://fallow.dev/schema.json",
261            "entry": ["src/main.ts"],
262            "ignorePatterns": ["generated/**"],
263            "ignoreDependencies": ["postcss"],
264            "production": true,
265            "plugins": ["custom-plugin.toml"],
266            "rules": {"unused-files": "warn"},
267            "duplicates": {"enabled": false},
268            "health": {"maxCyclomatic": 30}
269        }"#;
270        let config: FallowConfig = serde_json::from_str(json).unwrap();
271        assert_eq!(
272            config.schema.as_deref(),
273            Some("https://fallow.dev/schema.json")
274        );
275        assert_eq!(config.entry, vec!["src/main.ts"]);
276        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
277        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
278        assert!(config.production);
279        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
280        assert_eq!(config.rules.unused_files, Severity::Warn);
281        assert!(!config.duplicates.enabled);
282        assert_eq!(config.health.max_cyclomatic, 30);
283    }
284
285    #[test]
286    fn deserialize_json_deny_unknown_fields() {
287        let json = r#"{"unknownField": true}"#;
288        let result: Result<FallowConfig, _> = serde_json::from_str(json);
289        assert!(result.is_err(), "unknown fields should be rejected");
290    }
291
292    #[test]
293    fn deserialize_json_production_mode_default_false() {
294        let config: FallowConfig = serde_json::from_str("{}").unwrap();
295        assert!(!config.production);
296    }
297
298    #[test]
299    fn deserialize_json_production_mode_true() {
300        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
301        assert!(config.production);
302    }
303
304    // ── TOML deserialization ────────────────────────────────────────
305
306    #[test]
307    fn deserialize_toml_minimal() {
308        let toml_str = r#"
309entry = ["src/index.ts"]
310production = true
311"#;
312        let config: FallowConfig = toml::from_str(toml_str).unwrap();
313        assert_eq!(config.entry, vec!["src/index.ts"]);
314        assert!(config.production);
315    }
316
317    #[test]
318    fn deserialize_toml_with_inline_framework() {
319        let toml_str = r#"
320[[framework]]
321name = "my-framework"
322enablers = ["my-framework-pkg"]
323entryPoints = ["src/routes/**/*.tsx"]
324"#;
325        let config: FallowConfig = toml::from_str(toml_str).unwrap();
326        assert_eq!(config.framework.len(), 1);
327        assert_eq!(config.framework[0].name, "my-framework");
328        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
329        assert_eq!(
330            config.framework[0].entry_points,
331            vec!["src/routes/**/*.tsx"]
332        );
333    }
334
335    #[test]
336    fn deserialize_toml_with_workspace_config() {
337        let toml_str = r#"
338[workspaces]
339patterns = ["packages/*", "apps/*"]
340"#;
341        let config: FallowConfig = toml::from_str(toml_str).unwrap();
342        assert!(config.workspaces.is_some());
343        let ws = config.workspaces.unwrap();
344        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
345    }
346
347    #[test]
348    fn deserialize_toml_with_ignore_exports() {
349        let toml_str = r#"
350[[ignoreExports]]
351file = "src/types/**/*.ts"
352exports = ["*"]
353"#;
354        let config: FallowConfig = toml::from_str(toml_str).unwrap();
355        assert_eq!(config.ignore_exports.len(), 1);
356        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
357        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
358    }
359
360    #[test]
361    fn deserialize_toml_deny_unknown_fields() {
362        let toml_str = r"bogus_field = true";
363        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
364        assert!(result.is_err(), "unknown fields should be rejected");
365    }
366
367    // ── Serialization roundtrip ─────────────────────────────────────
368
369    #[test]
370    fn json_serialize_roundtrip() {
371        let config = FallowConfig {
372            entry: vec!["src/main.ts".to_string()],
373            production: true,
374            ..FallowConfig::default()
375        };
376        let json = serde_json::to_string(&config).unwrap();
377        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
378        assert_eq!(restored.entry, vec!["src/main.ts"]);
379        assert!(restored.production);
380    }
381
382    #[test]
383    fn schema_field_not_serialized() {
384        let config = FallowConfig {
385            schema: Some("https://example.com/schema.json".to_string()),
386            ..FallowConfig::default()
387        };
388        let json = serde_json::to_string(&config).unwrap();
389        // $schema has skip_serializing, should not appear in output
390        assert!(
391            !json.contains("$schema"),
392            "schema field should be skipped in serialization"
393        );
394    }
395
396    #[test]
397    fn extends_field_not_serialized() {
398        let config = FallowConfig {
399            extends: vec!["base.json".to_string()],
400            ..FallowConfig::default()
401        };
402        let json = serde_json::to_string(&config).unwrap();
403        assert!(
404            !json.contains("extends"),
405            "extends field should be skipped in serialization"
406        );
407    }
408
409    // ── RegressionConfig / RegressionBaseline ──────────────────────
410
411    #[test]
412    fn regression_config_deserialize_json() {
413        let json = r#"{
414            "regression": {
415                "baseline": {
416                    "totalIssues": 42,
417                    "unusedFiles": 10,
418                    "unusedExports": 5,
419                    "circularDependencies": 2
420                }
421            }
422        }"#;
423        let config: FallowConfig = serde_json::from_str(json).unwrap();
424        let regression = config.regression.unwrap();
425        let baseline = regression.baseline.unwrap();
426        assert_eq!(baseline.total_issues, 42);
427        assert_eq!(baseline.unused_files, 10);
428        assert_eq!(baseline.unused_exports, 5);
429        assert_eq!(baseline.circular_dependencies, 2);
430        // Unset fields default to 0
431        assert_eq!(baseline.unused_types, 0);
432        assert_eq!(baseline.boundary_violations, 0);
433    }
434
435    #[test]
436    fn regression_config_defaults_to_none() {
437        let config: FallowConfig = serde_json::from_str("{}").unwrap();
438        assert!(config.regression.is_none());
439    }
440
441    #[test]
442    fn regression_baseline_all_zeros_by_default() {
443        let baseline = RegressionBaseline::default();
444        assert_eq!(baseline.total_issues, 0);
445        assert_eq!(baseline.unused_files, 0);
446        assert_eq!(baseline.unused_exports, 0);
447        assert_eq!(baseline.unused_types, 0);
448        assert_eq!(baseline.unused_dependencies, 0);
449        assert_eq!(baseline.unused_dev_dependencies, 0);
450        assert_eq!(baseline.unused_optional_dependencies, 0);
451        assert_eq!(baseline.unused_enum_members, 0);
452        assert_eq!(baseline.unused_class_members, 0);
453        assert_eq!(baseline.unresolved_imports, 0);
454        assert_eq!(baseline.unlisted_dependencies, 0);
455        assert_eq!(baseline.duplicate_exports, 0);
456        assert_eq!(baseline.circular_dependencies, 0);
457        assert_eq!(baseline.type_only_dependencies, 0);
458        assert_eq!(baseline.test_only_dependencies, 0);
459        assert_eq!(baseline.boundary_violations, 0);
460    }
461
462    #[test]
463    fn regression_config_serialize_roundtrip() {
464        let baseline = RegressionBaseline {
465            total_issues: 100,
466            unused_files: 20,
467            unused_exports: 30,
468            ..RegressionBaseline::default()
469        };
470        let regression = RegressionConfig {
471            baseline: Some(baseline),
472        };
473        let config = FallowConfig {
474            regression: Some(regression),
475            ..FallowConfig::default()
476        };
477        let json = serde_json::to_string(&config).unwrap();
478        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
479        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
480        assert_eq!(restored_baseline.total_issues, 100);
481        assert_eq!(restored_baseline.unused_files, 20);
482        assert_eq!(restored_baseline.unused_exports, 30);
483        assert_eq!(restored_baseline.unused_types, 0);
484    }
485
486    #[test]
487    fn regression_config_empty_baseline_deserialize() {
488        let json = r#"{"regression": {}}"#;
489        let config: FallowConfig = serde_json::from_str(json).unwrap();
490        let regression = config.regression.unwrap();
491        assert!(regression.baseline.is_none());
492    }
493
494    #[test]
495    fn regression_baseline_not_serialized_when_none() {
496        let config = FallowConfig {
497            regression: None,
498            ..FallowConfig::default()
499        };
500        let json = serde_json::to_string(&config).unwrap();
501        assert!(
502            !json.contains("regression"),
503            "regression should be skipped when None"
504        );
505    }
506
507    // ── JSON config with overrides and boundaries ──────────────────
508
509    #[test]
510    fn deserialize_json_with_overrides() {
511        let json = r#"{
512            "overrides": [
513                {
514                    "files": ["*.test.ts", "*.spec.ts"],
515                    "rules": {
516                        "unused-exports": "off",
517                        "unused-files": "warn"
518                    }
519                }
520            ]
521        }"#;
522        let config: FallowConfig = serde_json::from_str(json).unwrap();
523        assert_eq!(config.overrides.len(), 1);
524        assert_eq!(config.overrides[0].files.len(), 2);
525        assert_eq!(
526            config.overrides[0].rules.unused_exports,
527            Some(Severity::Off)
528        );
529        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
530    }
531
532    #[test]
533    fn deserialize_json_with_boundaries() {
534        let json = r#"{
535            "boundaries": {
536                "preset": "layered"
537            }
538        }"#;
539        let config: FallowConfig = serde_json::from_str(json).unwrap();
540        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
541    }
542
543    // ── TOML with regression config ────────────────────────────────
544
545    #[test]
546    fn deserialize_toml_with_regression_baseline() {
547        let toml_str = r"
548[regression.baseline]
549totalIssues = 50
550unusedFiles = 10
551unusedExports = 15
552";
553        let config: FallowConfig = toml::from_str(toml_str).unwrap();
554        let baseline = config.regression.unwrap().baseline.unwrap();
555        assert_eq!(baseline.total_issues, 50);
556        assert_eq!(baseline.unused_files, 10);
557        assert_eq!(baseline.unused_exports, 15);
558    }
559
560    // ── TOML with multiple overrides ───────────────────────────────
561
562    #[test]
563    fn deserialize_toml_with_overrides() {
564        let toml_str = r#"
565[[overrides]]
566files = ["*.test.ts"]
567
568[overrides.rules]
569unused-exports = "off"
570
571[[overrides]]
572files = ["*.stories.tsx"]
573
574[overrides.rules]
575unused-files = "off"
576"#;
577        let config: FallowConfig = toml::from_str(toml_str).unwrap();
578        assert_eq!(config.overrides.len(), 2);
579        assert_eq!(
580            config.overrides[0].rules.unused_exports,
581            Some(Severity::Off)
582        );
583        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
584    }
585
586    // ── Default regression config ──────────────────────────────────
587
588    #[test]
589    fn regression_config_default_is_none_baseline() {
590        let config = RegressionConfig::default();
591        assert!(config.baseline.is_none());
592    }
593
594    // ── Config with multiple ignore export rules ───────────────────
595
596    #[test]
597    fn deserialize_json_multiple_ignore_export_rules() {
598        let json = r#"{
599            "ignoreExports": [
600                {"file": "src/types/**/*.ts", "exports": ["*"]},
601                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
602                {"file": "src/index.ts", "exports": ["default"]}
603            ]
604        }"#;
605        let config: FallowConfig = serde_json::from_str(json).unwrap();
606        assert_eq!(config.ignore_exports.len(), 3);
607        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
608    }
609}