Skip to main content

fallow_config/config/
resolution.rs

1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::duplicates_config::DuplicatesConfig;
8use super::format::OutputFormat;
9use super::health::HealthConfig;
10use super::rules::{PartialRulesConfig, RulesConfig, Severity};
11use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
12
13use super::FallowConfig;
14
15/// Rule for ignoring specific exports.
16#[derive(Debug, Deserialize, Serialize, JsonSchema)]
17pub struct IgnoreExportRule {
18    /// Glob pattern for files.
19    pub file: String,
20    /// Export names to ignore (`*` for all).
21    pub exports: Vec<String>,
22}
23
24/// Per-file override entry.
25#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct ConfigOverride {
28    /// Glob patterns to match files against (relative to config file location).
29    pub files: Vec<String>,
30    /// Partial rules — only specified fields override the base rules.
31    #[serde(default)]
32    pub rules: PartialRulesConfig,
33}
34
35/// Resolved override with pre-compiled glob matchers.
36#[derive(Debug)]
37pub struct ResolvedOverride {
38    pub matchers: Vec<globset::GlobMatcher>,
39    pub rules: PartialRulesConfig,
40}
41
42/// Fully resolved configuration with all globs pre-compiled.
43#[derive(Debug)]
44pub struct ResolvedConfig {
45    pub root: PathBuf,
46    pub entry_patterns: Vec<String>,
47    pub ignore_patterns: GlobSet,
48    pub output: OutputFormat,
49    pub cache_dir: PathBuf,
50    pub threads: usize,
51    pub no_cache: bool,
52    pub ignore_dependencies: Vec<String>,
53    pub ignore_export_rules: Vec<IgnoreExportRule>,
54    pub duplicates: DuplicatesConfig,
55    pub health: HealthConfig,
56    pub rules: RulesConfig,
57    /// Whether production mode is active.
58    pub production: bool,
59    /// Suppress progress output and non-essential stderr messages.
60    pub quiet: bool,
61    /// External plugin definitions (from plugin files + inline framework definitions).
62    pub external_plugins: Vec<ExternalPluginDef>,
63    /// Per-file rule overrides with pre-compiled glob matchers.
64    pub overrides: Vec<ResolvedOverride>,
65}
66
67impl FallowConfig {
68    /// Resolve into a fully resolved config with compiled globs.
69    #[expect(clippy::print_stderr)]
70    pub fn resolve(
71        self,
72        root: PathBuf,
73        output: OutputFormat,
74        threads: usize,
75        no_cache: bool,
76        quiet: bool,
77    ) -> ResolvedConfig {
78        let mut ignore_builder = GlobSetBuilder::new();
79        for pattern in &self.ignore_patterns {
80            match Glob::new(pattern) {
81                Ok(glob) => {
82                    ignore_builder.add(glob);
83                }
84                Err(e) => {
85                    eprintln!("Warning: Invalid ignore glob pattern '{pattern}': {e}");
86                }
87            }
88        }
89
90        // Default ignores
91        // Note: `build/` is only ignored at the project root (not `**/build/**`)
92        // because nested `build/` directories like `test/build/` may contain source files.
93        let default_ignores = [
94            "**/node_modules/**",
95            "**/dist/**",
96            "build/**",
97            "**/.git/**",
98            "**/coverage/**",
99            "**/*.min.js",
100            "**/*.min.mjs",
101        ];
102        for pattern in &default_ignores {
103            if let Ok(glob) = Glob::new(pattern) {
104                ignore_builder.add(glob);
105            }
106        }
107
108        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
109        let cache_dir = root.join(".fallow");
110
111        let mut rules = self.rules;
112
113        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
114        let production = self.production;
115        if production {
116            rules.unused_dev_dependencies = Severity::Off;
117            rules.unused_optional_dependencies = Severity::Off;
118        }
119
120        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
121        // Merge inline framework definitions into external plugins
122        external_plugins.extend(self.framework);
123
124        // Pre-compile override glob matchers
125        let overrides = self
126            .overrides
127            .into_iter()
128            .filter_map(|o| {
129                let matchers: Vec<globset::GlobMatcher> = o
130                    .files
131                    .iter()
132                    .filter_map(|pattern| match Glob::new(pattern) {
133                        Ok(glob) => Some(glob.compile_matcher()),
134                        Err(e) => {
135                            eprintln!("Warning: Invalid override glob pattern '{pattern}': {e}");
136                            None
137                        }
138                    })
139                    .collect();
140                if matchers.is_empty() {
141                    None
142                } else {
143                    Some(ResolvedOverride {
144                        matchers,
145                        rules: o.rules,
146                    })
147                }
148            })
149            .collect();
150
151        ResolvedConfig {
152            root,
153            entry_patterns: self.entry,
154            ignore_patterns: compiled_ignore_patterns,
155            output,
156            cache_dir,
157            threads,
158            no_cache,
159            ignore_dependencies: self.ignore_dependencies,
160            ignore_export_rules: self.ignore_exports,
161            duplicates: self.duplicates,
162            health: self.health,
163            rules,
164            production,
165            quiet,
166            external_plugins,
167            overrides,
168        }
169    }
170}
171
172impl ResolvedConfig {
173    /// Resolve the effective rules for a given file path.
174    /// Starts with base rules and applies matching overrides in order.
175    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
176        if self.overrides.is_empty() {
177            return self.rules.clone();
178        }
179
180        let relative = path.strip_prefix(&self.root).unwrap_or(path);
181        let relative_str = relative.to_string_lossy();
182
183        let mut rules = self.rules.clone();
184        for override_entry in &self.overrides {
185            let matches = override_entry
186                .matchers
187                .iter()
188                .any(|m| m.is_match(relative_str.as_ref()));
189            if matches {
190                rules.apply_partial(&override_entry.rules);
191            }
192        }
193        rules
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::config::health::HealthConfig;
201
202    #[test]
203    fn overrides_deserialize() {
204        let json_str = r#"{
205            "overrides": [{
206                "files": ["*.test.ts"],
207                "rules": {
208                    "unused-exports": "off"
209                }
210            }]
211        }"#;
212        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
213        assert_eq!(config.overrides.len(), 1);
214        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
215        assert_eq!(
216            config.overrides[0].rules.unused_exports,
217            Some(Severity::Off)
218        );
219        assert_eq!(config.overrides[0].rules.unused_files, None);
220    }
221
222    #[test]
223    fn resolve_rules_for_path_no_overrides() {
224        let config = FallowConfig {
225            schema: None,
226            extends: vec![],
227            entry: vec![],
228            ignore_patterns: vec![],
229            framework: vec![],
230            workspaces: None,
231            ignore_dependencies: vec![],
232            ignore_exports: vec![],
233            duplicates: DuplicatesConfig::default(),
234            health: HealthConfig::default(),
235            rules: RulesConfig::default(),
236            production: false,
237            plugins: vec![],
238            overrides: vec![],
239        };
240        let resolved = config.resolve(
241            PathBuf::from("/project"),
242            OutputFormat::Human,
243            1,
244            true,
245            true,
246        );
247        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
248        assert_eq!(rules.unused_files, Severity::Error);
249    }
250
251    #[test]
252    fn resolve_rules_for_path_with_matching_override() {
253        let config = FallowConfig {
254            schema: None,
255            extends: vec![],
256            entry: vec![],
257            ignore_patterns: vec![],
258            framework: vec![],
259            workspaces: None,
260            ignore_dependencies: vec![],
261            ignore_exports: vec![],
262            duplicates: DuplicatesConfig::default(),
263            health: HealthConfig::default(),
264            rules: RulesConfig::default(),
265            production: false,
266            plugins: vec![],
267            overrides: vec![ConfigOverride {
268                files: vec!["*.test.ts".to_string()],
269                rules: PartialRulesConfig {
270                    unused_exports: Some(Severity::Off),
271                    ..Default::default()
272                },
273            }],
274        };
275        let resolved = config.resolve(
276            PathBuf::from("/project"),
277            OutputFormat::Human,
278            1,
279            true,
280            true,
281        );
282
283        // Test file matches override
284        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
285        assert_eq!(test_rules.unused_exports, Severity::Off);
286        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
287
288        // Non-test file does not match
289        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
290        assert_eq!(src_rules.unused_exports, Severity::Error);
291    }
292
293    #[test]
294    fn resolve_rules_for_path_later_override_wins() {
295        let config = FallowConfig {
296            schema: None,
297            extends: vec![],
298            entry: vec![],
299            ignore_patterns: vec![],
300            framework: vec![],
301            workspaces: None,
302            ignore_dependencies: vec![],
303            ignore_exports: vec![],
304            duplicates: DuplicatesConfig::default(),
305            health: HealthConfig::default(),
306            rules: RulesConfig::default(),
307            production: false,
308            plugins: vec![],
309            overrides: vec![
310                ConfigOverride {
311                    files: vec!["*.ts".to_string()],
312                    rules: PartialRulesConfig {
313                        unused_files: Some(Severity::Warn),
314                        ..Default::default()
315                    },
316                },
317                ConfigOverride {
318                    files: vec!["*.test.ts".to_string()],
319                    rules: PartialRulesConfig {
320                        unused_files: Some(Severity::Off),
321                        ..Default::default()
322                    },
323                },
324            ],
325        };
326        let resolved = config.resolve(
327            PathBuf::from("/project"),
328            OutputFormat::Human,
329            1,
330            true,
331            true,
332        );
333
334        // First override matches *.ts, second matches *.test.ts; second wins
335        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
336        assert_eq!(rules.unused_files, Severity::Off);
337
338        // Non-test .ts file only matches first override
339        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
340        assert_eq!(rules2.unused_files, Severity::Warn);
341    }
342}