1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5mod health;
6mod parsing;
7mod resolution;
8mod resolve;
9mod rules;
10mod used_class_members;
11
12pub use boundaries::{
13 AuthoredRule, BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, LogicalGroup,
14 LogicalGroupStatus, ResolvedBoundaryConfig, ResolvedBoundaryRule, ResolvedZone,
15};
16pub use duplicates_config::{
17 DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
18};
19pub use flags::{FlagsConfig, SdkPattern};
20pub use format::OutputFormat;
21pub use health::{EmailMode, HealthConfig, OwnershipConfig};
22pub use resolution::{
23 CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
24 CompiledIgnoreExportRule, ConfigOverride, IgnoreCatalogReferenceRule,
25 IgnoreDependencyOverrideRule, IgnoreExportRule, ResolvedConfig, ResolvedOverride,
26};
27pub use resolve::ResolveConfig;
28pub use rules::{PartialRulesConfig, RulesConfig, Severity};
29pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Deserializer, Serialize};
33use std::ops::Not;
34
35use crate::external_plugin::ExternalPluginDef;
36use crate::workspace::WorkspaceConfig;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
41#[serde(untagged, rename_all = "camelCase")]
42pub enum IgnoreExportsUsedInFileConfig {
43 Bool(bool),
46 ByKind(IgnoreExportsUsedInFileByKind),
50}
51
52impl Default for IgnoreExportsUsedInFileConfig {
53 fn default() -> Self {
54 Self::Bool(false)
55 }
56}
57
58impl From<bool> for IgnoreExportsUsedInFileConfig {
59 fn from(value: bool) -> Self {
60 Self::Bool(value)
61 }
62}
63
64impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
65 fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
66 Self::ByKind(value)
67 }
68}
69
70impl IgnoreExportsUsedInFileConfig {
71 #[must_use]
73 pub const fn is_enabled(self) -> bool {
74 match self {
75 Self::Bool(value) => value,
76 Self::ByKind(kind) => kind.type_ || kind.interface,
77 }
78 }
79
80 #[must_use]
82 pub const fn suppresses(self, is_type_only: bool) -> bool {
83 match self {
84 Self::Bool(value) => value,
85 Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
86 }
87 }
88}
89
90#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
92#[serde(rename_all = "camelCase")]
93pub struct IgnoreExportsUsedInFileByKind {
94 #[serde(default, rename = "type")]
96 pub type_: bool,
97 #[serde(default)]
99 pub interface: bool,
100}
101
102#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
104#[serde(rename_all = "camelCase")]
105pub struct FixConfig {
106 #[serde(default)]
108 pub catalog: CatalogFixConfig,
109}
110
111#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
113#[serde(rename_all = "camelCase")]
114pub struct CatalogFixConfig {
115 #[serde(default)]
118 pub delete_preceding_comments: CatalogPrecedingCommentPolicy,
119}
120
121#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
123#[serde(rename_all = "lowercase")]
124pub enum CatalogPrecedingCommentPolicy {
125 #[default]
128 Auto,
129 Always,
131 Never,
133}
134
135#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
156#[serde(deny_unknown_fields, rename_all = "camelCase")]
157pub struct FallowConfig {
158 #[serde(rename = "$schema", default, skip_serializing)]
160 pub schema: Option<String>,
161
162 #[serde(default, skip_serializing)]
183 pub extends: Vec<String>,
184
185 #[serde(default)]
187 pub entry: Vec<String>,
188
189 #[serde(default)]
191 pub ignore_patterns: Vec<String>,
192
193 #[serde(default)]
195 pub framework: Vec<ExternalPluginDef>,
196
197 #[serde(default)]
199 pub workspaces: Option<WorkspaceConfig>,
200
201 #[serde(default)]
207 pub ignore_dependencies: Vec<String>,
208
209 #[serde(default)]
211 pub ignore_exports: Vec<IgnoreExportRule>,
212
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
222
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
232
233 #[serde(default)]
238 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
239
240 #[serde(default)]
245 pub used_class_members: Vec<UsedClassMemberRule>,
246
247 #[serde(default)]
249 pub duplicates: DuplicatesConfig,
250
251 #[serde(default)]
253 pub health: HealthConfig,
254
255 #[serde(default)]
257 pub rules: RulesConfig,
258
259 #[serde(default)]
261 pub boundaries: BoundaryConfig,
262
263 #[serde(default)]
265 pub flags: FlagsConfig,
266
267 #[serde(default)]
269 pub fix: FixConfig,
270
271 #[serde(default)]
273 pub resolve: ResolveConfig,
274
275 #[serde(default)]
280 pub production: ProductionConfig,
281
282 #[serde(default)]
290 pub plugins: Vec<String>,
291
292 #[serde(default)]
296 pub dynamically_loaded: Vec<String>,
297
298 #[serde(default)]
300 pub overrides: Vec<ConfigOverride>,
301
302 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub codeowners: Option<String>,
309
310 #[serde(default)]
313 pub public_packages: Vec<String>,
314
315 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub regression: Option<RegressionConfig>,
320
321 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
328 pub audit: AuditConfig,
329
330 #[serde(default)]
339 pub sealed: bool,
340
341 #[serde(default)]
346 pub include_entry_exports: bool,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum ProductionAnalysis {
352 DeadCode,
353 Health,
354 Dupes,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
359#[serde(untagged)]
360pub enum ProductionConfig {
361 Global(bool),
363 PerAnalysis(PerAnalysisProductionConfig),
365}
366
367impl<'de> Deserialize<'de> for ProductionConfig {
368 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
369 where
370 D: Deserializer<'de>,
371 {
372 struct ProductionConfigVisitor;
373
374 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
375 type Value = ProductionConfig;
376
377 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378 formatter.write_str("a boolean or per-analysis production config object")
379 }
380
381 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
382 where
383 E: serde::de::Error,
384 {
385 Ok(ProductionConfig::Global(value))
386 }
387
388 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
389 where
390 A: serde::de::MapAccess<'de>,
391 {
392 PerAnalysisProductionConfig::deserialize(
393 serde::de::value::MapAccessDeserializer::new(map),
394 )
395 .map(ProductionConfig::PerAnalysis)
396 }
397 }
398
399 deserializer.deserialize_any(ProductionConfigVisitor)
400 }
401}
402
403impl Default for ProductionConfig {
404 fn default() -> Self {
405 Self::Global(false)
406 }
407}
408
409impl From<bool> for ProductionConfig {
410 fn from(value: bool) -> Self {
411 Self::Global(value)
412 }
413}
414
415impl Not for ProductionConfig {
416 type Output = bool;
417
418 fn not(self) -> Self::Output {
419 !self.any_enabled()
420 }
421}
422
423impl ProductionConfig {
424 #[must_use]
425 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
426 match self {
427 Self::Global(value) => value,
428 Self::PerAnalysis(config) => match analysis {
429 ProductionAnalysis::DeadCode => config.dead_code,
430 ProductionAnalysis::Health => config.health,
431 ProductionAnalysis::Dupes => config.dupes,
432 },
433 }
434 }
435
436 #[must_use]
437 pub const fn global(self) -> bool {
438 match self {
439 Self::Global(value) => value,
440 Self::PerAnalysis(_) => false,
441 }
442 }
443
444 #[must_use]
445 pub const fn any_enabled(self) -> bool {
446 match self {
447 Self::Global(value) => value,
448 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
449 }
450 }
451}
452
453#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
455#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
456pub struct PerAnalysisProductionConfig {
457 pub dead_code: bool,
459 pub health: bool,
461 pub dupes: bool,
463}
464
465#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
472#[serde(rename_all = "camelCase")]
473pub struct AuditConfig {
474 #[serde(default, skip_serializing_if = "AuditGate::is_default")]
476 pub gate: AuditGate,
477
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub dead_code_baseline: Option<String>,
481
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub health_baseline: Option<String>,
485
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub dupes_baseline: Option<String>,
489}
490
491impl AuditConfig {
492 #[must_use]
494 pub fn is_empty(&self) -> bool {
495 self.gate.is_default()
496 && self.dead_code_baseline.is_none()
497 && self.health_baseline.is_none()
498 && self.dupes_baseline.is_none()
499 }
500}
501
502#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
504#[serde(rename_all = "kebab-case")]
505pub enum AuditGate {
506 #[default]
508 NewOnly,
509 All,
511}
512
513impl AuditGate {
514 #[must_use]
515 pub const fn is_default(&self) -> bool {
516 matches!(self, Self::NewOnly)
517 }
518}
519
520#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
527#[serde(rename_all = "camelCase")]
528pub struct RegressionConfig {
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub baseline: Option<RegressionBaseline>,
532}
533
534#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
536#[serde(rename_all = "camelCase")]
537pub struct RegressionBaseline {
538 #[serde(default)]
539 pub total_issues: usize,
540 #[serde(default)]
541 pub unused_files: usize,
542 #[serde(default)]
543 pub unused_exports: usize,
544 #[serde(default)]
545 pub unused_types: usize,
546 #[serde(default)]
547 pub unused_dependencies: usize,
548 #[serde(default)]
549 pub unused_dev_dependencies: usize,
550 #[serde(default)]
551 pub unused_optional_dependencies: usize,
552 #[serde(default)]
553 pub unused_enum_members: usize,
554 #[serde(default)]
555 pub unused_class_members: usize,
556 #[serde(default)]
557 pub unresolved_imports: usize,
558 #[serde(default)]
559 pub unlisted_dependencies: usize,
560 #[serde(default)]
561 pub duplicate_exports: usize,
562 #[serde(default)]
563 pub circular_dependencies: usize,
564 #[serde(default)]
565 pub type_only_dependencies: usize,
566 #[serde(default)]
567 pub test_only_dependencies: usize,
568 #[serde(default)]
569 pub boundary_violations: usize,
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
579 fn default_config_has_empty_collections() {
580 let config = FallowConfig::default();
581 assert!(config.schema.is_none());
582 assert!(config.extends.is_empty());
583 assert!(config.entry.is_empty());
584 assert!(config.ignore_patterns.is_empty());
585 assert!(config.framework.is_empty());
586 assert!(config.workspaces.is_none());
587 assert!(config.ignore_dependencies.is_empty());
588 assert!(config.ignore_exports.is_empty());
589 assert!(config.used_class_members.is_empty());
590 assert!(config.plugins.is_empty());
591 assert!(config.dynamically_loaded.is_empty());
592 assert!(config.overrides.is_empty());
593 assert!(config.public_packages.is_empty());
594 assert_eq!(
595 config.fix.catalog.delete_preceding_comments,
596 CatalogPrecedingCommentPolicy::Auto
597 );
598 assert!(!config.production);
599 }
600
601 #[test]
602 fn default_config_rules_are_error() {
603 let config = FallowConfig::default();
604 assert_eq!(config.rules.unused_files, Severity::Error);
605 assert_eq!(config.rules.unused_exports, Severity::Error);
606 assert_eq!(config.rules.unused_dependencies, Severity::Error);
607 }
608
609 #[test]
610 fn default_config_duplicates_enabled() {
611 let config = FallowConfig::default();
612 assert!(config.duplicates.enabled);
613 assert_eq!(config.duplicates.min_tokens, 50);
614 assert_eq!(config.duplicates.min_lines, 5);
615 }
616
617 #[test]
618 fn default_config_health_thresholds() {
619 let config = FallowConfig::default();
620 assert_eq!(config.health.max_cyclomatic, 20);
621 assert_eq!(config.health.max_cognitive, 15);
622 }
623
624 #[test]
627 fn deserialize_empty_json_object() {
628 let config: FallowConfig = serde_json::from_str("{}").unwrap();
629 assert!(config.entry.is_empty());
630 assert!(!config.production);
631 }
632
633 #[test]
634 fn deserialize_json_with_all_top_level_fields() {
635 let json = r#"{
636 "$schema": "https://fallow.dev/schema.json",
637 "entry": ["src/main.ts"],
638 "ignorePatterns": ["generated/**"],
639 "ignoreDependencies": ["postcss"],
640 "production": true,
641 "plugins": ["custom-plugin.toml"],
642 "rules": {"unused-files": "warn"},
643 "duplicates": {"enabled": false},
644 "health": {"maxCyclomatic": 30}
645 }"#;
646 let config: FallowConfig = serde_json::from_str(json).unwrap();
647 assert_eq!(
648 config.schema.as_deref(),
649 Some("https://fallow.dev/schema.json")
650 );
651 assert_eq!(config.entry, vec!["src/main.ts"]);
652 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
653 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
654 assert!(config.production);
655 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
656 assert_eq!(config.rules.unused_files, Severity::Warn);
657 assert!(!config.duplicates.enabled);
658 assert_eq!(config.health.max_cyclomatic, 30);
659 }
660
661 #[test]
662 fn deserialize_json_deny_unknown_fields() {
663 let json = r#"{"unknownField": true}"#;
664 let result: Result<FallowConfig, _> = serde_json::from_str(json);
665 assert!(result.is_err(), "unknown fields should be rejected");
666 }
667
668 #[test]
669 fn deserialize_json_production_mode_default_false() {
670 let config: FallowConfig = serde_json::from_str("{}").unwrap();
671 assert!(!config.production);
672 }
673
674 #[test]
675 fn deserialize_json_production_mode_true() {
676 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
677 assert!(config.production);
678 }
679
680 #[test]
681 fn deserialize_json_per_analysis_production_mode() {
682 let config: FallowConfig = serde_json::from_str(
683 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
684 )
685 .unwrap();
686 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
687 assert!(config.production.for_analysis(ProductionAnalysis::Health));
688 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
689 }
690
691 #[test]
692 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
693 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
694 .unwrap_err();
695 assert!(
696 err.to_string().contains("healthTypo"),
697 "error should name the unknown field: {err}"
698 );
699 }
700
701 #[test]
702 fn deserialize_json_dynamically_loaded() {
703 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
704 let config: FallowConfig = serde_json::from_str(json).unwrap();
705 assert_eq!(
706 config.dynamically_loaded,
707 vec!["plugins/**/*.ts", "locales/**/*.json"]
708 );
709 }
710
711 #[test]
712 fn deserialize_json_dynamically_loaded_defaults_empty() {
713 let config: FallowConfig = serde_json::from_str("{}").unwrap();
714 assert!(config.dynamically_loaded.is_empty());
715 }
716
717 #[test]
718 fn deserialize_json_fix_catalog_delete_preceding_comments() {
719 let config: FallowConfig =
720 serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
721 .unwrap();
722 assert_eq!(
723 config.fix.catalog.delete_preceding_comments,
724 CatalogPrecedingCommentPolicy::Always
725 );
726 }
727
728 #[test]
729 fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
730 let err = serde_json::from_str::<FallowConfig>(
731 r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
732 )
733 .unwrap_err();
734 assert!(
735 err.to_string().contains("sometimes"),
736 "error should name the bad policy: {err}"
737 );
738 }
739
740 #[test]
741 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
742 let json = r#"{
743 "usedClassMembers": [
744 "agInit",
745 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
746 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
747 ]
748 }"#;
749 let config: FallowConfig = serde_json::from_str(json).unwrap();
750 assert_eq!(
751 config.used_class_members,
752 vec![
753 UsedClassMemberRule::from("agInit"),
754 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
755 extends: None,
756 implements: Some("ICellRendererAngularComp".to_string()),
757 members: vec!["refresh".to_string()],
758 }),
759 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
760 extends: Some("BaseCommand".to_string()),
761 implements: Some("CanActivate".to_string()),
762 members: vec!["execute".to_string()],
763 }),
764 ]
765 );
766 }
767
768 #[test]
771 fn deserialize_toml_minimal() {
772 let toml_str = r#"
773entry = ["src/index.ts"]
774production = true
775"#;
776 let config: FallowConfig = toml::from_str(toml_str).unwrap();
777 assert_eq!(config.entry, vec!["src/index.ts"]);
778 assert!(config.production);
779 }
780
781 #[test]
782 fn deserialize_toml_per_analysis_production_mode() {
783 let toml_str = r"
784[production]
785deadCode = false
786health = true
787dupes = false
788";
789 let config: FallowConfig = toml::from_str(toml_str).unwrap();
790 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
791 assert!(config.production.for_analysis(ProductionAnalysis::Health));
792 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
793 }
794
795 #[test]
796 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
797 let err = toml::from_str::<FallowConfig>(
798 r"
799[production]
800healthTypo = true
801",
802 )
803 .unwrap_err();
804 assert!(
805 err.to_string().contains("healthTypo"),
806 "error should name the unknown field: {err}"
807 );
808 }
809
810 #[test]
811 fn deserialize_toml_with_inline_framework() {
812 let toml_str = r#"
813[[framework]]
814name = "my-framework"
815enablers = ["my-framework-pkg"]
816entryPoints = ["src/routes/**/*.tsx"]
817"#;
818 let config: FallowConfig = toml::from_str(toml_str).unwrap();
819 assert_eq!(config.framework.len(), 1);
820 assert_eq!(config.framework[0].name, "my-framework");
821 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
822 assert_eq!(
823 config.framework[0].entry_points,
824 vec!["src/routes/**/*.tsx"]
825 );
826 }
827
828 #[test]
829 fn deserialize_toml_fix_catalog_delete_preceding_comments() {
830 let toml_str = r#"
831[fix.catalog]
832deletePrecedingComments = "never"
833"#;
834 let config: FallowConfig = toml::from_str(toml_str).unwrap();
835 assert_eq!(
836 config.fix.catalog.delete_preceding_comments,
837 CatalogPrecedingCommentPolicy::Never
838 );
839 }
840
841 #[test]
842 fn deserialize_toml_with_workspace_config() {
843 let toml_str = r#"
844[workspaces]
845patterns = ["packages/*", "apps/*"]
846"#;
847 let config: FallowConfig = toml::from_str(toml_str).unwrap();
848 assert!(config.workspaces.is_some());
849 let ws = config.workspaces.unwrap();
850 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
851 }
852
853 #[test]
854 fn deserialize_toml_with_ignore_exports() {
855 let toml_str = r#"
856[[ignoreExports]]
857file = "src/types/**/*.ts"
858exports = ["*"]
859"#;
860 let config: FallowConfig = toml::from_str(toml_str).unwrap();
861 assert_eq!(config.ignore_exports.len(), 1);
862 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
863 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
864 }
865
866 #[test]
867 fn deserialize_toml_used_class_members_supports_scoped_rules() {
868 let toml_str = r#"
869usedClassMembers = [
870 { implements = "ICellRendererAngularComp", members = ["refresh"] },
871 { extends = "BaseCommand", members = ["execute"] },
872]
873"#;
874 let config: FallowConfig = toml::from_str(toml_str).unwrap();
875 assert_eq!(
876 config.used_class_members,
877 vec![
878 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
879 extends: None,
880 implements: Some("ICellRendererAngularComp".to_string()),
881 members: vec!["refresh".to_string()],
882 }),
883 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
884 extends: Some("BaseCommand".to_string()),
885 implements: None,
886 members: vec!["execute".to_string()],
887 }),
888 ]
889 );
890 }
891
892 #[test]
893 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
894 let result = serde_json::from_str::<FallowConfig>(
895 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
896 );
897 assert!(
898 result.is_err(),
899 "unconstrained scoped rule should be rejected"
900 );
901 }
902
903 #[test]
904 fn deserialize_ignore_exports_used_in_file_bool() {
905 let config: FallowConfig =
906 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
907
908 assert!(config.ignore_exports_used_in_file.suppresses(false));
909 assert!(config.ignore_exports_used_in_file.suppresses(true));
910 }
911
912 #[test]
913 fn deserialize_ignore_exports_used_in_file_kind_form() {
914 let config: FallowConfig =
915 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
916
917 assert!(!config.ignore_exports_used_in_file.suppresses(false));
918 assert!(config.ignore_exports_used_in_file.suppresses(true));
919 }
920
921 #[test]
922 fn deserialize_toml_deny_unknown_fields() {
923 let toml_str = r"bogus_field = true";
924 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
925 assert!(result.is_err(), "unknown fields should be rejected");
926 }
927
928 #[test]
931 fn json_serialize_roundtrip() {
932 let config = FallowConfig {
933 entry: vec!["src/main.ts".to_string()],
934 production: true.into(),
935 ..FallowConfig::default()
936 };
937 let json = serde_json::to_string(&config).unwrap();
938 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
939 assert_eq!(restored.entry, vec!["src/main.ts"]);
940 assert!(restored.production);
941 }
942
943 #[test]
944 fn schema_field_not_serialized() {
945 let config = FallowConfig {
946 schema: Some("https://example.com/schema.json".to_string()),
947 ..FallowConfig::default()
948 };
949 let json = serde_json::to_string(&config).unwrap();
950 assert!(
952 !json.contains("$schema"),
953 "schema field should be skipped in serialization"
954 );
955 }
956
957 #[test]
958 fn extends_field_not_serialized() {
959 let config = FallowConfig {
960 extends: vec!["base.json".to_string()],
961 ..FallowConfig::default()
962 };
963 let json = serde_json::to_string(&config).unwrap();
964 assert!(
965 !json.contains("extends"),
966 "extends field should be skipped in serialization"
967 );
968 }
969
970 #[test]
973 fn regression_config_deserialize_json() {
974 let json = r#"{
975 "regression": {
976 "baseline": {
977 "totalIssues": 42,
978 "unusedFiles": 10,
979 "unusedExports": 5,
980 "circularDependencies": 2
981 }
982 }
983 }"#;
984 let config: FallowConfig = serde_json::from_str(json).unwrap();
985 let regression = config.regression.unwrap();
986 let baseline = regression.baseline.unwrap();
987 assert_eq!(baseline.total_issues, 42);
988 assert_eq!(baseline.unused_files, 10);
989 assert_eq!(baseline.unused_exports, 5);
990 assert_eq!(baseline.circular_dependencies, 2);
991 assert_eq!(baseline.unused_types, 0);
993 assert_eq!(baseline.boundary_violations, 0);
994 }
995
996 #[test]
997 fn regression_config_defaults_to_none() {
998 let config: FallowConfig = serde_json::from_str("{}").unwrap();
999 assert!(config.regression.is_none());
1000 }
1001
1002 #[test]
1003 fn regression_baseline_all_zeros_by_default() {
1004 let baseline = RegressionBaseline::default();
1005 assert_eq!(baseline.total_issues, 0);
1006 assert_eq!(baseline.unused_files, 0);
1007 assert_eq!(baseline.unused_exports, 0);
1008 assert_eq!(baseline.unused_types, 0);
1009 assert_eq!(baseline.unused_dependencies, 0);
1010 assert_eq!(baseline.unused_dev_dependencies, 0);
1011 assert_eq!(baseline.unused_optional_dependencies, 0);
1012 assert_eq!(baseline.unused_enum_members, 0);
1013 assert_eq!(baseline.unused_class_members, 0);
1014 assert_eq!(baseline.unresolved_imports, 0);
1015 assert_eq!(baseline.unlisted_dependencies, 0);
1016 assert_eq!(baseline.duplicate_exports, 0);
1017 assert_eq!(baseline.circular_dependencies, 0);
1018 assert_eq!(baseline.type_only_dependencies, 0);
1019 assert_eq!(baseline.test_only_dependencies, 0);
1020 assert_eq!(baseline.boundary_violations, 0);
1021 }
1022
1023 #[test]
1024 fn regression_config_serialize_roundtrip() {
1025 let baseline = RegressionBaseline {
1026 total_issues: 100,
1027 unused_files: 20,
1028 unused_exports: 30,
1029 ..RegressionBaseline::default()
1030 };
1031 let regression = RegressionConfig {
1032 baseline: Some(baseline),
1033 };
1034 let config = FallowConfig {
1035 regression: Some(regression),
1036 ..FallowConfig::default()
1037 };
1038 let json = serde_json::to_string(&config).unwrap();
1039 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1040 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1041 assert_eq!(restored_baseline.total_issues, 100);
1042 assert_eq!(restored_baseline.unused_files, 20);
1043 assert_eq!(restored_baseline.unused_exports, 30);
1044 assert_eq!(restored_baseline.unused_types, 0);
1045 }
1046
1047 #[test]
1048 fn regression_config_empty_baseline_deserialize() {
1049 let json = r#"{"regression": {}}"#;
1050 let config: FallowConfig = serde_json::from_str(json).unwrap();
1051 let regression = config.regression.unwrap();
1052 assert!(regression.baseline.is_none());
1053 }
1054
1055 #[test]
1056 fn regression_baseline_not_serialized_when_none() {
1057 let config = FallowConfig {
1058 regression: None,
1059 ..FallowConfig::default()
1060 };
1061 let json = serde_json::to_string(&config).unwrap();
1062 assert!(
1063 !json.contains("regression"),
1064 "regression should be skipped when None"
1065 );
1066 }
1067
1068 #[test]
1071 fn deserialize_json_with_overrides() {
1072 let json = r#"{
1073 "overrides": [
1074 {
1075 "files": ["*.test.ts", "*.spec.ts"],
1076 "rules": {
1077 "unused-exports": "off",
1078 "unused-files": "warn"
1079 }
1080 }
1081 ]
1082 }"#;
1083 let config: FallowConfig = serde_json::from_str(json).unwrap();
1084 assert_eq!(config.overrides.len(), 1);
1085 assert_eq!(config.overrides[0].files.len(), 2);
1086 assert_eq!(
1087 config.overrides[0].rules.unused_exports,
1088 Some(Severity::Off)
1089 );
1090 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1091 }
1092
1093 #[test]
1094 fn deserialize_json_with_boundaries() {
1095 let json = r#"{
1096 "boundaries": {
1097 "preset": "layered"
1098 }
1099 }"#;
1100 let config: FallowConfig = serde_json::from_str(json).unwrap();
1101 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1102 }
1103
1104 #[test]
1107 fn deserialize_toml_with_regression_baseline() {
1108 let toml_str = r"
1109[regression.baseline]
1110totalIssues = 50
1111unusedFiles = 10
1112unusedExports = 15
1113";
1114 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1115 let baseline = config.regression.unwrap().baseline.unwrap();
1116 assert_eq!(baseline.total_issues, 50);
1117 assert_eq!(baseline.unused_files, 10);
1118 assert_eq!(baseline.unused_exports, 15);
1119 }
1120
1121 #[test]
1124 fn deserialize_toml_with_overrides() {
1125 let toml_str = r#"
1126[[overrides]]
1127files = ["*.test.ts"]
1128
1129[overrides.rules]
1130unused-exports = "off"
1131
1132[[overrides]]
1133files = ["*.stories.tsx"]
1134
1135[overrides.rules]
1136unused-files = "off"
1137"#;
1138 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1139 assert_eq!(config.overrides.len(), 2);
1140 assert_eq!(
1141 config.overrides[0].rules.unused_exports,
1142 Some(Severity::Off)
1143 );
1144 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1145 }
1146
1147 #[test]
1150 fn regression_config_default_is_none_baseline() {
1151 let config = RegressionConfig::default();
1152 assert!(config.baseline.is_none());
1153 }
1154
1155 #[test]
1158 fn deserialize_json_multiple_ignore_export_rules() {
1159 let json = r#"{
1160 "ignoreExports": [
1161 {"file": "src/types/**/*.ts", "exports": ["*"]},
1162 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1163 {"file": "src/index.ts", "exports": ["default"]}
1164 ]
1165 }"#;
1166 let config: FallowConfig = serde_json::from_str(json).unwrap();
1167 assert_eq!(config.ignore_exports.len(), 3);
1168 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1169 }
1170
1171 #[test]
1174 fn deserialize_json_public_packages_camel_case() {
1175 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1176 let config: FallowConfig = serde_json::from_str(json).unwrap();
1177 assert_eq!(
1178 config.public_packages,
1179 vec!["@myorg/shared-lib", "@myorg/utils"]
1180 );
1181 }
1182
1183 #[test]
1184 fn deserialize_json_public_packages_rejects_snake_case() {
1185 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1186 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1187 assert!(
1188 result.is_err(),
1189 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1190 );
1191 }
1192
1193 #[test]
1194 fn deserialize_json_public_packages_empty() {
1195 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1196 assert!(config.public_packages.is_empty());
1197 }
1198
1199 #[test]
1200 fn deserialize_toml_public_packages() {
1201 let toml_str = r#"
1202publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1203"#;
1204 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1205 assert_eq!(
1206 config.public_packages,
1207 vec!["@myorg/shared-lib", "@myorg/ui"]
1208 );
1209 }
1210
1211 #[test]
1212 fn public_packages_serialize_roundtrip() {
1213 let config = FallowConfig {
1214 public_packages: vec!["@myorg/shared-lib".to_string()],
1215 ..FallowConfig::default()
1216 };
1217 let json = serde_json::to_string(&config).unwrap();
1218 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1219 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1220 }
1221}