Skip to main content

fallow_config/
config.rs

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/// User-facing configuration loaded from `fallow.toml`.
10#[derive(Debug, Deserialize, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct FallowConfig {
13    /// Additional entry point glob patterns.
14    #[serde(default)]
15    pub entry: Vec<String>,
16
17    /// Glob patterns to ignore from analysis.
18    #[serde(default)]
19    pub ignore: Vec<String>,
20
21    /// What to detect.
22    #[serde(default)]
23    pub detect: DetectConfig,
24
25    /// Custom framework definitions.
26    #[serde(default)]
27    pub framework: Vec<FrameworkPreset>,
28
29    /// Workspace overrides.
30    #[serde(default)]
31    pub workspaces: Option<WorkspaceConfig>,
32
33    /// Dependencies to ignore (always considered used).
34    #[serde(default)]
35    pub ignore_dependencies: Vec<String>,
36
37    /// Export ignore rules.
38    #[serde(default)]
39    pub ignore_exports: Vec<IgnoreExportRule>,
40
41    /// Output format.
42    #[serde(default)]
43    pub output: OutputFormat,
44
45    /// Duplication detection settings.
46    #[serde(default)]
47    pub duplicates: DuplicatesConfig,
48}
49
50/// Configuration for code duplication detection.
51#[derive(Debug, Deserialize, Serialize)]
52pub struct DuplicatesConfig {
53    /// Whether duplication detection is enabled.
54    #[serde(default = "default_true")]
55    pub enabled: bool,
56
57    /// Detection mode: strict, mild, weak, or semantic.
58    #[serde(default)]
59    pub mode: DuplicatesMode,
60
61    /// Minimum number of tokens for a clone.
62    #[serde(default = "default_min_tokens")]
63    pub min_tokens: usize,
64
65    /// Minimum number of lines for a clone.
66    #[serde(default = "default_min_lines")]
67    pub min_lines: usize,
68
69    /// Maximum allowed duplication percentage (0 = no limit).
70    #[serde(default)]
71    pub threshold: f64,
72
73    /// Additional ignore patterns for duplication analysis.
74    #[serde(default)]
75    pub ignore: Vec<String>,
76
77    /// Only report cross-directory duplicates.
78    #[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/// Detection mode for duplication analysis.
97#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
98#[serde(rename_all = "lowercase")]
99pub enum DuplicatesMode {
100    /// All tokens preserved (Type-1 only).
101    Strict,
102    /// Skip whitespace/newline tokens (default).
103    #[default]
104    Mild,
105    /// Also skip comment tokens.
106    Weak,
107    /// Blind identifiers and literals (Type-2 detection).
108    Semantic,
109}
110
111const fn default_min_tokens() -> usize {
112    50
113}
114
115const fn default_min_lines() -> usize {
116    5
117}
118
119/// Controls which analyses to run.
120#[derive(Debug, Deserialize, Serialize)]
121pub struct DetectConfig {
122    /// Detect unused files (not reachable from entry points).
123    #[serde(default = "default_true")]
124    pub unused_files: bool,
125
126    /// Detect unused exports (exported but never imported).
127    #[serde(default = "default_true")]
128    pub unused_exports: bool,
129
130    /// Detect unused production dependencies.
131    #[serde(default = "default_true")]
132    pub unused_dependencies: bool,
133
134    /// Detect unused dev dependencies.
135    #[serde(default = "default_true")]
136    pub unused_dev_dependencies: bool,
137
138    /// Detect unused type exports.
139    #[serde(default = "default_true")]
140    pub unused_types: bool,
141
142    /// Detect unused enum members.
143    #[serde(default = "default_true")]
144    pub unused_enum_members: bool,
145
146    /// Detect unused class members.
147    #[serde(default = "default_true")]
148    pub unused_class_members: bool,
149
150    /// Detect unresolved imports.
151    #[serde(default = "default_true")]
152    pub unresolved_imports: bool,
153
154    /// Detect unlisted dependencies (used but not in package.json).
155    #[serde(default = "default_true")]
156    pub unlisted_dependencies: bool,
157
158    /// Detect duplicate exports.
159    #[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/// Output format for results.
181#[derive(Debug, Default, Clone, Deserialize, Serialize)]
182#[serde(rename_all = "lowercase")]
183pub enum OutputFormat {
184    /// Human-readable terminal output with source context.
185    #[default]
186    Human,
187    /// Machine-readable JSON.
188    Json,
189    /// SARIF format for GitHub Code Scanning.
190    Sarif,
191    /// One issue per line (grep-friendly).
192    Compact,
193}
194
195/// Rule for ignoring specific exports.
196#[derive(Debug, Deserialize, Serialize)]
197pub struct IgnoreExportRule {
198    /// Glob pattern for files.
199    pub file: String,
200    /// Export names to ignore (`*` for all).
201    pub exports: Vec<String>,
202}
203
204/// Fully resolved configuration with all globs pre-compiled.
205#[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    /// Load config from a `fallow.toml` file.
223    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    /// Find and load config from the current directory or ancestors.
231    ///
232    /// Stops searching at the first directory containing `.git` or `package.json`,
233    /// to avoid picking up unrelated config files above the project root.
234    ///
235    /// Returns `Ok(Some(...))` if a config was found and parsed, `Ok(None)` if
236    /// no config file exists, and `Err(...)` if a config file exists but fails to parse.
237    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            // Stop at project root indicators
254            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    /// Resolve into a fully resolved config with compiled globs.
266    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        // Default ignores
280        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); // default true
356    }
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        // Others should default to true
371        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        // Default ignores should be compiled
417        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}