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 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 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 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 external_plugins.extend(self.framework);
122
123 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 #[must_use]
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 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}