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
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    // ── Default trait ───────────────────────────────────────────────
117
118    #[test]
119    fn default_config_has_empty_collections() {
120        let config = FallowConfig::default();
121        assert!(config.schema.is_none());
122        assert!(config.extends.is_empty());
123        assert!(config.entry.is_empty());
124        assert!(config.ignore_patterns.is_empty());
125        assert!(config.framework.is_empty());
126        assert!(config.workspaces.is_none());
127        assert!(config.ignore_dependencies.is_empty());
128        assert!(config.ignore_exports.is_empty());
129        assert!(config.plugins.is_empty());
130        assert!(config.overrides.is_empty());
131        assert!(!config.production);
132    }
133
134    #[test]
135    fn default_config_rules_are_error() {
136        let config = FallowConfig::default();
137        assert_eq!(config.rules.unused_files, Severity::Error);
138        assert_eq!(config.rules.unused_exports, Severity::Error);
139        assert_eq!(config.rules.unused_dependencies, Severity::Error);
140    }
141
142    #[test]
143    fn default_config_duplicates_enabled() {
144        let config = FallowConfig::default();
145        assert!(config.duplicates.enabled);
146        assert_eq!(config.duplicates.min_tokens, 50);
147        assert_eq!(config.duplicates.min_lines, 5);
148    }
149
150    #[test]
151    fn default_config_health_thresholds() {
152        let config = FallowConfig::default();
153        assert_eq!(config.health.max_cyclomatic, 20);
154        assert_eq!(config.health.max_cognitive, 15);
155    }
156
157    // ── JSON deserialization ────────────────────────────────────────
158
159    #[test]
160    fn deserialize_empty_json_object() {
161        let config: FallowConfig = serde_json::from_str("{}").unwrap();
162        assert!(config.entry.is_empty());
163        assert!(!config.production);
164    }
165
166    #[test]
167    fn deserialize_json_with_all_top_level_fields() {
168        let json = r#"{
169            "$schema": "https://fallow.dev/schema.json",
170            "entry": ["src/main.ts"],
171            "ignorePatterns": ["generated/**"],
172            "ignoreDependencies": ["postcss"],
173            "production": true,
174            "plugins": ["custom-plugin.toml"],
175            "rules": {"unused-files": "warn"},
176            "duplicates": {"enabled": false},
177            "health": {"maxCyclomatic": 30}
178        }"#;
179        let config: FallowConfig = serde_json::from_str(json).unwrap();
180        assert_eq!(
181            config.schema.as_deref(),
182            Some("https://fallow.dev/schema.json")
183        );
184        assert_eq!(config.entry, vec!["src/main.ts"]);
185        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
186        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
187        assert!(config.production);
188        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
189        assert_eq!(config.rules.unused_files, Severity::Warn);
190        assert!(!config.duplicates.enabled);
191        assert_eq!(config.health.max_cyclomatic, 30);
192    }
193
194    #[test]
195    fn deserialize_json_deny_unknown_fields() {
196        let json = r#"{"unknownField": true}"#;
197        let result: Result<FallowConfig, _> = serde_json::from_str(json);
198        assert!(result.is_err(), "unknown fields should be rejected");
199    }
200
201    #[test]
202    fn deserialize_json_production_mode_default_false() {
203        let config: FallowConfig = serde_json::from_str("{}").unwrap();
204        assert!(!config.production);
205    }
206
207    #[test]
208    fn deserialize_json_production_mode_true() {
209        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
210        assert!(config.production);
211    }
212
213    // ── TOML deserialization ────────────────────────────────────────
214
215    #[test]
216    fn deserialize_toml_minimal() {
217        let toml_str = r#"
218entry = ["src/index.ts"]
219production = true
220"#;
221        let config: FallowConfig = toml::from_str(toml_str).unwrap();
222        assert_eq!(config.entry, vec!["src/index.ts"]);
223        assert!(config.production);
224    }
225
226    #[test]
227    fn deserialize_toml_with_inline_framework() {
228        let toml_str = r#"
229[[framework]]
230name = "my-framework"
231enablers = ["my-framework-pkg"]
232entryPoints = ["src/routes/**/*.tsx"]
233"#;
234        let config: FallowConfig = toml::from_str(toml_str).unwrap();
235        assert_eq!(config.framework.len(), 1);
236        assert_eq!(config.framework[0].name, "my-framework");
237        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
238        assert_eq!(
239            config.framework[0].entry_points,
240            vec!["src/routes/**/*.tsx"]
241        );
242    }
243
244    #[test]
245    fn deserialize_toml_with_workspace_config() {
246        let toml_str = r#"
247[workspaces]
248patterns = ["packages/*", "apps/*"]
249"#;
250        let config: FallowConfig = toml::from_str(toml_str).unwrap();
251        assert!(config.workspaces.is_some());
252        let ws = config.workspaces.unwrap();
253        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
254    }
255
256    #[test]
257    fn deserialize_toml_with_ignore_exports() {
258        let toml_str = r#"
259[[ignoreExports]]
260file = "src/types/**/*.ts"
261exports = ["*"]
262"#;
263        let config: FallowConfig = toml::from_str(toml_str).unwrap();
264        assert_eq!(config.ignore_exports.len(), 1);
265        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
266        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
267    }
268
269    #[test]
270    fn deserialize_toml_deny_unknown_fields() {
271        let toml_str = r"bogus_field = true";
272        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
273        assert!(result.is_err(), "unknown fields should be rejected");
274    }
275
276    // ── Serialization roundtrip ─────────────────────────────────────
277
278    #[test]
279    fn json_serialize_roundtrip() {
280        let config = FallowConfig {
281            entry: vec!["src/main.ts".to_string()],
282            production: true,
283            ..FallowConfig::default()
284        };
285        let json = serde_json::to_string(&config).unwrap();
286        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
287        assert_eq!(restored.entry, vec!["src/main.ts"]);
288        assert!(restored.production);
289    }
290
291    #[test]
292    fn schema_field_not_serialized() {
293        let config = FallowConfig {
294            schema: Some("https://example.com/schema.json".to_string()),
295            ..FallowConfig::default()
296        };
297        let json = serde_json::to_string(&config).unwrap();
298        // $schema has skip_serializing, should not appear in output
299        assert!(
300            !json.contains("$schema"),
301            "schema field should be skipped in serialization"
302        );
303    }
304
305    #[test]
306    fn extends_field_not_serialized() {
307        let config = FallowConfig {
308            extends: vec!["base.json".to_string()],
309            ..FallowConfig::default()
310        };
311        let json = serde_json::to_string(&config).unwrap();
312        assert!(
313            !json.contains("extends"),
314            "extends field should be skipped in serialization"
315        );
316    }
317}