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    pub fn resolve(
70        self,
71        root: PathBuf,
72        output: OutputFormat,
73        threads: usize,
74        no_cache: bool,
75        quiet: bool,
76    ) -> ResolvedConfig {
77        let mut ignore_builder = GlobSetBuilder::new();
78        for pattern in &self.ignore_patterns {
79            match Glob::new(pattern) {
80                Ok(glob) => {
81                    ignore_builder.add(glob);
82                }
83                Err(e) => {
84                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
85                }
86            }
87        }
88
89        // Default ignores
90        // Note: `build/` is only ignored at the project root (not `**/build/**`)
91        // because nested `build/` directories like `test/build/` may contain source files.
92        let default_ignores = [
93            "**/node_modules/**",
94            "**/dist/**",
95            "build/**",
96            "**/.git/**",
97            "**/coverage/**",
98            "**/*.min.js",
99            "**/*.min.mjs",
100        ];
101        for pattern in &default_ignores {
102            if let Ok(glob) = Glob::new(pattern) {
103                ignore_builder.add(glob);
104            }
105        }
106
107        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
108        let cache_dir = root.join(".fallow");
109
110        let mut rules = self.rules;
111
112        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
113        let production = self.production;
114        if production {
115            rules.unused_dev_dependencies = Severity::Off;
116            rules.unused_optional_dependencies = Severity::Off;
117        }
118
119        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
120        // Merge inline framework definitions into external plugins
121        external_plugins.extend(self.framework);
122
123        // Pre-compile override glob matchers
124        let overrides = self
125            .overrides
126            .into_iter()
127            .filter_map(|o| {
128                let matchers: Vec<globset::GlobMatcher> = o
129                    .files
130                    .iter()
131                    .filter_map(|pattern| match Glob::new(pattern) {
132                        Ok(glob) => Some(glob.compile_matcher()),
133                        Err(e) => {
134                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
135                            None
136                        }
137                    })
138                    .collect();
139                if matchers.is_empty() {
140                    None
141                } else {
142                    Some(ResolvedOverride {
143                        matchers,
144                        rules: o.rules,
145                    })
146                }
147            })
148            .collect();
149
150        ResolvedConfig {
151            root,
152            entry_patterns: self.entry,
153            ignore_patterns: compiled_ignore_patterns,
154            output,
155            cache_dir,
156            threads,
157            no_cache,
158            ignore_dependencies: self.ignore_dependencies,
159            ignore_export_rules: self.ignore_exports,
160            duplicates: self.duplicates,
161            health: self.health,
162            rules,
163            production,
164            quiet,
165            external_plugins,
166            overrides,
167        }
168    }
169}
170
171impl ResolvedConfig {
172    /// Resolve the effective rules for a given file path.
173    /// Starts with base rules and applies matching overrides in order.
174    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
175        if self.overrides.is_empty() {
176            return self.rules.clone();
177        }
178
179        let relative = path.strip_prefix(&self.root).unwrap_or(path);
180        let relative_str = relative.to_string_lossy();
181
182        let mut rules = self.rules.clone();
183        for override_entry in &self.overrides {
184            let matches = override_entry
185                .matchers
186                .iter()
187                .any(|m| m.is_match(relative_str.as_ref()));
188            if matches {
189                rules.apply_partial(&override_entry.rules);
190            }
191        }
192        rules
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::config::health::HealthConfig;
200
201    #[test]
202    fn overrides_deserialize() {
203        let json_str = r#"{
204            "overrides": [{
205                "files": ["*.test.ts"],
206                "rules": {
207                    "unused-exports": "off"
208                }
209            }]
210        }"#;
211        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
212        assert_eq!(config.overrides.len(), 1);
213        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
214        assert_eq!(
215            config.overrides[0].rules.unused_exports,
216            Some(Severity::Off)
217        );
218        assert_eq!(config.overrides[0].rules.unused_files, None);
219    }
220
221    #[test]
222    fn resolve_rules_for_path_no_overrides() {
223        let config = FallowConfig {
224            schema: None,
225            extends: vec![],
226            entry: vec![],
227            ignore_patterns: vec![],
228            framework: vec![],
229            workspaces: None,
230            ignore_dependencies: vec![],
231            ignore_exports: vec![],
232            duplicates: DuplicatesConfig::default(),
233            health: HealthConfig::default(),
234            rules: RulesConfig::default(),
235            production: false,
236            plugins: vec![],
237            overrides: vec![],
238        };
239        let resolved = config.resolve(
240            PathBuf::from("/project"),
241            OutputFormat::Human,
242            1,
243            true,
244            true,
245        );
246        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
247        assert_eq!(rules.unused_files, Severity::Error);
248    }
249
250    #[test]
251    fn resolve_rules_for_path_with_matching_override() {
252        let config = FallowConfig {
253            schema: None,
254            extends: vec![],
255            entry: vec![],
256            ignore_patterns: vec![],
257            framework: vec![],
258            workspaces: None,
259            ignore_dependencies: vec![],
260            ignore_exports: vec![],
261            duplicates: DuplicatesConfig::default(),
262            health: HealthConfig::default(),
263            rules: RulesConfig::default(),
264            production: false,
265            plugins: vec![],
266            overrides: vec![ConfigOverride {
267                files: vec!["*.test.ts".to_string()],
268                rules: PartialRulesConfig {
269                    unused_exports: Some(Severity::Off),
270                    ..Default::default()
271                },
272            }],
273        };
274        let resolved = config.resolve(
275            PathBuf::from("/project"),
276            OutputFormat::Human,
277            1,
278            true,
279            true,
280        );
281
282        // Test file matches override
283        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
284        assert_eq!(test_rules.unused_exports, Severity::Off);
285        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
286
287        // Non-test file does not match
288        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
289        assert_eq!(src_rules.unused_exports, Severity::Error);
290    }
291
292    #[test]
293    fn resolve_rules_for_path_later_override_wins() {
294        let config = FallowConfig {
295            schema: None,
296            extends: vec![],
297            entry: vec![],
298            ignore_patterns: vec![],
299            framework: vec![],
300            workspaces: None,
301            ignore_dependencies: vec![],
302            ignore_exports: vec![],
303            duplicates: DuplicatesConfig::default(),
304            health: HealthConfig::default(),
305            rules: RulesConfig::default(),
306            production: false,
307            plugins: vec![],
308            overrides: vec![
309                ConfigOverride {
310                    files: vec!["*.ts".to_string()],
311                    rules: PartialRulesConfig {
312                        unused_files: Some(Severity::Warn),
313                        ..Default::default()
314                    },
315                },
316                ConfigOverride {
317                    files: vec!["*.test.ts".to_string()],
318                    rules: PartialRulesConfig {
319                        unused_files: Some(Severity::Off),
320                        ..Default::default()
321                    },
322                },
323            ],
324        };
325        let resolved = config.resolve(
326            PathBuf::from("/project"),
327            OutputFormat::Human,
328            1,
329            true,
330            true,
331        );
332
333        // First override matches *.ts, second matches *.test.ts; second wins
334        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
335        assert_eq!(rules.unused_files, Severity::Off);
336
337        // Non-test .ts file only matches first override
338        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
339        assert_eq!(rules2.unused_files, Severity::Warn);
340    }
341}