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
13const CONFIG_NAMES: &[&str] = &[".fallowrc.json", "fallow.toml", ".fallow.toml"];
18
19#[derive(Debug, Deserialize, Serialize, JsonSchema)]
21#[serde(deny_unknown_fields, rename_all = "camelCase")]
22pub struct FallowConfig {
23 #[serde(rename = "$schema", default, skip_serializing)]
25 #[schemars(skip)]
26 pub schema: Option<String>,
27
28 #[serde(default, skip_serializing)]
33 pub extends: Vec<String>,
34
35 #[serde(default)]
37 pub entry: Vec<String>,
38
39 #[serde(default)]
41 pub ignore_patterns: Vec<String>,
42
43 #[serde(default)]
45 pub framework: Vec<ExternalPluginDef>,
46
47 #[serde(default)]
49 pub workspaces: Option<WorkspaceConfig>,
50
51 #[serde(default)]
53 pub ignore_dependencies: Vec<String>,
54
55 #[serde(default)]
57 pub ignore_exports: Vec<IgnoreExportRule>,
58
59 #[serde(default)]
61 pub duplicates: DuplicatesConfig,
62
63 #[serde(default)]
65 pub rules: RulesConfig,
66
67 #[serde(default)]
69 pub production: bool,
70
71 #[serde(default)]
79 pub plugins: Vec<String>,
80
81 #[serde(default)]
83 pub overrides: Vec<ConfigOverride>,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
88#[serde(rename_all = "camelCase")]
89pub struct DuplicatesConfig {
90 #[serde(default = "default_true")]
92 pub enabled: bool,
93
94 #[serde(default)]
96 pub mode: DetectionMode,
97
98 #[serde(default = "default_min_tokens")]
100 pub min_tokens: usize,
101
102 #[serde(default = "default_min_lines")]
104 pub min_lines: usize,
105
106 #[serde(default)]
108 pub threshold: f64,
109
110 #[serde(default)]
112 pub ignore: Vec<String>,
113
114 #[serde(default)]
116 pub skip_local: bool,
117
118 #[serde(default)]
124 pub cross_language: bool,
125
126 #[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#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct NormalizationConfig {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub ignore_identifiers: Option<bool>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub ignore_string_values: Option<bool>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub ignore_numeric_values: Option<bool>,
169}
170
171#[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 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
204#[serde(rename_all = "lowercase")]
205pub enum DetectionMode {
206 Strict,
208 #[default]
210 Mild,
211 Weak,
213 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#[derive(Debug, Default, Clone)]
254pub enum OutputFormat {
255 #[default]
257 Human,
258 Json,
260 Sarif,
262 Compact,
264 Markdown,
266}
267
268#[derive(Debug, Deserialize, Serialize, JsonSchema)]
270pub struct IgnoreExportRule {
271 pub file: String,
273 pub exports: Vec<String>,
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
279#[serde(rename_all = "camelCase")]
280pub struct ConfigOverride {
281 pub files: Vec<String>,
283 #[serde(default)]
285 pub rules: PartialRulesConfig,
286}
287
288#[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#[derive(Debug)]
320pub struct ResolvedOverride {
321 pub matchers: Vec<globset::GlobMatcher>,
322 pub rules: PartialRulesConfig,
323}
324
325#[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 pub production: bool,
341 pub external_plugins: Vec<ExternalPluginDef>,
343 pub overrides: Vec<ResolvedOverride>,
345}
346
347enum 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
364fn 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 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 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 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 pub fn json_schema() -> serde_json::Value {
548 serde_json::to_value(schemars::schema_for!(FallowConfig)).unwrap_or_default()
549 }
550
551 #[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 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 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 external_plugins.extend(self.framework);
605
606 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 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
686#[serde(rename_all = "lowercase")]
687pub enum Severity {
688 #[default]
690 Error,
691 Warn,
693 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#[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 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 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 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 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 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 #[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 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 assert_eq!(config.rules.unused_files, Severity::Error);
1309 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 assert_eq!(config.rules.unused_files, Severity::Warn);
1338 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 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 assert_eq!(config.entry, vec!["src/b.ts"]);
1411
1412 let _ = std::fs::remove_dir_all(&dir);
1413 }
1414
1415 #[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 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 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); 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 let rules = resolved.resolve_rules_for_path(Path::new("/project/foo.test.ts"));
1546 assert_eq!(rules.unused_files, Severity::Off);
1547
1548 let rules2 = resolved.resolve_rules_for_path(Path::new("/project/foo.ts"));
1550 assert_eq!(rules2.unused_files, Severity::Warn);
1551 }
1552}