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