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