1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5pub mod glob_validation;
6mod health;
7mod parsing;
8mod resolution;
9mod resolve;
10mod rules;
11mod used_class_members;
12
13pub use boundaries::{
14 AuthoredRule, BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, LogicalGroup,
15 LogicalGroupStatus, RedundantRootPrefix, ResolvedBoundaryConfig, ResolvedBoundaryRule,
16 ResolvedZone, UnknownZoneRef, ZoneReferenceKind, ZoneValidationError,
17};
18pub use duplicates_config::{
19 DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
20};
21pub use flags::{FlagsConfig, SdkPattern};
22pub use format::OutputFormat;
23pub use health::{EmailMode, HealthConfig, OwnershipConfig};
24pub use resolution::{
25 CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
26 CompiledIgnoreExportRule, ConfigOverride, IgnoreCatalogReferenceRule,
27 IgnoreDependencyOverrideRule, IgnoreExportRule, ResolvedConfig, ResolvedOverride,
28};
29pub use resolve::ResolveConfig;
30pub use rules::{PartialRulesConfig, RulesConfig, Severity};
31pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
32
33use schemars::JsonSchema;
34use serde::{Deserialize, Deserializer, Serialize};
35use std::ops::Not;
36
37use crate::external_plugin::ExternalPluginDef;
38use crate::workspace::WorkspaceConfig;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
43#[serde(untagged, rename_all = "camelCase")]
44pub enum IgnoreExportsUsedInFileConfig {
45 Bool(bool),
48 ByKind(IgnoreExportsUsedInFileByKind),
52}
53
54impl Default for IgnoreExportsUsedInFileConfig {
55 fn default() -> Self {
56 Self::Bool(false)
57 }
58}
59
60impl From<bool> for IgnoreExportsUsedInFileConfig {
61 fn from(value: bool) -> Self {
62 Self::Bool(value)
63 }
64}
65
66impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
67 fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
68 Self::ByKind(value)
69 }
70}
71
72impl IgnoreExportsUsedInFileConfig {
73 #[must_use]
75 pub const fn is_enabled(self) -> bool {
76 match self {
77 Self::Bool(value) => value,
78 Self::ByKind(kind) => kind.type_ || kind.interface,
79 }
80 }
81
82 #[must_use]
84 pub const fn suppresses(self, is_type_only: bool) -> bool {
85 match self {
86 Self::Bool(value) => value,
87 Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
88 }
89 }
90}
91
92#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
94#[serde(rename_all = "camelCase")]
95pub struct IgnoreExportsUsedInFileByKind {
96 #[serde(default, rename = "type")]
98 pub type_: bool,
99 #[serde(default)]
101 pub interface: bool,
102}
103
104#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
106#[serde(rename_all = "camelCase")]
107pub struct FixConfig {
108 #[serde(default)]
110 pub catalog: CatalogFixConfig,
111}
112
113#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
115#[serde(rename_all = "camelCase")]
116pub struct CatalogFixConfig {
117 #[serde(default)]
120 pub delete_preceding_comments: CatalogPrecedingCommentPolicy,
121}
122
123#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
125#[serde(rename_all = "lowercase")]
126pub enum CatalogPrecedingCommentPolicy {
127 #[default]
130 Auto,
131 Always,
133 Never,
135}
136
137#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
158#[serde(deny_unknown_fields, rename_all = "camelCase")]
159pub struct FallowConfig {
160 #[serde(rename = "$schema", default, skip_serializing)]
162 pub schema: Option<String>,
163
164 #[serde(default, skip_serializing)]
185 pub extends: Vec<String>,
186
187 #[serde(default)]
189 pub entry: Vec<String>,
190
191 #[serde(default)]
193 pub ignore_patterns: Vec<String>,
194
195 #[serde(default)]
197 pub framework: Vec<ExternalPluginDef>,
198
199 #[serde(default)]
201 pub workspaces: Option<WorkspaceConfig>,
202
203 #[serde(default)]
209 pub ignore_dependencies: Vec<String>,
210
211 #[serde(default)]
220 pub ignore_unresolved_imports: Vec<String>,
221
222 #[serde(default)]
224 pub ignore_exports: Vec<IgnoreExportRule>,
225
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
235
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
245
246 #[serde(default)]
251 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
252
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub ignore_decorators: Vec<String>,
271
272 #[serde(default)]
277 pub used_class_members: Vec<UsedClassMemberRule>,
278
279 #[serde(default)]
281 pub duplicates: DuplicatesConfig,
282
283 #[serde(default)]
285 pub health: HealthConfig,
286
287 #[serde(default)]
289 pub rules: RulesConfig,
290
291 #[serde(default)]
293 pub boundaries: BoundaryConfig,
294
295 #[serde(default)]
297 pub flags: FlagsConfig,
298
299 #[serde(default)]
301 pub fix: FixConfig,
302
303 #[serde(default)]
305 pub resolve: ResolveConfig,
306
307 #[serde(default)]
312 pub production: ProductionConfig,
313
314 #[serde(default)]
322 pub plugins: Vec<String>,
323
324 #[serde(default)]
328 pub dynamically_loaded: Vec<String>,
329
330 #[serde(default)]
332 pub overrides: Vec<ConfigOverride>,
333
334 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub codeowners: Option<String>,
341
342 #[serde(default)]
345 pub public_packages: Vec<String>,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub regression: Option<RegressionConfig>,
352
353 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
360 pub audit: AuditConfig,
361
362 #[serde(default)]
371 pub sealed: bool,
372
373 #[serde(default)]
378 pub include_entry_exports: bool,
379
380 #[serde(default)]
400 pub auto_imports: bool,
401
402 #[serde(default, skip_serializing_if = "CacheConfig::is_default")]
406 pub cache: CacheConfig,
407}
408
409#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
415#[serde(deny_unknown_fields, rename_all = "camelCase")]
416pub struct CacheConfig {
417 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub max_size_mb: Option<u32>,
422}
423
424impl CacheConfig {
425 #[must_use]
428 pub fn is_default(&self) -> bool {
429 self.max_size_mb.is_none()
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum ProductionAnalysis {
436 DeadCode,
437 Health,
438 Dupes,
439}
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
443#[serde(untagged)]
444pub enum ProductionConfig {
445 Global(bool),
447 PerAnalysis(PerAnalysisProductionConfig),
449}
450
451impl<'de> Deserialize<'de> for ProductionConfig {
452 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
453 where
454 D: Deserializer<'de>,
455 {
456 struct ProductionConfigVisitor;
457
458 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
459 type Value = ProductionConfig;
460
461 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 formatter.write_str("a boolean or per-analysis production config object")
463 }
464
465 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
466 where
467 E: serde::de::Error,
468 {
469 Ok(ProductionConfig::Global(value))
470 }
471
472 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
473 where
474 A: serde::de::MapAccess<'de>,
475 {
476 PerAnalysisProductionConfig::deserialize(
477 serde::de::value::MapAccessDeserializer::new(map),
478 )
479 .map(ProductionConfig::PerAnalysis)
480 }
481 }
482
483 deserializer.deserialize_any(ProductionConfigVisitor)
484 }
485}
486
487impl Default for ProductionConfig {
488 fn default() -> Self {
489 Self::Global(false)
490 }
491}
492
493impl From<bool> for ProductionConfig {
494 fn from(value: bool) -> Self {
495 Self::Global(value)
496 }
497}
498
499impl Not for ProductionConfig {
500 type Output = bool;
501
502 fn not(self) -> Self::Output {
503 !self.any_enabled()
504 }
505}
506
507impl ProductionConfig {
508 #[must_use]
509 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
510 match self {
511 Self::Global(value) => value,
512 Self::PerAnalysis(config) => match analysis {
513 ProductionAnalysis::DeadCode => config.dead_code,
514 ProductionAnalysis::Health => config.health,
515 ProductionAnalysis::Dupes => config.dupes,
516 },
517 }
518 }
519
520 #[must_use]
521 pub const fn global(self) -> bool {
522 match self {
523 Self::Global(value) => value,
524 Self::PerAnalysis(_) => false,
525 }
526 }
527
528 #[must_use]
529 pub const fn any_enabled(self) -> bool {
530 match self {
531 Self::Global(value) => value,
532 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
533 }
534 }
535}
536
537#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
539#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
540pub struct PerAnalysisProductionConfig {
541 pub dead_code: bool,
543 pub health: bool,
545 pub dupes: bool,
547}
548
549#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
556#[serde(rename_all = "camelCase")]
557pub struct AuditConfig {
558 #[serde(default, skip_serializing_if = "AuditGate::is_default")]
560 pub gate: AuditGate,
561
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub dead_code_baseline: Option<String>,
565
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub health_baseline: Option<String>,
569
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub dupes_baseline: Option<String>,
573
574 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub cache_max_age_days: Option<u32>,
584}
585
586impl AuditConfig {
587 #[must_use]
589 pub fn is_empty(&self) -> bool {
590 self.gate.is_default()
591 && self.dead_code_baseline.is_none()
592 && self.health_baseline.is_none()
593 && self.dupes_baseline.is_none()
594 && self.cache_max_age_days.is_none()
595 }
596}
597
598#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
600#[serde(rename_all = "kebab-case")]
601pub enum AuditGate {
602 #[default]
604 NewOnly,
605 All,
607}
608
609impl AuditGate {
610 #[must_use]
611 pub const fn is_default(&self) -> bool {
612 matches!(self, Self::NewOnly)
613 }
614}
615
616#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
623#[serde(rename_all = "camelCase")]
624pub struct RegressionConfig {
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub baseline: Option<RegressionBaseline>,
628}
629
630#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
632#[serde(rename_all = "camelCase")]
633pub struct RegressionBaseline {
634 #[serde(default)]
635 pub total_issues: usize,
636 #[serde(default)]
637 pub unused_files: usize,
638 #[serde(default)]
639 pub unused_exports: usize,
640 #[serde(default)]
641 pub unused_types: usize,
642 #[serde(default)]
643 pub unused_dependencies: usize,
644 #[serde(default)]
645 pub unused_dev_dependencies: usize,
646 #[serde(default)]
647 pub unused_optional_dependencies: usize,
648 #[serde(default)]
649 pub unused_enum_members: usize,
650 #[serde(default)]
651 pub unused_class_members: usize,
652 #[serde(default)]
653 pub unresolved_imports: usize,
654 #[serde(default)]
655 pub unlisted_dependencies: usize,
656 #[serde(default)]
657 pub duplicate_exports: usize,
658 #[serde(default)]
659 pub circular_dependencies: usize,
660 #[serde(default)]
661 pub re_export_cycles: usize,
662 #[serde(default)]
663 pub type_only_dependencies: usize,
664 #[serde(default)]
665 pub test_only_dependencies: usize,
666 #[serde(default)]
667 pub boundary_violations: usize,
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
677 fn default_config_has_empty_collections() {
678 let config = FallowConfig::default();
679 assert!(config.schema.is_none());
680 assert!(config.extends.is_empty());
681 assert!(config.entry.is_empty());
682 assert!(config.ignore_patterns.is_empty());
683 assert!(config.framework.is_empty());
684 assert!(config.workspaces.is_none());
685 assert!(config.ignore_dependencies.is_empty());
686 assert!(config.ignore_exports.is_empty());
687 assert!(config.used_class_members.is_empty());
688 assert!(config.plugins.is_empty());
689 assert!(config.dynamically_loaded.is_empty());
690 assert!(config.overrides.is_empty());
691 assert!(config.public_packages.is_empty());
692 assert_eq!(
693 config.fix.catalog.delete_preceding_comments,
694 CatalogPrecedingCommentPolicy::Auto
695 );
696 assert!(!config.production);
697 }
698
699 #[test]
700 fn default_config_rules_are_error() {
701 let config = FallowConfig::default();
702 assert_eq!(config.rules.unused_files, Severity::Error);
703 assert_eq!(config.rules.unused_exports, Severity::Error);
704 assert_eq!(config.rules.unused_dependencies, Severity::Error);
705 }
706
707 #[test]
708 fn default_config_duplicates_enabled() {
709 let config = FallowConfig::default();
710 assert!(config.duplicates.enabled);
711 assert_eq!(config.duplicates.min_tokens, 50);
712 assert_eq!(config.duplicates.min_lines, 5);
713 }
714
715 #[test]
716 fn default_config_health_thresholds() {
717 let config = FallowConfig::default();
718 assert_eq!(config.health.max_cyclomatic, 20);
719 assert_eq!(config.health.max_cognitive, 15);
720 }
721
722 #[test]
725 fn deserialize_empty_json_object() {
726 let config: FallowConfig = serde_json::from_str("{}").unwrap();
727 assert!(config.entry.is_empty());
728 assert!(!config.production);
729 }
730
731 #[test]
732 fn deserialize_json_with_all_top_level_fields() {
733 let json = r#"{
734 "$schema": "https://fallow.dev/schema.json",
735 "entry": ["src/main.ts"],
736 "ignorePatterns": ["generated/**"],
737 "ignoreDependencies": ["postcss"],
738 "production": true,
739 "plugins": ["custom-plugin.toml"],
740 "rules": {"unused-files": "warn"},
741 "duplicates": {"enabled": false},
742 "health": {"maxCyclomatic": 30}
743 }"#;
744 let config: FallowConfig = serde_json::from_str(json).unwrap();
745 assert_eq!(
746 config.schema.as_deref(),
747 Some("https://fallow.dev/schema.json")
748 );
749 assert_eq!(config.entry, vec!["src/main.ts"]);
750 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
751 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
752 assert!(config.production);
753 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
754 assert_eq!(config.rules.unused_files, Severity::Warn);
755 assert!(!config.duplicates.enabled);
756 assert_eq!(config.health.max_cyclomatic, 30);
757 }
758
759 #[test]
760 fn deserialize_json_deny_unknown_fields() {
761 let json = r#"{"unknownField": true}"#;
762 let result: Result<FallowConfig, _> = serde_json::from_str(json);
763 assert!(result.is_err(), "unknown fields should be rejected");
764 }
765
766 #[test]
767 fn deserialize_json_production_mode_default_false() {
768 let config: FallowConfig = serde_json::from_str("{}").unwrap();
769 assert!(!config.production);
770 }
771
772 #[test]
773 fn deserialize_json_production_mode_true() {
774 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
775 assert!(config.production);
776 }
777
778 #[test]
779 fn deserialize_json_per_analysis_production_mode() {
780 let config: FallowConfig = serde_json::from_str(
781 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
782 )
783 .unwrap();
784 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
785 assert!(config.production.for_analysis(ProductionAnalysis::Health));
786 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
787 }
788
789 #[test]
790 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
791 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
792 .unwrap_err();
793 assert!(
794 err.to_string().contains("healthTypo"),
795 "error should name the unknown field: {err}"
796 );
797 }
798
799 #[test]
800 fn deserialize_json_dynamically_loaded() {
801 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
802 let config: FallowConfig = serde_json::from_str(json).unwrap();
803 assert_eq!(
804 config.dynamically_loaded,
805 vec!["plugins/**/*.ts", "locales/**/*.json"]
806 );
807 }
808
809 #[test]
810 fn deserialize_json_dynamically_loaded_defaults_empty() {
811 let config: FallowConfig = serde_json::from_str("{}").unwrap();
812 assert!(config.dynamically_loaded.is_empty());
813 }
814
815 #[test]
816 fn deserialize_json_fix_catalog_delete_preceding_comments() {
817 let config: FallowConfig =
818 serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
819 .unwrap();
820 assert_eq!(
821 config.fix.catalog.delete_preceding_comments,
822 CatalogPrecedingCommentPolicy::Always
823 );
824 }
825
826 #[test]
827 fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
828 let err = serde_json::from_str::<FallowConfig>(
829 r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
830 )
831 .unwrap_err();
832 assert!(
833 err.to_string().contains("sometimes"),
834 "error should name the bad policy: {err}"
835 );
836 }
837
838 #[test]
839 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
840 let json = r#"{
841 "usedClassMembers": [
842 "agInit",
843 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
844 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
845 ]
846 }"#;
847 let config: FallowConfig = serde_json::from_str(json).unwrap();
848 assert_eq!(
849 config.used_class_members,
850 vec![
851 UsedClassMemberRule::from("agInit"),
852 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
853 extends: None,
854 implements: Some("ICellRendererAngularComp".to_string()),
855 members: vec!["refresh".to_string()],
856 }),
857 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
858 extends: Some("BaseCommand".to_string()),
859 implements: Some("CanActivate".to_string()),
860 members: vec!["execute".to_string()],
861 }),
862 ]
863 );
864 }
865
866 #[test]
869 fn deserialize_toml_minimal() {
870 let toml_str = r#"
871entry = ["src/index.ts"]
872production = true
873"#;
874 let config: FallowConfig = toml::from_str(toml_str).unwrap();
875 assert_eq!(config.entry, vec!["src/index.ts"]);
876 assert!(config.production);
877 }
878
879 #[test]
880 fn deserialize_toml_per_analysis_production_mode() {
881 let toml_str = r"
882[production]
883deadCode = false
884health = true
885dupes = false
886";
887 let config: FallowConfig = toml::from_str(toml_str).unwrap();
888 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
889 assert!(config.production.for_analysis(ProductionAnalysis::Health));
890 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
891 }
892
893 #[test]
894 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
895 let err = toml::from_str::<FallowConfig>(
896 r"
897[production]
898healthTypo = true
899",
900 )
901 .unwrap_err();
902 assert!(
903 err.to_string().contains("healthTypo"),
904 "error should name the unknown field: {err}"
905 );
906 }
907
908 #[test]
909 fn deserialize_toml_with_inline_framework() {
910 let toml_str = r#"
911[[framework]]
912name = "my-framework"
913enablers = ["my-framework-pkg"]
914entryPoints = ["src/routes/**/*.tsx"]
915"#;
916 let config: FallowConfig = toml::from_str(toml_str).unwrap();
917 assert_eq!(config.framework.len(), 1);
918 assert_eq!(config.framework[0].name, "my-framework");
919 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
920 assert_eq!(
921 config.framework[0].entry_points,
922 vec!["src/routes/**/*.tsx"]
923 );
924 }
925
926 #[test]
927 fn deserialize_toml_fix_catalog_delete_preceding_comments() {
928 let toml_str = r#"
929[fix.catalog]
930deletePrecedingComments = "never"
931"#;
932 let config: FallowConfig = toml::from_str(toml_str).unwrap();
933 assert_eq!(
934 config.fix.catalog.delete_preceding_comments,
935 CatalogPrecedingCommentPolicy::Never
936 );
937 }
938
939 #[test]
940 fn deserialize_toml_with_workspace_config() {
941 let toml_str = r#"
942[workspaces]
943patterns = ["packages/*", "apps/*"]
944"#;
945 let config: FallowConfig = toml::from_str(toml_str).unwrap();
946 assert!(config.workspaces.is_some());
947 let ws = config.workspaces.unwrap();
948 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
949 }
950
951 #[test]
952 fn deserialize_toml_with_ignore_exports() {
953 let toml_str = r#"
954[[ignoreExports]]
955file = "src/types/**/*.ts"
956exports = ["*"]
957"#;
958 let config: FallowConfig = toml::from_str(toml_str).unwrap();
959 assert_eq!(config.ignore_exports.len(), 1);
960 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
961 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
962 }
963
964 #[test]
965 fn deserialize_toml_used_class_members_supports_scoped_rules() {
966 let toml_str = r#"
967usedClassMembers = [
968 { implements = "ICellRendererAngularComp", members = ["refresh"] },
969 { extends = "BaseCommand", members = ["execute"] },
970]
971"#;
972 let config: FallowConfig = toml::from_str(toml_str).unwrap();
973 assert_eq!(
974 config.used_class_members,
975 vec![
976 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
977 extends: None,
978 implements: Some("ICellRendererAngularComp".to_string()),
979 members: vec!["refresh".to_string()],
980 }),
981 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
982 extends: Some("BaseCommand".to_string()),
983 implements: None,
984 members: vec!["execute".to_string()],
985 }),
986 ]
987 );
988 }
989
990 #[test]
991 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
992 let result = serde_json::from_str::<FallowConfig>(
993 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
994 );
995 assert!(
996 result.is_err(),
997 "unconstrained scoped rule should be rejected"
998 );
999 }
1000
1001 #[test]
1002 fn deserialize_ignore_exports_used_in_file_bool() {
1003 let config: FallowConfig =
1004 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
1005
1006 assert!(config.ignore_exports_used_in_file.suppresses(false));
1007 assert!(config.ignore_exports_used_in_file.suppresses(true));
1008 }
1009
1010 #[test]
1011 fn deserialize_ignore_exports_used_in_file_kind_form() {
1012 let config: FallowConfig =
1013 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
1014
1015 assert!(!config.ignore_exports_used_in_file.suppresses(false));
1016 assert!(config.ignore_exports_used_in_file.suppresses(true));
1017 }
1018
1019 #[test]
1020 fn deserialize_toml_deny_unknown_fields() {
1021 let toml_str = r"bogus_field = true";
1022 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
1023 assert!(result.is_err(), "unknown fields should be rejected");
1024 }
1025
1026 #[test]
1029 fn json_serialize_roundtrip() {
1030 let config = FallowConfig {
1031 entry: vec!["src/main.ts".to_string()],
1032 production: true.into(),
1033 ..FallowConfig::default()
1034 };
1035 let json = serde_json::to_string(&config).unwrap();
1036 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1037 assert_eq!(restored.entry, vec!["src/main.ts"]);
1038 assert!(restored.production);
1039 }
1040
1041 #[test]
1042 fn schema_field_not_serialized() {
1043 let config = FallowConfig {
1044 schema: Some("https://example.com/schema.json".to_string()),
1045 ..FallowConfig::default()
1046 };
1047 let json = serde_json::to_string(&config).unwrap();
1048 assert!(
1050 !json.contains("$schema"),
1051 "schema field should be skipped in serialization"
1052 );
1053 }
1054
1055 #[test]
1056 fn extends_field_not_serialized() {
1057 let config = FallowConfig {
1058 extends: vec!["base.json".to_string()],
1059 ..FallowConfig::default()
1060 };
1061 let json = serde_json::to_string(&config).unwrap();
1062 assert!(
1063 !json.contains("extends"),
1064 "extends field should be skipped in serialization"
1065 );
1066 }
1067
1068 #[test]
1071 fn regression_config_deserialize_json() {
1072 let json = r#"{
1073 "regression": {
1074 "baseline": {
1075 "totalIssues": 42,
1076 "unusedFiles": 10,
1077 "unusedExports": 5,
1078 "circularDependencies": 2
1079 }
1080 }
1081 }"#;
1082 let config: FallowConfig = serde_json::from_str(json).unwrap();
1083 let regression = config.regression.unwrap();
1084 let baseline = regression.baseline.unwrap();
1085 assert_eq!(baseline.total_issues, 42);
1086 assert_eq!(baseline.unused_files, 10);
1087 assert_eq!(baseline.unused_exports, 5);
1088 assert_eq!(baseline.circular_dependencies, 2);
1089 assert_eq!(baseline.unused_types, 0);
1091 assert_eq!(baseline.boundary_violations, 0);
1092 }
1093
1094 #[test]
1095 fn regression_config_defaults_to_none() {
1096 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1097 assert!(config.regression.is_none());
1098 }
1099
1100 #[test]
1101 fn regression_baseline_all_zeros_by_default() {
1102 let baseline = RegressionBaseline::default();
1103 assert_eq!(baseline.total_issues, 0);
1104 assert_eq!(baseline.unused_files, 0);
1105 assert_eq!(baseline.unused_exports, 0);
1106 assert_eq!(baseline.unused_types, 0);
1107 assert_eq!(baseline.unused_dependencies, 0);
1108 assert_eq!(baseline.unused_dev_dependencies, 0);
1109 assert_eq!(baseline.unused_optional_dependencies, 0);
1110 assert_eq!(baseline.unused_enum_members, 0);
1111 assert_eq!(baseline.unused_class_members, 0);
1112 assert_eq!(baseline.unresolved_imports, 0);
1113 assert_eq!(baseline.unlisted_dependencies, 0);
1114 assert_eq!(baseline.duplicate_exports, 0);
1115 assert_eq!(baseline.circular_dependencies, 0);
1116 assert_eq!(baseline.type_only_dependencies, 0);
1117 assert_eq!(baseline.test_only_dependencies, 0);
1118 assert_eq!(baseline.boundary_violations, 0);
1119 }
1120
1121 #[test]
1122 fn regression_config_serialize_roundtrip() {
1123 let baseline = RegressionBaseline {
1124 total_issues: 100,
1125 unused_files: 20,
1126 unused_exports: 30,
1127 ..RegressionBaseline::default()
1128 };
1129 let regression = RegressionConfig {
1130 baseline: Some(baseline),
1131 };
1132 let config = FallowConfig {
1133 regression: Some(regression),
1134 ..FallowConfig::default()
1135 };
1136 let json = serde_json::to_string(&config).unwrap();
1137 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1138 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1139 assert_eq!(restored_baseline.total_issues, 100);
1140 assert_eq!(restored_baseline.unused_files, 20);
1141 assert_eq!(restored_baseline.unused_exports, 30);
1142 assert_eq!(restored_baseline.unused_types, 0);
1143 }
1144
1145 #[test]
1146 fn regression_config_empty_baseline_deserialize() {
1147 let json = r#"{"regression": {}}"#;
1148 let config: FallowConfig = serde_json::from_str(json).unwrap();
1149 let regression = config.regression.unwrap();
1150 assert!(regression.baseline.is_none());
1151 }
1152
1153 #[test]
1154 fn regression_baseline_not_serialized_when_none() {
1155 let config = FallowConfig {
1156 regression: None,
1157 ..FallowConfig::default()
1158 };
1159 let json = serde_json::to_string(&config).unwrap();
1160 assert!(
1161 !json.contains("regression"),
1162 "regression should be skipped when None"
1163 );
1164 }
1165
1166 #[test]
1169 fn deserialize_json_with_overrides() {
1170 let json = r#"{
1171 "overrides": [
1172 {
1173 "files": ["*.test.ts", "*.spec.ts"],
1174 "rules": {
1175 "unused-exports": "off",
1176 "unused-files": "warn"
1177 }
1178 }
1179 ]
1180 }"#;
1181 let config: FallowConfig = serde_json::from_str(json).unwrap();
1182 assert_eq!(config.overrides.len(), 1);
1183 assert_eq!(config.overrides[0].files.len(), 2);
1184 assert_eq!(
1185 config.overrides[0].rules.unused_exports,
1186 Some(Severity::Off)
1187 );
1188 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1189 }
1190
1191 #[test]
1192 fn deserialize_json_with_boundaries() {
1193 let json = r#"{
1194 "boundaries": {
1195 "preset": "layered"
1196 }
1197 }"#;
1198 let config: FallowConfig = serde_json::from_str(json).unwrap();
1199 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1200 }
1201
1202 #[test]
1205 fn deserialize_toml_with_regression_baseline() {
1206 let toml_str = r"
1207[regression.baseline]
1208totalIssues = 50
1209unusedFiles = 10
1210unusedExports = 15
1211";
1212 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1213 let baseline = config.regression.unwrap().baseline.unwrap();
1214 assert_eq!(baseline.total_issues, 50);
1215 assert_eq!(baseline.unused_files, 10);
1216 assert_eq!(baseline.unused_exports, 15);
1217 }
1218
1219 #[test]
1222 fn deserialize_toml_with_overrides() {
1223 let toml_str = r#"
1224[[overrides]]
1225files = ["*.test.ts"]
1226
1227[overrides.rules]
1228unused-exports = "off"
1229
1230[[overrides]]
1231files = ["*.stories.tsx"]
1232
1233[overrides.rules]
1234unused-files = "off"
1235"#;
1236 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1237 assert_eq!(config.overrides.len(), 2);
1238 assert_eq!(
1239 config.overrides[0].rules.unused_exports,
1240 Some(Severity::Off)
1241 );
1242 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1243 }
1244
1245 #[test]
1248 fn regression_config_default_is_none_baseline() {
1249 let config = RegressionConfig::default();
1250 assert!(config.baseline.is_none());
1251 }
1252
1253 #[test]
1256 fn deserialize_json_multiple_ignore_export_rules() {
1257 let json = r#"{
1258 "ignoreExports": [
1259 {"file": "src/types/**/*.ts", "exports": ["*"]},
1260 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1261 {"file": "src/index.ts", "exports": ["default"]}
1262 ]
1263 }"#;
1264 let config: FallowConfig = serde_json::from_str(json).unwrap();
1265 assert_eq!(config.ignore_exports.len(), 3);
1266 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1267 }
1268
1269 #[test]
1272 fn deserialize_json_public_packages_camel_case() {
1273 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1274 let config: FallowConfig = serde_json::from_str(json).unwrap();
1275 assert_eq!(
1276 config.public_packages,
1277 vec!["@myorg/shared-lib", "@myorg/utils"]
1278 );
1279 }
1280
1281 #[test]
1282 fn deserialize_json_public_packages_rejects_snake_case() {
1283 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1284 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1285 assert!(
1286 result.is_err(),
1287 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1288 );
1289 }
1290
1291 #[test]
1292 fn deserialize_json_public_packages_empty() {
1293 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1294 assert!(config.public_packages.is_empty());
1295 }
1296
1297 #[test]
1298 fn deserialize_toml_public_packages() {
1299 let toml_str = r#"
1300publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1301"#;
1302 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1303 assert_eq!(
1304 config.public_packages,
1305 vec!["@myorg/shared-lib", "@myorg/ui"]
1306 );
1307 }
1308
1309 #[test]
1310 fn public_packages_serialize_roundtrip() {
1311 let config = FallowConfig {
1312 public_packages: vec!["@myorg/shared-lib".to_string()],
1313 ..FallowConfig::default()
1314 };
1315 let json = serde_json::to_string(&config).unwrap();
1316 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1317 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1318 }
1319}