Skip to main content

fallow_config/
config.rs

1use std::io::Read as _;
2use std::path::{Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::framework::FrameworkPreset;
9use crate::workspace::WorkspaceConfig;
10
11/// Supported config file names in priority order.
12///
13/// `find_and_load` checks these names in order within each directory,
14/// returning the first match found.
15const CONFIG_NAMES: &[&str] = &["fallow.jsonc", "fallow.json", "fallow.toml", ".fallow.toml"];
16
17/// User-facing configuration loaded from `fallow.jsonc`, `fallow.json`, or `fallow.toml`.
18#[derive(Debug, Deserialize, Serialize, JsonSchema)]
19#[serde(deny_unknown_fields)]
20pub struct FallowConfig {
21    /// JSON Schema reference (ignored during deserialization).
22    #[serde(rename = "$schema", default, skip_serializing)]
23    #[schemars(skip)]
24    pub schema: Option<String>,
25
26    /// Additional entry point glob patterns.
27    #[serde(default)]
28    pub entry: Vec<String>,
29
30    /// Glob patterns to ignore from analysis.
31    #[serde(default)]
32    pub ignore: Vec<String>,
33
34    /// What to detect.
35    #[serde(default)]
36    pub detect: DetectConfig,
37
38    /// Custom framework definitions.
39    #[serde(default)]
40    pub framework: Vec<FrameworkPreset>,
41
42    /// Workspace overrides.
43    #[serde(default)]
44    pub workspaces: Option<WorkspaceConfig>,
45
46    /// Dependencies to ignore (always considered used).
47    #[serde(default)]
48    pub ignore_dependencies: Vec<String>,
49
50    /// Export ignore rules.
51    #[serde(default)]
52    pub ignore_exports: Vec<IgnoreExportRule>,
53
54    /// Output format.
55    #[serde(default)]
56    pub output: OutputFormat,
57
58    /// Duplication detection settings.
59    #[serde(default)]
60    pub duplicates: DuplicatesConfig,
61
62    /// Per-issue-type severity rules.
63    #[serde(default)]
64    pub rules: RulesConfig,
65
66    /// Production mode: exclude test/dev files, only start/build scripts.
67    #[serde(default)]
68    pub production: bool,
69}
70
71/// Configuration for code duplication detection.
72#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
73pub struct DuplicatesConfig {
74    /// Whether duplication detection is enabled.
75    #[serde(default = "default_true")]
76    pub enabled: bool,
77
78    /// Detection mode: strict, mild, weak, or semantic.
79    #[serde(default)]
80    pub mode: DetectionMode,
81
82    /// Minimum number of tokens for a clone.
83    #[serde(default = "default_min_tokens")]
84    pub min_tokens: usize,
85
86    /// Minimum number of lines for a clone.
87    #[serde(default = "default_min_lines")]
88    pub min_lines: usize,
89
90    /// Maximum allowed duplication percentage (0 = no limit).
91    #[serde(default)]
92    pub threshold: f64,
93
94    /// Additional ignore patterns for duplication analysis.
95    #[serde(default)]
96    pub ignore: Vec<String>,
97
98    /// Only report cross-directory duplicates.
99    #[serde(default)]
100    pub skip_local: bool,
101}
102
103impl Default for DuplicatesConfig {
104    fn default() -> Self {
105        Self {
106            enabled: true,
107            mode: DetectionMode::default(),
108            min_tokens: default_min_tokens(),
109            min_lines: default_min_lines(),
110            threshold: 0.0,
111            ignore: vec![],
112            skip_local: false,
113        }
114    }
115}
116
117/// Detection mode controlling how aggressively tokens are normalized.
118///
119/// Since fallow uses AST-based tokenization (not lexer-based), whitespace and
120/// comments are inherently absent from the token stream. The `Strict` and `Mild`
121/// modes are currently equivalent. `Weak` mode additionally blinds string
122/// literals. `Semantic` mode blinds all identifiers and literal values for
123/// Type-2 (renamed variable) clone detection.
124#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
125#[serde(rename_all = "lowercase")]
126pub enum DetectionMode {
127    /// All tokens preserved including identifier names and literal values (Type-1 only).
128    Strict,
129    /// Default mode -- equivalent to strict for AST-based tokenization.
130    #[default]
131    Mild,
132    /// Blind string literal values (structure-preserving).
133    Weak,
134    /// Blind all identifiers and literal values for structural (Type-2) detection.
135    Semantic,
136}
137
138impl std::fmt::Display for DetectionMode {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            Self::Strict => write!(f, "strict"),
142            Self::Mild => write!(f, "mild"),
143            Self::Weak => write!(f, "weak"),
144            Self::Semantic => write!(f, "semantic"),
145        }
146    }
147}
148
149impl std::str::FromStr for DetectionMode {
150    type Err = String;
151
152    fn from_str(s: &str) -> Result<Self, Self::Err> {
153        match s.to_lowercase().as_str() {
154            "strict" => Ok(Self::Strict),
155            "mild" => Ok(Self::Mild),
156            "weak" => Ok(Self::Weak),
157            "semantic" => Ok(Self::Semantic),
158            other => Err(format!("unknown detection mode: '{other}'")),
159        }
160    }
161}
162
163const fn default_min_tokens() -> usize {
164    50
165}
166
167const fn default_min_lines() -> usize {
168    5
169}
170
171/// Controls which analyses to run.
172#[derive(Debug, Deserialize, Serialize, JsonSchema)]
173pub struct DetectConfig {
174    /// Detect unused files (not reachable from entry points).
175    #[serde(default = "default_true")]
176    pub unused_files: bool,
177
178    /// Detect unused exports (exported but never imported).
179    #[serde(default = "default_true")]
180    pub unused_exports: bool,
181
182    /// Detect unused production dependencies.
183    #[serde(default = "default_true")]
184    pub unused_dependencies: bool,
185
186    /// Detect unused dev dependencies.
187    #[serde(default = "default_true")]
188    pub unused_dev_dependencies: bool,
189
190    /// Detect unused type exports.
191    #[serde(default = "default_true")]
192    pub unused_types: bool,
193
194    /// Detect unused enum members.
195    #[serde(default = "default_true")]
196    pub unused_enum_members: bool,
197
198    /// Detect unused class members.
199    #[serde(default = "default_true")]
200    pub unused_class_members: bool,
201
202    /// Detect unresolved imports.
203    #[serde(default = "default_true")]
204    pub unresolved_imports: bool,
205
206    /// Detect unlisted dependencies (used but not in package.json).
207    #[serde(default = "default_true")]
208    pub unlisted_dependencies: bool,
209
210    /// Detect duplicate exports.
211    #[serde(default = "default_true")]
212    pub duplicate_exports: bool,
213}
214
215impl Default for DetectConfig {
216    fn default() -> Self {
217        Self {
218            unused_files: true,
219            unused_exports: true,
220            unused_dependencies: true,
221            unused_dev_dependencies: true,
222            unused_types: true,
223            unused_enum_members: true,
224            unused_class_members: true,
225            unresolved_imports: true,
226            unlisted_dependencies: true,
227            duplicate_exports: true,
228        }
229    }
230}
231
232/// Output format for results.
233#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
234#[serde(rename_all = "lowercase")]
235pub enum OutputFormat {
236    /// Human-readable terminal output with source context.
237    #[default]
238    Human,
239    /// Machine-readable JSON.
240    Json,
241    /// SARIF format for GitHub Code Scanning.
242    Sarif,
243    /// One issue per line (grep-friendly).
244    Compact,
245}
246
247/// Rule for ignoring specific exports.
248#[derive(Debug, Deserialize, Serialize, JsonSchema)]
249pub struct IgnoreExportRule {
250    /// Glob pattern for files.
251    pub file: String,
252    /// Export names to ignore (`*` for all).
253    pub exports: Vec<String>,
254}
255
256/// Fully resolved configuration with all globs pre-compiled.
257#[derive(Debug)]
258pub struct ResolvedConfig {
259    pub root: PathBuf,
260    pub entry_patterns: Vec<String>,
261    pub ignore_patterns: GlobSet,
262    pub detect: DetectConfig,
263    pub framework_rules: Vec<crate::framework::FrameworkRule>,
264    pub output: OutputFormat,
265    pub cache_dir: PathBuf,
266    pub threads: usize,
267    pub no_cache: bool,
268    pub ignore_dependencies: Vec<String>,
269    pub ignore_export_rules: Vec<IgnoreExportRule>,
270    pub duplicates: DuplicatesConfig,
271    pub rules: RulesConfig,
272    /// Whether production mode is active.
273    pub production: bool,
274}
275
276/// Detect config format from file extension.
277enum ConfigFormat {
278    Toml,
279    Json,
280    Jsonc,
281}
282
283impl ConfigFormat {
284    fn from_path(path: &Path) -> Self {
285        match path.extension().and_then(|e| e.to_str()) {
286            Some("toml") => Self::Toml,
287            Some("jsonc") => Self::Jsonc,
288            Some("json") => Self::Json,
289            _ => Self::Toml,
290        }
291    }
292}
293
294impl FallowConfig {
295    /// Load config from a fallow config file (TOML, JSON, or JSONC).
296    ///
297    /// The format is detected from the file extension:
298    /// - `.toml` → TOML
299    /// - `.json` → JSON
300    /// - `.jsonc` → JSONC (JSON with comments)
301    pub fn load(path: &Path) -> Result<Self, miette::Report> {
302        let content = std::fs::read_to_string(path)
303            .map_err(|e| miette::miette!("Failed to read config file {}: {}", path.display(), e))?;
304
305        match ConfigFormat::from_path(path) {
306            ConfigFormat::Toml => toml::from_str(&content).map_err(|e| {
307                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
308            }),
309            ConfigFormat::Json => serde_json::from_str(&content).map_err(|e| {
310                miette::miette!("Failed to parse config file {}: {}", path.display(), e)
311            }),
312            ConfigFormat::Jsonc => {
313                let mut stripped = String::new();
314                json_comments::StripComments::new(content.as_bytes())
315                    .read_to_string(&mut stripped)
316                    .map_err(|e| {
317                        miette::miette!("Failed to strip comments from {}: {}", path.display(), e)
318                    })?;
319                serde_json::from_str(&stripped).map_err(|e| {
320                    miette::miette!("Failed to parse config file {}: {}", path.display(), e)
321                })
322            }
323        }
324    }
325
326    /// Find and load config from the current directory or ancestors.
327    ///
328    /// Checks for config files in priority order:
329    /// `fallow.jsonc` > `fallow.json` > `fallow.toml` > `.fallow.toml`
330    ///
331    /// Stops searching at the first directory containing `.git` or `package.json`,
332    /// to avoid picking up unrelated config files above the project root.
333    ///
334    /// Returns `Ok(Some(...))` if a config was found and parsed, `Ok(None)` if
335    /// no config file exists, and `Err(...)` if a config file exists but fails to parse.
336    pub fn find_and_load(start: &Path) -> Result<Option<(Self, PathBuf)>, String> {
337        let mut dir = start;
338        loop {
339            for name in CONFIG_NAMES {
340                let candidate = dir.join(name);
341                if candidate.exists() {
342                    match Self::load(&candidate) {
343                        Ok(config) => return Ok(Some((config, candidate))),
344                        Err(e) => {
345                            return Err(format!("Failed to parse {}: {e}", candidate.display()));
346                        }
347                    }
348                }
349            }
350            // Stop at project root indicators
351            if dir.join(".git").exists() || dir.join("package.json").exists() {
352                break;
353            }
354            dir = match dir.parent() {
355                Some(parent) => parent,
356                None => break,
357            };
358        }
359        Ok(None)
360    }
361
362    /// Generate JSON Schema for the configuration format.
363    pub fn json_schema() -> serde_json::Value {
364        serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
365    }
366
367    /// Resolve into a fully resolved config with compiled globs.
368    pub fn resolve(self, root: PathBuf, threads: usize, no_cache: bool) -> ResolvedConfig {
369        let mut ignore_builder = GlobSetBuilder::new();
370        for pattern in &self.ignore {
371            match Glob::new(pattern) {
372                Ok(glob) => {
373                    ignore_builder.add(glob);
374                }
375                Err(e) => {
376                    eprintln!("Warning: Invalid ignore glob pattern '{pattern}': {e}");
377                }
378            }
379        }
380
381        // Default ignores
382        let default_ignores = [
383            "**/node_modules/**",
384            "**/dist/**",
385            "**/build/**",
386            "**/.git/**",
387            "**/coverage/**",
388            "**/*.min.js",
389            "**/*.min.mjs",
390        ];
391        for pattern in &default_ignores {
392            if let Ok(glob) = Glob::new(pattern) {
393                ignore_builder.add(glob);
394            }
395        }
396
397        let ignore_patterns = ignore_builder.build().unwrap_or_default();
398        let cache_dir = root.join(".fallow");
399
400        let framework_rules = crate::framework::resolve_framework_rules(&self.framework);
401
402        // Merge detect booleans into rules: detect=false forces Severity::Off
403        let mut rules = self.rules;
404        rules.merge_detect(&self.detect);
405
406        // In production mode, force unused_dev_dependencies off
407        let production = self.production;
408        if production {
409            rules.unused_dev_dependencies = Severity::Off;
410        }
411
412        ResolvedConfig {
413            root,
414            entry_patterns: self.entry,
415            ignore_patterns,
416            detect: self.detect,
417            framework_rules,
418            output: self.output,
419            cache_dir,
420            threads,
421            no_cache,
422            ignore_dependencies: self.ignore_dependencies,
423            ignore_export_rules: self.ignore_exports,
424            duplicates: self.duplicates,
425            rules,
426            production,
427        }
428    }
429}
430
431const fn default_true() -> bool {
432    true
433}
434
435/// Severity level for rules.
436///
437/// Controls whether an issue type causes CI failure (`error`), is reported
438/// without failing (`warn`), or is suppressed entirely (`off`).
439#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
440#[serde(rename_all = "lowercase")]
441pub enum Severity {
442    /// Report and fail CI (non-zero exit code).
443    #[default]
444    Error,
445    /// Report but don't fail CI.
446    Warn,
447    /// Don't detect or report.
448    Off,
449}
450
451impl std::fmt::Display for Severity {
452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        match self {
454            Self::Error => write!(f, "error"),
455            Self::Warn => write!(f, "warn"),
456            Self::Off => write!(f, "off"),
457        }
458    }
459}
460
461impl std::str::FromStr for Severity {
462    type Err = String;
463
464    fn from_str(s: &str) -> Result<Self, Self::Err> {
465        match s.to_lowercase().as_str() {
466            "error" => Ok(Self::Error),
467            "warn" | "warning" => Ok(Self::Warn),
468            "off" | "none" => Ok(Self::Off),
469            other => Err(format!(
470                "unknown severity: '{other}' (expected error, warn, or off)"
471            )),
472        }
473    }
474}
475
476/// Per-issue-type severity configuration.
477///
478/// Controls which issue types cause CI failure, are reported as warnings,
479/// or are suppressed entirely. All fields default to `Severity::Error`
480/// for backwards compatibility.
481#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
482pub struct RulesConfig {
483    #[serde(default)]
484    pub unused_files: Severity,
485    #[serde(default)]
486    pub unused_exports: Severity,
487    #[serde(default)]
488    pub unused_types: Severity,
489    #[serde(default)]
490    pub unused_dependencies: Severity,
491    #[serde(default)]
492    pub unused_dev_dependencies: Severity,
493    #[serde(default)]
494    pub unused_enum_members: Severity,
495    #[serde(default)]
496    pub unused_class_members: Severity,
497    #[serde(default)]
498    pub unresolved_imports: Severity,
499    #[serde(default)]
500    pub unlisted_dependencies: Severity,
501    #[serde(default)]
502    pub duplicate_exports: Severity,
503}
504
505impl Default for RulesConfig {
506    fn default() -> Self {
507        Self {
508            unused_files: Severity::Error,
509            unused_exports: Severity::Error,
510            unused_types: Severity::Error,
511            unused_dependencies: Severity::Error,
512            unused_dev_dependencies: Severity::Error,
513            unused_enum_members: Severity::Error,
514            unused_class_members: Severity::Error,
515            unresolved_imports: Severity::Error,
516            unlisted_dependencies: Severity::Error,
517            duplicate_exports: Severity::Error,
518        }
519    }
520}
521
522impl RulesConfig {
523    /// Merge `DetectConfig` booleans: if `detect.X = false`, force `rules.X = Off`.
524    pub fn merge_detect(&mut self, detect: &DetectConfig) {
525        if !detect.unused_files {
526            self.unused_files = Severity::Off;
527        }
528        if !detect.unused_exports {
529            self.unused_exports = Severity::Off;
530        }
531        if !detect.unused_types {
532            self.unused_types = Severity::Off;
533        }
534        if !detect.unused_dependencies {
535            self.unused_dependencies = Severity::Off;
536        }
537        if !detect.unused_dev_dependencies {
538            self.unused_dev_dependencies = Severity::Off;
539        }
540        if !detect.unused_enum_members {
541            self.unused_enum_members = Severity::Off;
542        }
543        if !detect.unused_class_members {
544            self.unused_class_members = Severity::Off;
545        }
546        if !detect.unresolved_imports {
547            self.unresolved_imports = Severity::Off;
548        }
549        if !detect.unlisted_dependencies {
550            self.unlisted_dependencies = Severity::Off;
551        }
552        if !detect.duplicate_exports {
553            self.duplicate_exports = Severity::Off;
554        }
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::PackageJson;
562
563    #[test]
564    fn detect_config_default_all_true() {
565        let config = DetectConfig::default();
566        assert!(config.unused_files);
567        assert!(config.unused_exports);
568        assert!(config.unused_dependencies);
569        assert!(config.unused_dev_dependencies);
570        assert!(config.unused_types);
571        assert!(config.unused_enum_members);
572        assert!(config.unused_class_members);
573        assert!(config.unresolved_imports);
574        assert!(config.unlisted_dependencies);
575        assert!(config.duplicate_exports);
576    }
577
578    #[test]
579    fn output_format_default_is_human() {
580        let format = OutputFormat::default();
581        assert!(matches!(format, OutputFormat::Human));
582    }
583
584    #[test]
585    fn fallow_config_deserialize_minimal() {
586        let toml_str = r#"
587entry = ["src/main.ts"]
588"#;
589        let config: FallowConfig = toml::from_str(toml_str).unwrap();
590        assert_eq!(config.entry, vec!["src/main.ts"]);
591        assert!(config.ignore.is_empty());
592        assert!(config.detect.unused_files); // default true
593    }
594
595    #[test]
596    fn fallow_config_deserialize_detect_overrides() {
597        let toml_str = r#"
598[detect]
599unused_files = false
600unused_exports = true
601unused_dependencies = false
602"#;
603        let config: FallowConfig = toml::from_str(toml_str).unwrap();
604        assert!(!config.detect.unused_files);
605        assert!(config.detect.unused_exports);
606        assert!(!config.detect.unused_dependencies);
607        // Others should default to true
608        assert!(config.detect.unused_types);
609    }
610
611    #[test]
612    fn fallow_config_deserialize_ignore_exports() {
613        let toml_str = r#"
614[[ignore_exports]]
615file = "src/types/*.ts"
616exports = ["*"]
617
618[[ignore_exports]]
619file = "src/constants.ts"
620exports = ["FOO", "BAR"]
621"#;
622        let config: FallowConfig = toml::from_str(toml_str).unwrap();
623        assert_eq!(config.ignore_exports.len(), 2);
624        assert_eq!(config.ignore_exports[0].file, "src/types/*.ts");
625        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
626        assert_eq!(config.ignore_exports[1].exports, vec!["FOO", "BAR"]);
627    }
628
629    #[test]
630    fn fallow_config_deserialize_ignore_dependencies() {
631        let toml_str = r#"
632ignore_dependencies = ["autoprefixer", "postcss"]
633"#;
634        let config: FallowConfig = toml::from_str(toml_str).unwrap();
635        assert_eq!(config.ignore_dependencies, vec!["autoprefixer", "postcss"]);
636    }
637
638    #[test]
639    fn fallow_config_resolve_default_ignores() {
640        let config = FallowConfig {
641            schema: None,
642            entry: vec![],
643            ignore: vec![],
644            detect: DetectConfig::default(),
645            framework: vec![],
646            workspaces: None,
647            ignore_dependencies: vec![],
648            ignore_exports: vec![],
649            output: OutputFormat::Human,
650            duplicates: DuplicatesConfig::default(),
651            rules: RulesConfig::default(),
652            production: false,
653        };
654        let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
655
656        // Default ignores should be compiled
657        assert!(resolved.ignore_patterns.is_match("node_modules/foo/bar.ts"));
658        assert!(resolved.ignore_patterns.is_match("dist/bundle.js"));
659        assert!(resolved.ignore_patterns.is_match("build/output.js"));
660        assert!(resolved.ignore_patterns.is_match(".git/config"));
661        assert!(resolved.ignore_patterns.is_match("coverage/report.js"));
662        assert!(resolved.ignore_patterns.is_match("foo.min.js"));
663        assert!(resolved.ignore_patterns.is_match("bar.min.mjs"));
664    }
665
666    #[test]
667    fn fallow_config_resolve_custom_ignores() {
668        let config = FallowConfig {
669            schema: None,
670            entry: vec!["src/**/*.ts".to_string()],
671            ignore: vec!["**/*.generated.ts".to_string()],
672            detect: DetectConfig::default(),
673            framework: vec![],
674            workspaces: None,
675            ignore_dependencies: vec![],
676            ignore_exports: vec![],
677            output: OutputFormat::Json,
678            duplicates: DuplicatesConfig::default(),
679            rules: RulesConfig::default(),
680            production: false,
681        };
682        let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, false);
683
684        assert!(resolved.ignore_patterns.is_match("src/foo.generated.ts"));
685        assert_eq!(resolved.entry_patterns, vec!["src/**/*.ts"]);
686        assert!(matches!(resolved.output, OutputFormat::Json));
687        assert!(!resolved.no_cache);
688    }
689
690    #[test]
691    fn fallow_config_resolve_cache_dir() {
692        let config = FallowConfig {
693            schema: None,
694            entry: vec![],
695            ignore: vec![],
696            detect: DetectConfig::default(),
697            framework: vec![],
698            workspaces: None,
699            ignore_dependencies: vec![],
700            ignore_exports: vec![],
701            output: OutputFormat::Human,
702            duplicates: DuplicatesConfig::default(),
703            rules: RulesConfig::default(),
704            production: false,
705        };
706        let resolved = config.resolve(PathBuf::from("/tmp/project"), 4, true);
707        assert_eq!(resolved.cache_dir, PathBuf::from("/tmp/project/.fallow"));
708        assert!(resolved.no_cache);
709    }
710
711    #[test]
712    fn package_json_entry_points_main() {
713        let pkg: PackageJson = serde_json::from_str(r#"{"main": "dist/index.js"}"#).unwrap();
714        let entries = pkg.entry_points();
715        assert!(entries.contains(&"dist/index.js".to_string()));
716    }
717
718    #[test]
719    fn package_json_entry_points_module() {
720        let pkg: PackageJson = serde_json::from_str(r#"{"module": "dist/index.mjs"}"#).unwrap();
721        let entries = pkg.entry_points();
722        assert!(entries.contains(&"dist/index.mjs".to_string()));
723    }
724
725    #[test]
726    fn package_json_entry_points_types() {
727        let pkg: PackageJson = serde_json::from_str(r#"{"types": "dist/index.d.ts"}"#).unwrap();
728        let entries = pkg.entry_points();
729        assert!(entries.contains(&"dist/index.d.ts".to_string()));
730    }
731
732    #[test]
733    fn package_json_entry_points_bin_string() {
734        let pkg: PackageJson = serde_json::from_str(r#"{"bin": "bin/cli.js"}"#).unwrap();
735        let entries = pkg.entry_points();
736        assert!(entries.contains(&"bin/cli.js".to_string()));
737    }
738
739    #[test]
740    fn package_json_entry_points_bin_object() {
741        let pkg: PackageJson =
742            serde_json::from_str(r#"{"bin": {"cli": "bin/cli.js", "serve": "bin/serve.js"}}"#)
743                .unwrap();
744        let entries = pkg.entry_points();
745        assert!(entries.contains(&"bin/cli.js".to_string()));
746        assert!(entries.contains(&"bin/serve.js".to_string()));
747    }
748
749    #[test]
750    fn package_json_entry_points_exports_string() {
751        let pkg: PackageJson = serde_json::from_str(r#"{"exports": "./dist/index.js"}"#).unwrap();
752        let entries = pkg.entry_points();
753        assert!(entries.contains(&"./dist/index.js".to_string()));
754    }
755
756    #[test]
757    fn package_json_entry_points_exports_object() {
758        let pkg: PackageJson = serde_json::from_str(
759            r#"{"exports": {".": {"import": "./dist/index.mjs", "require": "./dist/index.cjs"}}}"#,
760        )
761        .unwrap();
762        let entries = pkg.entry_points();
763        assert!(entries.contains(&"./dist/index.mjs".to_string()));
764        assert!(entries.contains(&"./dist/index.cjs".to_string()));
765    }
766
767    #[test]
768    fn package_json_dependency_names() {
769        let pkg: PackageJson = serde_json::from_str(
770            r#"{
771            "dependencies": {"react": "^18", "lodash": "^4"},
772            "devDependencies": {"typescript": "^5"},
773            "peerDependencies": {"react-dom": "^18"}
774        }"#,
775        )
776        .unwrap();
777
778        let all = pkg.all_dependency_names();
779        assert!(all.contains(&"react".to_string()));
780        assert!(all.contains(&"lodash".to_string()));
781        assert!(all.contains(&"typescript".to_string()));
782        assert!(all.contains(&"react-dom".to_string()));
783
784        let prod = pkg.production_dependency_names();
785        assert!(prod.contains(&"react".to_string()));
786        assert!(!prod.contains(&"typescript".to_string()));
787
788        let dev = pkg.dev_dependency_names();
789        assert!(dev.contains(&"typescript".to_string()));
790        assert!(!dev.contains(&"react".to_string()));
791    }
792
793    #[test]
794    fn package_json_no_dependencies() {
795        let pkg: PackageJson = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
796        assert!(pkg.all_dependency_names().is_empty());
797        assert!(pkg.production_dependency_names().is_empty());
798        assert!(pkg.dev_dependency_names().is_empty());
799        assert!(pkg.entry_points().is_empty());
800    }
801
802    #[test]
803    fn rules_default_all_error() {
804        let rules = RulesConfig::default();
805        assert_eq!(rules.unused_files, Severity::Error);
806        assert_eq!(rules.unused_exports, Severity::Error);
807        assert_eq!(rules.unused_types, Severity::Error);
808        assert_eq!(rules.unused_dependencies, Severity::Error);
809        assert_eq!(rules.unused_dev_dependencies, Severity::Error);
810        assert_eq!(rules.unused_enum_members, Severity::Error);
811        assert_eq!(rules.unused_class_members, Severity::Error);
812        assert_eq!(rules.unresolved_imports, Severity::Error);
813        assert_eq!(rules.unlisted_dependencies, Severity::Error);
814        assert_eq!(rules.duplicate_exports, Severity::Error);
815    }
816
817    #[test]
818    fn rules_deserialize_mixed_severities() {
819        let toml_str = r#"
820[rules]
821unused_files = "error"
822unused_exports = "warn"
823unused_types = "off"
824"#;
825        let config: FallowConfig = toml::from_str(toml_str).unwrap();
826        assert_eq!(config.rules.unused_files, Severity::Error);
827        assert_eq!(config.rules.unused_exports, Severity::Warn);
828        assert_eq!(config.rules.unused_types, Severity::Off);
829        // Unset fields default to error
830        assert_eq!(config.rules.unresolved_imports, Severity::Error);
831    }
832
833    #[test]
834    fn detect_false_forces_severity_off() {
835        let toml_str = r#"
836[detect]
837unused_files = false
838
839[rules]
840unused_files = "error"
841"#;
842        let config: FallowConfig = toml::from_str(toml_str).unwrap();
843        let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
844        // detect=false overrides rules=error → off
845        assert_eq!(resolved.rules.unused_files, Severity::Off);
846    }
847
848    #[test]
849    fn rules_off_independent_of_detect() {
850        let toml_str = r#"
851[detect]
852unused_files = true
853
854[rules]
855unused_files = "off"
856"#;
857        let config: FallowConfig = toml::from_str(toml_str).unwrap();
858        let resolved = config.resolve(PathBuf::from("/tmp/test"), 4, true);
859        // detect=true but rules=off → off (rules win when detect is true)
860        assert_eq!(resolved.rules.unused_files, Severity::Off);
861    }
862
863    #[test]
864    fn severity_from_str() {
865        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
866        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
867        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
868        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
869        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
870        assert!("invalid".parse::<Severity>().is_err());
871    }
872
873    #[test]
874    fn config_without_rules_defaults_to_error() {
875        let toml_str = r#"
876entry = ["src/main.ts"]
877"#;
878        let config: FallowConfig = toml::from_str(toml_str).unwrap();
879        assert_eq!(config.rules.unused_files, Severity::Error);
880        assert_eq!(config.rules.unused_exports, Severity::Error);
881    }
882
883    #[test]
884    fn fallow_config_denies_unknown_fields() {
885        let toml_str = r#"
886unknown_field = true
887"#;
888        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
889        assert!(result.is_err());
890    }
891
892    #[test]
893    fn fallow_config_deserialize_json() {
894        let json_str = r#"{"entry": ["src/main.ts"], "detect": {"unused_files": false}}"#;
895        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
896        assert_eq!(config.entry, vec!["src/main.ts"]);
897        assert!(!config.detect.unused_files);
898        assert!(config.detect.unused_exports); // default true
899    }
900
901    #[test]
902    fn fallow_config_deserialize_jsonc() {
903        let jsonc_str = r#"{
904            // This is a comment
905            "entry": ["src/main.ts"],
906            "rules": {
907                "unused_files": "warn"
908            }
909        }"#;
910        let mut stripped = String::new();
911        json_comments::StripComments::new(jsonc_str.as_bytes())
912            .read_to_string(&mut stripped)
913            .unwrap();
914        let config: FallowConfig = serde_json::from_str(&stripped).unwrap();
915        assert_eq!(config.entry, vec!["src/main.ts"]);
916        assert_eq!(config.rules.unused_files, Severity::Warn);
917    }
918
919    #[test]
920    fn fallow_config_json_with_schema_field() {
921        let json_str = r#"{"$schema": "https://fallow.dev/schema.json", "entry": ["src/main.ts"]}"#;
922        let config: FallowConfig = serde_json::from_str(json_str).unwrap();
923        assert_eq!(config.entry, vec!["src/main.ts"]);
924    }
925
926    #[test]
927    fn fallow_config_json_schema_generation() {
928        let schema = FallowConfig::json_schema();
929        assert!(schema.is_object());
930        let obj = schema.as_object().unwrap();
931        assert!(obj.contains_key("properties"));
932    }
933
934    #[test]
935    fn config_format_detection() {
936        assert!(matches!(
937            ConfigFormat::from_path(Path::new("fallow.toml")),
938            ConfigFormat::Toml
939        ));
940        assert!(matches!(
941            ConfigFormat::from_path(Path::new("fallow.json")),
942            ConfigFormat::Json
943        ));
944        assert!(matches!(
945            ConfigFormat::from_path(Path::new("fallow.jsonc")),
946            ConfigFormat::Jsonc
947        ));
948        assert!(matches!(
949            ConfigFormat::from_path(Path::new(".fallow.toml")),
950            ConfigFormat::Toml
951        ));
952    }
953
954    #[test]
955    fn config_names_priority_order() {
956        assert_eq!(CONFIG_NAMES[0], "fallow.jsonc");
957        assert_eq!(CONFIG_NAMES[1], "fallow.json");
958        assert_eq!(CONFIG_NAMES[2], "fallow.toml");
959        assert_eq!(CONFIG_NAMES[3], ".fallow.toml");
960    }
961
962    #[test]
963    fn load_json_config_file() {
964        let dir = std::env::temp_dir().join("fallow-test-json-config");
965        let _ = std::fs::create_dir_all(&dir);
966        let config_path = dir.join("fallow.json");
967        std::fs::write(
968            &config_path,
969            r#"{"entry": ["src/index.ts"], "rules": {"unused_exports": "warn"}}"#,
970        )
971        .unwrap();
972
973        let config = FallowConfig::load(&config_path).unwrap();
974        assert_eq!(config.entry, vec!["src/index.ts"]);
975        assert_eq!(config.rules.unused_exports, Severity::Warn);
976
977        let _ = std::fs::remove_dir_all(&dir);
978    }
979
980    #[test]
981    fn load_jsonc_config_file() {
982        let dir = std::env::temp_dir().join("fallow-test-jsonc-config");
983        let _ = std::fs::create_dir_all(&dir);
984        let config_path = dir.join("fallow.jsonc");
985        std::fs::write(
986            &config_path,
987            r#"{
988                // Entry points for analysis
989                "entry": ["src/index.ts"],
990                /* Block comment */
991                "rules": {
992                    "unused_exports": "warn"
993                }
994            }"#,
995        )
996        .unwrap();
997
998        let config = FallowConfig::load(&config_path).unwrap();
999        assert_eq!(config.entry, vec!["src/index.ts"]);
1000        assert_eq!(config.rules.unused_exports, Severity::Warn);
1001
1002        let _ = std::fs::remove_dir_all(&dir);
1003    }
1004}