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 BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
14 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, Deserialize, Serialize, JsonSchema)]
123#[serde(deny_unknown_fields, rename_all = "camelCase")]
124pub struct FallowConfig {
125 #[serde(rename = "$schema", default, skip_serializing)]
127 pub schema: Option<String>,
128
129 #[serde(default, skip_serializing)]
150 pub extends: Vec<String>,
151
152 #[serde(default)]
154 pub entry: Vec<String>,
155
156 #[serde(default)]
158 pub ignore_patterns: Vec<String>,
159
160 #[serde(default)]
162 pub framework: Vec<ExternalPluginDef>,
163
164 #[serde(default)]
166 pub workspaces: Option<WorkspaceConfig>,
167
168 #[serde(default)]
174 pub ignore_dependencies: Vec<String>,
175
176 #[serde(default)]
178 pub ignore_exports: Vec<IgnoreExportRule>,
179
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
189
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
199
200 #[serde(default)]
205 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
206
207 #[serde(default)]
212 pub used_class_members: Vec<UsedClassMemberRule>,
213
214 #[serde(default)]
216 pub duplicates: DuplicatesConfig,
217
218 #[serde(default)]
220 pub health: HealthConfig,
221
222 #[serde(default)]
224 pub rules: RulesConfig,
225
226 #[serde(default)]
228 pub boundaries: BoundaryConfig,
229
230 #[serde(default)]
232 pub flags: FlagsConfig,
233
234 #[serde(default)]
236 pub resolve: ResolveConfig,
237
238 #[serde(default)]
243 pub production: ProductionConfig,
244
245 #[serde(default)]
253 pub plugins: Vec<String>,
254
255 #[serde(default)]
259 pub dynamically_loaded: Vec<String>,
260
261 #[serde(default)]
263 pub overrides: Vec<ConfigOverride>,
264
265 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub codeowners: Option<String>,
272
273 #[serde(default)]
276 pub public_packages: Vec<String>,
277
278 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub regression: Option<RegressionConfig>,
283
284 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
291 pub audit: AuditConfig,
292
293 #[serde(default)]
302 pub sealed: bool,
303
304 #[serde(default)]
309 pub include_entry_exports: bool,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ProductionAnalysis {
315 DeadCode,
316 Health,
317 Dupes,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
322#[serde(untagged)]
323pub enum ProductionConfig {
324 Global(bool),
326 PerAnalysis(PerAnalysisProductionConfig),
328}
329
330impl<'de> Deserialize<'de> for ProductionConfig {
331 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
332 where
333 D: Deserializer<'de>,
334 {
335 struct ProductionConfigVisitor;
336
337 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
338 type Value = ProductionConfig;
339
340 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 formatter.write_str("a boolean or per-analysis production config object")
342 }
343
344 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
345 where
346 E: serde::de::Error,
347 {
348 Ok(ProductionConfig::Global(value))
349 }
350
351 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
352 where
353 A: serde::de::MapAccess<'de>,
354 {
355 PerAnalysisProductionConfig::deserialize(
356 serde::de::value::MapAccessDeserializer::new(map),
357 )
358 .map(ProductionConfig::PerAnalysis)
359 }
360 }
361
362 deserializer.deserialize_any(ProductionConfigVisitor)
363 }
364}
365
366impl Default for ProductionConfig {
367 fn default() -> Self {
368 Self::Global(false)
369 }
370}
371
372impl From<bool> for ProductionConfig {
373 fn from(value: bool) -> Self {
374 Self::Global(value)
375 }
376}
377
378impl Not for ProductionConfig {
379 type Output = bool;
380
381 fn not(self) -> Self::Output {
382 !self.any_enabled()
383 }
384}
385
386impl ProductionConfig {
387 #[must_use]
388 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
389 match self {
390 Self::Global(value) => value,
391 Self::PerAnalysis(config) => match analysis {
392 ProductionAnalysis::DeadCode => config.dead_code,
393 ProductionAnalysis::Health => config.health,
394 ProductionAnalysis::Dupes => config.dupes,
395 },
396 }
397 }
398
399 #[must_use]
400 pub const fn global(self) -> bool {
401 match self {
402 Self::Global(value) => value,
403 Self::PerAnalysis(_) => false,
404 }
405 }
406
407 #[must_use]
408 pub const fn any_enabled(self) -> bool {
409 match self {
410 Self::Global(value) => value,
411 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
412 }
413 }
414}
415
416#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
418#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
419pub struct PerAnalysisProductionConfig {
420 pub dead_code: bool,
422 pub health: bool,
424 pub dupes: bool,
426}
427
428#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
435#[serde(rename_all = "camelCase")]
436pub struct AuditConfig {
437 #[serde(default, skip_serializing_if = "AuditGate::is_default")]
439 pub gate: AuditGate,
440
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub dead_code_baseline: Option<String>,
444
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub health_baseline: Option<String>,
448
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub dupes_baseline: Option<String>,
452}
453
454impl AuditConfig {
455 #[must_use]
457 pub fn is_empty(&self) -> bool {
458 self.gate.is_default()
459 && self.dead_code_baseline.is_none()
460 && self.health_baseline.is_none()
461 && self.dupes_baseline.is_none()
462 }
463}
464
465#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
467#[serde(rename_all = "kebab-case")]
468pub enum AuditGate {
469 #[default]
471 NewOnly,
472 All,
474}
475
476impl AuditGate {
477 #[must_use]
478 pub const fn is_default(&self) -> bool {
479 matches!(self, Self::NewOnly)
480 }
481}
482
483#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
490#[serde(rename_all = "camelCase")]
491pub struct RegressionConfig {
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub baseline: Option<RegressionBaseline>,
495}
496
497#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
499#[serde(rename_all = "camelCase")]
500pub struct RegressionBaseline {
501 #[serde(default)]
502 pub total_issues: usize,
503 #[serde(default)]
504 pub unused_files: usize,
505 #[serde(default)]
506 pub unused_exports: usize,
507 #[serde(default)]
508 pub unused_types: usize,
509 #[serde(default)]
510 pub unused_dependencies: usize,
511 #[serde(default)]
512 pub unused_dev_dependencies: usize,
513 #[serde(default)]
514 pub unused_optional_dependencies: usize,
515 #[serde(default)]
516 pub unused_enum_members: usize,
517 #[serde(default)]
518 pub unused_class_members: usize,
519 #[serde(default)]
520 pub unresolved_imports: usize,
521 #[serde(default)]
522 pub unlisted_dependencies: usize,
523 #[serde(default)]
524 pub duplicate_exports: usize,
525 #[serde(default)]
526 pub circular_dependencies: usize,
527 #[serde(default)]
528 pub type_only_dependencies: usize,
529 #[serde(default)]
530 pub test_only_dependencies: usize,
531 #[serde(default)]
532 pub boundary_violations: usize,
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
542 fn default_config_has_empty_collections() {
543 let config = FallowConfig::default();
544 assert!(config.schema.is_none());
545 assert!(config.extends.is_empty());
546 assert!(config.entry.is_empty());
547 assert!(config.ignore_patterns.is_empty());
548 assert!(config.framework.is_empty());
549 assert!(config.workspaces.is_none());
550 assert!(config.ignore_dependencies.is_empty());
551 assert!(config.ignore_exports.is_empty());
552 assert!(config.used_class_members.is_empty());
553 assert!(config.plugins.is_empty());
554 assert!(config.dynamically_loaded.is_empty());
555 assert!(config.overrides.is_empty());
556 assert!(config.public_packages.is_empty());
557 assert!(!config.production);
558 }
559
560 #[test]
561 fn default_config_rules_are_error() {
562 let config = FallowConfig::default();
563 assert_eq!(config.rules.unused_files, Severity::Error);
564 assert_eq!(config.rules.unused_exports, Severity::Error);
565 assert_eq!(config.rules.unused_dependencies, Severity::Error);
566 }
567
568 #[test]
569 fn default_config_duplicates_enabled() {
570 let config = FallowConfig::default();
571 assert!(config.duplicates.enabled);
572 assert_eq!(config.duplicates.min_tokens, 50);
573 assert_eq!(config.duplicates.min_lines, 5);
574 }
575
576 #[test]
577 fn default_config_health_thresholds() {
578 let config = FallowConfig::default();
579 assert_eq!(config.health.max_cyclomatic, 20);
580 assert_eq!(config.health.max_cognitive, 15);
581 }
582
583 #[test]
586 fn deserialize_empty_json_object() {
587 let config: FallowConfig = serde_json::from_str("{}").unwrap();
588 assert!(config.entry.is_empty());
589 assert!(!config.production);
590 }
591
592 #[test]
593 fn deserialize_json_with_all_top_level_fields() {
594 let json = r#"{
595 "$schema": "https://fallow.dev/schema.json",
596 "entry": ["src/main.ts"],
597 "ignorePatterns": ["generated/**"],
598 "ignoreDependencies": ["postcss"],
599 "production": true,
600 "plugins": ["custom-plugin.toml"],
601 "rules": {"unused-files": "warn"},
602 "duplicates": {"enabled": false},
603 "health": {"maxCyclomatic": 30}
604 }"#;
605 let config: FallowConfig = serde_json::from_str(json).unwrap();
606 assert_eq!(
607 config.schema.as_deref(),
608 Some("https://fallow.dev/schema.json")
609 );
610 assert_eq!(config.entry, vec!["src/main.ts"]);
611 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
612 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
613 assert!(config.production);
614 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
615 assert_eq!(config.rules.unused_files, Severity::Warn);
616 assert!(!config.duplicates.enabled);
617 assert_eq!(config.health.max_cyclomatic, 30);
618 }
619
620 #[test]
621 fn deserialize_json_deny_unknown_fields() {
622 let json = r#"{"unknownField": true}"#;
623 let result: Result<FallowConfig, _> = serde_json::from_str(json);
624 assert!(result.is_err(), "unknown fields should be rejected");
625 }
626
627 #[test]
628 fn deserialize_json_production_mode_default_false() {
629 let config: FallowConfig = serde_json::from_str("{}").unwrap();
630 assert!(!config.production);
631 }
632
633 #[test]
634 fn deserialize_json_production_mode_true() {
635 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
636 assert!(config.production);
637 }
638
639 #[test]
640 fn deserialize_json_per_analysis_production_mode() {
641 let config: FallowConfig = serde_json::from_str(
642 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
643 )
644 .unwrap();
645 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
646 assert!(config.production.for_analysis(ProductionAnalysis::Health));
647 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
648 }
649
650 #[test]
651 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
652 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
653 .unwrap_err();
654 assert!(
655 err.to_string().contains("healthTypo"),
656 "error should name the unknown field: {err}"
657 );
658 }
659
660 #[test]
661 fn deserialize_json_dynamically_loaded() {
662 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
663 let config: FallowConfig = serde_json::from_str(json).unwrap();
664 assert_eq!(
665 config.dynamically_loaded,
666 vec!["plugins/**/*.ts", "locales/**/*.json"]
667 );
668 }
669
670 #[test]
671 fn deserialize_json_dynamically_loaded_defaults_empty() {
672 let config: FallowConfig = serde_json::from_str("{}").unwrap();
673 assert!(config.dynamically_loaded.is_empty());
674 }
675
676 #[test]
677 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
678 let json = r#"{
679 "usedClassMembers": [
680 "agInit",
681 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
682 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
683 ]
684 }"#;
685 let config: FallowConfig = serde_json::from_str(json).unwrap();
686 assert_eq!(
687 config.used_class_members,
688 vec![
689 UsedClassMemberRule::from("agInit"),
690 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
691 extends: None,
692 implements: Some("ICellRendererAngularComp".to_string()),
693 members: vec!["refresh".to_string()],
694 }),
695 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
696 extends: Some("BaseCommand".to_string()),
697 implements: Some("CanActivate".to_string()),
698 members: vec!["execute".to_string()],
699 }),
700 ]
701 );
702 }
703
704 #[test]
707 fn deserialize_toml_minimal() {
708 let toml_str = r#"
709entry = ["src/index.ts"]
710production = true
711"#;
712 let config: FallowConfig = toml::from_str(toml_str).unwrap();
713 assert_eq!(config.entry, vec!["src/index.ts"]);
714 assert!(config.production);
715 }
716
717 #[test]
718 fn deserialize_toml_per_analysis_production_mode() {
719 let toml_str = r"
720[production]
721deadCode = false
722health = true
723dupes = false
724";
725 let config: FallowConfig = toml::from_str(toml_str).unwrap();
726 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
727 assert!(config.production.for_analysis(ProductionAnalysis::Health));
728 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
729 }
730
731 #[test]
732 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
733 let err = toml::from_str::<FallowConfig>(
734 r"
735[production]
736healthTypo = true
737",
738 )
739 .unwrap_err();
740 assert!(
741 err.to_string().contains("healthTypo"),
742 "error should name the unknown field: {err}"
743 );
744 }
745
746 #[test]
747 fn deserialize_toml_with_inline_framework() {
748 let toml_str = r#"
749[[framework]]
750name = "my-framework"
751enablers = ["my-framework-pkg"]
752entryPoints = ["src/routes/**/*.tsx"]
753"#;
754 let config: FallowConfig = toml::from_str(toml_str).unwrap();
755 assert_eq!(config.framework.len(), 1);
756 assert_eq!(config.framework[0].name, "my-framework");
757 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
758 assert_eq!(
759 config.framework[0].entry_points,
760 vec!["src/routes/**/*.tsx"]
761 );
762 }
763
764 #[test]
765 fn deserialize_toml_with_workspace_config() {
766 let toml_str = r#"
767[workspaces]
768patterns = ["packages/*", "apps/*"]
769"#;
770 let config: FallowConfig = toml::from_str(toml_str).unwrap();
771 assert!(config.workspaces.is_some());
772 let ws = config.workspaces.unwrap();
773 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
774 }
775
776 #[test]
777 fn deserialize_toml_with_ignore_exports() {
778 let toml_str = r#"
779[[ignoreExports]]
780file = "src/types/**/*.ts"
781exports = ["*"]
782"#;
783 let config: FallowConfig = toml::from_str(toml_str).unwrap();
784 assert_eq!(config.ignore_exports.len(), 1);
785 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
786 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
787 }
788
789 #[test]
790 fn deserialize_toml_used_class_members_supports_scoped_rules() {
791 let toml_str = r#"
792usedClassMembers = [
793 { implements = "ICellRendererAngularComp", members = ["refresh"] },
794 { extends = "BaseCommand", members = ["execute"] },
795]
796"#;
797 let config: FallowConfig = toml::from_str(toml_str).unwrap();
798 assert_eq!(
799 config.used_class_members,
800 vec![
801 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
802 extends: None,
803 implements: Some("ICellRendererAngularComp".to_string()),
804 members: vec!["refresh".to_string()],
805 }),
806 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
807 extends: Some("BaseCommand".to_string()),
808 implements: None,
809 members: vec!["execute".to_string()],
810 }),
811 ]
812 );
813 }
814
815 #[test]
816 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
817 let result = serde_json::from_str::<FallowConfig>(
818 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
819 );
820 assert!(
821 result.is_err(),
822 "unconstrained scoped rule should be rejected"
823 );
824 }
825
826 #[test]
827 fn deserialize_ignore_exports_used_in_file_bool() {
828 let config: FallowConfig =
829 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
830
831 assert!(config.ignore_exports_used_in_file.suppresses(false));
832 assert!(config.ignore_exports_used_in_file.suppresses(true));
833 }
834
835 #[test]
836 fn deserialize_ignore_exports_used_in_file_kind_form() {
837 let config: FallowConfig =
838 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
839
840 assert!(!config.ignore_exports_used_in_file.suppresses(false));
841 assert!(config.ignore_exports_used_in_file.suppresses(true));
842 }
843
844 #[test]
845 fn deserialize_toml_deny_unknown_fields() {
846 let toml_str = r"bogus_field = true";
847 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
848 assert!(result.is_err(), "unknown fields should be rejected");
849 }
850
851 #[test]
854 fn json_serialize_roundtrip() {
855 let config = FallowConfig {
856 entry: vec!["src/main.ts".to_string()],
857 production: true.into(),
858 ..FallowConfig::default()
859 };
860 let json = serde_json::to_string(&config).unwrap();
861 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
862 assert_eq!(restored.entry, vec!["src/main.ts"]);
863 assert!(restored.production);
864 }
865
866 #[test]
867 fn schema_field_not_serialized() {
868 let config = FallowConfig {
869 schema: Some("https://example.com/schema.json".to_string()),
870 ..FallowConfig::default()
871 };
872 let json = serde_json::to_string(&config).unwrap();
873 assert!(
875 !json.contains("$schema"),
876 "schema field should be skipped in serialization"
877 );
878 }
879
880 #[test]
881 fn extends_field_not_serialized() {
882 let config = FallowConfig {
883 extends: vec!["base.json".to_string()],
884 ..FallowConfig::default()
885 };
886 let json = serde_json::to_string(&config).unwrap();
887 assert!(
888 !json.contains("extends"),
889 "extends field should be skipped in serialization"
890 );
891 }
892
893 #[test]
896 fn regression_config_deserialize_json() {
897 let json = r#"{
898 "regression": {
899 "baseline": {
900 "totalIssues": 42,
901 "unusedFiles": 10,
902 "unusedExports": 5,
903 "circularDependencies": 2
904 }
905 }
906 }"#;
907 let config: FallowConfig = serde_json::from_str(json).unwrap();
908 let regression = config.regression.unwrap();
909 let baseline = regression.baseline.unwrap();
910 assert_eq!(baseline.total_issues, 42);
911 assert_eq!(baseline.unused_files, 10);
912 assert_eq!(baseline.unused_exports, 5);
913 assert_eq!(baseline.circular_dependencies, 2);
914 assert_eq!(baseline.unused_types, 0);
916 assert_eq!(baseline.boundary_violations, 0);
917 }
918
919 #[test]
920 fn regression_config_defaults_to_none() {
921 let config: FallowConfig = serde_json::from_str("{}").unwrap();
922 assert!(config.regression.is_none());
923 }
924
925 #[test]
926 fn regression_baseline_all_zeros_by_default() {
927 let baseline = RegressionBaseline::default();
928 assert_eq!(baseline.total_issues, 0);
929 assert_eq!(baseline.unused_files, 0);
930 assert_eq!(baseline.unused_exports, 0);
931 assert_eq!(baseline.unused_types, 0);
932 assert_eq!(baseline.unused_dependencies, 0);
933 assert_eq!(baseline.unused_dev_dependencies, 0);
934 assert_eq!(baseline.unused_optional_dependencies, 0);
935 assert_eq!(baseline.unused_enum_members, 0);
936 assert_eq!(baseline.unused_class_members, 0);
937 assert_eq!(baseline.unresolved_imports, 0);
938 assert_eq!(baseline.unlisted_dependencies, 0);
939 assert_eq!(baseline.duplicate_exports, 0);
940 assert_eq!(baseline.circular_dependencies, 0);
941 assert_eq!(baseline.type_only_dependencies, 0);
942 assert_eq!(baseline.test_only_dependencies, 0);
943 assert_eq!(baseline.boundary_violations, 0);
944 }
945
946 #[test]
947 fn regression_config_serialize_roundtrip() {
948 let baseline = RegressionBaseline {
949 total_issues: 100,
950 unused_files: 20,
951 unused_exports: 30,
952 ..RegressionBaseline::default()
953 };
954 let regression = RegressionConfig {
955 baseline: Some(baseline),
956 };
957 let config = FallowConfig {
958 regression: Some(regression),
959 ..FallowConfig::default()
960 };
961 let json = serde_json::to_string(&config).unwrap();
962 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
963 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
964 assert_eq!(restored_baseline.total_issues, 100);
965 assert_eq!(restored_baseline.unused_files, 20);
966 assert_eq!(restored_baseline.unused_exports, 30);
967 assert_eq!(restored_baseline.unused_types, 0);
968 }
969
970 #[test]
971 fn regression_config_empty_baseline_deserialize() {
972 let json = r#"{"regression": {}}"#;
973 let config: FallowConfig = serde_json::from_str(json).unwrap();
974 let regression = config.regression.unwrap();
975 assert!(regression.baseline.is_none());
976 }
977
978 #[test]
979 fn regression_baseline_not_serialized_when_none() {
980 let config = FallowConfig {
981 regression: None,
982 ..FallowConfig::default()
983 };
984 let json = serde_json::to_string(&config).unwrap();
985 assert!(
986 !json.contains("regression"),
987 "regression should be skipped when None"
988 );
989 }
990
991 #[test]
994 fn deserialize_json_with_overrides() {
995 let json = r#"{
996 "overrides": [
997 {
998 "files": ["*.test.ts", "*.spec.ts"],
999 "rules": {
1000 "unused-exports": "off",
1001 "unused-files": "warn"
1002 }
1003 }
1004 ]
1005 }"#;
1006 let config: FallowConfig = serde_json::from_str(json).unwrap();
1007 assert_eq!(config.overrides.len(), 1);
1008 assert_eq!(config.overrides[0].files.len(), 2);
1009 assert_eq!(
1010 config.overrides[0].rules.unused_exports,
1011 Some(Severity::Off)
1012 );
1013 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1014 }
1015
1016 #[test]
1017 fn deserialize_json_with_boundaries() {
1018 let json = r#"{
1019 "boundaries": {
1020 "preset": "layered"
1021 }
1022 }"#;
1023 let config: FallowConfig = serde_json::from_str(json).unwrap();
1024 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1025 }
1026
1027 #[test]
1030 fn deserialize_toml_with_regression_baseline() {
1031 let toml_str = r"
1032[regression.baseline]
1033totalIssues = 50
1034unusedFiles = 10
1035unusedExports = 15
1036";
1037 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1038 let baseline = config.regression.unwrap().baseline.unwrap();
1039 assert_eq!(baseline.total_issues, 50);
1040 assert_eq!(baseline.unused_files, 10);
1041 assert_eq!(baseline.unused_exports, 15);
1042 }
1043
1044 #[test]
1047 fn deserialize_toml_with_overrides() {
1048 let toml_str = r#"
1049[[overrides]]
1050files = ["*.test.ts"]
1051
1052[overrides.rules]
1053unused-exports = "off"
1054
1055[[overrides]]
1056files = ["*.stories.tsx"]
1057
1058[overrides.rules]
1059unused-files = "off"
1060"#;
1061 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1062 assert_eq!(config.overrides.len(), 2);
1063 assert_eq!(
1064 config.overrides[0].rules.unused_exports,
1065 Some(Severity::Off)
1066 );
1067 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1068 }
1069
1070 #[test]
1073 fn regression_config_default_is_none_baseline() {
1074 let config = RegressionConfig::default();
1075 assert!(config.baseline.is_none());
1076 }
1077
1078 #[test]
1081 fn deserialize_json_multiple_ignore_export_rules() {
1082 let json = r#"{
1083 "ignoreExports": [
1084 {"file": "src/types/**/*.ts", "exports": ["*"]},
1085 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1086 {"file": "src/index.ts", "exports": ["default"]}
1087 ]
1088 }"#;
1089 let config: FallowConfig = serde_json::from_str(json).unwrap();
1090 assert_eq!(config.ignore_exports.len(), 3);
1091 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1092 }
1093
1094 #[test]
1097 fn deserialize_json_public_packages_camel_case() {
1098 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1099 let config: FallowConfig = serde_json::from_str(json).unwrap();
1100 assert_eq!(
1101 config.public_packages,
1102 vec!["@myorg/shared-lib", "@myorg/utils"]
1103 );
1104 }
1105
1106 #[test]
1107 fn deserialize_json_public_packages_rejects_snake_case() {
1108 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1109 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1110 assert!(
1111 result.is_err(),
1112 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1113 );
1114 }
1115
1116 #[test]
1117 fn deserialize_json_public_packages_empty() {
1118 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1119 assert!(config.public_packages.is_empty());
1120 }
1121
1122 #[test]
1123 fn deserialize_toml_public_packages() {
1124 let toml_str = r#"
1125publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1126"#;
1127 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1128 assert_eq!(
1129 config.public_packages,
1130 vec!["@myorg/shared-lib", "@myorg/ui"]
1131 );
1132 }
1133
1134 #[test]
1135 fn public_packages_serialize_roundtrip() {
1136 let config = FallowConfig {
1137 public_packages: vec!["@myorg/shared-lib".to_string()],
1138 ..FallowConfig::default()
1139 };
1140 let json = serde_json::to_string(&config).unwrap();
1141 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1142 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1143 }
1144}