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)]
213 pub ignore_exports: Vec<IgnoreExportRule>,
214
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
224
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
234
235 #[serde(default)]
240 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
241
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
259 pub ignore_decorators: Vec<String>,
260
261 #[serde(default)]
266 pub used_class_members: Vec<UsedClassMemberRule>,
267
268 #[serde(default)]
270 pub duplicates: DuplicatesConfig,
271
272 #[serde(default)]
274 pub health: HealthConfig,
275
276 #[serde(default)]
278 pub rules: RulesConfig,
279
280 #[serde(default)]
282 pub boundaries: BoundaryConfig,
283
284 #[serde(default)]
286 pub flags: FlagsConfig,
287
288 #[serde(default)]
290 pub fix: FixConfig,
291
292 #[serde(default)]
294 pub resolve: ResolveConfig,
295
296 #[serde(default)]
301 pub production: ProductionConfig,
302
303 #[serde(default)]
311 pub plugins: Vec<String>,
312
313 #[serde(default)]
317 pub dynamically_loaded: Vec<String>,
318
319 #[serde(default)]
321 pub overrides: Vec<ConfigOverride>,
322
323 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub codeowners: Option<String>,
330
331 #[serde(default)]
334 pub public_packages: Vec<String>,
335
336 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub regression: Option<RegressionConfig>,
341
342 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
349 pub audit: AuditConfig,
350
351 #[serde(default)]
360 pub sealed: bool,
361
362 #[serde(default)]
367 pub include_entry_exports: bool,
368
369 #[serde(default, skip_serializing_if = "CacheConfig::is_default")]
373 pub cache: CacheConfig,
374}
375
376#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
382#[serde(deny_unknown_fields, rename_all = "camelCase")]
383pub struct CacheConfig {
384 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub max_size_mb: Option<u32>,
389}
390
391impl CacheConfig {
392 #[must_use]
395 pub fn is_default(&self) -> bool {
396 self.max_size_mb.is_none()
397 }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum ProductionAnalysis {
403 DeadCode,
404 Health,
405 Dupes,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
410#[serde(untagged)]
411pub enum ProductionConfig {
412 Global(bool),
414 PerAnalysis(PerAnalysisProductionConfig),
416}
417
418impl<'de> Deserialize<'de> for ProductionConfig {
419 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
420 where
421 D: Deserializer<'de>,
422 {
423 struct ProductionConfigVisitor;
424
425 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
426 type Value = ProductionConfig;
427
428 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429 formatter.write_str("a boolean or per-analysis production config object")
430 }
431
432 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
433 where
434 E: serde::de::Error,
435 {
436 Ok(ProductionConfig::Global(value))
437 }
438
439 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
440 where
441 A: serde::de::MapAccess<'de>,
442 {
443 PerAnalysisProductionConfig::deserialize(
444 serde::de::value::MapAccessDeserializer::new(map),
445 )
446 .map(ProductionConfig::PerAnalysis)
447 }
448 }
449
450 deserializer.deserialize_any(ProductionConfigVisitor)
451 }
452}
453
454impl Default for ProductionConfig {
455 fn default() -> Self {
456 Self::Global(false)
457 }
458}
459
460impl From<bool> for ProductionConfig {
461 fn from(value: bool) -> Self {
462 Self::Global(value)
463 }
464}
465
466impl Not for ProductionConfig {
467 type Output = bool;
468
469 fn not(self) -> Self::Output {
470 !self.any_enabled()
471 }
472}
473
474impl ProductionConfig {
475 #[must_use]
476 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
477 match self {
478 Self::Global(value) => value,
479 Self::PerAnalysis(config) => match analysis {
480 ProductionAnalysis::DeadCode => config.dead_code,
481 ProductionAnalysis::Health => config.health,
482 ProductionAnalysis::Dupes => config.dupes,
483 },
484 }
485 }
486
487 #[must_use]
488 pub const fn global(self) -> bool {
489 match self {
490 Self::Global(value) => value,
491 Self::PerAnalysis(_) => false,
492 }
493 }
494
495 #[must_use]
496 pub const fn any_enabled(self) -> bool {
497 match self {
498 Self::Global(value) => value,
499 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
500 }
501 }
502}
503
504#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
506#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
507pub struct PerAnalysisProductionConfig {
508 pub dead_code: bool,
510 pub health: bool,
512 pub dupes: bool,
514}
515
516#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
523#[serde(rename_all = "camelCase")]
524pub struct AuditConfig {
525 #[serde(default, skip_serializing_if = "AuditGate::is_default")]
527 pub gate: AuditGate,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub dead_code_baseline: Option<String>,
532
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub health_baseline: Option<String>,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub dupes_baseline: Option<String>,
540
541 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub cache_max_age_days: Option<u32>,
551}
552
553impl AuditConfig {
554 #[must_use]
556 pub fn is_empty(&self) -> bool {
557 self.gate.is_default()
558 && self.dead_code_baseline.is_none()
559 && self.health_baseline.is_none()
560 && self.dupes_baseline.is_none()
561 && self.cache_max_age_days.is_none()
562 }
563}
564
565#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
567#[serde(rename_all = "kebab-case")]
568pub enum AuditGate {
569 #[default]
571 NewOnly,
572 All,
574}
575
576impl AuditGate {
577 #[must_use]
578 pub const fn is_default(&self) -> bool {
579 matches!(self, Self::NewOnly)
580 }
581}
582
583#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
590#[serde(rename_all = "camelCase")]
591pub struct RegressionConfig {
592 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub baseline: Option<RegressionBaseline>,
595}
596
597#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
599#[serde(rename_all = "camelCase")]
600pub struct RegressionBaseline {
601 #[serde(default)]
602 pub total_issues: usize,
603 #[serde(default)]
604 pub unused_files: usize,
605 #[serde(default)]
606 pub unused_exports: usize,
607 #[serde(default)]
608 pub unused_types: usize,
609 #[serde(default)]
610 pub unused_dependencies: usize,
611 #[serde(default)]
612 pub unused_dev_dependencies: usize,
613 #[serde(default)]
614 pub unused_optional_dependencies: usize,
615 #[serde(default)]
616 pub unused_enum_members: usize,
617 #[serde(default)]
618 pub unused_class_members: usize,
619 #[serde(default)]
620 pub unresolved_imports: usize,
621 #[serde(default)]
622 pub unlisted_dependencies: usize,
623 #[serde(default)]
624 pub duplicate_exports: usize,
625 #[serde(default)]
626 pub circular_dependencies: usize,
627 #[serde(default)]
628 pub re_export_cycles: usize,
629 #[serde(default)]
630 pub type_only_dependencies: usize,
631 #[serde(default)]
632 pub test_only_dependencies: usize,
633 #[serde(default)]
634 pub boundary_violations: usize,
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
644 fn default_config_has_empty_collections() {
645 let config = FallowConfig::default();
646 assert!(config.schema.is_none());
647 assert!(config.extends.is_empty());
648 assert!(config.entry.is_empty());
649 assert!(config.ignore_patterns.is_empty());
650 assert!(config.framework.is_empty());
651 assert!(config.workspaces.is_none());
652 assert!(config.ignore_dependencies.is_empty());
653 assert!(config.ignore_exports.is_empty());
654 assert!(config.used_class_members.is_empty());
655 assert!(config.plugins.is_empty());
656 assert!(config.dynamically_loaded.is_empty());
657 assert!(config.overrides.is_empty());
658 assert!(config.public_packages.is_empty());
659 assert_eq!(
660 config.fix.catalog.delete_preceding_comments,
661 CatalogPrecedingCommentPolicy::Auto
662 );
663 assert!(!config.production);
664 }
665
666 #[test]
667 fn default_config_rules_are_error() {
668 let config = FallowConfig::default();
669 assert_eq!(config.rules.unused_files, Severity::Error);
670 assert_eq!(config.rules.unused_exports, Severity::Error);
671 assert_eq!(config.rules.unused_dependencies, Severity::Error);
672 }
673
674 #[test]
675 fn default_config_duplicates_enabled() {
676 let config = FallowConfig::default();
677 assert!(config.duplicates.enabled);
678 assert_eq!(config.duplicates.min_tokens, 50);
679 assert_eq!(config.duplicates.min_lines, 5);
680 }
681
682 #[test]
683 fn default_config_health_thresholds() {
684 let config = FallowConfig::default();
685 assert_eq!(config.health.max_cyclomatic, 20);
686 assert_eq!(config.health.max_cognitive, 15);
687 }
688
689 #[test]
692 fn deserialize_empty_json_object() {
693 let config: FallowConfig = serde_json::from_str("{}").unwrap();
694 assert!(config.entry.is_empty());
695 assert!(!config.production);
696 }
697
698 #[test]
699 fn deserialize_json_with_all_top_level_fields() {
700 let json = r#"{
701 "$schema": "https://fallow.dev/schema.json",
702 "entry": ["src/main.ts"],
703 "ignorePatterns": ["generated/**"],
704 "ignoreDependencies": ["postcss"],
705 "production": true,
706 "plugins": ["custom-plugin.toml"],
707 "rules": {"unused-files": "warn"},
708 "duplicates": {"enabled": false},
709 "health": {"maxCyclomatic": 30}
710 }"#;
711 let config: FallowConfig = serde_json::from_str(json).unwrap();
712 assert_eq!(
713 config.schema.as_deref(),
714 Some("https://fallow.dev/schema.json")
715 );
716 assert_eq!(config.entry, vec!["src/main.ts"]);
717 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
718 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
719 assert!(config.production);
720 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
721 assert_eq!(config.rules.unused_files, Severity::Warn);
722 assert!(!config.duplicates.enabled);
723 assert_eq!(config.health.max_cyclomatic, 30);
724 }
725
726 #[test]
727 fn deserialize_json_deny_unknown_fields() {
728 let json = r#"{"unknownField": true}"#;
729 let result: Result<FallowConfig, _> = serde_json::from_str(json);
730 assert!(result.is_err(), "unknown fields should be rejected");
731 }
732
733 #[test]
734 fn deserialize_json_production_mode_default_false() {
735 let config: FallowConfig = serde_json::from_str("{}").unwrap();
736 assert!(!config.production);
737 }
738
739 #[test]
740 fn deserialize_json_production_mode_true() {
741 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
742 assert!(config.production);
743 }
744
745 #[test]
746 fn deserialize_json_per_analysis_production_mode() {
747 let config: FallowConfig = serde_json::from_str(
748 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
749 )
750 .unwrap();
751 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
752 assert!(config.production.for_analysis(ProductionAnalysis::Health));
753 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
754 }
755
756 #[test]
757 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
758 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
759 .unwrap_err();
760 assert!(
761 err.to_string().contains("healthTypo"),
762 "error should name the unknown field: {err}"
763 );
764 }
765
766 #[test]
767 fn deserialize_json_dynamically_loaded() {
768 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
769 let config: FallowConfig = serde_json::from_str(json).unwrap();
770 assert_eq!(
771 config.dynamically_loaded,
772 vec!["plugins/**/*.ts", "locales/**/*.json"]
773 );
774 }
775
776 #[test]
777 fn deserialize_json_dynamically_loaded_defaults_empty() {
778 let config: FallowConfig = serde_json::from_str("{}").unwrap();
779 assert!(config.dynamically_loaded.is_empty());
780 }
781
782 #[test]
783 fn deserialize_json_fix_catalog_delete_preceding_comments() {
784 let config: FallowConfig =
785 serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
786 .unwrap();
787 assert_eq!(
788 config.fix.catalog.delete_preceding_comments,
789 CatalogPrecedingCommentPolicy::Always
790 );
791 }
792
793 #[test]
794 fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
795 let err = serde_json::from_str::<FallowConfig>(
796 r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
797 )
798 .unwrap_err();
799 assert!(
800 err.to_string().contains("sometimes"),
801 "error should name the bad policy: {err}"
802 );
803 }
804
805 #[test]
806 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
807 let json = r#"{
808 "usedClassMembers": [
809 "agInit",
810 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
811 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
812 ]
813 }"#;
814 let config: FallowConfig = serde_json::from_str(json).unwrap();
815 assert_eq!(
816 config.used_class_members,
817 vec![
818 UsedClassMemberRule::from("agInit"),
819 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
820 extends: None,
821 implements: Some("ICellRendererAngularComp".to_string()),
822 members: vec!["refresh".to_string()],
823 }),
824 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
825 extends: Some("BaseCommand".to_string()),
826 implements: Some("CanActivate".to_string()),
827 members: vec!["execute".to_string()],
828 }),
829 ]
830 );
831 }
832
833 #[test]
836 fn deserialize_toml_minimal() {
837 let toml_str = r#"
838entry = ["src/index.ts"]
839production = true
840"#;
841 let config: FallowConfig = toml::from_str(toml_str).unwrap();
842 assert_eq!(config.entry, vec!["src/index.ts"]);
843 assert!(config.production);
844 }
845
846 #[test]
847 fn deserialize_toml_per_analysis_production_mode() {
848 let toml_str = r"
849[production]
850deadCode = false
851health = true
852dupes = false
853";
854 let config: FallowConfig = toml::from_str(toml_str).unwrap();
855 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
856 assert!(config.production.for_analysis(ProductionAnalysis::Health));
857 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
858 }
859
860 #[test]
861 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
862 let err = toml::from_str::<FallowConfig>(
863 r"
864[production]
865healthTypo = true
866",
867 )
868 .unwrap_err();
869 assert!(
870 err.to_string().contains("healthTypo"),
871 "error should name the unknown field: {err}"
872 );
873 }
874
875 #[test]
876 fn deserialize_toml_with_inline_framework() {
877 let toml_str = r#"
878[[framework]]
879name = "my-framework"
880enablers = ["my-framework-pkg"]
881entryPoints = ["src/routes/**/*.tsx"]
882"#;
883 let config: FallowConfig = toml::from_str(toml_str).unwrap();
884 assert_eq!(config.framework.len(), 1);
885 assert_eq!(config.framework[0].name, "my-framework");
886 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
887 assert_eq!(
888 config.framework[0].entry_points,
889 vec!["src/routes/**/*.tsx"]
890 );
891 }
892
893 #[test]
894 fn deserialize_toml_fix_catalog_delete_preceding_comments() {
895 let toml_str = r#"
896[fix.catalog]
897deletePrecedingComments = "never"
898"#;
899 let config: FallowConfig = toml::from_str(toml_str).unwrap();
900 assert_eq!(
901 config.fix.catalog.delete_preceding_comments,
902 CatalogPrecedingCommentPolicy::Never
903 );
904 }
905
906 #[test]
907 fn deserialize_toml_with_workspace_config() {
908 let toml_str = r#"
909[workspaces]
910patterns = ["packages/*", "apps/*"]
911"#;
912 let config: FallowConfig = toml::from_str(toml_str).unwrap();
913 assert!(config.workspaces.is_some());
914 let ws = config.workspaces.unwrap();
915 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
916 }
917
918 #[test]
919 fn deserialize_toml_with_ignore_exports() {
920 let toml_str = r#"
921[[ignoreExports]]
922file = "src/types/**/*.ts"
923exports = ["*"]
924"#;
925 let config: FallowConfig = toml::from_str(toml_str).unwrap();
926 assert_eq!(config.ignore_exports.len(), 1);
927 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
928 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
929 }
930
931 #[test]
932 fn deserialize_toml_used_class_members_supports_scoped_rules() {
933 let toml_str = r#"
934usedClassMembers = [
935 { implements = "ICellRendererAngularComp", members = ["refresh"] },
936 { extends = "BaseCommand", members = ["execute"] },
937]
938"#;
939 let config: FallowConfig = toml::from_str(toml_str).unwrap();
940 assert_eq!(
941 config.used_class_members,
942 vec![
943 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
944 extends: None,
945 implements: Some("ICellRendererAngularComp".to_string()),
946 members: vec!["refresh".to_string()],
947 }),
948 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
949 extends: Some("BaseCommand".to_string()),
950 implements: None,
951 members: vec!["execute".to_string()],
952 }),
953 ]
954 );
955 }
956
957 #[test]
958 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
959 let result = serde_json::from_str::<FallowConfig>(
960 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
961 );
962 assert!(
963 result.is_err(),
964 "unconstrained scoped rule should be rejected"
965 );
966 }
967
968 #[test]
969 fn deserialize_ignore_exports_used_in_file_bool() {
970 let config: FallowConfig =
971 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
972
973 assert!(config.ignore_exports_used_in_file.suppresses(false));
974 assert!(config.ignore_exports_used_in_file.suppresses(true));
975 }
976
977 #[test]
978 fn deserialize_ignore_exports_used_in_file_kind_form() {
979 let config: FallowConfig =
980 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
981
982 assert!(!config.ignore_exports_used_in_file.suppresses(false));
983 assert!(config.ignore_exports_used_in_file.suppresses(true));
984 }
985
986 #[test]
987 fn deserialize_toml_deny_unknown_fields() {
988 let toml_str = r"bogus_field = true";
989 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
990 assert!(result.is_err(), "unknown fields should be rejected");
991 }
992
993 #[test]
996 fn json_serialize_roundtrip() {
997 let config = FallowConfig {
998 entry: vec!["src/main.ts".to_string()],
999 production: true.into(),
1000 ..FallowConfig::default()
1001 };
1002 let json = serde_json::to_string(&config).unwrap();
1003 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1004 assert_eq!(restored.entry, vec!["src/main.ts"]);
1005 assert!(restored.production);
1006 }
1007
1008 #[test]
1009 fn schema_field_not_serialized() {
1010 let config = FallowConfig {
1011 schema: Some("https://example.com/schema.json".to_string()),
1012 ..FallowConfig::default()
1013 };
1014 let json = serde_json::to_string(&config).unwrap();
1015 assert!(
1017 !json.contains("$schema"),
1018 "schema field should be skipped in serialization"
1019 );
1020 }
1021
1022 #[test]
1023 fn extends_field_not_serialized() {
1024 let config = FallowConfig {
1025 extends: vec!["base.json".to_string()],
1026 ..FallowConfig::default()
1027 };
1028 let json = serde_json::to_string(&config).unwrap();
1029 assert!(
1030 !json.contains("extends"),
1031 "extends field should be skipped in serialization"
1032 );
1033 }
1034
1035 #[test]
1038 fn regression_config_deserialize_json() {
1039 let json = r#"{
1040 "regression": {
1041 "baseline": {
1042 "totalIssues": 42,
1043 "unusedFiles": 10,
1044 "unusedExports": 5,
1045 "circularDependencies": 2
1046 }
1047 }
1048 }"#;
1049 let config: FallowConfig = serde_json::from_str(json).unwrap();
1050 let regression = config.regression.unwrap();
1051 let baseline = regression.baseline.unwrap();
1052 assert_eq!(baseline.total_issues, 42);
1053 assert_eq!(baseline.unused_files, 10);
1054 assert_eq!(baseline.unused_exports, 5);
1055 assert_eq!(baseline.circular_dependencies, 2);
1056 assert_eq!(baseline.unused_types, 0);
1058 assert_eq!(baseline.boundary_violations, 0);
1059 }
1060
1061 #[test]
1062 fn regression_config_defaults_to_none() {
1063 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1064 assert!(config.regression.is_none());
1065 }
1066
1067 #[test]
1068 fn regression_baseline_all_zeros_by_default() {
1069 let baseline = RegressionBaseline::default();
1070 assert_eq!(baseline.total_issues, 0);
1071 assert_eq!(baseline.unused_files, 0);
1072 assert_eq!(baseline.unused_exports, 0);
1073 assert_eq!(baseline.unused_types, 0);
1074 assert_eq!(baseline.unused_dependencies, 0);
1075 assert_eq!(baseline.unused_dev_dependencies, 0);
1076 assert_eq!(baseline.unused_optional_dependencies, 0);
1077 assert_eq!(baseline.unused_enum_members, 0);
1078 assert_eq!(baseline.unused_class_members, 0);
1079 assert_eq!(baseline.unresolved_imports, 0);
1080 assert_eq!(baseline.unlisted_dependencies, 0);
1081 assert_eq!(baseline.duplicate_exports, 0);
1082 assert_eq!(baseline.circular_dependencies, 0);
1083 assert_eq!(baseline.type_only_dependencies, 0);
1084 assert_eq!(baseline.test_only_dependencies, 0);
1085 assert_eq!(baseline.boundary_violations, 0);
1086 }
1087
1088 #[test]
1089 fn regression_config_serialize_roundtrip() {
1090 let baseline = RegressionBaseline {
1091 total_issues: 100,
1092 unused_files: 20,
1093 unused_exports: 30,
1094 ..RegressionBaseline::default()
1095 };
1096 let regression = RegressionConfig {
1097 baseline: Some(baseline),
1098 };
1099 let config = FallowConfig {
1100 regression: Some(regression),
1101 ..FallowConfig::default()
1102 };
1103 let json = serde_json::to_string(&config).unwrap();
1104 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1105 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1106 assert_eq!(restored_baseline.total_issues, 100);
1107 assert_eq!(restored_baseline.unused_files, 20);
1108 assert_eq!(restored_baseline.unused_exports, 30);
1109 assert_eq!(restored_baseline.unused_types, 0);
1110 }
1111
1112 #[test]
1113 fn regression_config_empty_baseline_deserialize() {
1114 let json = r#"{"regression": {}}"#;
1115 let config: FallowConfig = serde_json::from_str(json).unwrap();
1116 let regression = config.regression.unwrap();
1117 assert!(regression.baseline.is_none());
1118 }
1119
1120 #[test]
1121 fn regression_baseline_not_serialized_when_none() {
1122 let config = FallowConfig {
1123 regression: None,
1124 ..FallowConfig::default()
1125 };
1126 let json = serde_json::to_string(&config).unwrap();
1127 assert!(
1128 !json.contains("regression"),
1129 "regression should be skipped when None"
1130 );
1131 }
1132
1133 #[test]
1136 fn deserialize_json_with_overrides() {
1137 let json = r#"{
1138 "overrides": [
1139 {
1140 "files": ["*.test.ts", "*.spec.ts"],
1141 "rules": {
1142 "unused-exports": "off",
1143 "unused-files": "warn"
1144 }
1145 }
1146 ]
1147 }"#;
1148 let config: FallowConfig = serde_json::from_str(json).unwrap();
1149 assert_eq!(config.overrides.len(), 1);
1150 assert_eq!(config.overrides[0].files.len(), 2);
1151 assert_eq!(
1152 config.overrides[0].rules.unused_exports,
1153 Some(Severity::Off)
1154 );
1155 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1156 }
1157
1158 #[test]
1159 fn deserialize_json_with_boundaries() {
1160 let json = r#"{
1161 "boundaries": {
1162 "preset": "layered"
1163 }
1164 }"#;
1165 let config: FallowConfig = serde_json::from_str(json).unwrap();
1166 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1167 }
1168
1169 #[test]
1172 fn deserialize_toml_with_regression_baseline() {
1173 let toml_str = r"
1174[regression.baseline]
1175totalIssues = 50
1176unusedFiles = 10
1177unusedExports = 15
1178";
1179 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1180 let baseline = config.regression.unwrap().baseline.unwrap();
1181 assert_eq!(baseline.total_issues, 50);
1182 assert_eq!(baseline.unused_files, 10);
1183 assert_eq!(baseline.unused_exports, 15);
1184 }
1185
1186 #[test]
1189 fn deserialize_toml_with_overrides() {
1190 let toml_str = r#"
1191[[overrides]]
1192files = ["*.test.ts"]
1193
1194[overrides.rules]
1195unused-exports = "off"
1196
1197[[overrides]]
1198files = ["*.stories.tsx"]
1199
1200[overrides.rules]
1201unused-files = "off"
1202"#;
1203 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1204 assert_eq!(config.overrides.len(), 2);
1205 assert_eq!(
1206 config.overrides[0].rules.unused_exports,
1207 Some(Severity::Off)
1208 );
1209 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1210 }
1211
1212 #[test]
1215 fn regression_config_default_is_none_baseline() {
1216 let config = RegressionConfig::default();
1217 assert!(config.baseline.is_none());
1218 }
1219
1220 #[test]
1223 fn deserialize_json_multiple_ignore_export_rules() {
1224 let json = r#"{
1225 "ignoreExports": [
1226 {"file": "src/types/**/*.ts", "exports": ["*"]},
1227 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1228 {"file": "src/index.ts", "exports": ["default"]}
1229 ]
1230 }"#;
1231 let config: FallowConfig = serde_json::from_str(json).unwrap();
1232 assert_eq!(config.ignore_exports.len(), 3);
1233 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1234 }
1235
1236 #[test]
1239 fn deserialize_json_public_packages_camel_case() {
1240 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1241 let config: FallowConfig = serde_json::from_str(json).unwrap();
1242 assert_eq!(
1243 config.public_packages,
1244 vec!["@myorg/shared-lib", "@myorg/utils"]
1245 );
1246 }
1247
1248 #[test]
1249 fn deserialize_json_public_packages_rejects_snake_case() {
1250 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1251 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1252 assert!(
1253 result.is_err(),
1254 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1255 );
1256 }
1257
1258 #[test]
1259 fn deserialize_json_public_packages_empty() {
1260 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1261 assert!(config.public_packages.is_empty());
1262 }
1263
1264 #[test]
1265 fn deserialize_toml_public_packages() {
1266 let toml_str = r#"
1267publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1268"#;
1269 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1270 assert_eq!(
1271 config.public_packages,
1272 vec!["@myorg/shared-lib", "@myorg/ui"]
1273 );
1274 }
1275
1276 #[test]
1277 fn public_packages_serialize_roundtrip() {
1278 let config = FallowConfig {
1279 public_packages: vec!["@myorg/shared-lib".to_string()],
1280 ..FallowConfig::default()
1281 };
1282 let json = serde_json::to_string(&config).unwrap();
1283 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1284 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1285 }
1286}