1mod boundaries;
2mod duplicates_config;
3mod finding_ignore;
4mod flags;
5mod format;
6pub mod glob_validation;
7mod health;
8mod parsing;
9mod resolution;
10mod resolve;
11mod rules;
12mod used_class_members;
13
14#[expect(
15 clippy::redundant_pub_crate,
16 reason = "this module is glob re-exported from lib.rs, so `pub` would leak the helper into the public API; pub(crate) keeps it internal to the crate"
17)]
18pub(crate) use boundaries::wildcard_placement_error;
19pub use boundaries::{
20 AuthoredRule, BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryPreset,
21 BoundaryRule, BoundaryZone, ForbiddenCallRule, ForbiddenCallee, InvalidForbiddenCallee,
22 LogicalGroup, LogicalGroupStatus, RedundantRootPrefix, ResolvedBoundaryConfig,
23 ResolvedBoundaryCoverageConfig, ResolvedBoundaryRule, ResolvedZone, UnknownZoneRef,
24 ZoneReferenceKind, ZoneValidationError,
25};
26pub use duplicates_config::{
27 DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
28};
29pub use finding_ignore::FindingIgnoreMatcher;
30pub use flags::{FlagsConfig, SdkPattern};
31pub use format::OutputFormat;
32pub use health::{EmailMode, HealthConfig, HealthThresholdOverride, OwnershipConfig};
33pub use parsing::ConfigLoadOptions;
34pub use resolution::{
35 AnalysisSnapshot, CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
36 CompiledIgnoreExportRule, ConfigOverride, DEFAULT_MAX_FILE_SIZE_BYTES,
37 DEFAULT_MAX_FILE_SIZE_MB, IgnoreCatalogReferenceRule, IgnoreDependencyOverrideRule,
38 IgnoreExportRule, ResolvedConfig, ResolvedOverride, resolve_max_file_size_bytes,
39};
40pub use resolve::ResolveConfig;
41pub use rules::{
42 KNOWN_RULE_NAMES, PartialRulesConfig, RulesConfig, Severity, closest_known_rule_name,
43 default_severity_for_kind, is_opt_in_kind,
44};
45pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
46
47use schemars::JsonSchema;
48use serde::{Deserialize, Deserializer, Serialize};
49use std::ops::Not;
50use std::path::PathBuf;
51
52use crate::external_plugin::ExternalPluginDef;
53use crate::workspace::WorkspaceConfig;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
56#[serde(untagged, rename_all = "camelCase")]
57pub enum IgnoreExportsUsedInFileConfig {
58 Bool(bool),
59 ByKind(IgnoreExportsUsedInFileByKind),
60}
61
62impl Default for IgnoreExportsUsedInFileConfig {
63 fn default() -> Self {
64 Self::Bool(false)
65 }
66}
67
68impl From<bool> for IgnoreExportsUsedInFileConfig {
69 fn from(value: bool) -> Self {
70 Self::Bool(value)
71 }
72}
73
74impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
75 fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
76 Self::ByKind(value)
77 }
78}
79
80impl IgnoreExportsUsedInFileConfig {
81 #[must_use]
82 pub const fn is_enabled(self) -> bool {
83 match self {
84 Self::Bool(value) => value,
85 Self::ByKind(kind) => kind.type_ || kind.interface,
86 }
87 }
88
89 #[must_use]
90 pub const fn suppresses(self, is_type_only: bool) -> bool {
91 match self {
92 Self::Bool(value) => value,
93 Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
94 }
95 }
96}
97
98#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
99#[serde(rename_all = "camelCase")]
100pub struct IgnoreExportsUsedInFileByKind {
101 #[serde(default, rename = "type")]
103 pub type_: bool,
104 #[serde(default)]
106 pub interface: bool,
107}
108
109#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
119#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
120pub struct UnusedComponentPropsConfig {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub ignore_pattern: Option<String>,
131}
132
133impl UnusedComponentPropsConfig {
134 #[must_use]
135 pub fn is_default(&self) -> bool {
136 self.ignore_pattern.is_none()
137 }
138}
139
140#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
141#[serde(rename_all = "camelCase")]
142pub struct FixConfig {
143 #[serde(default)]
145 pub catalog: CatalogFixConfig,
146}
147
148#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
149#[serde(rename_all = "camelCase")]
150pub struct CatalogFixConfig {
151 #[serde(default)]
153 pub delete_preceding_comments: CatalogPrecedingCommentPolicy,
154}
155
156#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
157#[serde(rename_all = "lowercase")]
158pub enum CatalogPrecedingCommentPolicy {
159 #[default]
160 Auto,
161 Always,
162 Never,
163}
164
165#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
167#[serde(rename_all = "kebab-case")]
168pub enum TypeAwareRequire {
169 #[default]
171 BestEffort,
172 Complete,
174}
175
176impl From<TypeAwareRequire> for fallow_types::semantic::SemanticCompletenessRequirement {
177 fn from(value: TypeAwareRequire) -> Self {
178 match value {
179 TypeAwareRequire::BestEffort => Self::BestEffort,
180 TypeAwareRequire::Complete => Self::Complete,
181 }
182 }
183}
184
185#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
187#[serde(deny_unknown_fields, rename_all = "camelCase")]
188pub struct TypeAwareConfig {
189 #[serde(default)]
191 pub enabled: bool,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub projects: Vec<String>,
195 #[serde(default, skip_serializing_if = "is_default_type_aware_require")]
197 pub require: TypeAwareRequire,
198}
199
200#[expect(
201 clippy::trivially_copy_pass_by_ref,
202 reason = "serde skip_serializing_if callbacks receive field values by reference"
203)]
204fn is_default_type_aware_require(value: &TypeAwareRequire) -> bool {
205 matches!(value, TypeAwareRequire::BestEffort)
206}
207
208impl TypeAwareConfig {
209 #[must_use]
210 pub fn is_default(&self) -> bool {
211 !self.enabled && self.projects.is_empty() && self.require == TypeAwareRequire::BestEffort
212 }
213}
214
215#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
216#[serde(deny_unknown_fields, rename_all = "camelCase")]
217pub struct FallowConfig {
218 #[serde(rename = "$schema", default, skip_serializing)]
220 pub schema: Option<String>,
221
222 #[serde(default, skip_serializing)]
224 pub extends: Vec<String>,
225
226 #[serde(default)]
228 pub entry: Vec<String>,
229
230 #[serde(default)]
232 pub ignore_patterns: Vec<String>,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub ignore_findings: Vec<String>,
237
238 #[serde(default)]
240 pub framework: Vec<ExternalPluginDef>,
241
242 #[serde(default)]
244 pub workspaces: Option<WorkspaceConfig>,
245
246 #[serde(default)]
248 pub ignore_dependencies: Vec<String>,
249
250 #[serde(default)]
252 pub ignore_unresolved_imports: Vec<String>,
253
254 #[serde(default)]
256 pub ignore_exports: Vec<IgnoreExportRule>,
257
258 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
261
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
265
266 #[serde(default)]
268 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
269
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub ignore_decorators: Vec<String>,
273
274 #[serde(default)]
276 pub used_class_members: Vec<UsedClassMemberRule>,
277
278 #[serde(default)]
280 pub duplicates: DuplicatesConfig,
281
282 #[serde(default)]
284 pub health: HealthConfig,
285
286 #[serde(default, skip_serializing_if = "TypeAwareConfig::is_default")]
290 pub type_aware: TypeAwareConfig,
291
292 #[serde(default)]
294 pub rules: RulesConfig,
295
296 #[serde(
297 default,
298 skip_serializing_if = "UnusedComponentPropsConfig::is_default"
299 )]
300 pub unused_component_props: UnusedComponentPropsConfig,
302
303 #[serde(default)]
305 pub boundaries: BoundaryConfig,
306
307 #[serde(default)]
309 pub flags: FlagsConfig,
310
311 #[serde(default)]
313 pub security: SecurityConfig,
314
315 #[serde(default)]
317 pub fix: FixConfig,
318
319 #[serde(default)]
321 pub resolve: ResolveConfig,
322
323 #[serde(default)]
325 pub production: ProductionConfig,
326
327 #[serde(default)]
329 pub plugins: Vec<String>,
330
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub rule_packs: Vec<String>,
338
339 #[serde(default)]
341 pub dynamically_loaded: Vec<String>,
342
343 #[serde(default)]
345 pub overrides: Vec<ConfigOverride>,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub codeowners: Option<String>,
350
351 #[serde(default)]
353 pub public_packages: Vec<String>,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub regression: Option<RegressionConfig>,
358
359 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
361 pub audit: AuditConfig,
362
363 #[serde(default)]
365 pub sealed: bool,
366
367 #[serde(default)]
369 pub include_entry_exports: bool,
370
371 #[serde(default)]
373 pub auto_imports: bool,
374
375 #[serde(default, skip_serializing_if = "CacheConfig::is_default")]
377 pub cache: CacheConfig,
378}
379
380#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
384#[serde(deny_unknown_fields, rename_all = "camelCase")]
385pub struct SecurityConfig {
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub categories: Option<SecurityCategories>,
389 #[serde(default, skip_serializing_if = "Vec::is_empty")]
394 pub request_receivers: Vec<String>,
395}
396
397impl SecurityConfig {
398 #[must_use]
399 pub fn normalized_request_receivers(&self) -> Vec<String> {
400 let mut receivers = Vec::new();
401 for receiver in &self.request_receivers {
402 let normalized = receiver.trim().to_ascii_lowercase();
403 if !normalized.is_empty() && !receivers.contains(&normalized) {
404 receivers.push(normalized);
405 }
406 }
407 receivers
408 }
409
410 #[must_use]
411 pub fn request_receivers_are_valid(&self) -> bool {
412 self.request_receivers
413 .iter()
414 .all(|receiver| !receiver.trim().is_empty())
415 }
416}
417
418#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
423#[serde(deny_unknown_fields, rename_all = "camelCase")]
424pub struct SecurityCategories {
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub include: Option<Vec<String>>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub exclude: Option<Vec<String>>,
431}
432
433#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
434#[serde(deny_unknown_fields, rename_all = "camelCase")]
435pub struct CacheConfig {
436 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub dir: Option<PathBuf>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub max_size_mb: Option<u32>,
443}
444
445impl CacheConfig {
446 #[must_use]
447 pub fn is_default(&self) -> bool {
448 self.dir.is_none() && self.max_size_mb.is_none()
449 }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum ProductionAnalysis {
454 DeadCode,
455 Health,
456 Dupes,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
460#[serde(untagged)]
461pub enum ProductionConfig {
462 Global(bool),
463 PerAnalysis(PerAnalysisProductionConfig),
464}
465
466impl<'de> Deserialize<'de> for ProductionConfig {
467 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
468 where
469 D: Deserializer<'de>,
470 {
471 struct ProductionConfigVisitor;
472
473 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
474 type Value = ProductionConfig;
475
476 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477 formatter.write_str("a boolean or per-analysis production config object")
478 }
479
480 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
481 where
482 E: serde::de::Error,
483 {
484 Ok(ProductionConfig::Global(value))
485 }
486
487 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
488 where
489 A: serde::de::MapAccess<'de>,
490 {
491 PerAnalysisProductionConfig::deserialize(
492 serde::de::value::MapAccessDeserializer::new(map),
493 )
494 .map(ProductionConfig::PerAnalysis)
495 }
496 }
497
498 deserializer.deserialize_any(ProductionConfigVisitor)
499 }
500}
501
502impl Default for ProductionConfig {
503 fn default() -> Self {
504 Self::Global(false)
505 }
506}
507
508impl From<bool> for ProductionConfig {
509 fn from(value: bool) -> Self {
510 Self::Global(value)
511 }
512}
513
514impl Not for ProductionConfig {
515 type Output = bool;
516
517 fn not(self) -> Self::Output {
518 !self.any_enabled()
519 }
520}
521
522impl ProductionConfig {
523 #[must_use]
524 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
525 match self {
526 Self::Global(value) => value,
527 Self::PerAnalysis(config) => match analysis {
528 ProductionAnalysis::DeadCode => config.dead_code,
529 ProductionAnalysis::Health => config.health,
530 ProductionAnalysis::Dupes => config.dupes,
531 },
532 }
533 }
534
535 #[must_use]
536 pub const fn global(self) -> bool {
537 match self {
538 Self::Global(value) => value,
539 Self::PerAnalysis(_) => false,
540 }
541 }
542
543 #[must_use]
544 pub const fn any_enabled(self) -> bool {
545 match self {
546 Self::Global(value) => value,
547 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
548 }
549 }
550}
551
552#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
553#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
554pub struct PerAnalysisProductionConfig {
555 pub dead_code: bool,
557 pub health: bool,
559 pub dupes: bool,
561}
562
563#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
564#[serde(rename_all = "camelCase")]
565pub struct AuditConfig {
566 #[serde(default, skip_serializing_if = "AuditGate::is_default")]
568 pub gate: AuditGate,
569
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub css: Option<bool>,
573
574 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub css_deep: Option<bool>,
577
578 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub dead_code_baseline: Option<String>,
581
582 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub health_baseline: Option<String>,
585
586 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub dupes_baseline: Option<String>,
589
590 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub cache_max_age_days: Option<u32>,
593
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub type_aware: Option<bool>,
597}
598
599impl AuditConfig {
600 #[must_use]
601 pub fn is_empty(&self) -> bool {
602 self.gate.is_default()
603 && self.css.is_none()
604 && self.css_deep.is_none()
605 && self.dead_code_baseline.is_none()
606 && self.health_baseline.is_none()
607 && self.dupes_baseline.is_none()
608 && self.cache_max_age_days.is_none()
609 && self.type_aware.is_none()
610 }
611}
612
613#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
614#[serde(rename_all = "kebab-case")]
615pub enum AuditGate {
616 #[default]
617 NewOnly,
618 All,
619}
620
621impl AuditGate {
622 #[must_use]
623 pub const fn is_default(&self) -> bool {
624 matches!(self, Self::NewOnly)
625 }
626}
627
628#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
629#[serde(rename_all = "camelCase")]
630pub struct RegressionConfig {
631 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub baseline: Option<RegressionBaseline>,
634}
635
636#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
637#[serde(rename_all = "camelCase")]
638pub struct RegressionBaseline {
639 #[serde(default)]
642 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
643 #[serde(default)]
644 pub total_issues: usize,
645 #[serde(default)]
646 pub unused_files: usize,
647 #[serde(default)]
648 pub unused_exports: usize,
649 #[serde(default)]
650 pub unused_types: usize,
651 #[serde(default)]
652 pub unused_dependencies: usize,
653 #[serde(default)]
654 pub unused_dev_dependencies: usize,
655 #[serde(default)]
656 pub unused_optional_dependencies: usize,
657 #[serde(default)]
658 pub unused_enum_members: usize,
659 #[serde(default)]
660 pub unused_class_members: usize,
661 #[serde(default)]
662 pub unresolved_imports: usize,
663 #[serde(default)]
664 pub unlisted_dependencies: usize,
665 #[serde(default)]
666 pub duplicate_exports: usize,
667 #[serde(default)]
668 pub circular_dependencies: usize,
669 #[serde(default)]
670 pub re_export_cycles: usize,
671 #[serde(default)]
672 pub type_only_dependencies: usize,
673 #[serde(default)]
674 pub test_only_dependencies: usize,
675 #[serde(default)]
676 pub dev_dependencies_in_production: usize,
677 #[serde(default)]
678 pub boundary_violations: usize,
679 #[serde(default)]
680 pub boundary_coverage_violations: usize,
681 #[serde(default)]
682 pub boundary_call_violations: usize,
683 #[serde(default)]
684 pub policy_violations: usize,
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn default_config_has_empty_collections() {
693 let config = FallowConfig::default();
694 assert!(config.schema.is_none());
695 assert!(config.extends.is_empty());
696 assert!(config.entry.is_empty());
697 assert!(config.ignore_patterns.is_empty());
698 assert!(config.ignore_findings.is_empty());
699 assert!(config.framework.is_empty());
700 assert!(config.workspaces.is_none());
701 assert!(config.ignore_dependencies.is_empty());
702 assert!(config.ignore_exports.is_empty());
703 assert!(config.used_class_members.is_empty());
704 assert!(config.plugins.is_empty());
705 assert!(config.dynamically_loaded.is_empty());
706 assert!(config.overrides.is_empty());
707 assert!(config.public_packages.is_empty());
708 assert_eq!(
709 config.fix.catalog.delete_preceding_comments,
710 CatalogPrecedingCommentPolicy::Auto
711 );
712 assert!(!config.production);
713 }
714
715 #[test]
716 fn default_config_rules_are_error() {
717 let config = FallowConfig::default();
718 assert_eq!(config.rules.unused_files, Severity::Error);
719 assert_eq!(config.rules.unused_exports, Severity::Error);
720 assert_eq!(config.rules.unused_dependencies, Severity::Error);
721 }
722
723 #[test]
724 fn default_config_duplicates_enabled() {
725 let config = FallowConfig::default();
726 assert!(config.duplicates.enabled);
727 assert_eq!(config.duplicates.min_tokens, 50);
728 assert_eq!(config.duplicates.min_lines, 5);
729 }
730
731 #[test]
732 fn default_config_health_thresholds() {
733 let config = FallowConfig::default();
734 assert_eq!(config.health.max_cyclomatic, 20);
735 assert_eq!(config.health.max_cognitive, 15);
736 }
737
738 #[test]
739 fn deserialize_empty_json_object() {
740 let config: FallowConfig = serde_json::from_str("{}").unwrap();
741 assert!(config.entry.is_empty());
742 assert!(!config.production);
743 assert!(!config.type_aware.enabled);
744 assert_eq!(config.type_aware.require, TypeAwareRequire::BestEffort);
745 }
746
747 #[test]
748 fn deserialize_type_aware_config() {
749 let config: FallowConfig = serde_json::from_str(
750 r#"{"typeAware":{"enabled":true,"projects":["tsconfig.app.json"],"require":"complete"}}"#,
751 )
752 .unwrap();
753
754 assert!(config.type_aware.enabled);
755 assert_eq!(config.type_aware.projects, ["tsconfig.app.json"]);
756 assert_eq!(config.type_aware.require, TypeAwareRequire::Complete);
757 }
758
759 #[test]
760 fn deserialize_type_aware_config_rejects_unknown_fields() {
761 let result = serde_json::from_str::<FallowConfig>(
762 r#"{"typeAware":{"enabled":true,"compilerDiagnostics":true}}"#,
763 );
764 assert!(result.is_err());
765 }
766
767 #[test]
768 fn deserialize_json_with_all_top_level_fields() {
769 let json = r#"{
770 "$schema": "./node_modules/fallow/schema.json",
771 "entry": ["src/main.ts"],
772 "ignorePatterns": ["generated/**"],
773 "ignoreFindings": ["**/*.test.ts", "!src/public/**"],
774 "ignoreDependencies": ["postcss"],
775 "production": true,
776 "plugins": ["custom-plugin.toml"],
777 "rules": {"unused-files": "warn"},
778 "duplicates": {"enabled": false},
779 "health": {"maxCyclomatic": 30}
780 }"#;
781 let config: FallowConfig = serde_json::from_str(json).unwrap();
782 assert_eq!(
783 config.schema.as_deref(),
784 Some("./node_modules/fallow/schema.json")
785 );
786 assert_eq!(config.entry, vec!["src/main.ts"]);
787 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
788 assert_eq!(
789 config.ignore_findings,
790 vec!["**/*.test.ts", "!src/public/**"]
791 );
792 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
793 assert!(config.production);
794 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
795 assert_eq!(config.rules.unused_files, Severity::Warn);
796 assert!(!config.duplicates.enabled);
797 assert_eq!(config.health.max_cyclomatic, 30);
798 }
799
800 #[test]
801 fn deserialize_json_deny_unknown_fields() {
802 let json = r#"{"unknownField": true}"#;
803 let result: Result<FallowConfig, _> = serde_json::from_str(json);
804 assert!(result.is_err(), "unknown fields should be rejected");
805 }
806
807 #[test]
808 fn ignore_findings_serialization_is_canonical_and_sparse() {
809 let default_value = serde_json::to_value(FallowConfig::default()).unwrap();
810 assert!(default_value.get("ignoreFindings").is_none());
811
812 let config = FallowConfig {
813 ignore_findings: vec!["**/*.test.ts".to_string(), "!src/public/**".to_string()],
814 ..Default::default()
815 };
816 let value = serde_json::to_value(config).unwrap();
817 assert_eq!(
818 value.get("ignoreFindings"),
819 Some(&serde_json::json!(["**/*.test.ts", "!src/public/**"]))
820 );
821 }
822
823 #[test]
824 fn ignore_findings_deserializes_from_toml() {
825 let config: FallowConfig =
826 toml::from_str(r#"ignoreFindings = ["**/*.test.ts", "!src/public/**"]"#).unwrap();
827
828 assert_eq!(
829 config.ignore_findings,
830 vec!["**/*.test.ts", "!src/public/**"]
831 );
832 }
833
834 #[test]
835 fn generic_ignore_alias_is_rejected() {
836 let result = serde_json::from_str::<FallowConfig>(r#"{"ignore": ["**/*.test.ts"]}"#);
837
838 assert!(result.is_err());
839 }
840
841 #[test]
842 fn deserialize_json_production_mode_default_false() {
843 let config: FallowConfig = serde_json::from_str("{}").unwrap();
844 assert!(!config.production);
845 }
846
847 #[test]
848 fn deserialize_json_production_mode_true() {
849 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
850 assert!(config.production);
851 }
852
853 #[test]
854 fn deserialize_json_per_analysis_production_mode() {
855 let config: FallowConfig = serde_json::from_str(
856 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
857 )
858 .unwrap();
859 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
860 assert!(config.production.for_analysis(ProductionAnalysis::Health));
861 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
862 }
863
864 #[test]
865 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
866 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
867 .unwrap_err();
868 assert!(
869 err.to_string().contains("healthTypo"),
870 "error should name the unknown field: {err}"
871 );
872 }
873
874 #[test]
875 fn deserialize_json_dynamically_loaded() {
876 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
877 let config: FallowConfig = serde_json::from_str(json).unwrap();
878 assert_eq!(
879 config.dynamically_loaded,
880 vec!["plugins/**/*.ts", "locales/**/*.json"]
881 );
882 }
883
884 #[test]
885 fn deserialize_json_dynamically_loaded_defaults_empty() {
886 let config: FallowConfig = serde_json::from_str("{}").unwrap();
887 assert!(config.dynamically_loaded.is_empty());
888 }
889
890 #[test]
891 fn deserialize_json_fix_catalog_delete_preceding_comments() {
892 let config: FallowConfig =
893 serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
894 .unwrap();
895 assert_eq!(
896 config.fix.catalog.delete_preceding_comments,
897 CatalogPrecedingCommentPolicy::Always
898 );
899 }
900
901 #[test]
902 fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
903 let err = serde_json::from_str::<FallowConfig>(
904 r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
905 )
906 .unwrap_err();
907 assert!(
908 err.to_string().contains("sometimes"),
909 "error should name the bad policy: {err}"
910 );
911 }
912
913 #[test]
914 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
915 let json = r#"{
916 "usedClassMembers": [
917 "agInit",
918 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
919 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
920 ]
921 }"#;
922 let config: FallowConfig = serde_json::from_str(json).unwrap();
923 assert_eq!(
924 config.used_class_members,
925 vec![
926 UsedClassMemberRule::from("agInit"),
927 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
928 extends: None,
929 implements: Some("ICellRendererAngularComp".to_string()),
930 members: vec!["refresh".to_string()],
931 }),
932 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
933 extends: Some("BaseCommand".to_string()),
934 implements: Some("CanActivate".to_string()),
935 members: vec!["execute".to_string()],
936 }),
937 ]
938 );
939 }
940
941 #[test]
942 fn deserialize_toml_minimal() {
943 let toml_str = r#"
944entry = ["src/index.ts"]
945production = true
946"#;
947 let config: FallowConfig = toml::from_str(toml_str).unwrap();
948 assert_eq!(config.entry, vec!["src/index.ts"]);
949 assert!(config.production);
950 }
951
952 #[test]
953 fn workspaces_packages_key_is_accepted_as_patterns_alias() {
954 let config: FallowConfig =
958 toml::from_str("[workspaces]\npackages = [\"packages/*\", \"apps/*\"]").unwrap();
959 assert_eq!(
960 config.workspaces.map(|w| w.patterns).unwrap_or_default(),
961 vec!["packages/*".to_string(), "apps/*".to_string()],
962 "the `packages` alias must populate `patterns`"
963 );
964 }
965
966 #[test]
967 fn deserialize_toml_per_analysis_production_mode() {
968 let toml_str = r"
969[production]
970deadCode = false
971health = true
972dupes = false
973";
974 let config: FallowConfig = toml::from_str(toml_str).unwrap();
975 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
976 assert!(config.production.for_analysis(ProductionAnalysis::Health));
977 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
978 }
979
980 #[test]
981 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
982 let err = toml::from_str::<FallowConfig>(
983 r"
984[production]
985healthTypo = true
986",
987 )
988 .unwrap_err();
989 assert!(
990 err.to_string().contains("healthTypo"),
991 "error should name the unknown field: {err}"
992 );
993 }
994
995 #[test]
996 fn deserialize_toml_with_inline_framework() {
997 let toml_str = r#"
998[[framework]]
999name = "my-framework"
1000enablers = ["my-framework-pkg"]
1001entryPoints = ["src/routes/**/*.tsx"]
1002"#;
1003 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1004 assert_eq!(config.framework.len(), 1);
1005 assert_eq!(config.framework[0].name, "my-framework");
1006 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
1007 assert_eq!(
1008 config.framework[0].entry_points,
1009 vec!["src/routes/**/*.tsx"]
1010 );
1011 }
1012
1013 #[test]
1014 fn deserialize_toml_fix_catalog_delete_preceding_comments() {
1015 let toml_str = r#"
1016[fix.catalog]
1017deletePrecedingComments = "never"
1018"#;
1019 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1020 assert_eq!(
1021 config.fix.catalog.delete_preceding_comments,
1022 CatalogPrecedingCommentPolicy::Never
1023 );
1024 }
1025
1026 #[test]
1027 fn deserialize_toml_with_workspace_config() {
1028 let toml_str = r#"
1029[workspaces]
1030patterns = ["packages/*", "apps/*"]
1031"#;
1032 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1033 assert!(config.workspaces.is_some());
1034 let ws = config.workspaces.unwrap();
1035 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
1036 }
1037
1038 #[test]
1039 fn deserialize_toml_with_ignore_exports() {
1040 let toml_str = r#"
1041[[ignoreExports]]
1042file = "src/types/**/*.ts"
1043exports = ["*"]
1044"#;
1045 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1046 assert_eq!(config.ignore_exports.len(), 1);
1047 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
1048 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1049 }
1050
1051 #[test]
1052 fn deserialize_toml_used_class_members_supports_scoped_rules() {
1053 let toml_str = r#"
1054usedClassMembers = [
1055 { implements = "ICellRendererAngularComp", members = ["refresh"] },
1056 { extends = "BaseCommand", members = ["execute"] },
1057]
1058"#;
1059 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1060 assert_eq!(
1061 config.used_class_members,
1062 vec![
1063 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1064 extends: None,
1065 implements: Some("ICellRendererAngularComp".to_string()),
1066 members: vec!["refresh".to_string()],
1067 }),
1068 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1069 extends: Some("BaseCommand".to_string()),
1070 implements: None,
1071 members: vec!["execute".to_string()],
1072 }),
1073 ]
1074 );
1075 }
1076
1077 #[test]
1078 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
1079 let result = serde_json::from_str::<FallowConfig>(
1080 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
1081 );
1082 assert!(
1083 result.is_err(),
1084 "unconstrained scoped rule should be rejected"
1085 );
1086 }
1087
1088 #[test]
1089 fn deserialize_ignore_exports_used_in_file_bool() {
1090 let config: FallowConfig =
1091 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
1092
1093 assert!(config.ignore_exports_used_in_file.suppresses(false));
1094 assert!(config.ignore_exports_used_in_file.suppresses(true));
1095 }
1096
1097 #[test]
1098 fn deserialize_ignore_exports_used_in_file_kind_form() {
1099 let config: FallowConfig =
1100 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
1101
1102 assert!(!config.ignore_exports_used_in_file.suppresses(false));
1103 assert!(config.ignore_exports_used_in_file.suppresses(true));
1104 }
1105
1106 #[test]
1107 fn deserialize_toml_deny_unknown_fields() {
1108 let toml_str = r"bogus_field = true";
1109 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1110 assert!(result.is_err(), "unknown fields should be rejected");
1111 }
1112
1113 #[test]
1114 fn json_serialize_roundtrip() {
1115 let config = FallowConfig {
1116 entry: vec!["src/main.ts".to_string()],
1117 production: true.into(),
1118 ..FallowConfig::default()
1119 };
1120 let json = serde_json::to_string(&config).unwrap();
1121 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1122 assert_eq!(restored.entry, vec!["src/main.ts"]);
1123 assert!(restored.production);
1124 }
1125
1126 #[test]
1127 fn schema_field_not_serialized() {
1128 let config = FallowConfig {
1129 schema: Some("https://example.com/schema.json".to_string()),
1130 ..FallowConfig::default()
1131 };
1132 let json = serde_json::to_string(&config).unwrap();
1133 assert!(
1134 !json.contains("$schema"),
1135 "schema field should be skipped in serialization"
1136 );
1137 }
1138
1139 #[test]
1140 fn extends_field_not_serialized() {
1141 let config = FallowConfig {
1142 extends: vec!["base.json".to_string()],
1143 ..FallowConfig::default()
1144 };
1145 let json = serde_json::to_string(&config).unwrap();
1146 assert!(
1147 !json.contains("extends"),
1148 "extends field should be skipped in serialization"
1149 );
1150 }
1151
1152 #[test]
1153 fn regression_config_deserialize_json() {
1154 let json = r#"{
1155 "regression": {
1156 "baseline": {
1157 "totalIssues": 42,
1158 "unusedFiles": 10,
1159 "unusedExports": 5,
1160 "circularDependencies": 2
1161 }
1162 }
1163 }"#;
1164 let config: FallowConfig = serde_json::from_str(json).unwrap();
1165 let regression = config.regression.unwrap();
1166 let baseline = regression.baseline.unwrap();
1167 assert_eq!(baseline.total_issues, 42);
1168 assert_eq!(baseline.unused_files, 10);
1169 assert_eq!(baseline.unused_exports, 5);
1170 assert_eq!(baseline.circular_dependencies, 2);
1171 assert_eq!(baseline.unused_types, 0);
1172 assert_eq!(baseline.boundary_violations, 0);
1173 }
1174
1175 #[test]
1176 fn regression_config_defaults_to_none() {
1177 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1178 assert!(config.regression.is_none());
1179 }
1180
1181 #[test]
1182 fn regression_baseline_all_zeros_by_default() {
1183 let baseline = RegressionBaseline::default();
1184 assert_eq!(baseline.total_issues, 0);
1185 assert_eq!(baseline.unused_files, 0);
1186 assert_eq!(baseline.unused_exports, 0);
1187 assert_eq!(baseline.unused_types, 0);
1188 assert_eq!(baseline.unused_dependencies, 0);
1189 assert_eq!(baseline.unused_dev_dependencies, 0);
1190 assert_eq!(baseline.unused_optional_dependencies, 0);
1191 assert_eq!(baseline.unused_enum_members, 0);
1192 assert_eq!(baseline.unused_class_members, 0);
1193 assert_eq!(baseline.unresolved_imports, 0);
1194 assert_eq!(baseline.unlisted_dependencies, 0);
1195 assert_eq!(baseline.duplicate_exports, 0);
1196 assert_eq!(baseline.circular_dependencies, 0);
1197 assert_eq!(baseline.type_only_dependencies, 0);
1198 assert_eq!(baseline.test_only_dependencies, 0);
1199 assert_eq!(baseline.boundary_violations, 0);
1200 }
1201
1202 #[test]
1203 fn regression_config_serialize_roundtrip() {
1204 let baseline = RegressionBaseline {
1205 total_issues: 100,
1206 unused_files: 20,
1207 unused_exports: 30,
1208 ..RegressionBaseline::default()
1209 };
1210 let regression = RegressionConfig {
1211 baseline: Some(baseline),
1212 };
1213 let config = FallowConfig {
1214 regression: Some(regression),
1215 ..FallowConfig::default()
1216 };
1217 let json = serde_json::to_string(&config).unwrap();
1218 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1219 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1220 assert_eq!(restored_baseline.total_issues, 100);
1221 assert_eq!(restored_baseline.unused_files, 20);
1222 assert_eq!(restored_baseline.unused_exports, 30);
1223 assert_eq!(restored_baseline.unused_types, 0);
1224 }
1225
1226 #[test]
1227 fn regression_config_empty_baseline_deserialize() {
1228 let json = r#"{"regression": {}}"#;
1229 let config: FallowConfig = serde_json::from_str(json).unwrap();
1230 let regression = config.regression.unwrap();
1231 assert!(regression.baseline.is_none());
1232 }
1233
1234 #[test]
1235 fn regression_baseline_not_serialized_when_none() {
1236 let config = FallowConfig {
1237 regression: None,
1238 ..FallowConfig::default()
1239 };
1240 let json = serde_json::to_string(&config).unwrap();
1241 assert!(
1242 !json.contains("regression"),
1243 "regression should be skipped when None"
1244 );
1245 }
1246
1247 #[test]
1248 fn deserialize_json_with_overrides() {
1249 let json = r#"{
1250 "overrides": [
1251 {
1252 "files": ["*.test.ts", "*.spec.ts"],
1253 "rules": {
1254 "unused-exports": "off",
1255 "unused-files": "warn"
1256 }
1257 }
1258 ]
1259 }"#;
1260 let config: FallowConfig = serde_json::from_str(json).unwrap();
1261 assert_eq!(config.overrides.len(), 1);
1262 assert_eq!(config.overrides[0].files.len(), 2);
1263 assert_eq!(
1264 config.overrides[0].rules.unused_exports,
1265 Some(Severity::Off)
1266 );
1267 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1268 }
1269
1270 #[test]
1271 fn deserialize_json_with_boundaries() {
1272 let json = r#"{
1273 "boundaries": {
1274 "preset": "layered"
1275 }
1276 }"#;
1277 let config: FallowConfig = serde_json::from_str(json).unwrap();
1278 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1279 }
1280
1281 #[test]
1282 fn deserialize_toml_with_regression_baseline() {
1283 let toml_str = r"
1284[regression.baseline]
1285totalIssues = 50
1286unusedFiles = 10
1287unusedExports = 15
1288";
1289 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1290 let baseline = config.regression.unwrap().baseline.unwrap();
1291 assert_eq!(baseline.total_issues, 50);
1292 assert_eq!(baseline.unused_files, 10);
1293 assert_eq!(baseline.unused_exports, 15);
1294 }
1295
1296 #[test]
1297 fn deserialize_toml_with_overrides() {
1298 let toml_str = r#"
1299[[overrides]]
1300files = ["*.test.ts"]
1301
1302[overrides.rules]
1303unused-exports = "off"
1304
1305[[overrides]]
1306files = ["*.stories.tsx"]
1307
1308[overrides.rules]
1309unused-files = "off"
1310"#;
1311 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1312 assert_eq!(config.overrides.len(), 2);
1313 assert_eq!(
1314 config.overrides[0].rules.unused_exports,
1315 Some(Severity::Off)
1316 );
1317 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1318 }
1319
1320 #[test]
1321 fn regression_config_default_is_none_baseline() {
1322 let config = RegressionConfig::default();
1323 assert!(config.baseline.is_none());
1324 }
1325
1326 #[test]
1327 fn deserialize_json_multiple_ignore_export_rules() {
1328 let json = r#"{
1329 "ignoreExports": [
1330 {"file": "src/types/**/*.ts", "exports": ["*"]},
1331 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1332 {"file": "src/index.ts", "exports": ["default"]}
1333 ]
1334 }"#;
1335 let config: FallowConfig = serde_json::from_str(json).unwrap();
1336 assert_eq!(config.ignore_exports.len(), 3);
1337 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1338 }
1339
1340 #[test]
1341 fn deserialize_json_public_packages_camel_case() {
1342 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1343 let config: FallowConfig = serde_json::from_str(json).unwrap();
1344 assert_eq!(
1345 config.public_packages,
1346 vec!["@myorg/shared-lib", "@myorg/utils"]
1347 );
1348 }
1349
1350 #[test]
1351 fn deserialize_json_public_packages_rejects_snake_case() {
1352 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1353 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1354 assert!(
1355 result.is_err(),
1356 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1357 );
1358 }
1359
1360 #[test]
1361 fn deserialize_json_public_packages_empty() {
1362 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1363 assert!(config.public_packages.is_empty());
1364 }
1365
1366 #[test]
1367 fn deserialize_toml_public_packages() {
1368 let toml_str = r#"
1369publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1370"#;
1371 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1372 assert_eq!(
1373 config.public_packages,
1374 vec!["@myorg/shared-lib", "@myorg/ui"]
1375 );
1376 }
1377
1378 #[test]
1379 fn public_packages_serialize_roundtrip() {
1380 let config = FallowConfig {
1381 public_packages: vec!["@myorg/shared-lib".to_string()],
1382 ..FallowConfig::default()
1383 };
1384 let json = serde_json::to_string(&config).unwrap();
1385 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1386 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1387 }
1388}