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#[derive(Debug, Deserialize, Serialize, JsonSchema)]
17pub struct IgnoreExportRule {
18 pub file: String,
20 pub exports: Vec<String>,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct ConfigOverride {
28 pub files: Vec<String>,
30 #[serde(default)]
32 pub rules: PartialRulesConfig,
33}
34
35#[derive(Debug)]
37pub struct ResolvedOverride {
38 pub matchers: Vec<globset::GlobMatcher>,
39 pub rules: PartialRulesConfig,
40}
41
42#[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 pub production: bool,
59 pub quiet: bool,
61 pub external_plugins: Vec<ExternalPluginDef>,
63 pub overrides: Vec<ResolvedOverride>,
65}
66
67impl FallowConfig {
68 #[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 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 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 external_plugins.extend(self.framework);
123
124 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 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 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); 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 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
336 assert_eq!(rules.unused_files, Severity::Off);
337
338 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
340 assert_eq!(rules2.unused_files, Severity::Warn);
341 }
342}