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