Skip to main content

fallow_config/
config.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::FxHashSet;
5
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::external_plugin::{ExternalPluginDef, discover_external_plugins};
11use crate::workspace::WorkspaceConfig;
12
13/// Supported config file names in priority order.
14///
15/// `find_and_load` checks these names in order within each directory,
16/// returning the first match found.
17const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
18
19/// User-facing configuration loaded from `.fallowrc.json` or `fallow.toml`.
20#[derive(Debug, Deserialize, Serialize, JsonSchema)]
21#[serde(deny_unknown_fields, rename_all = "camelCase")]
22pub struct FallowConfig {
23    /// JSON Schema reference (ignored during deserialization).
24    #[serde(rename = "$schema", default, skip_serializing)]
25    #[schemars(skip)]
26    pub schema: Option<String>,
27
28    /// Paths to base config files to extend from.
29    /// Paths are resolved relative to the config file containing the `extends`.
30    /// Base configs are loaded first, then this config's values override them.
31    /// Later entries in the array override earlier ones.
32    #[serde(default, skip_serializing)]
33    pub extends: Vec<String>,
34
35    /// Additional entry point glob patterns.
36    #[serde(default)]
37    pub entry: Vec<String>,
38
39    /// Glob patterns to ignore from analysis.
40    #[serde(default)]
41    pub ignore_patterns: Vec<String>,
42
43    /// Custom framework definitions (inline plugin definitions).
44    #[serde(default)]
45    pub framework: Vec<ExternalPluginDef>,
46
47    /// Workspace overrides.
48    #[serde(default)]
49    pub workspaces: Option<WorkspaceConfig>,
50
51    /// Dependencies to ignore (always considered used).
52    #[serde(default)]
53    pub ignore_dependencies: Vec<String>,
54
55    /// Export ignore rules.
56    #[serde(default)]
57    pub ignore_exports: Vec<IgnoreExportRule>,
58
59    /// Duplication detection settings.
60    #[serde(default)]
61    pub duplicates: DuplicatesConfig,
62
63    /// Per-issue-type severity rules.
64    #[serde(default)]
65    pub rules: RulesConfig,
66
67    /// Production mode: exclude test/dev files, only start/build scripts.
68    #[serde(default)]
69    pub production: bool,
70
71    /// Paths to external plugin files or directories containing plugin files.
72    ///
73    /// Supports TOML, JSON, and JSONC formats.
74    ///
75    /// In addition to these explicit paths, fallow automatically discovers:
76    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
77    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
78    #[serde(default)]
79    pub plugins: Vec<String>,
80
81    /// Per-file rule overrides matching oxlint's overrides pattern.
82    #[serde(default)]
83    pub overrides: Vec<ConfigOverride>,
84}
85
86/// Configuration for code duplication detection.
87#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
88#[serde(rename_all = "camelCase")]
89pub struct DuplicatesConfig {
90    /// Whether duplication detection is enabled.
91    #[serde(default = "default_true")]
92    pub enabled: bool,
93
94    /// Detection mode: strict, mild, weak, or semantic.
95    #[serde(default)]
96    pub mode: DetectionMode,
97
98    /// Minimum number of tokens for a clone.
99    #[serde(default = "default_min_tokens")]
100    pub min_tokens: usize,
101
102    /// Minimum number of lines for a clone.
103    #[serde(default = "default_min_lines")]
104    pub min_lines: usize,
105
106    /// Maximum allowed duplication percentage (0 = no limit).
107    #[serde(default)]
108    pub threshold: f64,
109
110    /// Additional ignore patterns for duplication analysis.
111    #[serde(default)]
112    pub ignore: Vec<String>,
113
114    /// Only report cross-directory duplicates.
115    #[serde(default)]
116    pub skip_local: bool,
117
118    /// Enable cross-language clone detection by stripping type annotations.
119    ///
120    /// When enabled, TypeScript type annotations (parameter types, return types,
121    /// generics, interfaces, type aliases) are stripped from the token stream,
122    /// allowing detection of clones between `.ts` and `.js` files.
123    #[serde(default)]
124    pub cross_language: bool,
125
126    /// Fine-grained normalization overrides on top of the detection mode.
127    #[serde(default)]
128    pub normalization: NormalizationConfig,
129}
130
131impl Default for DuplicatesConfig {
132    fn default() -> Self {
133        Self {
134            enabled: true,
135            mode: DetectionMode::default(),
136            min_tokens: default_min_tokens(),
137            min_lines: default_min_lines(),
138            threshold: 0.0,
139            ignore: vec![],
140            skip_local: false,
141            cross_language: false,
142            normalization: NormalizationConfig::default(),
143        }
144    }
145}
146
147/// Fine-grained normalization overrides.
148///
149/// Each option, when set to `Some(true)`, forces that normalization regardless of
150/// the detection mode. When set to `Some(false)`, it forces preservation. When
151/// `None`, the detection mode's default behavior applies.
152#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct NormalizationConfig {
155    /// Blind all identifiers (variable names, function names, etc.) to the same hash.
156    /// Default in `semantic` mode.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub ignore_identifiers: Option<bool>,
159
160    /// Blind string literal values to the same hash.
161    /// Default in `weak` and `semantic` modes.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub ignore_string_values: Option<bool>,
164
165    /// Blind numeric literal values to the same hash.
166    /// Default in `semantic` mode.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub ignore_numeric_values: Option<bool>,
169}
170
171/// Resolved normalization flags: mode defaults merged with user overrides.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ResolvedNormalization {
174    pub ignore_identifiers: bool,
175    pub ignore_string_values: bool,
176    pub ignore_numeric_values: bool,
177}
178
179impl ResolvedNormalization {
180    /// Resolve normalization from a detection mode and optional overrides.
181    pub fn resolve(mode: DetectionMode, overrides: &NormalizationConfig) -> Self {
182        let (default_ids, default_strings, default_numbers) = match mode {
183            DetectionMode::Strict | DetectionMode::Mild => (false, false, false),
184            DetectionMode::Weak => (false, true, false),
185            DetectionMode::Semantic => (true, true, true),
186        };
187
188        Self {
189            ignore_identifiers: overrides.ignore_identifiers.unwrap_or(default_ids),
190            ignore_string_values: overrides.ignore_string_values.unwrap_or(default_strings),
191            ignore_numeric_values: overrides.ignore_numeric_values.unwrap_or(default_numbers),
192        }
193    }
194}
195
196/// Detection mode controlling how aggressively tokens are normalized.
197///
198/// Since fallow uses AST-based tokenization (not lexer-based), whitespace and
199/// comments are inherently absent from the token stream. The `Strict` and `Mild`
200/// modes are currently equivalent. `Weak` mode additionally blinds string
201/// literals. `Semantic` mode blinds all identifiers and literal values for
202/// Type-2 (renamed variable) clone detection.
203#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
204#[serde(rename_all = "lowercase")]
205pub enum DetectionMode {
206    /// All tokens preserved including identifier names and literal values (Type-1 only).
207    Strict,
208    /// Default mode -- equivalent to strict for AST-based tokenization.
209    #[default]
210    Mild,
211    /// Blind string literal values (structure-preserving).
212    Weak,
213    /// Blind all identifiers and literal values for structural (Type-2) detection.
214    Semantic,
215}
216
217impl std::fmt::Display for DetectionMode {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        match self {
220            Self::Strict => write!(f, "strict"),
221            Self::Mild => write!(f, "mild"),
222            Self::Weak => write!(f, "weak"),
223            Self::Semantic => write!(f, "semantic"),
224        }
225    }
226}
227
228impl std::str::FromStr for DetectionMode {
229    type Err = String;
230
231    fn from_str(s: &str) -> Result<Self, Self::Err> {
232        match s.to_lowercase().as_str() {
233            "strict" => Ok(Self::Strict),
234            "mild" => Ok(Self::Mild),
235            "weak" => Ok(Self::Weak),
236            "semantic" => Ok(Self::Semantic),
237            other => Err(format!("unknown detection mode: '{other}'")),
238        }
239    }
240}
241
242const fn default_min_tokens() -> usize {
243    50
244}
245
246const fn default_min_lines() -> usize {
247    5
248}
249
250/// Output format for results.
251///
252/// This is CLI-only (via `--format` flag), not stored in config files.
253#[derive(Debug, Default, Clone)]
254pub enum OutputFormat {
255    /// Human-readable terminal output with source context.
256    #[default]
257    Human,
258    /// Machine-readable JSON.
259    Json,
260    /// SARIF format for GitHub Code Scanning.
261    Sarif,
262    /// One issue per line (grep-friendly).
263    Compact,
264}
265
266/// Rule for ignoring specific exports.
267#[derive(Debug, Deserialize, Serialize, JsonSchema)]
268pub struct IgnoreExportRule {
269    /// Glob pattern for files.
270    pub file: String,
271    /// Export names to ignore (`*` for all).
272    pub exports: Vec<String>,
273}
274
275/// Per-file override entry.
276#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
277#[serde(rename_all = "camelCase")]
278pub struct ConfigOverride {
279    /// Glob patterns to match files against (relative to config file location).
280    pub files: Vec<String>,
281    /// Partial rules — only specified fields override the base rules.
282    #[serde(default)]
283    pub rules: PartialRulesConfig,
284}
285
286/// Partial per-issue-type severity for overrides. All fields optional.
287#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
288#[serde(rename_all = "kebab-case")]
289pub struct PartialRulesConfig {
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub unused_files: Option<Severity>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub unused_exports: Option<Severity>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub unused_types: Option<Severity>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub unused_dependencies: Option<Severity>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub unused_dev_dependencies: Option<Severity>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub unused_enum_members: Option<Severity>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub unused_class_members: Option<Severity>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub unresolved_imports: Option<Severity>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub unlisted_dependencies: Option<Severity>,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub duplicate_exports: Option<Severity>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub circular_dependencies: Option<Severity>,
312}
313
314/// Resolved override with pre-compiled glob matchers.
315#[derive(Debug)]
316pub struct ResolvedOverride {
317    pub matchers: Vec<globset::GlobMatcher>,
318    pub rules: PartialRulesConfig,
319}
320
321/// Fully resolved configuration with all globs pre-compiled.
322#[derive(Debug)]
323pub struct ResolvedConfig {
324    pub root: PathBuf,
325    pub entry_patterns: Vec<String>,
326    pub ignore_patterns: GlobSet,
327    pub output: OutputFormat,
328    pub cache_dir: PathBuf,
329    pub threads: usize,
330    pub no_cache: bool,
331    pub ignore_dependencies: Vec<String>,
332    pub ignore_export_rules: Vec<IgnoreExportRule>,
333    pub duplicates: DuplicatesConfig,
334    pub rules: RulesConfig,
335    /// Whether production mode is active.
336    pub production: bool,
337    /// External plugin definitions (from plugin files + inline framework definitions).
338    pub external_plugins: Vec<ExternalPluginDef>,
339    /// Per-file rule overrides with pre-compiled glob matchers.
340    pub overrides: Vec<ResolvedOverride>,
341}
342
343/// Detect config format from file extension.
344enum ConfigFormat {
345    Toml,
346    Json,
347}
348
349impl ConfigFormat {
350    fn from_path(path: &Path) -> Self {
351        match path.extension().and_then(|e| e.to_str()) {
352            Some("json") => Self::Json,
353            _ => Self::Toml,
354        }
355    }
356}
357
358const MAX_EXTENDS_DEPTH: usize = 10;
359
360/// Deep-merge two JSON values. `base` is lower-priority, `overlay` is higher.
361/// Objects: merge field by field. Arrays/scalars: overlay replaces base.
362fn deep_merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
363    match (base, overlay) {
364        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
365            for (key, value) in overlay_map {
366                if let Some(base_value) = base_map.get_mut(&key) {
367                    deep_merge_json(base_value, value);
368                } else {
369                    base_map.insert(key, value);
370                }
371            }
372        }
373        (base, overlay) => {
374            *base = overlay;
375        }
376    }
377}
378
379fn parse_config_to_value(path: &Path) -> Result<serde_json::Value, miette::Report> {
380    let content = std::fs::read_to_string(path)
381        .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
382
383    match ConfigFormat::from_path(path) {
384        ConfigFormat::Toml => {
385            let toml_value: toml::Value = toml::from_str(&content).map_err(|e| {
386                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
387            })?;
388            serde_json::to_value(toml_value).map_err(|e| {
389                miette::miette!(
390                    "Failed to convert TOML to JSON for {}: {}",
391                    path.display(),
392                    e
393                )
394            })
395        }
396        ConfigFormat::Json => {
397            let mut stripped = String::new();
398            json_comments::StripComments::new(content.as_bytes())
399                .read_to_string(&mut stripped)
400                .map_err(|e| {
401                    miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
402                })?;
403            serde_json::from_str(&stripped).map_err(|e| {
404                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
405            })
406        }
407    }
408}
409
410fn resolve_extends(
411    path: &Path,
412    visited: &mut FxHashSet<PathBuf>,
413    depth: usize,
414) -> Result<serde_json::Value, miette::Report> {
415    if depth >= MAX_EXTENDS_DEPTH {
416        return Err(miette::miette!(
417            "Config extends chain too deep (>={MAX_EXTENDS_DEPTH} levels) at {}",
418            path.display()
419        ));
420    }
421
422    let canonical = path.canonicalize().map_err(|e| {
423        miette::miette!(
424            "Config file not found or unresolvable: {}: {}",
425            path.display(),
426            e
427        )
428    })?;
429
430    if !visited.insert(canonical.clone()) {
431        return Err(miette::miette!(
432            "Circular extends detected: {} was already visited in the extends chain",
433            path.display()
434        ));
435    }
436
437    let mut value = parse_config_to_value(path)?;
438
439    let extends = value
440        .as_object_mut()
441        .and_then(|obj| obj.remove("extends"))
442        .and_then(|v| match v {
443            serde_json::Value::Array(arr) => Some(
444                arr.into_iter()
445                    .filter_map(|v| v.as_str().map(String::from))
446                    .collect::<Vec<_>>(),
447            ),
448            serde_json::Value::String(s) => Some(vec![s]),
449            _ => None,
450        })
451        .unwrap_or_default();
452
453    if extends.is_empty() {
454        return Ok(value);
455    }
456
457    let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
458    let mut merged = serde_json::Value::Object(serde_json::Map::new());
459
460    for extend_path_str in &extends {
461        if Path::new(extend_path_str).is_absolute() {
462            return Err(miette::miette!(
463                "extends paths must be relative, got absolute path: {} (in {})",
464                extend_path_str,
465                path.display()
466            ));
467        }
468        let extend_path = config_dir.join(extend_path_str);
469        if !extend_path.exists() {
470            return Err(miette::miette!(
471                "Extended config file not found: {} (referenced from {})",
472                extend_path.display(),
473                path.display()
474            ));
475        }
476        let base = resolve_extends(&extend_path, visited, depth + 1)?;
477        deep_merge_json(&mut merged, base);
478    }
479
480    deep_merge_json(&mut merged, value);
481    Ok(merged)
482}
483
484impl FallowConfig {
485    /// Load config from a fallow config file (TOML or JSON/JSONC).
486    ///
487    /// The format is detected from the file extension:
488    /// - `.toml` → TOML
489    /// - `.json` → JSON (with JSONC comment stripping)
490    ///
491    /// Supports `extends` for config inheritance. Extended configs are loaded
492    /// and deep-merged before this config's values are applied.
493    pub fn load(path: &Path) -> Result<Self, miette::Report> {
494        let mut visited = FxHashSet::default();
495        let merged = resolve_extends(path, &mut visited, 0)?;
496
497        serde_json::from_value(merged).map_err(|e| {
498            miette::miette!(
499                "Failed to deserialize config from {}: {}",
500                path.display(),
501                e
502            )
503        })
504    }
505
506    /// Find and load config from the current directory or ancestors.
507    ///
508    /// Checks for config files in priority order:
509    /// `.fallowrc.json` > `fallow.toml` > `.fallow.toml`
510    ///
511    /// Stops searching at the first directory containing `.git` or `package.json`,
512    /// to avoid picking up unrelated config files above the project root.
513    ///
514    /// Returns `Ok(Some(...))` if a config was found and parsed, `Ok(None)` if
515    /// no config file exists, and `Err(...)` if a config file exists but fails to parse.
516    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
517        let mut dir = start;
518        loop {
519            for name in CONFIG_NAMES {
520                let candidate = dir.join(name);
521                if candidate.exists() {
522                    match Self::load(&candidate) {
523                        Ok(config) => return Ok(Some((config, candidate))),
524                        Err(e) => {
525                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
526                        }
527                    }
528                }
529            }
530            // Stop at project root indicators
531            if dir.join(".git").exists() || dir.join("package.json").exists() {
532                break;
533            }
534            dir = match dir.parent() {
535                Some(parent) => parent,
536                None => break,
537            };
538        }
539        Ok(None)
540    }
541
542    /// Generate JSON Schema for the configuration format.
543    pub fn json_schema() -> serde_json::Value {
544        #[allow(clippy::use_self)] // schemars macro requires the concrete type name
545        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
546    }
547
548    /// Resolve into a fully resolved config with compiled globs.
549    #[allow(clippy::print_stderr)]
550    pub fn resolve(
551        self,
552        root: PathBuf,
553        output: OutputFormat,
554        threads: usize,
555        no_cache: bool,
556    ) -> ResolvedConfig {
557        let mut ignore_builder = GlobSetBuilder::new();
558        for pattern in &self.ignore_patterns {
559            match Glob::new(pattern) {
560                Ok(glob) => {
561                    ignore_builder.add(glob);
562                }
563                Err(e) => {
564                    eprintln!("Warning: Invalid ignore glob pattern '{pattern}': {e}");
565                }
566            }
567        }
568
569        // Default ignores
570        // Note: `build/` is only ignored at the project root (not `**/build/**`)
571        // because nested `build/` directories like `test/build/` may contain source files.
572        let default_ignores = [
573            "**/node_modules/**",
574            "**/dist/**",
575            "build/**",
576            "**/.git/**",
577            "**/coverage/**",
578            "**/*.min.js",
579            "**/*.min.mjs",
580        ];
581        for pattern in &default_ignores {
582            if let Ok(glob) = Glob::new(pattern) {
583                ignore_builder.add(glob);
584            }
585        }
586
587        let compiled_ignore_patterns = ignore_builder.build().unwrap_or_default();
588        let cache_dir = root.join(".fallow");
589
590        let mut rules = self.rules;
591
592        // In production mode, force unused_dev_dependencies off
593        let production = self.production;
594        if production {
595            rules.unused_dev_dependencies = Severity::Off;
596        }
597
598        let mut external_plugins = discover_external_plugins(&root, &self.plugins);
599        // Merge inline framework definitions into external plugins
600        external_plugins.extend(self.framework);
601
602        // Pre-compile override glob matchers
603        let overrides = self
604            .overrides
605            .into_iter()
606            .filter_map(|o| {
607                let matchers: Vec<globset::GlobMatcher> = o
608                    .files
609                    .iter()
610                    .filter_map(|pattern| match Glob::new(pattern) {
611                        Ok(glob) => Some(glob.compile_matcher()),
612                        Err(e) => {
613                            eprintln!("Warning: Invalid override glob pattern '{pattern}': {e}");
614                            None
615                        }
616                    })
617                    .collect();
618                if matchers.is_empty() {
619                    None
620                } else {
621                    Some(ResolvedOverride {
622                        matchers,
623                        rules: o.rules,
624                    })
625                }
626            })
627            .collect();
628
629        ResolvedConfig {
630            root,
631            entry_patterns: self.entry,
632            ignore_patterns: compiled_ignore_patterns,
633            output,
634            cache_dir,
635            threads,
636            no_cache,
637            ignore_dependencies: self.ignore_dependencies,
638            ignore_export_rules: self.ignore_exports,
639            duplicates: self.duplicates,
640            rules,
641            production,
642            external_plugins,
643            overrides,
644        }
645    }
646}
647
648impl ResolvedConfig {
649    /// Resolve the effective rules for a given file path.
650    /// Starts with base rules and applies matching overrides in order.
651    pub fn resolve_rules_for_path(&self, path: &Path) -> RulesConfig {
652        if self.overrides.is_empty() {
653            return self.rules.clone();
654        }
655
656        let relative = path.strip_prefix(&self.root).unwrap_or(path);
657        let relative_str = relative.to_string_lossy();
658
659        let mut rules = self.rules.clone();
660        for override_entry in &self.overrides {
661            let matches = override_entry
662                .matchers
663                .iter()
664                .any(|m| m.is_match(relative_str.as_ref()));
665            if matches {
666                rules.apply_partial(&override_entry.rules);
667            }
668        }
669        rules
670    }
671}
672
673const fn default_true() -> bool {
674    true
675}
676
677/// Severity level for rules.
678///
679/// Controls whether an issue type causes CI failure (`error`), is reported
680/// without failing (`warn`), or is suppressed entirely (`off`).
681#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
682#[serde(rename_all = "lowercase")]
683pub enum Severity {
684    /// Report and fail CI (non-zero exit code).
685    #[default]
686    Error,
687    /// Report but don't fail CI.
688    Warn,
689    /// Don't detect or report.
690    Off,
691}
692
693impl std::fmt::Display for Severity {
694    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
695        match self {
696            Self::Error => write!(f, "error"),
697            Self::Warn => write!(f, "warn"),
698            Self::Off => write!(f, "off"),
699        }
700    }
701}
702
703impl std::str::FromStr for Severity {
704    type Err = String;
705
706    fn from_str(s: &str) -> Result<Self, Self::Err> {
707        match s.to_lowercase().as_str() {
708            "error" => Ok(Self::Error),
709            "warn" | "warning" => Ok(Self::Warn),
710            "off" | "none" => Ok(Self::Off),
711            other => Err(format!(
712                "unknown severity: '{other}' (expected error, warn, or off)"
713            )),
714        }
715    }
716}
717
718/// Per-issue-type severity configuration.
719///
720/// Controls which issue types cause CI failure, are reported as warnings,
721/// or are suppressed entirely. All fields default to `Severity::Error`.
722///
723/// Rule names use kebab-case in config files (e.g., `"unused-files": "error"`).
724#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
725#[serde(rename_all = "kebab-case")]
726pub struct RulesConfig {
727    #[serde(default)]
728    pub unused_files: Severity,
729    #[serde(default)]
730    pub unused_exports: Severity,
731    #[serde(default)]
732    pub unused_types: Severity,
733    #[serde(default)]
734    pub unused_dependencies: Severity,
735    #[serde(default)]
736    pub unused_dev_dependencies: Severity,
737    #[serde(default)]
738    pub unused_enum_members: Severity,
739    #[serde(default)]
740    pub unused_class_members: Severity,
741    #[serde(default)]
742    pub unresolved_imports: Severity,
743    #[serde(default)]
744    pub unlisted_dependencies: Severity,
745    #[serde(default)]
746    pub duplicate_exports: Severity,
747    #[serde(default)]
748    pub circular_dependencies: Severity,
749}
750
751impl Default for RulesConfig {
752    fn default() -> Self {
753        Self {
754            unused_files: Severity::Error,
755            unused_exports: Severity::Error,
756            unused_types: Severity::Error,
757            unused_dependencies: Severity::Error,
758            unused_dev_dependencies: Severity::Error,
759            unused_enum_members: Severity::Error,
760            unused_class_members: Severity::Error,
761            unresolved_imports: Severity::Error,
762            unlisted_dependencies: Severity::Error,
763            duplicate_exports: Severity::Error,
764            circular_dependencies: Severity::Error,
765        }
766    }
767}
768
769impl RulesConfig {
770    /// Apply a partial rules config on top. Only `Some` fields override.
771    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
772        if let Some(s) = partial.unused_files {
773            self.unused_files = s;
774        }
775        if let Some(s) = partial.unused_exports {
776            self.unused_exports = s;
777        }
778        if let Some(s) = partial.unused_types {
779            self.unused_types = s;
780        }
781        if let Some(s) = partial.unused_dependencies {
782            self.unused_dependencies = s;
783        }
784        if let Some(s) = partial.unused_dev_dependencies {
785            self.unused_dev_dependencies = s;
786        }
787        if let Some(s) = partial.unused_enum_members {
788            self.unused_enum_members = s;
789        }
790        if let Some(s) = partial.unused_class_members {
791            self.unused_class_members = s;
792        }
793        if let Some(s) = partial.unresolved_imports {
794            self.unresolved_imports = s;
795        }
796        if let Some(s) = partial.unlisted_dependencies {
797            self.unlisted_dependencies = s;
798        }
799        if let Some(s) = partial.duplicate_exports {
800            self.duplicate_exports = s;
801        }
802        if let Some(s) = partial.circular_dependencies {
803            self.circular_dependencies = s;
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use crate::PackageJson;
812
813    /// Create a unique temp directory for this test to avoid parallel test races.
814    fn test_dir(name: &str) -> PathBuf {
815        use std::sync::atomic::{AtomicU64, Ordering};
816        static COUNTER: AtomicU64 = AtomicU64::new(0);
817        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
818        let dir = std::env::temp_dir().join(format!("fallow-{name}-{id}"));
819        let _ = std::fs::remove_dir_all(&dir);
820        std::fs::create_dir_all(&dir).unwrap();
821        dir
822    }
823
824    #[test]
825    fn output_format_default_is_human() {
826        let format = OutputFormat::default();
827        assert!(matches!(format, OutputFormat::Human));
828    }
829
830    #[test]
831    fn fallow_config_deserialize_minimal() {
832        let toml_str = r#"
833entry = ["src/main.ts"]
834"#;
835        let config: FallowConfig = toml::from_str(toml_str).unwrap();
836        assert_eq!(config.entry, vec!["src/main.ts"]);
837        assert!(config.ignore_patterns.is_empty());
838    }
839
840    #[test]
841    fn fallow_config_deserialize_ignore_exports() {
842        let toml_str = r#"
843[[ignoreExports]]
844file = "src/types/*.ts"
845exports = ["*"]
846
847[[ignoreExports]]
848file = "src/constants.ts"
849exports = ["FOO", "BAR"]
850"#;
851        let config: FallowConfig = toml::from_str(toml_str).unwrap();
852        assert_eq!(config.ignore_exports.len(), 2);
853        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
854        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
855        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
856    }
857
858    #[test]
859    fn fallow_config_deserialize_ignore_dependencies() {
860        let toml_str = r#"
861ignoreDependencies = ["autoprefixer", "postcss"]
862"#;
863        let config: FallowConfig = toml::from_str(toml_str).unwrap();
864        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
865    }
866
867    #[test]
868    fn fallow_config_resolve_default_ignores() {
869        let config = FallowConfig {
870            schema: None,
871            extends: vec![],
872            entry: vec![],
873            ignore_patterns: vec![],
874            framework: vec![],
875            workspaces: None,
876            ignore_dependencies: vec![],
877            ignore_exports: vec![],
878            duplicates: DuplicatesConfig::default(),
879            rules: RulesConfig::default(),
880            production: false,
881            plugins: vec![],
882            overrides: vec![],
883        };
884        let resolved = config.resolve(PathBuf::from("/tmp/test"), OutputFormat::Human, 4, true);
885
886        // Default ignores should be compiled
887        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
888        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
889        assert!(resolved.ignore_patterns.is_match("build/output.js"));
890        assert!(resolved.ignore_patterns.is_match(".git/config"));
891        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
892        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
893        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
894    }
895
896    #[test]
897    fn fallow_config_resolve_custom_ignores() {
898        let config = FallowConfig {
899            schema: None,
900            extends: vec![],
901            entry: vec!["src/**/*.ts".to_string()],
902            ignore_patterns: vec!["**/*.generated.ts".to_string()],
903            framework: vec![],
904            workspaces: None,
905            ignore_dependencies: vec![],
906            ignore_exports: vec![],
907            duplicates: DuplicatesConfig::default(),
908            rules: RulesConfig::default(),
909            production: false,
910            plugins: vec![],
911            overrides: vec![],
912        };
913        let resolved = config.resolve(PathBuf::from("/tmp/test"), OutputFormat::Json, 4, false);
914
915        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
916        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
917        assert!(matches!(resolved.output, OutputFormat::Json));
918        assert!(!resolved.no_cache);
919    }
920
921    #[test]
922    fn fallow_config_resolve_cache_dir() {
923        let config = FallowConfig {
924            schema: None,
925            extends: vec![],
926            entry: vec![],
927            ignore_patterns: vec![],
928            framework: vec![],
929            workspaces: None,
930            ignore_dependencies: vec![],
931            ignore_exports: vec![],
932            duplicates: DuplicatesConfig::default(),
933            rules: RulesConfig::default(),
934            production: false,
935            plugins: vec![],
936            overrides: vec![],
937        };
938        let resolved = config.resolve(PathBuf::from("/tmp/project"), OutputFormat::Human, 4, true);
939        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
940        assert!(resolved.no_cache);
941    }
942
943    #[test]
944    fn package_json_entry_points_main() {
945        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
946        let entries = pkg.entry_points();
947        assert!(entries.contains(&"dist/index.js".to_string()));
948    }
949
950    #[test]
951    fn package_json_entry_points_module() {
952        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
953        let entries = pkg.entry_points();
954        assert!(entries.contains(&"dist/index.mjs".to_string()));
955    }
956
957    #[test]
958    fn package_json_entry_points_types() {
959        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
960        let entries = pkg.entry_points();
961        assert!(entries.contains(&"dist/index.d.ts".to_string()));
962    }
963
964    #[test]
965    fn package_json_entry_points_bin_string() {
966        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
967        let entries = pkg.entry_points();
968        assert!(entries.contains(&"bin/cli.js".to_string()));
969    }
970
971    #[test]
972    fn package_json_entry_points_bin_object() {
973        let pkg: PackageJson =
974            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
975                .unwrap();
976        let entries = pkg.entry_points();
977        assert!(entries.contains(&"bin/cli.js".to_string()));
978        assert!(entries.contains(&"bin/serve.js".to_string()));
979    }
980
981    #[test]
982    fn package_json_entry_points_exports_string() {
983        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
984        let entries = pkg.entry_points();
985        assert!(entries.contains(&"./dist/index.js".to_string()));
986    }
987
988    #[test]
989    fn package_json_entry_points_exports_object() {
990        let pkg: PackageJson = serde_json::from_str(
991            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
992        )
993        .unwrap();
994        let entries = pkg.entry_points();
995        assert!(entries.contains(&"./dist/index.mjs".to_string()));
996        assert!(entries.contains(&"./dist/index.cjs".to_string()));
997    }
998
999    #[test]
1000    fn package_json_dependency_names() {
1001        let pkg: PackageJson = serde_json::from_str(
1002            r#"{
1003            "dependencies": {"react": "^18", "lodash": "^4"},
1004            "devDependencies": {"typescript": "^5"},
1005            "peerDependencies": {"react-dom": "^18"}
1006        }"#,
1007        )
1008        .unwrap();
1009
1010        let all = pkg.all_dependency_names();
1011        assert!(all.contains(&"react".to_string()));
1012        assert!(all.contains(&"lodash".to_string()));
1013        assert!(all.contains(&"typescript".to_string()));
1014        assert!(all.contains(&"react-dom".to_string()));
1015
1016        let prod = pkg.production_dependency_names();
1017        assert!(prod.contains(&"react".to_string()));
1018        assert!(!prod.contains(&"typescript".to_string()));
1019
1020        let dev = pkg.dev_dependency_names();
1021        assert!(dev.contains(&"typescript".to_string()));
1022        assert!(!dev.contains(&"react".to_string()));
1023    }
1024
1025    #[test]
1026    fn package_json_no_dependencies() {
1027        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
1028        assert!(pkg.all_dependency_names().is_empty());
1029        assert!(pkg.production_dependency_names().is_empty());
1030        assert!(pkg.dev_dependency_names().is_empty());
1031        assert!(pkg.entry_points().is_empty());
1032    }
1033
1034    #[test]
1035    fn rules_default_all_error() {
1036        let rules = RulesConfig::default();
1037        assert_eq!(rules.unused_files, Severity::Error);
1038        assert_eq!(rules.unused_exports, Severity::Error);
1039        assert_eq!(rules.unused_types, Severity::Error);
1040        assert_eq!(rules.unused_dependencies, Severity::Error);
1041        assert_eq!(rules.unused_dev_dependencies, Severity::Error);
1042        assert_eq!(rules.unused_enum_members, Severity::Error);
1043        assert_eq!(rules.unused_class_members, Severity::Error);
1044        assert_eq!(rules.unresolved_imports, Severity::Error);
1045        assert_eq!(rules.unlisted_dependencies, Severity::Error);
1046        assert_eq!(rules.duplicate_exports, Severity::Error);
1047    }
1048
1049    #[test]
1050    fn rules_deserialize_kebab_case() {
1051        let json_str = r#"{
1052            "rules": {
1053                "unused-files": "error",
1054                "unused-exports": "warn",
1055                "unused-types": "off"
1056            }
1057        }"#;
1058        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1059        assert_eq!(config.rules.unused_files, Severity::Error);
1060        assert_eq!(config.rules.unused_exports, Severity::Warn);
1061        assert_eq!(config.rules.unused_types, Severity::Off);
1062        // Unset fields default to error
1063        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1064    }
1065
1066    #[test]
1067    fn rules_deserialize_toml_kebab_case() {
1068        let toml_str = r#"
1069[rules]
1070unused-files = "error"
1071unused-exports = "warn"
1072unused-types = "off"
1073"#;
1074        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1075        assert_eq!(config.rules.unused_files, Severity::Error);
1076        assert_eq!(config.rules.unused_exports, Severity::Warn);
1077        assert_eq!(config.rules.unused_types, Severity::Off);
1078        // Unset fields default to error
1079        assert_eq!(config.rules.unresolved_imports, Severity::Error);
1080    }
1081
1082    #[test]
1083    fn severity_from_str() {
1084        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
1085        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
1086        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
1087        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
1088        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
1089        assert!("invalid".parse::<Severity>().is_err());
1090    }
1091
1092    #[test]
1093    fn config_without_rules_defaults_to_error() {
1094        let toml_str = r#"
1095entry = ["src/main.ts"]
1096"#;
1097        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1098        assert_eq!(config.rules.unused_files, Severity::Error);
1099        assert_eq!(config.rules.unused_exports, Severity::Error);
1100    }
1101
1102    #[test]
1103    fn fallow_config_denies_unknown_fields() {
1104        let toml_str = r#"
1105unknown_field = true
1106"#;
1107        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1108        assert!(result.is_err());
1109    }
1110
1111    #[test]
1112    fn fallow_config_deserialize_json() {
1113        let json_str = r#"{"entry": ["src/main.ts"]}"#;
1114        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1115        assert_eq!(config.entry, vec!["src/main.ts"]);
1116    }
1117
1118    #[test]
1119    fn fallow_config_deserialize_jsonc() {
1120        let jsonc_str = r#"{
1121            // This is a comment
1122            "entry": ["src/main.ts"],
1123            "rules": {
1124                "unused-files": "warn"
1125            }
1126        }"#;
1127        let mut stripped = String::new();
1128        json_comments::StripComments::new(jsonc_str.as_bytes())
1129            .read_to_string(&mut stripped)
1130            .unwrap();
1131        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
1132        assert_eq!(config.entry, vec!["src/main.ts"]);
1133        assert_eq!(config.rules.unused_files, Severity::Warn);
1134    }
1135
1136    #[test]
1137    fn fallow_config_json_with_schema_field() {
1138        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
1139        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1140        assert_eq!(config.entry, vec!["src/main.ts"]);
1141    }
1142
1143    #[test]
1144    fn fallow_config_json_schema_generation() {
1145        let schema = FallowConfig::json_schema();
1146        assert!(schema.is_object());
1147        let obj = schema.as_object().unwrap();
1148        assert!(obj.contains_key("properties"));
1149    }
1150
1151    #[test]
1152    fn config_format_detection() {
1153        assert!(matches!(
1154            ConfigFormat::from_path(Path::new("fallow.toml")),
1155            ConfigFormat::Toml
1156        ));
1157        assert!(matches!(
1158            ConfigFormat::from_path(Path::new(".fallowrc.json")),
1159            ConfigFormat::Json
1160        ));
1161        assert!(matches!(
1162            ConfigFormat::from_path(Path::new(".fallow.toml")),
1163            ConfigFormat::Toml
1164        ));
1165    }
1166
1167    #[test]
1168    fn config_names_priority_order() {
1169        assert_eq!(CONFIG_NAMES[0], ".fallowrc.json");
1170        assert_eq!(CONFIG_NAMES[1], "fallow.toml");
1171        assert_eq!(CONFIG_NAMES[2], ".fallow.toml");
1172    }
1173
1174    #[test]
1175    fn load_json_config_file() {
1176        let dir = test_dir("json-config");
1177        let config_path = dir.join(".fallowrc.json");
1178        std::fs::write(
1179            &config_path,
1180            r#"{"entry": ["src/index.ts"], "rules": {"unused-exports": "warn"}}"#,
1181        )
1182        .unwrap();
1183
1184        let config = FallowConfig::load(&config_path).unwrap();
1185        assert_eq!(config.entry, vec!["src/index.ts"]);
1186        assert_eq!(config.rules.unused_exports, Severity::Warn);
1187
1188        let _ = std::fs::remove_dir_all(&dir);
1189    }
1190
1191    #[test]
1192    fn load_jsonc_config_file() {
1193        let dir = test_dir("jsonc-config");
1194        let config_path = dir.join(".fallowrc.json");
1195        std::fs::write(
1196            &config_path,
1197            r#"{
1198                // Entry points for analysis
1199                "entry": ["src/index.ts"],
1200                /* Block comment */
1201                "rules": {
1202                    "unused-exports": "warn"
1203                }
1204            }"#,
1205        )
1206        .unwrap();
1207
1208        let config = FallowConfig::load(&config_path).unwrap();
1209        assert_eq!(config.entry, vec!["src/index.ts"]);
1210        assert_eq!(config.rules.unused_exports, Severity::Warn);
1211
1212        let _ = std::fs::remove_dir_all(&dir);
1213    }
1214
1215    #[test]
1216    fn json_config_ignore_dependencies_camel_case() {
1217        let json_str = r#"{"ignoreDependencies": ["autoprefixer", "postcss"]}"#;
1218        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1219        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
1220    }
1221
1222    #[test]
1223    fn json_config_all_fields() {
1224        let json_str = r#"{
1225            "ignoreDependencies": ["lodash"],
1226            "ignoreExports": [{"file": "src/*.ts", "exports": ["*"]}],
1227            "rules": {
1228                "unused-files": "off",
1229                "unused-exports": "warn",
1230                "unused-dependencies": "error",
1231                "unused-dev-dependencies": "off",
1232                "unused-types": "warn",
1233                "unused-enum-members": "error",
1234                "unused-class-members": "off",
1235                "unresolved-imports": "warn",
1236                "unlisted-dependencies": "error",
1237                "duplicate-exports": "off"
1238            },
1239            "duplicates": {
1240                "minTokens": 100,
1241                "minLines": 10,
1242                "skipLocal": true
1243            }
1244        }"#;
1245        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1246        assert_eq!(config.ignore_dependencies, vec!["lodash"]);
1247        assert_eq!(config.rules.unused_files, Severity::Off);
1248        assert_eq!(config.rules.unused_exports, Severity::Warn);
1249        assert_eq!(config.rules.unused_dependencies, Severity::Error);
1250        assert_eq!(config.duplicates.min_tokens, 100);
1251        assert_eq!(config.duplicates.min_lines, 10);
1252        assert!(config.duplicates.skip_local);
1253    }
1254
1255    // ── extends tests ──────────────────────────────────────────────
1256
1257    #[test]
1258    fn extends_single_base() {
1259        let dir = test_dir("extends-single");
1260
1261        std::fs::write(
1262            dir.join("base.json"),
1263            r#"{"rules": {"unused-files": "warn"}}"#,
1264        )
1265        .unwrap();
1266        std::fs::write(
1267            dir.join(".fallowrc.json"),
1268            r#"{"extends": ["base.json"], "entry": ["src/index.ts"]}"#,
1269        )
1270        .unwrap();
1271
1272        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1273        assert_eq!(config.rules.unused_files, Severity::Warn);
1274        assert_eq!(config.entry, vec!["src/index.ts"]);
1275        // Unset fields from base still default
1276        assert_eq!(config.rules.unused_exports, Severity::Error);
1277
1278        let _ = std::fs::remove_dir_all(&dir);
1279    }
1280
1281    #[test]
1282    fn extends_overlay_overrides_base() {
1283        let dir = test_dir("extends-overlay");
1284
1285        std::fs::write(
1286            dir.join("base.json"),
1287            r#"{"rules": {"unused-files": "warn", "unused-exports": "off"}}"#,
1288        )
1289        .unwrap();
1290        std::fs::write(
1291            dir.join(".fallowrc.json"),
1292            r#"{"extends": ["base.json"], "rules": {"unused-files": "error"}}"#,
1293        )
1294        .unwrap();
1295
1296        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1297        // Overlay overrides base
1298        assert_eq!(config.rules.unused_files, Severity::Error);
1299        // Base value preserved when not overridden
1300        assert_eq!(config.rules.unused_exports, Severity::Off);
1301
1302        let _ = std::fs::remove_dir_all(&dir);
1303    }
1304
1305    #[test]
1306    fn extends_chained() {
1307        let dir = test_dir("extends-chained");
1308
1309        std::fs::write(
1310            dir.join("grandparent.json"),
1311            r#"{"rules": {"unused-files": "off", "unused-exports": "warn"}}"#,
1312        )
1313        .unwrap();
1314        std::fs::write(
1315            dir.join("parent.json"),
1316            r#"{"extends": ["grandparent.json"], "rules": {"unused-files": "warn"}}"#,
1317        )
1318        .unwrap();
1319        std::fs::write(
1320            dir.join(".fallowrc.json"),
1321            r#"{"extends": ["parent.json"]}"#,
1322        )
1323        .unwrap();
1324
1325        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1326        // grandparent: off -> parent: warn -> child: inherits warn
1327        assert_eq!(config.rules.unused_files, Severity::Warn);
1328        // grandparent: warn, not overridden
1329        assert_eq!(config.rules.unused_exports, Severity::Warn);
1330
1331        let _ = std::fs::remove_dir_all(&dir);
1332    }
1333
1334    #[test]
1335    fn extends_circular_detected() {
1336        let dir = test_dir("extends-circular");
1337
1338        std::fs::write(dir.join("a.json"), r#"{"extends": ["b.json"]}"#).unwrap();
1339        std::fs::write(dir.join("b.json"), r#"{"extends": ["a.json"]}"#).unwrap();
1340
1341        let result = FallowConfig::load(&dir.join("a.json"));
1342        assert!(result.is_err());
1343        let err_msg = format!("{}", result.unwrap_err());
1344        assert!(
1345            err_msg.contains("Circular extends"),
1346            "Expected circular error, got: {err_msg}"
1347        );
1348
1349        let _ = std::fs::remove_dir_all(&dir);
1350    }
1351
1352    #[test]
1353    fn extends_missing_file_errors() {
1354        let dir = test_dir("extends-missing");
1355
1356        std::fs::write(
1357            dir.join(".fallowrc.json"),
1358            r#"{"extends": ["nonexistent.json"]}"#,
1359        )
1360        .unwrap();
1361
1362        let result = FallowConfig::load(&dir.join(".fallowrc.json"));
1363        assert!(result.is_err());
1364        let err_msg = format!("{}", result.unwrap_err());
1365        assert!(
1366            err_msg.contains("not found"),
1367            "Expected not found error, got: {err_msg}"
1368        );
1369
1370        let _ = std::fs::remove_dir_all(&dir);
1371    }
1372
1373    #[test]
1374    fn extends_string_sugar() {
1375        let dir = test_dir("extends-string");
1376
1377        std::fs::write(dir.join("base.json"), r#"{"ignorePatterns": ["gen/**"]}"#).unwrap();
1378        // String form instead of array
1379        std::fs::write(dir.join(".fallowrc.json"), r#"{"extends": "base.json"}"#).unwrap();
1380
1381        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1382        assert_eq!(config.ignore_patterns, vec!["gen/**"]);
1383
1384        let _ = std::fs::remove_dir_all(&dir);
1385    }
1386
1387    #[test]
1388    fn extends_deep_merge_preserves_arrays() {
1389        let dir = test_dir("extends-array");
1390
1391        std::fs::write(dir.join("base.json"), r#"{"entry": ["src/a.ts"]}"#).unwrap();
1392        std::fs::write(
1393            dir.join(".fallowrc.json"),
1394            r#"{"extends": ["base.json"], "entry": ["src/b.ts"]}"#,
1395        )
1396        .unwrap();
1397
1398        let config = FallowConfig::load(&dir.join(".fallowrc.json")).unwrap();
1399        // Arrays are replaced, not merged (overlay replaces base)
1400        assert_eq!(config.entry, vec!["src/b.ts"]);
1401
1402        let _ = std::fs::remove_dir_all(&dir);
1403    }
1404
1405    // ── overrides tests ────────────────────────────────────────────
1406
1407    #[test]
1408    fn overrides_deserialize() {
1409        let json_str = r#"{
1410            "overrides": [{
1411                "files": ["*.test.ts"],
1412                "rules": {
1413                    "unused-exports": "off"
1414                }
1415            }]
1416        }"#;
1417        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
1418        assert_eq!(config.overrides.len(), 1);
1419        assert_eq!(config.overrides[0].files, vec!["*.test.ts"]);
1420        assert_eq!(
1421            config.overrides[0].rules.unused_exports,
1422            Some(Severity::Off)
1423        );
1424        assert_eq!(config.overrides[0].rules.unused_files, None);
1425    }
1426
1427    #[test]
1428    fn apply_partial_only_some_fields() {
1429        let mut rules = RulesConfig::default();
1430        let partial = PartialRulesConfig {
1431            unused_files: Some(Severity::Warn),
1432            unused_exports: Some(Severity::Off),
1433            ..Default::default()
1434        };
1435        rules.apply_partial(&partial);
1436        assert_eq!(rules.unused_files, Severity::Warn);
1437        assert_eq!(rules.unused_exports, Severity::Off);
1438        // Unset fields unchanged
1439        assert_eq!(rules.unused_types, Severity::Error);
1440        assert_eq!(rules.unresolved_imports, Severity::Error);
1441    }
1442
1443    #[test]
1444    fn resolve_rules_for_path_no_overrides() {
1445        let config = FallowConfig {
1446            schema: None,
1447            extends: vec![],
1448            entry: vec![],
1449            ignore_patterns: vec![],
1450            framework: vec![],
1451            workspaces: None,
1452            ignore_dependencies: vec![],
1453            ignore_exports: vec![],
1454            duplicates: DuplicatesConfig::default(),
1455            rules: RulesConfig::default(),
1456            production: false,
1457            plugins: vec![],
1458            overrides: vec![],
1459        };
1460        let resolved = config.resolve(PathBuf::from("/project"), OutputFormat::Human, 1, true);
1461        let rules = resolved.resolve_rules_for_path(Path::new("/project/src/foo.ts"));
1462        assert_eq!(rules.unused_files, Severity::Error);
1463    }
1464
1465    #[test]
1466    fn resolve_rules_for_path_with_matching_override() {
1467        let config = FallowConfig {
1468            schema: None,
1469            extends: vec![],
1470            entry: vec![],
1471            ignore_patterns: vec![],
1472            framework: vec![],
1473            workspaces: None,
1474            ignore_dependencies: vec![],
1475            ignore_exports: vec![],
1476            duplicates: DuplicatesConfig::default(),
1477            rules: RulesConfig::default(),
1478            production: false,
1479            plugins: vec![],
1480            overrides: vec![ConfigOverride {
1481                files: vec!["*.test.ts".to_string()],
1482                rules: PartialRulesConfig {
1483                    unused_exports: Some(Severity::Off),
1484                    ..Default::default()
1485                },
1486            }],
1487        };
1488        let resolved = config.resolve(PathBuf::from("/project"), OutputFormat::Human, 1, true);
1489
1490        // Test file matches override
1491        let test_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.test.ts"));
1492        assert_eq!(test_rules.unused_exports, Severity::Off);
1493        assert_eq!(test_rules.unused_files, Severity::Error); // not overridden
1494
1495        // Non-test file does not match
1496        let src_rules = resolved.resolve_rules_for_path(Path::new("/project/src/utils.ts"));
1497        assert_eq!(src_rules.unused_exports, Severity::Error);
1498    }
1499
1500    #[test]
1501    fn resolve_rules_for_path_later_override_wins() {
1502        let config = FallowConfig {
1503            schema: None,
1504            extends: vec![],
1505            entry: vec![],
1506            ignore_patterns: vec![],
1507            framework: vec![],
1508            workspaces: None,
1509            ignore_dependencies: vec![],
1510            ignore_exports: vec![],
1511            duplicates: DuplicatesConfig::default(),
1512            rules: RulesConfig::default(),
1513            production: false,
1514            plugins: vec![],
1515            overrides: vec![
1516                ConfigOverride {
1517                    files: vec!["*.ts".to_string()],
1518                    rules: PartialRulesConfig {
1519                        unused_files: Some(Severity::Warn),
1520                        ..Default::default()
1521                    },
1522                },
1523                ConfigOverride {
1524                    files: vec!["*.test.ts".to_string()],
1525                    rules: PartialRulesConfig {
1526                        unused_files: Some(Severity::Off),
1527                        ..Default::default()
1528                    },
1529                },
1530            ],
1531        };
1532        let resolved = config.resolve(PathBuf::from("/project"), OutputFormat::Human, 1, true);
1533
1534        // First override matches *.ts, second matches *.test.ts; second wins
1535        let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1536        assert_eq!(rules.unused_files, Severity::Off);
1537
1538        // Non-test .ts file only matches first override
1539        let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1540        assert_eq!(rules2.unused_files, Severity::Warn);
1541    }
1542}