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#[derive(Debug, Deserialize, Serialize, JsonSchema)]
16pub struct IgnoreExportRule {
17 pub file: String,
19 pub exports: Vec<String>,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
25#[serde(rename_all = "camelCase")]
26pub struct ConfigOverride {
27 pub files: Vec<String>,
29 #[serde(default)]
31 pub rules: PartialRulesConfig,
32}
33
34#[derive(Debug)]
36pub struct ResolvedOverride {
37 pub matchers: Vec<globset::GlobMatcher>,
38 pub rules: PartialRulesConfig,
39}
40
41#[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 pub production: bool,
57 pub quiet: bool,
59 pub external_plugins: Vec<ExternalPluginDef>,
61 pub overrides: Vec<ResolvedOverride>,
63}
64
65impl FallowConfig {
66 #[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 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 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 external_plugins.extend(self.framework);
121
122 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 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 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); 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 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
329 assert_eq!(rules.unused_files, Severity::Off);
330
331 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
333 assert_eq!(rules2.unused_files, Severity::Warn);
334 }
335}