Skip to main content

fallow_config/config/
mod.rs

1mod duplicates_config;
2mod format;
3mod health;
4mod parsing;
5mod resolution;
6mod rules;
7
8pub use duplicates_config::{
9    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
10};
11pub use format::OutputFormat;
12pub use health::HealthConfig;
13pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
14pub use rules::{PartialRulesConfig, RulesConfig, Severity};
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use crate::external_plugin::ExternalPluginDef;
20use crate::workspace::WorkspaceConfig;
21
22/// User-facing configuration loaded from `.fallowrc.json` or `fallow.toml`.
23///
24/// # Examples
25///
26/// ```
27/// use fallow_config::FallowConfig;
28///
29/// // Default config has sensible defaults
30/// let config = FallowConfig::default();
31/// assert!(config.entry.is_empty());
32/// assert!(!config.production);
33///
34/// // Deserialize from JSON
35/// let config: FallowConfig = serde_json::from_str(r#"{
36///     "entry": ["src/main.ts"],
37///     "production": true
38/// }"#).unwrap();
39/// assert_eq!(config.entry, vec!["src/main.ts"]);
40/// assert!(config.production);
41/// ```
42#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
43#[serde(deny_unknown_fields, rename_all = "camelCase")]
44pub struct FallowConfig {
45    /// JSON Schema reference (ignored during deserialization).
46    #[serde(rename = "$schema", default, skip_serializing)]
47    #[schemars(skip)]
48    pub schema: Option<String>,
49
50    /// Paths to base config files to extend from.
51    /// Paths are resolved relative to the config file containing the `extends`.
52    /// Base configs are loaded first, then this config's values override them.
53    /// Later entries in the array override earlier ones.
54    #[serde(default, skip_serializing)]
55    pub extends: Vec<String>,
56
57    /// Additional entry point glob patterns.
58    #[serde(default)]
59    pub entry: Vec<String>,
60
61    /// Glob patterns to ignore from analysis.
62    #[serde(default)]
63    pub ignore_patterns: Vec<String>,
64
65    /// Custom framework definitions (inline plugin definitions).
66    #[serde(default)]
67    pub framework: Vec<ExternalPluginDef>,
68
69    /// Workspace overrides.
70    #[serde(default)]
71    pub workspaces: Option<WorkspaceConfig>,
72
73    /// Dependencies to ignore (always considered used).
74    #[serde(default)]
75    pub ignore_dependencies: Vec<String>,
76
77    /// Export ignore rules.
78    #[serde(default)]
79    pub ignore_exports: Vec<IgnoreExportRule>,
80
81    /// Duplication detection settings.
82    #[serde(default)]
83    pub duplicates: DuplicatesConfig,
84
85    /// Complexity health metrics settings.
86    #[serde(default)]
87    pub health: HealthConfig,
88
89    /// Per-issue-type severity rules.
90    #[serde(default)]
91    pub rules: RulesConfig,
92
93    /// Production mode: exclude test/dev files, only start/build scripts.
94    #[serde(default)]
95    pub production: bool,
96
97    /// Paths to external plugin files or directories containing plugin files.
98    ///
99    /// Supports TOML, JSON, and JSONC formats.
100    ///
101    /// In addition to these explicit paths, fallow automatically discovers:
102    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
103    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
104    #[serde(default)]
105    pub plugins: Vec<String>,
106
107    /// Per-file rule overrides matching oxlint's overrides pattern.
108    #[serde(default)]
109    pub overrides: Vec<ConfigOverride>,
110
111    /// Regression detection baseline embedded in config.
112    /// Stores issue counts from a known-good state for CI regression checks.
113    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub regression: Option<RegressionConfig>,
116}
117
118/// Regression baseline counts, embedded in the config file.
119///
120/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
121/// fallow reads the baseline from this config section.
122/// When `--save-regression-baseline` is used without a path argument,
123/// fallow writes the baseline into the config file.
124#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
125#[serde(rename_all = "camelCase")]
126pub struct RegressionConfig {
127    /// Dead code issue counts baseline.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub baseline: Option<RegressionBaseline>,
130}
131
132/// Per-type issue counts for regression comparison.
133#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
134#[serde(rename_all = "camelCase")]
135pub struct RegressionBaseline {
136    #[serde(default)]
137    pub total_issues: usize,
138    #[serde(default)]
139    pub unused_files: usize,
140    #[serde(default)]
141    pub unused_exports: usize,
142    #[serde(default)]
143    pub unused_types: usize,
144    #[serde(default)]
145    pub unused_dependencies: usize,
146    #[serde(default)]
147    pub unused_dev_dependencies: usize,
148    #[serde(default)]
149    pub unused_optional_dependencies: usize,
150    #[serde(default)]
151    pub unused_enum_members: usize,
152    #[serde(default)]
153    pub unused_class_members: usize,
154    #[serde(default)]
155    pub unresolved_imports: usize,
156    #[serde(default)]
157    pub unlisted_dependencies: usize,
158    #[serde(default)]
159    pub duplicate_exports: usize,
160    #[serde(default)]
161    pub circular_dependencies: usize,
162    #[serde(default)]
163    pub type_only_dependencies: usize,
164    #[serde(default)]
165    pub test_only_dependencies: usize,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    // ── Default trait ───────────────────────────────────────────────
173
174    #[test]
175    fn default_config_has_empty_collections() {
176        let config = FallowConfig::default();
177        assert!(config.schema.is_none());
178        assert!(config.extends.is_empty());
179        assert!(config.entry.is_empty());
180        assert!(config.ignore_patterns.is_empty());
181        assert!(config.framework.is_empty());
182        assert!(config.workspaces.is_none());
183        assert!(config.ignore_dependencies.is_empty());
184        assert!(config.ignore_exports.is_empty());
185        assert!(config.plugins.is_empty());
186        assert!(config.overrides.is_empty());
187        assert!(!config.production);
188    }
189
190    #[test]
191    fn default_config_rules_are_error() {
192        let config = FallowConfig::default();
193        assert_eq!(config.rules.unused_files, Severity::Error);
194        assert_eq!(config.rules.unused_exports, Severity::Error);
195        assert_eq!(config.rules.unused_dependencies, Severity::Error);
196    }
197
198    #[test]
199    fn default_config_duplicates_enabled() {
200        let config = FallowConfig::default();
201        assert!(config.duplicates.enabled);
202        assert_eq!(config.duplicates.min_tokens, 50);
203        assert_eq!(config.duplicates.min_lines, 5);
204    }
205
206    #[test]
207    fn default_config_health_thresholds() {
208        let config = FallowConfig::default();
209        assert_eq!(config.health.max_cyclomatic, 20);
210        assert_eq!(config.health.max_cognitive, 15);
211    }
212
213    // ── JSON deserialization ────────────────────────────────────────
214
215    #[test]
216    fn deserialize_empty_json_object() {
217        let config: FallowConfig = serde_json::from_str("{}").unwrap();
218        assert!(config.entry.is_empty());
219        assert!(!config.production);
220    }
221
222    #[test]
223    fn deserialize_json_with_all_top_level_fields() {
224        let json = r#"{
225            "$schema": "https://fallow.dev/schema.json",
226            "entry": ["src/main.ts"],
227            "ignorePatterns": ["generated/**"],
228            "ignoreDependencies": ["postcss"],
229            "production": true,
230            "plugins": ["custom-plugin.toml"],
231            "rules": {"unused-files": "warn"},
232            "duplicates": {"enabled": false},
233            "health": {"maxCyclomatic": 30}
234        }"#;
235        let config: FallowConfig = serde_json::from_str(json).unwrap();
236        assert_eq!(
237            config.schema.as_deref(),
238            Some("https://fallow.dev/schema.json")
239        );
240        assert_eq!(config.entry, vec!["src/main.ts"]);
241        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
242        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
243        assert!(config.production);
244        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
245        assert_eq!(config.rules.unused_files, Severity::Warn);
246        assert!(!config.duplicates.enabled);
247        assert_eq!(config.health.max_cyclomatic, 30);
248    }
249
250    #[test]
251    fn deserialize_json_deny_unknown_fields() {
252        let json = r#"{"unknownField": true}"#;
253        let result: Result<FallowConfig, _> = serde_json::from_str(json);
254        assert!(result.is_err(), "unknown fields should be rejected");
255    }
256
257    #[test]
258    fn deserialize_json_production_mode_default_false() {
259        let config: FallowConfig = serde_json::from_str("{}").unwrap();
260        assert!(!config.production);
261    }
262
263    #[test]
264    fn deserialize_json_production_mode_true() {
265        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
266        assert!(config.production);
267    }
268
269    // ── TOML deserialization ────────────────────────────────────────
270
271    #[test]
272    fn deserialize_toml_minimal() {
273        let toml_str = r#"
274entry = ["src/index.ts"]
275production = true
276"#;
277        let config: FallowConfig = toml::from_str(toml_str).unwrap();
278        assert_eq!(config.entry, vec!["src/index.ts"]);
279        assert!(config.production);
280    }
281
282    #[test]
283    fn deserialize_toml_with_inline_framework() {
284        let toml_str = r#"
285[[framework]]
286name = "my-framework"
287enablers = ["my-framework-pkg"]
288entryPoints = ["src/routes/**/*.tsx"]
289"#;
290        let config: FallowConfig = toml::from_str(toml_str).unwrap();
291        assert_eq!(config.framework.len(), 1);
292        assert_eq!(config.framework[0].name, "my-framework");
293        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
294        assert_eq!(
295            config.framework[0].entry_points,
296            vec!["src/routes/**/*.tsx"]
297        );
298    }
299
300    #[test]
301    fn deserialize_toml_with_workspace_config() {
302        let toml_str = r#"
303[workspaces]
304patterns = ["packages/*", "apps/*"]
305"#;
306        let config: FallowConfig = toml::from_str(toml_str).unwrap();
307        assert!(config.workspaces.is_some());
308        let ws = config.workspaces.unwrap();
309        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
310    }
311
312    #[test]
313    fn deserialize_toml_with_ignore_exports() {
314        let toml_str = r#"
315[[ignoreExports]]
316file = "src/types/**/*.ts"
317exports = ["*"]
318"#;
319        let config: FallowConfig = toml::from_str(toml_str).unwrap();
320        assert_eq!(config.ignore_exports.len(), 1);
321        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
322        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
323    }
324
325    #[test]
326    fn deserialize_toml_deny_unknown_fields() {
327        let toml_str = r"bogus_field = true";
328        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
329        assert!(result.is_err(), "unknown fields should be rejected");
330    }
331
332    // ── Serialization roundtrip ─────────────────────────────────────
333
334    #[test]
335    fn json_serialize_roundtrip() {
336        let config = FallowConfig {
337            entry: vec!["src/main.ts".to_string()],
338            production: true,
339            ..FallowConfig::default()
340        };
341        let json = serde_json::to_string(&config).unwrap();
342        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
343        assert_eq!(restored.entry, vec!["src/main.ts"]);
344        assert!(restored.production);
345    }
346
347    #[test]
348    fn schema_field_not_serialized() {
349        let config = FallowConfig {
350            schema: Some("https://example.com/schema.json".to_string()),
351            ..FallowConfig::default()
352        };
353        let json = serde_json::to_string(&config).unwrap();
354        // $schema has skip_serializing, should not appear in output
355        assert!(
356            !json.contains("$schema"),
357            "schema field should be skipped in serialization"
358        );
359    }
360
361    #[test]
362    fn extends_field_not_serialized() {
363        let config = FallowConfig {
364            extends: vec!["base.json".to_string()],
365            ..FallowConfig::default()
366        };
367        let json = serde_json::to_string(&config).unwrap();
368        assert!(
369            !json.contains("extends"),
370            "extends field should be skipped in serialization"
371        );
372    }
373}