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 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 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); 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 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
335 assert_eq!(rules.unused_files, Severity::Off);
336
337 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
339 assert_eq!(rules2.unused_files, Severity::Warn);
340 }
341}