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 = "Option::is_none")]
408 pub dead_code_baseline: Option<String>,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub health_baseline: Option<String>,
413
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub dupes_baseline: Option<String>,
417}
418
419impl AuditConfig {
420 #[must_use]
422 pub fn is_empty(&self) -> bool {
423 self.dead_code_baseline.is_none()
424 && self.health_baseline.is_none()
425 && self.dupes_baseline.is_none()
426 }
427}
428
429#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
436#[serde(rename_all = "camelCase")]
437pub struct RegressionConfig {
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub baseline: Option<RegressionBaseline>,
441}
442
443#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
445#[serde(rename_all = "camelCase")]
446pub struct RegressionBaseline {
447 #[serde(default)]
448 pub total_issues: usize,
449 #[serde(default)]
450 pub unused_files: usize,
451 #[serde(default)]
452 pub unused_exports: usize,
453 #[serde(default)]
454 pub unused_types: usize,
455 #[serde(default)]
456 pub unused_dependencies: usize,
457 #[serde(default)]
458 pub unused_dev_dependencies: usize,
459 #[serde(default)]
460 pub unused_optional_dependencies: usize,
461 #[serde(default)]
462 pub unused_enum_members: usize,
463 #[serde(default)]
464 pub unused_class_members: usize,
465 #[serde(default)]
466 pub unresolved_imports: usize,
467 #[serde(default)]
468 pub unlisted_dependencies: usize,
469 #[serde(default)]
470 pub duplicate_exports: usize,
471 #[serde(default)]
472 pub circular_dependencies: usize,
473 #[serde(default)]
474 pub type_only_dependencies: usize,
475 #[serde(default)]
476 pub test_only_dependencies: usize,
477 #[serde(default)]
478 pub boundary_violations: usize,
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
488 fn default_config_has_empty_collections() {
489 let config = FallowConfig::default();
490 assert!(config.schema.is_none());
491 assert!(config.extends.is_empty());
492 assert!(config.entry.is_empty());
493 assert!(config.ignore_patterns.is_empty());
494 assert!(config.framework.is_empty());
495 assert!(config.workspaces.is_none());
496 assert!(config.ignore_dependencies.is_empty());
497 assert!(config.ignore_exports.is_empty());
498 assert!(config.used_class_members.is_empty());
499 assert!(config.plugins.is_empty());
500 assert!(config.dynamically_loaded.is_empty());
501 assert!(config.overrides.is_empty());
502 assert!(config.public_packages.is_empty());
503 assert!(!config.production);
504 }
505
506 #[test]
507 fn default_config_rules_are_error() {
508 let config = FallowConfig::default();
509 assert_eq!(config.rules.unused_files, Severity::Error);
510 assert_eq!(config.rules.unused_exports, Severity::Error);
511 assert_eq!(config.rules.unused_dependencies, Severity::Error);
512 }
513
514 #[test]
515 fn default_config_duplicates_enabled() {
516 let config = FallowConfig::default();
517 assert!(config.duplicates.enabled);
518 assert_eq!(config.duplicates.min_tokens, 50);
519 assert_eq!(config.duplicates.min_lines, 5);
520 }
521
522 #[test]
523 fn default_config_health_thresholds() {
524 let config = FallowConfig::default();
525 assert_eq!(config.health.max_cyclomatic, 20);
526 assert_eq!(config.health.max_cognitive, 15);
527 }
528
529 #[test]
532 fn deserialize_empty_json_object() {
533 let config: FallowConfig = serde_json::from_str("{}").unwrap();
534 assert!(config.entry.is_empty());
535 assert!(!config.production);
536 }
537
538 #[test]
539 fn deserialize_json_with_all_top_level_fields() {
540 let json = r#"{
541 "$schema": "https://fallow.dev/schema.json",
542 "entry": ["src/main.ts"],
543 "ignorePatterns": ["generated/**"],
544 "ignoreDependencies": ["postcss"],
545 "production": true,
546 "plugins": ["custom-plugin.toml"],
547 "rules": {"unused-files": "warn"},
548 "duplicates": {"enabled": false},
549 "health": {"maxCyclomatic": 30}
550 }"#;
551 let config: FallowConfig = serde_json::from_str(json).unwrap();
552 assert_eq!(
553 config.schema.as_deref(),
554 Some("https://fallow.dev/schema.json")
555 );
556 assert_eq!(config.entry, vec!["src/main.ts"]);
557 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
558 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
559 assert!(config.production);
560 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
561 assert_eq!(config.rules.unused_files, Severity::Warn);
562 assert!(!config.duplicates.enabled);
563 assert_eq!(config.health.max_cyclomatic, 30);
564 }
565
566 #[test]
567 fn deserialize_json_deny_unknown_fields() {
568 let json = r#"{"unknownField": true}"#;
569 let result: Result<FallowConfig, _> = serde_json::from_str(json);
570 assert!(result.is_err(), "unknown fields should be rejected");
571 }
572
573 #[test]
574 fn deserialize_json_production_mode_default_false() {
575 let config: FallowConfig = serde_json::from_str("{}").unwrap();
576 assert!(!config.production);
577 }
578
579 #[test]
580 fn deserialize_json_production_mode_true() {
581 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
582 assert!(config.production);
583 }
584
585 #[test]
586 fn deserialize_json_per_analysis_production_mode() {
587 let config: FallowConfig = serde_json::from_str(
588 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
589 )
590 .unwrap();
591 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
592 assert!(config.production.for_analysis(ProductionAnalysis::Health));
593 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
594 }
595
596 #[test]
597 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
598 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
599 .unwrap_err();
600 assert!(
601 err.to_string().contains("healthTypo"),
602 "error should name the unknown field: {err}"
603 );
604 }
605
606 #[test]
607 fn deserialize_json_dynamically_loaded() {
608 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
609 let config: FallowConfig = serde_json::from_str(json).unwrap();
610 assert_eq!(
611 config.dynamically_loaded,
612 vec!["plugins/**/*.ts", "locales/**/*.json"]
613 );
614 }
615
616 #[test]
617 fn deserialize_json_dynamically_loaded_defaults_empty() {
618 let config: FallowConfig = serde_json::from_str("{}").unwrap();
619 assert!(config.dynamically_loaded.is_empty());
620 }
621
622 #[test]
623 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
624 let json = r#"{
625 "usedClassMembers": [
626 "agInit",
627 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
628 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
629 ]
630 }"#;
631 let config: FallowConfig = serde_json::from_str(json).unwrap();
632 assert_eq!(
633 config.used_class_members,
634 vec![
635 UsedClassMemberRule::from("agInit"),
636 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
637 extends: None,
638 implements: Some("ICellRendererAngularComp".to_string()),
639 members: vec!["refresh".to_string()],
640 }),
641 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
642 extends: Some("BaseCommand".to_string()),
643 implements: Some("CanActivate".to_string()),
644 members: vec!["execute".to_string()],
645 }),
646 ]
647 );
648 }
649
650 #[test]
653 fn deserialize_toml_minimal() {
654 let toml_str = r#"
655entry = ["src/index.ts"]
656production = true
657"#;
658 let config: FallowConfig = toml::from_str(toml_str).unwrap();
659 assert_eq!(config.entry, vec!["src/index.ts"]);
660 assert!(config.production);
661 }
662
663 #[test]
664 fn deserialize_toml_per_analysis_production_mode() {
665 let toml_str = r"
666[production]
667deadCode = false
668health = true
669dupes = false
670";
671 let config: FallowConfig = toml::from_str(toml_str).unwrap();
672 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
673 assert!(config.production.for_analysis(ProductionAnalysis::Health));
674 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
675 }
676
677 #[test]
678 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
679 let err = toml::from_str::<FallowConfig>(
680 r"
681[production]
682healthTypo = true
683",
684 )
685 .unwrap_err();
686 assert!(
687 err.to_string().contains("healthTypo"),
688 "error should name the unknown field: {err}"
689 );
690 }
691
692 #[test]
693 fn deserialize_toml_with_inline_framework() {
694 let toml_str = r#"
695[[framework]]
696name = "my-framework"
697enablers = ["my-framework-pkg"]
698entryPoints = ["src/routes/**/*.tsx"]
699"#;
700 let config: FallowConfig = toml::from_str(toml_str).unwrap();
701 assert_eq!(config.framework.len(), 1);
702 assert_eq!(config.framework[0].name, "my-framework");
703 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
704 assert_eq!(
705 config.framework[0].entry_points,
706 vec!["src/routes/**/*.tsx"]
707 );
708 }
709
710 #[test]
711 fn deserialize_toml_with_workspace_config() {
712 let toml_str = r#"
713[workspaces]
714patterns = ["packages/*", "apps/*"]
715"#;
716 let config: FallowConfig = toml::from_str(toml_str).unwrap();
717 assert!(config.workspaces.is_some());
718 let ws = config.workspaces.unwrap();
719 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
720 }
721
722 #[test]
723 fn deserialize_toml_with_ignore_exports() {
724 let toml_str = r#"
725[[ignoreExports]]
726file = "src/types/**/*.ts"
727exports = ["*"]
728"#;
729 let config: FallowConfig = toml::from_str(toml_str).unwrap();
730 assert_eq!(config.ignore_exports.len(), 1);
731 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
732 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
733 }
734
735 #[test]
736 fn deserialize_toml_used_class_members_supports_scoped_rules() {
737 let toml_str = r#"
738usedClassMembers = [
739 { implements = "ICellRendererAngularComp", members = ["refresh"] },
740 { extends = "BaseCommand", members = ["execute"] },
741]
742"#;
743 let config: FallowConfig = toml::from_str(toml_str).unwrap();
744 assert_eq!(
745 config.used_class_members,
746 vec![
747 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
748 extends: None,
749 implements: Some("ICellRendererAngularComp".to_string()),
750 members: vec!["refresh".to_string()],
751 }),
752 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
753 extends: Some("BaseCommand".to_string()),
754 implements: None,
755 members: vec!["execute".to_string()],
756 }),
757 ]
758 );
759 }
760
761 #[test]
762 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
763 let result = serde_json::from_str::<FallowConfig>(
764 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
765 );
766 assert!(
767 result.is_err(),
768 "unconstrained scoped rule should be rejected"
769 );
770 }
771
772 #[test]
773 fn deserialize_ignore_exports_used_in_file_bool() {
774 let config: FallowConfig =
775 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
776
777 assert!(config.ignore_exports_used_in_file.suppresses(false));
778 assert!(config.ignore_exports_used_in_file.suppresses(true));
779 }
780
781 #[test]
782 fn deserialize_ignore_exports_used_in_file_kind_form() {
783 let config: FallowConfig =
784 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
785
786 assert!(!config.ignore_exports_used_in_file.suppresses(false));
787 assert!(config.ignore_exports_used_in_file.suppresses(true));
788 }
789
790 #[test]
791 fn deserialize_toml_deny_unknown_fields() {
792 let toml_str = r"bogus_field = true";
793 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
794 assert!(result.is_err(), "unknown fields should be rejected");
795 }
796
797 #[test]
800 fn json_serialize_roundtrip() {
801 let config = FallowConfig {
802 entry: vec!["src/main.ts".to_string()],
803 production: true.into(),
804 ..FallowConfig::default()
805 };
806 let json = serde_json::to_string(&config).unwrap();
807 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
808 assert_eq!(restored.entry, vec!["src/main.ts"]);
809 assert!(restored.production);
810 }
811
812 #[test]
813 fn schema_field_not_serialized() {
814 let config = FallowConfig {
815 schema: Some("https://example.com/schema.json".to_string()),
816 ..FallowConfig::default()
817 };
818 let json = serde_json::to_string(&config).unwrap();
819 assert!(
821 !json.contains("$schema"),
822 "schema field should be skipped in serialization"
823 );
824 }
825
826 #[test]
827 fn extends_field_not_serialized() {
828 let config = FallowConfig {
829 extends: vec!["base.json".to_string()],
830 ..FallowConfig::default()
831 };
832 let json = serde_json::to_string(&config).unwrap();
833 assert!(
834 !json.contains("extends"),
835 "extends field should be skipped in serialization"
836 );
837 }
838
839 #[test]
842 fn regression_config_deserialize_json() {
843 let json = r#"{
844 "regression": {
845 "baseline": {
846 "totalIssues": 42,
847 "unusedFiles": 10,
848 "unusedExports": 5,
849 "circularDependencies": 2
850 }
851 }
852 }"#;
853 let config: FallowConfig = serde_json::from_str(json).unwrap();
854 let regression = config.regression.unwrap();
855 let baseline = regression.baseline.unwrap();
856 assert_eq!(baseline.total_issues, 42);
857 assert_eq!(baseline.unused_files, 10);
858 assert_eq!(baseline.unused_exports, 5);
859 assert_eq!(baseline.circular_dependencies, 2);
860 assert_eq!(baseline.unused_types, 0);
862 assert_eq!(baseline.boundary_violations, 0);
863 }
864
865 #[test]
866 fn regression_config_defaults_to_none() {
867 let config: FallowConfig = serde_json::from_str("{}").unwrap();
868 assert!(config.regression.is_none());
869 }
870
871 #[test]
872 fn regression_baseline_all_zeros_by_default() {
873 let baseline = RegressionBaseline::default();
874 assert_eq!(baseline.total_issues, 0);
875 assert_eq!(baseline.unused_files, 0);
876 assert_eq!(baseline.unused_exports, 0);
877 assert_eq!(baseline.unused_types, 0);
878 assert_eq!(baseline.unused_dependencies, 0);
879 assert_eq!(baseline.unused_dev_dependencies, 0);
880 assert_eq!(baseline.unused_optional_dependencies, 0);
881 assert_eq!(baseline.unused_enum_members, 0);
882 assert_eq!(baseline.unused_class_members, 0);
883 assert_eq!(baseline.unresolved_imports, 0);
884 assert_eq!(baseline.unlisted_dependencies, 0);
885 assert_eq!(baseline.duplicate_exports, 0);
886 assert_eq!(baseline.circular_dependencies, 0);
887 assert_eq!(baseline.type_only_dependencies, 0);
888 assert_eq!(baseline.test_only_dependencies, 0);
889 assert_eq!(baseline.boundary_violations, 0);
890 }
891
892 #[test]
893 fn regression_config_serialize_roundtrip() {
894 let baseline = RegressionBaseline {
895 total_issues: 100,
896 unused_files: 20,
897 unused_exports: 30,
898 ..RegressionBaseline::default()
899 };
900 let regression = RegressionConfig {
901 baseline: Some(baseline),
902 };
903 let config = FallowConfig {
904 regression: Some(regression),
905 ..FallowConfig::default()
906 };
907 let json = serde_json::to_string(&config).unwrap();
908 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
909 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
910 assert_eq!(restored_baseline.total_issues, 100);
911 assert_eq!(restored_baseline.unused_files, 20);
912 assert_eq!(restored_baseline.unused_exports, 30);
913 assert_eq!(restored_baseline.unused_types, 0);
914 }
915
916 #[test]
917 fn regression_config_empty_baseline_deserialize() {
918 let json = r#"{"regression": {}}"#;
919 let config: FallowConfig = serde_json::from_str(json).unwrap();
920 let regression = config.regression.unwrap();
921 assert!(regression.baseline.is_none());
922 }
923
924 #[test]
925 fn regression_baseline_not_serialized_when_none() {
926 let config = FallowConfig {
927 regression: None,
928 ..FallowConfig::default()
929 };
930 let json = serde_json::to_string(&config).unwrap();
931 assert!(
932 !json.contains("regression"),
933 "regression should be skipped when None"
934 );
935 }
936
937 #[test]
940 fn deserialize_json_with_overrides() {
941 let json = r#"{
942 "overrides": [
943 {
944 "files": ["*.test.ts", "*.spec.ts"],
945 "rules": {
946 "unused-exports": "off",
947 "unused-files": "warn"
948 }
949 }
950 ]
951 }"#;
952 let config: FallowConfig = serde_json::from_str(json).unwrap();
953 assert_eq!(config.overrides.len(), 1);
954 assert_eq!(config.overrides[0].files.len(), 2);
955 assert_eq!(
956 config.overrides[0].rules.unused_exports,
957 Some(Severity::Off)
958 );
959 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
960 }
961
962 #[test]
963 fn deserialize_json_with_boundaries() {
964 let json = r#"{
965 "boundaries": {
966 "preset": "layered"
967 }
968 }"#;
969 let config: FallowConfig = serde_json::from_str(json).unwrap();
970 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
971 }
972
973 #[test]
976 fn deserialize_toml_with_regression_baseline() {
977 let toml_str = r"
978[regression.baseline]
979totalIssues = 50
980unusedFiles = 10
981unusedExports = 15
982";
983 let config: FallowConfig = toml::from_str(toml_str).unwrap();
984 let baseline = config.regression.unwrap().baseline.unwrap();
985 assert_eq!(baseline.total_issues, 50);
986 assert_eq!(baseline.unused_files, 10);
987 assert_eq!(baseline.unused_exports, 15);
988 }
989
990 #[test]
993 fn deserialize_toml_with_overrides() {
994 let toml_str = r#"
995[[overrides]]
996files = ["*.test.ts"]
997
998[overrides.rules]
999unused-exports = "off"
1000
1001[[overrides]]
1002files = ["*.stories.tsx"]
1003
1004[overrides.rules]
1005unused-files = "off"
1006"#;
1007 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1008 assert_eq!(config.overrides.len(), 2);
1009 assert_eq!(
1010 config.overrides[0].rules.unused_exports,
1011 Some(Severity::Off)
1012 );
1013 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1014 }
1015
1016 #[test]
1019 fn regression_config_default_is_none_baseline() {
1020 let config = RegressionConfig::default();
1021 assert!(config.baseline.is_none());
1022 }
1023
1024 #[test]
1027 fn deserialize_json_multiple_ignore_export_rules() {
1028 let json = r#"{
1029 "ignoreExports": [
1030 {"file": "src/types/**/*.ts", "exports": ["*"]},
1031 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1032 {"file": "src/index.ts", "exports": ["default"]}
1033 ]
1034 }"#;
1035 let config: FallowConfig = serde_json::from_str(json).unwrap();
1036 assert_eq!(config.ignore_exports.len(), 3);
1037 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1038 }
1039
1040 #[test]
1043 fn deserialize_json_public_packages_camel_case() {
1044 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1045 let config: FallowConfig = serde_json::from_str(json).unwrap();
1046 assert_eq!(
1047 config.public_packages,
1048 vec!["@myorg/shared-lib", "@myorg/utils"]
1049 );
1050 }
1051
1052 #[test]
1053 fn deserialize_json_public_packages_rejects_snake_case() {
1054 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1055 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1056 assert!(
1057 result.is_err(),
1058 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1059 );
1060 }
1061
1062 #[test]
1063 fn deserialize_json_public_packages_empty() {
1064 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1065 assert!(config.public_packages.is_empty());
1066 }
1067
1068 #[test]
1069 fn deserialize_toml_public_packages() {
1070 let toml_str = r#"
1071publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1072"#;
1073 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1074 assert_eq!(
1075 config.public_packages,
1076 vec!["@myorg/shared-lib", "@myorg/ui"]
1077 );
1078 }
1079
1080 #[test]
1081 fn public_packages_serialize_roundtrip() {
1082 let config = FallowConfig {
1083 public_packages: vec!["@myorg/shared-lib".to_string()],
1084 ..FallowConfig::default()
1085 };
1086 let json = serde_json::to_string(&config).unwrap();
1087 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1088 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1089 }
1090}