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 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
595impl AuditConfig {
596 #[must_use]
597 pub fn is_empty(&self) -> bool {
598 self.gate.is_default()
599 && self.css.is_none()
600 && self.css_deep.is_none()
601 && self.dead_code_baseline.is_none()
602 && self.health_baseline.is_none()
603 && self.dupes_baseline.is_none()
604 && self.cache_max_age_days.is_none()
605 }
606}
607
608#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
609#[serde(rename_all = "kebab-case")]
610pub enum AuditGate {
611 #[default]
612 NewOnly,
613 All,
614}
615
616impl AuditGate {
617 #[must_use]
618 pub const fn is_default(&self) -> bool {
619 matches!(self, Self::NewOnly)
620 }
621}
622
623#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
624#[serde(rename_all = "camelCase")]
625pub struct RegressionConfig {
626 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub baseline: Option<RegressionBaseline>,
629}
630
631#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
632#[serde(rename_all = "camelCase")]
633pub struct RegressionBaseline {
634 #[serde(default)]
637 pub analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
638 #[serde(default)]
639 pub total_issues: usize,
640 #[serde(default)]
641 pub unused_files: usize,
642 #[serde(default)]
643 pub unused_exports: usize,
644 #[serde(default)]
645 pub unused_types: usize,
646 #[serde(default)]
647 pub unused_dependencies: usize,
648 #[serde(default)]
649 pub unused_dev_dependencies: usize,
650 #[serde(default)]
651 pub unused_optional_dependencies: usize,
652 #[serde(default)]
653 pub unused_enum_members: usize,
654 #[serde(default)]
655 pub unused_class_members: usize,
656 #[serde(default)]
657 pub unresolved_imports: usize,
658 #[serde(default)]
659 pub unlisted_dependencies: usize,
660 #[serde(default)]
661 pub duplicate_exports: usize,
662 #[serde(default)]
663 pub circular_dependencies: usize,
664 #[serde(default)]
665 pub re_export_cycles: usize,
666 #[serde(default)]
667 pub type_only_dependencies: usize,
668 #[serde(default)]
669 pub test_only_dependencies: usize,
670 #[serde(default)]
671 pub dev_dependencies_in_production: usize,
672 #[serde(default)]
673 pub boundary_violations: usize,
674 #[serde(default)]
675 pub boundary_coverage_violations: usize,
676 #[serde(default)]
677 pub boundary_call_violations: usize,
678 #[serde(default)]
679 pub policy_violations: usize,
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
687 fn default_config_has_empty_collections() {
688 let config = FallowConfig::default();
689 assert!(config.schema.is_none());
690 assert!(config.extends.is_empty());
691 assert!(config.entry.is_empty());
692 assert!(config.ignore_patterns.is_empty());
693 assert!(config.ignore_findings.is_empty());
694 assert!(config.framework.is_empty());
695 assert!(config.workspaces.is_none());
696 assert!(config.ignore_dependencies.is_empty());
697 assert!(config.ignore_exports.is_empty());
698 assert!(config.used_class_members.is_empty());
699 assert!(config.plugins.is_empty());
700 assert!(config.dynamically_loaded.is_empty());
701 assert!(config.overrides.is_empty());
702 assert!(config.public_packages.is_empty());
703 assert_eq!(
704 config.fix.catalog.delete_preceding_comments,
705 CatalogPrecedingCommentPolicy::Auto
706 );
707 assert!(!config.production);
708 }
709
710 #[test]
711 fn default_config_rules_are_error() {
712 let config = FallowConfig::default();
713 assert_eq!(config.rules.unused_files, Severity::Error);
714 assert_eq!(config.rules.unused_exports, Severity::Error);
715 assert_eq!(config.rules.unused_dependencies, Severity::Error);
716 }
717
718 #[test]
719 fn default_config_duplicates_enabled() {
720 let config = FallowConfig::default();
721 assert!(config.duplicates.enabled);
722 assert_eq!(config.duplicates.min_tokens, 50);
723 assert_eq!(config.duplicates.min_lines, 5);
724 }
725
726 #[test]
727 fn default_config_health_thresholds() {
728 let config = FallowConfig::default();
729 assert_eq!(config.health.max_cyclomatic, 20);
730 assert_eq!(config.health.max_cognitive, 15);
731 }
732
733 #[test]
734 fn deserialize_empty_json_object() {
735 let config: FallowConfig = serde_json::from_str("{}").unwrap();
736 assert!(config.entry.is_empty());
737 assert!(!config.production);
738 assert!(!config.type_aware.enabled);
739 assert_eq!(config.type_aware.require, TypeAwareRequire::BestEffort);
740 }
741
742 #[test]
743 fn deserialize_type_aware_config() {
744 let config: FallowConfig = serde_json::from_str(
745 r#"{"typeAware":{"enabled":true,"projects":["tsconfig.app.json"],"require":"complete"}}"#,
746 )
747 .unwrap();
748
749 assert!(config.type_aware.enabled);
750 assert_eq!(config.type_aware.projects, ["tsconfig.app.json"]);
751 assert_eq!(config.type_aware.require, TypeAwareRequire::Complete);
752 }
753
754 #[test]
755 fn deserialize_type_aware_config_rejects_unknown_fields() {
756 let result = serde_json::from_str::<FallowConfig>(
757 r#"{"typeAware":{"enabled":true,"compilerDiagnostics":true}}"#,
758 );
759 assert!(result.is_err());
760 }
761
762 #[test]
763 fn deserialize_json_with_all_top_level_fields() {
764 let json = r#"{
765 "$schema": "./node_modules/fallow/schema.json",
766 "entry": ["src/main.ts"],
767 "ignorePatterns": ["generated/**"],
768 "ignoreFindings": ["**/*.test.ts", "!src/public/**"],
769 "ignoreDependencies": ["postcss"],
770 "production": true,
771 "plugins": ["custom-plugin.toml"],
772 "rules": {"unused-files": "warn"},
773 "duplicates": {"enabled": false},
774 "health": {"maxCyclomatic": 30}
775 }"#;
776 let config: FallowConfig = serde_json::from_str(json).unwrap();
777 assert_eq!(
778 config.schema.as_deref(),
779 Some("./node_modules/fallow/schema.json")
780 );
781 assert_eq!(config.entry, vec!["src/main.ts"]);
782 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
783 assert_eq!(
784 config.ignore_findings,
785 vec!["**/*.test.ts", "!src/public/**"]
786 );
787 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
788 assert!(config.production);
789 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
790 assert_eq!(config.rules.unused_files, Severity::Warn);
791 assert!(!config.duplicates.enabled);
792 assert_eq!(config.health.max_cyclomatic, 30);
793 }
794
795 #[test]
796 fn deserialize_json_deny_unknown_fields() {
797 let json = r#"{"unknownField": true}"#;
798 let result: Result<FallowConfig, _> = serde_json::from_str(json);
799 assert!(result.is_err(), "unknown fields should be rejected");
800 }
801
802 #[test]
803 fn ignore_findings_serialization_is_canonical_and_sparse() {
804 let default_value = serde_json::to_value(FallowConfig::default()).unwrap();
805 assert!(default_value.get("ignoreFindings").is_none());
806
807 let config = FallowConfig {
808 ignore_findings: vec!["**/*.test.ts".to_string(), "!src/public/**".to_string()],
809 ..Default::default()
810 };
811 let value = serde_json::to_value(config).unwrap();
812 assert_eq!(
813 value.get("ignoreFindings"),
814 Some(&serde_json::json!(["**/*.test.ts", "!src/public/**"]))
815 );
816 }
817
818 #[test]
819 fn ignore_findings_deserializes_from_toml() {
820 let config: FallowConfig =
821 toml::from_str(r#"ignoreFindings = ["**/*.test.ts", "!src/public/**"]"#).unwrap();
822
823 assert_eq!(
824 config.ignore_findings,
825 vec!["**/*.test.ts", "!src/public/**"]
826 );
827 }
828
829 #[test]
830 fn generic_ignore_alias_is_rejected() {
831 let result = serde_json::from_str::<FallowConfig>(r#"{"ignore": ["**/*.test.ts"]}"#);
832
833 assert!(result.is_err());
834 }
835
836 #[test]
837 fn deserialize_json_production_mode_default_false() {
838 let config: FallowConfig = serde_json::from_str("{}").unwrap();
839 assert!(!config.production);
840 }
841
842 #[test]
843 fn deserialize_json_production_mode_true() {
844 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
845 assert!(config.production);
846 }
847
848 #[test]
849 fn deserialize_json_per_analysis_production_mode() {
850 let config: FallowConfig = serde_json::from_str(
851 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
852 )
853 .unwrap();
854 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
855 assert!(config.production.for_analysis(ProductionAnalysis::Health));
856 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
857 }
858
859 #[test]
860 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
861 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
862 .unwrap_err();
863 assert!(
864 err.to_string().contains("healthTypo"),
865 "error should name the unknown field: {err}"
866 );
867 }
868
869 #[test]
870 fn deserialize_json_dynamically_loaded() {
871 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
872 let config: FallowConfig = serde_json::from_str(json).unwrap();
873 assert_eq!(
874 config.dynamically_loaded,
875 vec!["plugins/**/*.ts", "locales/**/*.json"]
876 );
877 }
878
879 #[test]
880 fn deserialize_json_dynamically_loaded_defaults_empty() {
881 let config: FallowConfig = serde_json::from_str("{}").unwrap();
882 assert!(config.dynamically_loaded.is_empty());
883 }
884
885 #[test]
886 fn deserialize_json_fix_catalog_delete_preceding_comments() {
887 let config: FallowConfig =
888 serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
889 .unwrap();
890 assert_eq!(
891 config.fix.catalog.delete_preceding_comments,
892 CatalogPrecedingCommentPolicy::Always
893 );
894 }
895
896 #[test]
897 fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
898 let err = serde_json::from_str::<FallowConfig>(
899 r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
900 )
901 .unwrap_err();
902 assert!(
903 err.to_string().contains("sometimes"),
904 "error should name the bad policy: {err}"
905 );
906 }
907
908 #[test]
909 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
910 let json = r#"{
911 "usedClassMembers": [
912 "agInit",
913 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
914 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
915 ]
916 }"#;
917 let config: FallowConfig = serde_json::from_str(json).unwrap();
918 assert_eq!(
919 config.used_class_members,
920 vec![
921 UsedClassMemberRule::from("agInit"),
922 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
923 extends: None,
924 implements: Some("ICellRendererAngularComp".to_string()),
925 members: vec!["refresh".to_string()],
926 }),
927 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
928 extends: Some("BaseCommand".to_string()),
929 implements: Some("CanActivate".to_string()),
930 members: vec!["execute".to_string()],
931 }),
932 ]
933 );
934 }
935
936 #[test]
937 fn deserialize_toml_minimal() {
938 let toml_str = r#"
939entry = ["src/index.ts"]
940production = true
941"#;
942 let config: FallowConfig = toml::from_str(toml_str).unwrap();
943 assert_eq!(config.entry, vec!["src/index.ts"]);
944 assert!(config.production);
945 }
946
947 #[test]
948 fn workspaces_packages_key_is_accepted_as_patterns_alias() {
949 let config: FallowConfig =
953 toml::from_str("[workspaces]\npackages = [\"packages/*\", \"apps/*\"]").unwrap();
954 assert_eq!(
955 config.workspaces.map(|w| w.patterns).unwrap_or_default(),
956 vec!["packages/*".to_string(), "apps/*".to_string()],
957 "the `packages` alias must populate `patterns`"
958 );
959 }
960
961 #[test]
962 fn deserialize_toml_per_analysis_production_mode() {
963 let toml_str = r"
964[production]
965deadCode = false
966health = true
967dupes = false
968";
969 let config: FallowConfig = toml::from_str(toml_str).unwrap();
970 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
971 assert!(config.production.for_analysis(ProductionAnalysis::Health));
972 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
973 }
974
975 #[test]
976 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
977 let err = toml::from_str::<FallowConfig>(
978 r"
979[production]
980healthTypo = true
981",
982 )
983 .unwrap_err();
984 assert!(
985 err.to_string().contains("healthTypo"),
986 "error should name the unknown field: {err}"
987 );
988 }
989
990 #[test]
991 fn deserialize_toml_with_inline_framework() {
992 let toml_str = r#"
993[[framework]]
994name = "my-framework"
995enablers = ["my-framework-pkg"]
996entryPoints = ["src/routes/**/*.tsx"]
997"#;
998 let config: FallowConfig = toml::from_str(toml_str).unwrap();
999 assert_eq!(config.framework.len(), 1);
1000 assert_eq!(config.framework[0].name, "my-framework");
1001 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
1002 assert_eq!(
1003 config.framework[0].entry_points,
1004 vec!["src/routes/**/*.tsx"]
1005 );
1006 }
1007
1008 #[test]
1009 fn deserialize_toml_fix_catalog_delete_preceding_comments() {
1010 let toml_str = r#"
1011[fix.catalog]
1012deletePrecedingComments = "never"
1013"#;
1014 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1015 assert_eq!(
1016 config.fix.catalog.delete_preceding_comments,
1017 CatalogPrecedingCommentPolicy::Never
1018 );
1019 }
1020
1021 #[test]
1022 fn deserialize_toml_with_workspace_config() {
1023 let toml_str = r#"
1024[workspaces]
1025patterns = ["packages/*", "apps/*"]
1026"#;
1027 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1028 assert!(config.workspaces.is_some());
1029 let ws = config.workspaces.unwrap();
1030 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
1031 }
1032
1033 #[test]
1034 fn deserialize_toml_with_ignore_exports() {
1035 let toml_str = r#"
1036[[ignoreExports]]
1037file = "src/types/**/*.ts"
1038exports = ["*"]
1039"#;
1040 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1041 assert_eq!(config.ignore_exports.len(), 1);
1042 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
1043 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
1044 }
1045
1046 #[test]
1047 fn deserialize_toml_used_class_members_supports_scoped_rules() {
1048 let toml_str = r#"
1049usedClassMembers = [
1050 { implements = "ICellRendererAngularComp", members = ["refresh"] },
1051 { extends = "BaseCommand", members = ["execute"] },
1052]
1053"#;
1054 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1055 assert_eq!(
1056 config.used_class_members,
1057 vec![
1058 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1059 extends: None,
1060 implements: Some("ICellRendererAngularComp".to_string()),
1061 members: vec!["refresh".to_string()],
1062 }),
1063 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
1064 extends: Some("BaseCommand".to_string()),
1065 implements: None,
1066 members: vec!["execute".to_string()],
1067 }),
1068 ]
1069 );
1070 }
1071
1072 #[test]
1073 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
1074 let result = serde_json::from_str::<FallowConfig>(
1075 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
1076 );
1077 assert!(
1078 result.is_err(),
1079 "unconstrained scoped rule should be rejected"
1080 );
1081 }
1082
1083 #[test]
1084 fn deserialize_ignore_exports_used_in_file_bool() {
1085 let config: FallowConfig =
1086 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
1087
1088 assert!(config.ignore_exports_used_in_file.suppresses(false));
1089 assert!(config.ignore_exports_used_in_file.suppresses(true));
1090 }
1091
1092 #[test]
1093 fn deserialize_ignore_exports_used_in_file_kind_form() {
1094 let config: FallowConfig =
1095 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
1096
1097 assert!(!config.ignore_exports_used_in_file.suppresses(false));
1098 assert!(config.ignore_exports_used_in_file.suppresses(true));
1099 }
1100
1101 #[test]
1102 fn deserialize_toml_deny_unknown_fields() {
1103 let toml_str = r"bogus_field = true";
1104 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1105 assert!(result.is_err(), "unknown fields should be rejected");
1106 }
1107
1108 #[test]
1109 fn json_serialize_roundtrip() {
1110 let config = FallowConfig {
1111 entry: vec!["src/main.ts".to_string()],
1112 production: true.into(),
1113 ..FallowConfig::default()
1114 };
1115 let json = serde_json::to_string(&config).unwrap();
1116 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1117 assert_eq!(restored.entry, vec!["src/main.ts"]);
1118 assert!(restored.production);
1119 }
1120
1121 #[test]
1122 fn schema_field_not_serialized() {
1123 let config = FallowConfig {
1124 schema: Some("https://example.com/schema.json".to_string()),
1125 ..FallowConfig::default()
1126 };
1127 let json = serde_json::to_string(&config).unwrap();
1128 assert!(
1129 !json.contains("$schema"),
1130 "schema field should be skipped in serialization"
1131 );
1132 }
1133
1134 #[test]
1135 fn extends_field_not_serialized() {
1136 let config = FallowConfig {
1137 extends: vec!["base.json".to_string()],
1138 ..FallowConfig::default()
1139 };
1140 let json = serde_json::to_string(&config).unwrap();
1141 assert!(
1142 !json.contains("extends"),
1143 "extends field should be skipped in serialization"
1144 );
1145 }
1146
1147 #[test]
1148 fn regression_config_deserialize_json() {
1149 let json = r#"{
1150 "regression": {
1151 "baseline": {
1152 "totalIssues": 42,
1153 "unusedFiles": 10,
1154 "unusedExports": 5,
1155 "circularDependencies": 2
1156 }
1157 }
1158 }"#;
1159 let config: FallowConfig = serde_json::from_str(json).unwrap();
1160 let regression = config.regression.unwrap();
1161 let baseline = regression.baseline.unwrap();
1162 assert_eq!(baseline.total_issues, 42);
1163 assert_eq!(baseline.unused_files, 10);
1164 assert_eq!(baseline.unused_exports, 5);
1165 assert_eq!(baseline.circular_dependencies, 2);
1166 assert_eq!(baseline.unused_types, 0);
1167 assert_eq!(baseline.boundary_violations, 0);
1168 }
1169
1170 #[test]
1171 fn regression_config_defaults_to_none() {
1172 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1173 assert!(config.regression.is_none());
1174 }
1175
1176 #[test]
1177 fn regression_baseline_all_zeros_by_default() {
1178 let baseline = RegressionBaseline::default();
1179 assert_eq!(baseline.total_issues, 0);
1180 assert_eq!(baseline.unused_files, 0);
1181 assert_eq!(baseline.unused_exports, 0);
1182 assert_eq!(baseline.unused_types, 0);
1183 assert_eq!(baseline.unused_dependencies, 0);
1184 assert_eq!(baseline.unused_dev_dependencies, 0);
1185 assert_eq!(baseline.unused_optional_dependencies, 0);
1186 assert_eq!(baseline.unused_enum_members, 0);
1187 assert_eq!(baseline.unused_class_members, 0);
1188 assert_eq!(baseline.unresolved_imports, 0);
1189 assert_eq!(baseline.unlisted_dependencies, 0);
1190 assert_eq!(baseline.duplicate_exports, 0);
1191 assert_eq!(baseline.circular_dependencies, 0);
1192 assert_eq!(baseline.type_only_dependencies, 0);
1193 assert_eq!(baseline.test_only_dependencies, 0);
1194 assert_eq!(baseline.boundary_violations, 0);
1195 }
1196
1197 #[test]
1198 fn regression_config_serialize_roundtrip() {
1199 let baseline = RegressionBaseline {
1200 total_issues: 100,
1201 unused_files: 20,
1202 unused_exports: 30,
1203 ..RegressionBaseline::default()
1204 };
1205 let regression = RegressionConfig {
1206 baseline: Some(baseline),
1207 };
1208 let config = FallowConfig {
1209 regression: Some(regression),
1210 ..FallowConfig::default()
1211 };
1212 let json = serde_json::to_string(&config).unwrap();
1213 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1214 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1215 assert_eq!(restored_baseline.total_issues, 100);
1216 assert_eq!(restored_baseline.unused_files, 20);
1217 assert_eq!(restored_baseline.unused_exports, 30);
1218 assert_eq!(restored_baseline.unused_types, 0);
1219 }
1220
1221 #[test]
1222 fn regression_config_empty_baseline_deserialize() {
1223 let json = r#"{"regression": {}}"#;
1224 let config: FallowConfig = serde_json::from_str(json).unwrap();
1225 let regression = config.regression.unwrap();
1226 assert!(regression.baseline.is_none());
1227 }
1228
1229 #[test]
1230 fn regression_baseline_not_serialized_when_none() {
1231 let config = FallowConfig {
1232 regression: None,
1233 ..FallowConfig::default()
1234 };
1235 let json = serde_json::to_string(&config).unwrap();
1236 assert!(
1237 !json.contains("regression"),
1238 "regression should be skipped when None"
1239 );
1240 }
1241
1242 #[test]
1243 fn deserialize_json_with_overrides() {
1244 let json = r#"{
1245 "overrides": [
1246 {
1247 "files": ["*.test.ts", "*.spec.ts"],
1248 "rules": {
1249 "unused-exports": "off",
1250 "unused-files": "warn"
1251 }
1252 }
1253 ]
1254 }"#;
1255 let config: FallowConfig = serde_json::from_str(json).unwrap();
1256 assert_eq!(config.overrides.len(), 1);
1257 assert_eq!(config.overrides[0].files.len(), 2);
1258 assert_eq!(
1259 config.overrides[0].rules.unused_exports,
1260 Some(Severity::Off)
1261 );
1262 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1263 }
1264
1265 #[test]
1266 fn deserialize_json_with_boundaries() {
1267 let json = r#"{
1268 "boundaries": {
1269 "preset": "layered"
1270 }
1271 }"#;
1272 let config: FallowConfig = serde_json::from_str(json).unwrap();
1273 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1274 }
1275
1276 #[test]
1277 fn deserialize_toml_with_regression_baseline() {
1278 let toml_str = r"
1279[regression.baseline]
1280totalIssues = 50
1281unusedFiles = 10
1282unusedExports = 15
1283";
1284 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1285 let baseline = config.regression.unwrap().baseline.unwrap();
1286 assert_eq!(baseline.total_issues, 50);
1287 assert_eq!(baseline.unused_files, 10);
1288 assert_eq!(baseline.unused_exports, 15);
1289 }
1290
1291 #[test]
1292 fn deserialize_toml_with_overrides() {
1293 let toml_str = r#"
1294[[overrides]]
1295files = ["*.test.ts"]
1296
1297[overrides.rules]
1298unused-exports = "off"
1299
1300[[overrides]]
1301files = ["*.stories.tsx"]
1302
1303[overrides.rules]
1304unused-files = "off"
1305"#;
1306 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1307 assert_eq!(config.overrides.len(), 2);
1308 assert_eq!(
1309 config.overrides[0].rules.unused_exports,
1310 Some(Severity::Off)
1311 );
1312 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1313 }
1314
1315 #[test]
1316 fn regression_config_default_is_none_baseline() {
1317 let config = RegressionConfig::default();
1318 assert!(config.baseline.is_none());
1319 }
1320
1321 #[test]
1322 fn deserialize_json_multiple_ignore_export_rules() {
1323 let json = r#"{
1324 "ignoreExports": [
1325 {"file": "src/types/**/*.ts", "exports": ["*"]},
1326 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1327 {"file": "src/index.ts", "exports": ["default"]}
1328 ]
1329 }"#;
1330 let config: FallowConfig = serde_json::from_str(json).unwrap();
1331 assert_eq!(config.ignore_exports.len(), 3);
1332 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1333 }
1334
1335 #[test]
1336 fn deserialize_json_public_packages_camel_case() {
1337 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1338 let config: FallowConfig = serde_json::from_str(json).unwrap();
1339 assert_eq!(
1340 config.public_packages,
1341 vec!["@myorg/shared-lib", "@myorg/utils"]
1342 );
1343 }
1344
1345 #[test]
1346 fn deserialize_json_public_packages_rejects_snake_case() {
1347 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1348 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1349 assert!(
1350 result.is_err(),
1351 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1352 );
1353 }
1354
1355 #[test]
1356 fn deserialize_json_public_packages_empty() {
1357 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1358 assert!(config.public_packages.is_empty());
1359 }
1360
1361 #[test]
1362 fn deserialize_toml_public_packages() {
1363 let toml_str = r#"
1364publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1365"#;
1366 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1367 assert_eq!(
1368 config.public_packages,
1369 vec!["@myorg/shared-lib", "@myorg/ui"]
1370 );
1371 }
1372
1373 #[test]
1374 fn public_packages_serialize_roundtrip() {
1375 let config = FallowConfig {
1376 public_packages: vec!["@myorg/shared-lib".to_string()],
1377 ..FallowConfig::default()
1378 };
1379 let json = serde_json::to_string(&config).unwrap();
1380 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1381 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1382 }
1383}