Skip to main content

fallow_config/config/
resolution.rs

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/// Rule for ignoring specific exports.
16#[derive(Debug, Deserialize, Serialize, JsonSchema)]
17pub struct IgnoreExportRule {
18    /// Glob pattern for files.
19    pub file: String,
20    /// Export names to ignore (`*` for all).
21    pub exports: Vec<String>,
22}
23
24/// Per-file override entry.
25#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
26#[serde(rename_all = "camelCase")]
27pub struct ConfigOverride {
28    /// Glob patterns to match files against (relative to config file location).
29    pub files: Vec<String>,
30    /// Partial rules — only specified fields override the base rules.
31    #[serde(default)]
32    pub rules: PartialRulesConfig,
33}
34
35/// Resolved override with pre-compiled glob matchers.
36#[derive(Debug)]
37pub struct ResolvedOverride {
38    pub matchers: Vec<globset::GlobMatcher>,
39    pub rules: PartialRulesConfig,
40}
41
42/// Fully resolved configuration with all globs pre-compiled.
43#[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    /// Whether production mode is active.
58    pub production: bool,
59    /// Suppress progress output and non-essential stderr messages.
60    pub quiet: bool,
61    /// External plugin definitions (from plugin files + inline framework definitions).
62    pub external_plugins: Vec<ExternalPluginDef>,
63    /// Per-file rule overrides with pre-compiled glob matchers.
64    pub overrides: Vec<ResolvedOverride>,
65    /// Regression config (passed through from user config, not resolved).
66    pub regression: Option<super::RegressionConfig>,
67}
68
69impl FallowConfig {
70    /// Resolve into a fully resolved config with compiled globs.
71    pub fn resolve(
72        self,
73        root: PathBuf,
74        output: OutputFormat,
75        threads: usize,
76        no_cache: bool,
77        quiet: bool,
78    ) -> ResolvedConfig {
79        let mut ignore_builder = GlobSetBuilder::new();
80        for pattern in &self.ignore_patterns {
81            match Glob::new(pattern) {
82                Ok(glob) => {
83                    ignore_builder.add(glob);
84                }
85                Err(e) => {
86                    tracing::warn!("invalid ignore glob pattern '{pattern}': {e}");
87                }
88            }
89        }
90
91        // Default ignores
92        // Note: `build/` is only ignored at the project root (not `**/build/**`)
93        // because nested `build/` directories like `test/build/` may contain source files.
94        let default_ignores = [
95            "**/node_modules/**",
96            "**/dist/**",
97            "build/**",
98            "**/.git/**",
99            "**/coverage/**",
100            "**/*.min.js",
101            "**/*.min.mjs",
102        ];
103        for pattern in &default_ignores {
104            if let Ok(glob) = Glob::new(pattern) {
105                ignore_builder.add(glob);
106            }
107        }
108
109        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
110        let cache_dir = root.join(".fallow");
111
112        let mut rules = self.rules;
113
114        // In production mode, force unused_dev_dependencies and unused_optional_dependencies off
115        let production = self.production;
116        if production {
117            rules.unused_dev_dependencies = Severity::Off;
118            rules.unused_optional_dependencies = Severity::Off;
119        }
120
121        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
122        // Merge inline framework definitions into external plugins
123        external_plugins.extend(self.framework);
124
125        // Pre-compile override glob matchers
126        let overrides = self
127            .overrides
128            .into_iter()
129            .filter_map(|o| {
130                let matchers: Vec<globset::GlobMatcher> = o
131                    .files
132                    .iter()
133                    .filter_map(|pattern| match Glob::new(pattern) {
134                        Ok(glob) => Some(glob.compile_matcher()),
135                        Err(e) => {
136                            tracing::warn!("invalid override glob pattern '{pattern}': {e}");
137                            None
138                        }
139                    })
140                    .collect();
141                if matchers.is_empty() {
142                    None
143                } else {
144                    Some(ResolvedOverride {
145                        matchers,
146                        rules: o.rules,
147                    })
148                }
149            })
150            .collect();
151
152        ResolvedConfig {
153            root,
154            entry_patterns: self.entry,
155            ignore_patterns: compiled_ignore_patterns,
156            output,
157            cache_dir,
158            threads,
159            no_cache,
160            ignore_dependencies: self.ignore_dependencies,
161            ignore_export_rules: self.ignore_exports,
162            duplicates: self.duplicates,
163            health: self.health,
164            rules,
165            production,
166            quiet,
167            external_plugins,
168            overrides,
169            regression: self.regression,
170        }
171    }
172}
173
174impl ResolvedConfig {
175    /// Resolve the effective rules for a given file path.
176    /// Starts with base rules and applies matching overrides in order.
177    #[must_use]
178    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
179        if self.overrides.is_empty() {
180            return self.rules.clone();
181        }
182
183        let relative = path.strip_prefix(&self.root).unwrap_or(path);
184        let relative_str = relative.to_string_lossy();
185
186        let mut rules = self.rules.clone();
187        for override_entry in &self.overrides {
188            let matches = override_entry
189                .matchers
190                .iter()
191                .any(|m| m.is_match(relative_str.as_ref()));
192            if matches {
193                rules.apply_partial(&override_entry.rules);
194            }
195        }
196        rules
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::config::health::HealthConfig;
204
205    #[test]
206    fn overrides_deserialize() {
207        let json_str = r#"{
208            "overrides": [{
209                "files": ["*.test.ts"],
210                "rules": {
211                    "unused-exports": "off"
212                }
213            }]
214        }"#;
215        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
216        assert_eq!(config.overrides.len(), 1);
217        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
218        assert_eq!(
219            config.overrides[0].rules.unused_exports,
220            Some(Severity::Off)
221        );
222        assert_eq!(config.overrides[0].rules.unused_files, None);
223    }
224
225    #[test]
226    fn resolve_rules_for_path_no_overrides() {
227        let config = FallowConfig {
228            schema: None,
229            extends: vec![],
230            entry: vec![],
231            ignore_patterns: vec![],
232            framework: vec![],
233            workspaces: None,
234            ignore_dependencies: vec![],
235            ignore_exports: vec![],
236            duplicates: DuplicatesConfig::default(),
237            health: HealthConfig::default(),
238            rules: RulesConfig::default(),
239            production: false,
240            plugins: vec![],
241            overrides: vec![],
242            regression: None,
243        };
244        let resolved = config.resolve(
245            PathBuf::from("/project"),
246            OutputFormat::Human,
247            1,
248            true,
249            true,
250        );
251        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
252        assert_eq!(rules.unused_files, Severity::Error);
253    }
254
255    #[test]
256    fn resolve_rules_for_path_with_matching_override() {
257        let config = FallowConfig {
258            schema: None,
259            extends: vec![],
260            entry: vec![],
261            ignore_patterns: vec![],
262            framework: vec![],
263            workspaces: None,
264            ignore_dependencies: vec![],
265            ignore_exports: vec![],
266            duplicates: DuplicatesConfig::default(),
267            health: HealthConfig::default(),
268            rules: RulesConfig::default(),
269            production: false,
270            plugins: vec![],
271            overrides: vec![ConfigOverride {
272                files: vec!["*.test.ts".to_string()],
273                rules: PartialRulesConfig {
274                    unused_exports: Some(Severity::Off),
275                    ..Default::default()
276                },
277            }],
278            regression: None,
279        };
280        let resolved = config.resolve(
281            PathBuf::from("/project"),
282            OutputFormat::Human,
283            1,
284            true,
285            true,
286        );
287
288        // Test file matches override
289        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
290        assert_eq!(test_rules.unused_exports, Severity::Off);
291        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
292
293        // Non-test file does not match
294        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
295        assert_eq!(src_rules.unused_exports, Severity::Error);
296    }
297
298    #[test]
299    fn resolve_rules_for_path_later_override_wins() {
300        let config = FallowConfig {
301            schema: None,
302            extends: vec![],
303            entry: vec![],
304            ignore_patterns: vec![],
305            framework: vec![],
306            workspaces: None,
307            ignore_dependencies: vec![],
308            ignore_exports: vec![],
309            duplicates: DuplicatesConfig::default(),
310            health: HealthConfig::default(),
311            rules: RulesConfig::default(),
312            production: false,
313            plugins: vec![],
314            overrides: vec![
315                ConfigOverride {
316                    files: vec!["*.ts".to_string()],
317                    rules: PartialRulesConfig {
318                        unused_files: Some(Severity::Warn),
319                        ..Default::default()
320                    },
321                },
322                ConfigOverride {
323                    files: vec!["*.test.ts".to_string()],
324                    rules: PartialRulesConfig {
325                        unused_files: Some(Severity::Off),
326                        ..Default::default()
327                    },
328                },
329            ],
330            regression: None,
331        };
332        let resolved = config.resolve(
333            PathBuf::from("/project"),
334            OutputFormat::Human,
335            1,
336            true,
337            true,
338        );
339
340        // First override matches *.ts, second matches *.test.ts; second wins
341        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
342        assert_eq!(rules.unused_files, Severity::Off);
343
344        // Non-test .ts file only matches first override
345        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
346        assert_eq!(rules2.unused_files, Severity::Warn);
347    }
348
349    /// Helper to build a FallowConfig with minimal boilerplate.
350    fn make_config(production: bool) -> FallowConfig {
351        FallowConfig {
352            schema: None,
353            extends: vec![],
354            entry: vec![],
355            ignore_patterns: vec![],
356            framework: vec![],
357            workspaces: None,
358            ignore_dependencies: vec![],
359            ignore_exports: vec![],
360            duplicates: DuplicatesConfig::default(),
361            health: HealthConfig::default(),
362            rules: RulesConfig::default(),
363            production,
364            plugins: vec![],
365            overrides: vec![],
366            regression: None,
367        }
368    }
369
370    // ── Production mode ─────────────────────────────────────────────
371
372    #[test]
373    fn resolve_production_forces_dev_deps_off() {
374        let resolved = make_config(true).resolve(
375            PathBuf::from("/project"),
376            OutputFormat::Human,
377            1,
378            true,
379            true,
380        );
381        assert_eq!(
382            resolved.rules.unused_dev_dependencies,
383            Severity::Off,
384            "production mode should force unused_dev_dependencies to off"
385        );
386    }
387
388    #[test]
389    fn resolve_production_forces_optional_deps_off() {
390        let resolved = make_config(true).resolve(
391            PathBuf::from("/project"),
392            OutputFormat::Human,
393            1,
394            true,
395            true,
396        );
397        assert_eq!(
398            resolved.rules.unused_optional_dependencies,
399            Severity::Off,
400            "production mode should force unused_optional_dependencies to off"
401        );
402    }
403
404    #[test]
405    fn resolve_production_preserves_other_rules() {
406        let resolved = make_config(true).resolve(
407            PathBuf::from("/project"),
408            OutputFormat::Human,
409            1,
410            true,
411            true,
412        );
413        // Other rules should remain at their defaults
414        assert_eq!(resolved.rules.unused_files, Severity::Error);
415        assert_eq!(resolved.rules.unused_exports, Severity::Error);
416        assert_eq!(resolved.rules.unused_dependencies, Severity::Error);
417    }
418
419    #[test]
420    fn resolve_non_production_keeps_dev_deps_default() {
421        let resolved = make_config(false).resolve(
422            PathBuf::from("/project"),
423            OutputFormat::Human,
424            1,
425            true,
426            true,
427        );
428        assert_eq!(
429            resolved.rules.unused_dev_dependencies,
430            Severity::Error,
431            "non-production should keep default severity"
432        );
433        assert_eq!(resolved.rules.unused_optional_dependencies, Severity::Error);
434    }
435
436    #[test]
437    fn resolve_production_flag_stored() {
438        let resolved = make_config(true).resolve(
439            PathBuf::from("/project"),
440            OutputFormat::Human,
441            1,
442            true,
443            true,
444        );
445        assert!(resolved.production);
446
447        let resolved2 = make_config(false).resolve(
448            PathBuf::from("/project"),
449            OutputFormat::Human,
450            1,
451            true,
452            true,
453        );
454        assert!(!resolved2.production);
455    }
456
457    // ── Default ignore patterns ─────────────────────────────────────
458
459    #[test]
460    fn resolve_default_ignores_node_modules() {
461        let resolved = make_config(false).resolve(
462            PathBuf::from("/project"),
463            OutputFormat::Human,
464            1,
465            true,
466            true,
467        );
468        assert!(
469            resolved
470                .ignore_patterns
471                .is_match("node_modules/lodash/index.js")
472        );
473        assert!(
474            resolved
475                .ignore_patterns
476                .is_match("packages/a/node_modules/react/index.js")
477        );
478    }
479
480    #[test]
481    fn resolve_default_ignores_dist() {
482        let resolved = make_config(false).resolve(
483            PathBuf::from("/project"),
484            OutputFormat::Human,
485            1,
486            true,
487            true,
488        );
489        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
490        assert!(
491            resolved
492                .ignore_patterns
493                .is_match("packages/ui/dist/index.js")
494        );
495    }
496
497    #[test]
498    fn resolve_default_ignores_root_build_only() {
499        let resolved = make_config(false).resolve(
500            PathBuf::from("/project"),
501            OutputFormat::Human,
502            1,
503            true,
504            true,
505        );
506        assert!(
507            resolved.ignore_patterns.is_match("build/output.js"),
508            "root build/ should be ignored"
509        );
510        // The pattern is `build/**` (root-only), not `**/build/**`
511        assert!(
512            !resolved.ignore_patterns.is_match("src/build/helper.ts"),
513            "nested build/ should NOT be ignored by default"
514        );
515    }
516
517    #[test]
518    fn resolve_default_ignores_minified_files() {
519        let resolved = make_config(false).resolve(
520            PathBuf::from("/project"),
521            OutputFormat::Human,
522            1,
523            true,
524            true,
525        );
526        assert!(resolved.ignore_patterns.is_match("vendor/jquery.min.js"));
527        assert!(resolved.ignore_patterns.is_match("lib/utils.min.mjs"));
528    }
529
530    #[test]
531    fn resolve_default_ignores_git() {
532        let resolved = make_config(false).resolve(
533            PathBuf::from("/project"),
534            OutputFormat::Human,
535            1,
536            true,
537            true,
538        );
539        assert!(resolved.ignore_patterns.is_match(".git/objects/ab/123.js"));
540    }
541
542    #[test]
543    fn resolve_default_ignores_coverage() {
544        let resolved = make_config(false).resolve(
545            PathBuf::from("/project"),
546            OutputFormat::Human,
547            1,
548            true,
549            true,
550        );
551        assert!(
552            resolved
553                .ignore_patterns
554                .is_match("coverage/lcov-report/index.js")
555        );
556    }
557
558    #[test]
559    fn resolve_source_files_not_ignored_by_default() {
560        let resolved = make_config(false).resolve(
561            PathBuf::from("/project"),
562            OutputFormat::Human,
563            1,
564            true,
565            true,
566        );
567        assert!(!resolved.ignore_patterns.is_match("src/index.ts"));
568        assert!(
569            !resolved
570                .ignore_patterns
571                .is_match("src/components/Button.tsx")
572        );
573        assert!(!resolved.ignore_patterns.is_match("lib/utils.js"));
574    }
575
576    // ── Custom ignore patterns ──────────────────────────────────────
577
578    #[test]
579    fn resolve_custom_ignore_patterns_merged_with_defaults() {
580        let mut config = make_config(false);
581        config.ignore_patterns = vec!["**/__generated__/**".to_string()];
582        let resolved = config.resolve(
583            PathBuf::from("/project"),
584            OutputFormat::Human,
585            1,
586            true,
587            true,
588        );
589        // Custom pattern works
590        assert!(
591            resolved
592                .ignore_patterns
593                .is_match("src/__generated__/types.ts")
594        );
595        // Default patterns still work
596        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.js"));
597    }
598
599    // ── Config fields passthrough ───────────────────────────────────
600
601    #[test]
602    fn resolve_passes_through_entry_patterns() {
603        let mut config = make_config(false);
604        config.entry = vec!["src/**/*.ts".to_string(), "lib/**/*.js".to_string()];
605        let resolved = config.resolve(
606            PathBuf::from("/project"),
607            OutputFormat::Human,
608            1,
609            true,
610            true,
611        );
612        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts", "lib/**/*.js"]);
613    }
614
615    #[test]
616    fn resolve_passes_through_ignore_dependencies() {
617        let mut config = make_config(false);
618        config.ignore_dependencies = vec!["postcss".to_string(), "autoprefixer".to_string()];
619        let resolved = config.resolve(
620            PathBuf::from("/project"),
621            OutputFormat::Human,
622            1,
623            true,
624            true,
625        );
626        assert_eq!(
627            resolved.ignore_dependencies,
628            vec!["postcss", "autoprefixer"]
629        );
630    }
631
632    #[test]
633    fn resolve_sets_cache_dir() {
634        let resolved = make_config(false).resolve(
635            PathBuf::from("/my/project"),
636            OutputFormat::Human,
637            1,
638            true,
639            true,
640        );
641        assert_eq!(resolved.cache_dir, PathBuf::from("/my/project/.fallow"));
642    }
643
644    #[test]
645    fn resolve_passes_through_thread_count() {
646        let resolved = make_config(false).resolve(
647            PathBuf::from("/project"),
648            OutputFormat::Human,
649            8,
650            true,
651            true,
652        );
653        assert_eq!(resolved.threads, 8);
654    }
655
656    #[test]
657    fn resolve_passes_through_quiet_flag() {
658        let resolved = make_config(false).resolve(
659            PathBuf::from("/project"),
660            OutputFormat::Human,
661            1,
662            true,
663            false,
664        );
665        assert!(!resolved.quiet);
666
667        let resolved2 = make_config(false).resolve(
668            PathBuf::from("/project"),
669            OutputFormat::Human,
670            1,
671            true,
672            true,
673        );
674        assert!(resolved2.quiet);
675    }
676
677    #[test]
678    fn resolve_passes_through_no_cache_flag() {
679        let resolved_no_cache = make_config(false).resolve(
680            PathBuf::from("/project"),
681            OutputFormat::Human,
682            1,
683            true,
684            true,
685        );
686        assert!(resolved_no_cache.no_cache);
687
688        let resolved_with_cache = make_config(false).resolve(
689            PathBuf::from("/project"),
690            OutputFormat::Human,
691            1,
692            false,
693            true,
694        );
695        assert!(!resolved_with_cache.no_cache);
696    }
697
698    // ── Override resolution edge cases ───────────────────────────────
699
700    #[test]
701    fn resolve_override_with_invalid_glob_skipped() {
702        let mut config = make_config(false);
703        config.overrides = vec![ConfigOverride {
704            files: vec!["[invalid".to_string()],
705            rules: PartialRulesConfig {
706                unused_files: Some(Severity::Off),
707                ..Default::default()
708            },
709        }];
710        let resolved = config.resolve(
711            PathBuf::from("/project"),
712            OutputFormat::Human,
713            1,
714            true,
715            true,
716        );
717        // Invalid glob should be skipped, so no overrides should be compiled
718        assert!(
719            resolved.overrides.is_empty(),
720            "override with invalid glob should be skipped"
721        );
722    }
723
724    #[test]
725    fn resolve_override_with_empty_files_skipped() {
726        let mut config = make_config(false);
727        config.overrides = vec![ConfigOverride {
728            files: vec![],
729            rules: PartialRulesConfig {
730                unused_files: Some(Severity::Off),
731                ..Default::default()
732            },
733        }];
734        let resolved = config.resolve(
735            PathBuf::from("/project"),
736            OutputFormat::Human,
737            1,
738            true,
739            true,
740        );
741        assert!(
742            resolved.overrides.is_empty(),
743            "override with no file patterns should be skipped"
744        );
745    }
746
747    #[test]
748    fn resolve_multiple_valid_overrides() {
749        let mut config = make_config(false);
750        config.overrides = vec![
751            ConfigOverride {
752                files: vec!["*.test.ts".to_string()],
753                rules: PartialRulesConfig {
754                    unused_exports: Some(Severity::Off),
755                    ..Default::default()
756                },
757            },
758            ConfigOverride {
759                files: vec!["*.stories.tsx".to_string()],
760                rules: PartialRulesConfig {
761                    unused_files: Some(Severity::Off),
762                    ..Default::default()
763                },
764            },
765        ];
766        let resolved = config.resolve(
767            PathBuf::from("/project"),
768            OutputFormat::Human,
769            1,
770            true,
771            true,
772        );
773        assert_eq!(resolved.overrides.len(), 2);
774    }
775
776    // ── IgnoreExportRule ────────────────────────────────────────────
777
778    #[test]
779    fn ignore_export_rule_deserialize() {
780        let json = r#"{"file": "src/types/*.ts", "exports": ["*"]}"#;
781        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
782        assert_eq!(rule.file, "src/types/*.ts");
783        assert_eq!(rule.exports, vec!["*"]);
784    }
785
786    #[test]
787    fn ignore_export_rule_specific_exports() {
788        let json = r#"{"file": "src/constants.ts", "exports": ["FOO", "BAR", "BAZ"]}"#;
789        let rule: IgnoreExportRule = serde_json::from_str(json).unwrap();
790        assert_eq!(rule.exports.len(), 3);
791        assert!(rule.exports.contains(&"FOO".to_string()));
792    }
793
794    mod proptests {
795        use super::*;
796        use proptest::prelude::*;
797
798        fn arb_resolved_config(production: bool) -> ResolvedConfig {
799            make_config(production).resolve(
800                PathBuf::from("/project"),
801                OutputFormat::Human,
802                1,
803                true,
804                true,
805            )
806        }
807
808        proptest! {
809            /// Resolved config always has non-empty ignore patterns (defaults are always added).
810            #[test]
811            fn resolved_config_has_default_ignores(production in any::<bool>()) {
812                let resolved = arb_resolved_config(production);
813                // Default patterns include node_modules, dist, build, .git, coverage, *.min.js, *.min.mjs
814                prop_assert!(
815                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
816                    "Default ignore should match node_modules"
817                );
818                prop_assert!(
819                    resolved.ignore_patterns.is_match("dist/bundle.js"),
820                    "Default ignore should match dist"
821                );
822            }
823
824            /// Production mode always forces dev and optional deps to Off.
825            #[test]
826            fn production_forces_dev_deps_off(_unused in Just(())) {
827                let resolved = arb_resolved_config(true);
828                prop_assert_eq!(
829                    resolved.rules.unused_dev_dependencies,
830                    Severity::Off,
831                    "Production should force unused_dev_dependencies off"
832                );
833                prop_assert_eq!(
834                    resolved.rules.unused_optional_dependencies,
835                    Severity::Off,
836                    "Production should force unused_optional_dependencies off"
837                );
838            }
839
840            /// Non-production mode preserves default severity for dev deps.
841            #[test]
842            fn non_production_preserves_dev_deps_default(_unused in Just(())) {
843                let resolved = arb_resolved_config(false);
844                prop_assert_eq!(
845                    resolved.rules.unused_dev_dependencies,
846                    Severity::Error,
847                    "Non-production should keep default dev dep severity"
848                );
849            }
850
851            /// Cache dir is always root/.fallow.
852            #[test]
853            fn cache_dir_is_root_fallow(dir_suffix in "[a-zA-Z0-9_]{1,20}") {
854                let root = PathBuf::from(format!("/project/{dir_suffix}"));
855                let expected_cache = root.join(".fallow");
856                let resolved = make_config(false).resolve(
857                    root,
858                    OutputFormat::Human,
859                    1,
860                    true,
861                    true,
862                );
863                prop_assert_eq!(
864                    resolved.cache_dir, expected_cache,
865                    "Cache dir should be root/.fallow"
866                );
867            }
868
869            /// Thread count is always passed through exactly.
870            #[test]
871            fn threads_passed_through(threads in 1..64usize) {
872                let resolved = make_config(false).resolve(
873                    PathBuf::from("/project"),
874                    OutputFormat::Human,
875                    threads,
876                    true,
877                    true,
878                );
879                prop_assert_eq!(
880                    resolved.threads, threads,
881                    "Thread count should be passed through"
882                );
883            }
884
885            /// Custom ignore patterns are merged with defaults, not replacing them.
886            #[test]
887            fn custom_ignores_dont_replace_defaults(pattern in "[a-zA-Z0-9_*/.]{1,30}") {
888                let mut config = make_config(false);
889                config.ignore_patterns = vec![pattern];
890                let resolved = config.resolve(
891                    PathBuf::from("/project"),
892                    OutputFormat::Human,
893                    1,
894                    true,
895                    true,
896                );
897                // Defaults should still be present
898                prop_assert!(
899                    resolved.ignore_patterns.is_match("node_modules/foo/bar.js"),
900                    "Default node_modules ignore should still be active"
901                );
902            }
903        }
904    }
905}