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