1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use serde::{Deserialize, Serialize};
5
6use crate::framework::FrameworkPreset;
7use crate::workspace::WorkspaceConfig;
8
9#[derive(Debug, Deserialize, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct FallowConfig {
13 #[serde(default)]
15 pub entry: Vec<String>,
16
17 #[serde(default)]
19 pub ignore: Vec<String>,
20
21 #[serde(default)]
23 pub detect: DetectConfig,
24
25 #[serde(default)]
27 pub framework: Vec<FrameworkPreset>,
28
29 #[serde(default)]
31 pub workspaces: Option<WorkspaceConfig>,
32
33 #[serde(default)]
35 pub ignore_dependencies: Vec<String>,
36
37 #[serde(default)]
39 pub ignore_exports: Vec<IgnoreExportRule>,
40
41 #[serde(default)]
43 pub output: OutputFormat,
44
45 #[serde(default)]
47 pub duplicates: DuplicatesConfig,
48}
49
50#[derive(Debug, Deserialize, Serialize)]
52pub struct DuplicatesConfig {
53 #[serde(default = "default_true")]
55 pub enabled: bool,
56
57 #[serde(default)]
59 pub mode: DuplicatesMode,
60
61 #[serde(default = "default_min_tokens")]
63 pub min_tokens: usize,
64
65 #[serde(default = "default_min_lines")]
67 pub min_lines: usize,
68
69 #[serde(default)]
71 pub threshold: f64,
72
73 #[serde(default)]
75 pub ignore: Vec<String>,
76
77 #[serde(default)]
79 pub skip_local: bool,
80}
81
82impl Default for DuplicatesConfig {
83 fn default() -> Self {
84 Self {
85 enabled: true,
86 mode: DuplicatesMode::default(),
87 min_tokens: default_min_tokens(),
88 min_lines: default_min_lines(),
89 threshold: 0.0,
90 ignore: vec![],
91 skip_local: false,
92 }
93 }
94}
95
96#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
98#[serde(rename_all = "lowercase")]
99pub enum DuplicatesMode {
100 Strict,
102 #[default]
104 Mild,
105 Weak,
107 Semantic,
109}
110
111const fn default_min_tokens() -> usize {
112 50
113}
114
115const fn default_min_lines() -> usize {
116 5
117}
118
119#[derive(Debug, Deserialize, Serialize)]
121pub struct DetectConfig {
122 #[serde(default = "default_true")]
124 pub unused_files: bool,
125
126 #[serde(default = "default_true")]
128 pub unused_exports: bool,
129
130 #[serde(default = "default_true")]
132 pub unused_dependencies: bool,
133
134 #[serde(default = "default_true")]
136 pub unused_dev_dependencies: bool,
137
138 #[serde(default = "default_true")]
140 pub unused_types: bool,
141
142 #[serde(default = "default_true")]
144 pub unused_enum_members: bool,
145
146 #[serde(default = "default_true")]
148 pub unused_class_members: bool,
149
150 #[serde(default = "default_true")]
152 pub unresolved_imports: bool,
153
154 #[serde(default = "default_true")]
156 pub unlisted_dependencies: bool,
157
158 #[serde(default = "default_true")]
160 pub duplicate_exports: bool,
161}
162
163impl Default for DetectConfig {
164 fn default() -> Self {
165 Self {
166 unused_files: true,
167 unused_exports: true,
168 unused_dependencies: true,
169 unused_dev_dependencies: true,
170 unused_types: true,
171 unused_enum_members: true,
172 unused_class_members: true,
173 unresolved_imports: true,
174 unlisted_dependencies: true,
175 duplicate_exports: true,
176 }
177 }
178}
179
180#[derive(Debug, Default, Clone, Deserialize, Serialize)]
182#[serde(rename_all = "lowercase")]
183pub enum OutputFormat {
184 #[default]
186 Human,
187 Json,
189 Sarif,
191 Compact,
193}
194
195#[derive(Debug, Deserialize, Serialize)]
197pub struct IgnoreExportRule {
198 pub file: String,
200 pub exports: Vec<String>,
202}
203
204#[derive(Debug)]
206pub struct ResolvedConfig {
207 pub root: PathBuf,
208 pub entry_patterns: Vec<String>,
209 pub ignore_patterns: GlobSet,
210 pub detect: DetectConfig,
211 pub framework_rules: Vec<crate::framework::FrameworkRule>,
212 pub output: OutputFormat,
213 pub cache_dir: PathBuf,
214 pub threads: usize,
215 pub no_cache: bool,
216 pub ignore_dependencies: Vec<String>,
217 pub ignore_export_rules: Vec<IgnoreExportRule>,
218 pub duplicates: DuplicatesConfig,
219}
220
221impl FallowConfig {
222 pub fn load(path: &Path) -> Result<Self, miette::Report> {
224 let content = std::fs::read_to_string(path)
225 .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
226 toml::from_str(&content)
227 .map_err(|e| miette::miette!("Failed to parse config file {}: {}", path.display(), e))
228 }
229
230 pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
238 let config_names = ["fallow.toml", ".fallow.toml"];
239
240 let mut dir = start;
241 loop {
242 for name in &config_names {
243 let candidate = dir.join(name);
244 if candidate.exists() {
245 match Self::load(&candidate) {
246 Ok(config) => return Ok(Some((config, candidate))),
247 Err(e) => {
248 return Err(format!("Failed to parse {}: {e}", candidate.display()));
249 }
250 }
251 }
252 }
253 if dir.join(".git").exists() || dir.join("package.json").exists() {
255 break;
256 }
257 dir = match dir.parent() {
258 Some(parent) => parent,
259 None => break,
260 };
261 }
262 Ok(None)
263 }
264
265 pub fn resolve(self, root: PathBuf, threads: usize, no_cache: bool) -> ResolvedConfig {
267 let mut ignore_builder = GlobSetBuilder::new();
268 for pattern in &self.ignore {
269 match Glob::new(pattern) {
270 Ok(glob) => {
271 ignore_builder.add(glob);
272 }
273 Err(e) => {
274 eprintln!("Warning: Invalid ignore glob pattern '{pattern}': {e}");
275 }
276 }
277 }
278
279 let default_ignores = [
281 "**/node_modules/**",
282 "**/dist/**",
283 "**/build/**",
284 "**/.git/**",
285 "**/coverage/**",
286 "**/*.min.js",
287 "**/*.min.mjs",
288 ];
289 for pattern in &default_ignores {
290 if let Ok(glob) = Glob::new(pattern) {
291 ignore_builder.add(glob);
292 }
293 }
294
295 let ignore_patterns = ignore_builder.build().unwrap_or_default();
296 let cache_dir = root.join(".fallow");
297
298 let framework_rules = crate::framework::resolve_framework_rules(&self.framework);
299
300 ResolvedConfig {
301 root,
302 entry_patterns: self.entry,
303 ignore_patterns,
304 detect: self.detect,
305 framework_rules,
306 output: self.output,
307 cache_dir,
308 threads,
309 no_cache,
310 ignore_dependencies: self.ignore_dependencies,
311 ignore_export_rules: self.ignore_exports,
312 duplicates: self.duplicates,
313 }
314 }
315}
316
317const fn default_true() -> bool {
318 true
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::PackageJson;
325
326 #[test]
327 fn detect_config_default_all_true() {
328 let config = DetectConfig::default();
329 assert!(config.unused_files);
330 assert!(config.unused_exports);
331 assert!(config.unused_dependencies);
332 assert!(config.unused_dev_dependencies);
333 assert!(config.unused_types);
334 assert!(config.unused_enum_members);
335 assert!(config.unused_class_members);
336 assert!(config.unresolved_imports);
337 assert!(config.unlisted_dependencies);
338 assert!(config.duplicate_exports);
339 }
340
341 #[test]
342 fn output_format_default_is_human() {
343 let format = OutputFormat::default();
344 assert!(matches!(format, OutputFormat::Human));
345 }
346
347 #[test]
348 fn fallow_config_deserialize_minimal() {
349 let toml_str = r#"
350entry = ["src/main.ts"]
351"#;
352 let config: FallowConfig = toml::from_str(toml_str).unwrap();
353 assert_eq!(config.entry, vec!["src/main.ts"]);
354 assert!(config.ignore.is_empty());
355 assert!(config.detect.unused_files); }
357
358 #[test]
359 fn fallow_config_deserialize_detect_overrides() {
360 let toml_str = r#"
361[detect]
362unused_files = false
363unused_exports = true
364unused_dependencies = false
365"#;
366 let config: FallowConfig = toml::from_str(toml_str).unwrap();
367 assert!(!config.detect.unused_files);
368 assert!(config.detect.unused_exports);
369 assert!(!config.detect.unused_dependencies);
370 assert!(config.detect.unused_types);
372 }
373
374 #[test]
375 fn fallow_config_deserialize_ignore_exports() {
376 let toml_str = r#"
377[[ignore_exports]]
378file = "src/types/*.ts"
379exports = ["*"]
380
381[[ignore_exports]]
382file = "src/constants.ts"
383exports = ["FOO", "BAR"]
384"#;
385 let config: FallowConfig = toml::from_str(toml_str).unwrap();
386 assert_eq!(config.ignore_exports.len(), 2);
387 assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
388 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
389 assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
390 }
391
392 #[test]
393 fn fallow_config_deserialize_ignore_dependencies() {
394 let toml_str = r#"
395ignore_dependencies = ["autoprefixer", "postcss"]
396"#;
397 let config: FallowConfig = toml::from_str(toml_str).unwrap();
398 assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
399 }
400
401 #[test]
402 fn fallow_config_resolve_default_ignores() {
403 let config = FallowConfig {
404 entry: vec![],
405 ignore: vec![],
406 detect: DetectConfig::default(),
407 framework: vec![],
408 workspaces: None,
409 ignore_dependencies: vec![],
410 ignore_exports: vec![],
411 output: OutputFormat::Human,
412 duplicates: DuplicatesConfig::default(),
413 };
414 let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
415
416 assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
418 assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
419 assert!(resolved.ignore_patterns.is_match("build/output.js"));
420 assert!(resolved.ignore_patterns.is_match(".git/config"));
421 assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
422 assert!(resolved.ignore_patterns.is_match("foo.min.js"));
423 assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
424 }
425
426 #[test]
427 fn fallow_config_resolve_custom_ignores() {
428 let config = FallowConfig {
429 entry: vec!["src/**/*.ts".to_string()],
430 ignore: vec!["**/*.generated.ts".to_string()],
431 detect: DetectConfig::default(),
432 framework: vec![],
433 workspaces: None,
434 ignore_dependencies: vec![],
435 ignore_exports: vec![],
436 output: OutputFormat::Json,
437 duplicates: DuplicatesConfig::default(),
438 };
439 let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, false);
440
441 assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
442 assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
443 assert!(matches!(resolved.output, OutputFormat::Json));
444 assert!(!resolved.no_cache);
445 }
446
447 #[test]
448 fn fallow_config_resolve_cache_dir() {
449 let config = FallowConfig {
450 entry: vec![],
451 ignore: vec![],
452 detect: DetectConfig::default(),
453 framework: vec![],
454 workspaces: None,
455 ignore_dependencies: vec![],
456 ignore_exports: vec![],
457 output: OutputFormat::Human,
458 duplicates: DuplicatesConfig::default(),
459 };
460 let resolved = config.resolve(PathBuf::from("/tmp/project"), 4, true);
461 assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
462 assert!(resolved.no_cache);
463 }
464
465 #[test]
466 fn package_json_entry_points_main() {
467 let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
468 let entries = pkg.entry_points();
469 assert!(entries.contains(&"dist/index.js".to_string()));
470 }
471
472 #[test]
473 fn package_json_entry_points_module() {
474 let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
475 let entries = pkg.entry_points();
476 assert!(entries.contains(&"dist/index.mjs".to_string()));
477 }
478
479 #[test]
480 fn package_json_entry_points_types() {
481 let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
482 let entries = pkg.entry_points();
483 assert!(entries.contains(&"dist/index.d.ts".to_string()));
484 }
485
486 #[test]
487 fn package_json_entry_points_bin_string() {
488 let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
489 let entries = pkg.entry_points();
490 assert!(entries.contains(&"bin/cli.js".to_string()));
491 }
492
493 #[test]
494 fn package_json_entry_points_bin_object() {
495 let pkg: PackageJson =
496 serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
497 .unwrap();
498 let entries = pkg.entry_points();
499 assert!(entries.contains(&"bin/cli.js".to_string()));
500 assert!(entries.contains(&"bin/serve.js".to_string()));
501 }
502
503 #[test]
504 fn package_json_entry_points_exports_string() {
505 let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
506 let entries = pkg.entry_points();
507 assert!(entries.contains(&"./dist/index.js".to_string()));
508 }
509
510 #[test]
511 fn package_json_entry_points_exports_object() {
512 let pkg: PackageJson = serde_json::from_str(
513 r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
514 )
515 .unwrap();
516 let entries = pkg.entry_points();
517 assert!(entries.contains(&"./dist/index.mjs".to_string()));
518 assert!(entries.contains(&"./dist/index.cjs".to_string()));
519 }
520
521 #[test]
522 fn package_json_dependency_names() {
523 let pkg: PackageJson = serde_json::from_str(
524 r#"{
525 "dependencies": {"react": "^18", "lodash": "^4"},
526 "devDependencies": {"typescript": "^5"},
527 "peerDependencies": {"react-dom": "^18"}
528 }"#,
529 )
530 .unwrap();
531
532 let all = pkg.all_dependency_names();
533 assert!(all.contains(&"react".to_string()));
534 assert!(all.contains(&"lodash".to_string()));
535 assert!(all.contains(&"typescript".to_string()));
536 assert!(all.contains(&"react-dom".to_string()));
537
538 let prod = pkg.production_dependency_names();
539 assert!(prod.contains(&"react".to_string()));
540 assert!(!prod.contains(&"typescript".to_string()));
541
542 let dev = pkg.dev_dependency_names();
543 assert!(dev.contains(&"typescript".to_string()));
544 assert!(!dev.contains(&"react".to_string()));
545 }
546
547 #[test]
548 fn package_json_no_dependencies() {
549 let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
550 assert!(pkg.all_dependency_names().is_empty());
551 assert!(pkg.production_dependency_names().is_empty());
552 assert!(pkg.dev_dependency_names().is_empty());
553 assert!(pkg.entry_points().is_empty());
554 }
555
556 #[test]
557 fn fallow_config_denies_unknown_fields() {
558 let toml_str = r#"
559unknown_field = true
560"#;
561 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
562 assert!(result.is_err());
563 }
564}