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