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