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