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, Default, Deserialize, Serialize, JsonSchema)]
55#[serde(deny_unknown_fields, rename_all = "camelCase")]
56pub struct FallowConfig {
57 #[serde(rename = "$schema", default, skip_serializing)]
59 pub schema: Option<String>,
60
61 #[serde(default, skip_serializing)]
82 pub extends: Vec<String>,
83
84 #[serde(default)]
86 pub entry: Vec<String>,
87
88 #[serde(default)]
90 pub ignore_patterns: Vec<String>,
91
92 #[serde(default)]
94 pub framework: Vec<ExternalPluginDef>,
95
96 #[serde(default)]
98 pub workspaces: Option<WorkspaceConfig>,
99
100 #[serde(default)]
106 pub ignore_dependencies: Vec<String>,
107
108 #[serde(default)]
110 pub ignore_exports: Vec<IgnoreExportRule>,
111
112 #[serde(default)]
117 pub used_class_members: Vec<UsedClassMemberRule>,
118
119 #[serde(default)]
121 pub duplicates: DuplicatesConfig,
122
123 #[serde(default)]
125 pub health: HealthConfig,
126
127 #[serde(default)]
129 pub rules: RulesConfig,
130
131 #[serde(default)]
133 pub boundaries: BoundaryConfig,
134
135 #[serde(default)]
137 pub flags: FlagsConfig,
138
139 #[serde(default)]
141 pub resolve: ResolveConfig,
142
143 #[serde(default)]
148 pub production: ProductionConfig,
149
150 #[serde(default)]
158 pub plugins: Vec<String>,
159
160 #[serde(default)]
164 pub dynamically_loaded: Vec<String>,
165
166 #[serde(default)]
168 pub overrides: Vec<ConfigOverride>,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub codeowners: Option<String>,
177
178 #[serde(default)]
181 pub public_packages: Vec<String>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub regression: Option<RegressionConfig>,
188
189 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
196 pub audit: AuditConfig,
197
198 #[serde(default)]
207 pub sealed: bool,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum ProductionAnalysis {
213 DeadCode,
214 Health,
215 Dupes,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
220#[serde(untagged)]
221pub enum ProductionConfig {
222 Global(bool),
224 PerAnalysis(PerAnalysisProductionConfig),
226}
227
228impl<'de> Deserialize<'de> for ProductionConfig {
229 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230 where
231 D: Deserializer<'de>,
232 {
233 struct ProductionConfigVisitor;
234
235 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
236 type Value = ProductionConfig;
237
238 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 formatter.write_str("a boolean or per-analysis production config object")
240 }
241
242 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
243 where
244 E: serde::de::Error,
245 {
246 Ok(ProductionConfig::Global(value))
247 }
248
249 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
250 where
251 A: serde::de::MapAccess<'de>,
252 {
253 PerAnalysisProductionConfig::deserialize(
254 serde::de::value::MapAccessDeserializer::new(map),
255 )
256 .map(ProductionConfig::PerAnalysis)
257 }
258 }
259
260 deserializer.deserialize_any(ProductionConfigVisitor)
261 }
262}
263
264impl Default for ProductionConfig {
265 fn default() -> Self {
266 Self::Global(false)
267 }
268}
269
270impl From<bool> for ProductionConfig {
271 fn from(value: bool) -> Self {
272 Self::Global(value)
273 }
274}
275
276impl Not for ProductionConfig {
277 type Output = bool;
278
279 fn not(self) -> Self::Output {
280 !self.any_enabled()
281 }
282}
283
284impl ProductionConfig {
285 #[must_use]
286 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
287 match self {
288 Self::Global(value) => value,
289 Self::PerAnalysis(config) => match analysis {
290 ProductionAnalysis::DeadCode => config.dead_code,
291 ProductionAnalysis::Health => config.health,
292 ProductionAnalysis::Dupes => config.dupes,
293 },
294 }
295 }
296
297 #[must_use]
298 pub const fn global(self) -> bool {
299 match self {
300 Self::Global(value) => value,
301 Self::PerAnalysis(_) => false,
302 }
303 }
304
305 #[must_use]
306 pub const fn any_enabled(self) -> bool {
307 match self {
308 Self::Global(value) => value,
309 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
310 }
311 }
312}
313
314#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
316#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
317pub struct PerAnalysisProductionConfig {
318 pub dead_code: bool,
320 pub health: bool,
322 pub dupes: bool,
324}
325
326#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
333#[serde(rename_all = "camelCase")]
334pub struct AuditConfig {
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub dead_code_baseline: Option<String>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub health_baseline: Option<String>,
342
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub dupes_baseline: Option<String>,
346}
347
348impl AuditConfig {
349 #[must_use]
351 pub fn is_empty(&self) -> bool {
352 self.dead_code_baseline.is_none()
353 && self.health_baseline.is_none()
354 && self.dupes_baseline.is_none()
355 }
356}
357
358#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
365#[serde(rename_all = "camelCase")]
366pub struct RegressionConfig {
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub baseline: Option<RegressionBaseline>,
370}
371
372#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
374#[serde(rename_all = "camelCase")]
375pub struct RegressionBaseline {
376 #[serde(default)]
377 pub total_issues: usize,
378 #[serde(default)]
379 pub unused_files: usize,
380 #[serde(default)]
381 pub unused_exports: usize,
382 #[serde(default)]
383 pub unused_types: usize,
384 #[serde(default)]
385 pub unused_dependencies: usize,
386 #[serde(default)]
387 pub unused_dev_dependencies: usize,
388 #[serde(default)]
389 pub unused_optional_dependencies: usize,
390 #[serde(default)]
391 pub unused_enum_members: usize,
392 #[serde(default)]
393 pub unused_class_members: usize,
394 #[serde(default)]
395 pub unresolved_imports: usize,
396 #[serde(default)]
397 pub unlisted_dependencies: usize,
398 #[serde(default)]
399 pub duplicate_exports: usize,
400 #[serde(default)]
401 pub circular_dependencies: usize,
402 #[serde(default)]
403 pub type_only_dependencies: usize,
404 #[serde(default)]
405 pub test_only_dependencies: usize,
406 #[serde(default)]
407 pub boundary_violations: usize,
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
417 fn default_config_has_empty_collections() {
418 let config = FallowConfig::default();
419 assert!(config.schema.is_none());
420 assert!(config.extends.is_empty());
421 assert!(config.entry.is_empty());
422 assert!(config.ignore_patterns.is_empty());
423 assert!(config.framework.is_empty());
424 assert!(config.workspaces.is_none());
425 assert!(config.ignore_dependencies.is_empty());
426 assert!(config.ignore_exports.is_empty());
427 assert!(config.used_class_members.is_empty());
428 assert!(config.plugins.is_empty());
429 assert!(config.dynamically_loaded.is_empty());
430 assert!(config.overrides.is_empty());
431 assert!(config.public_packages.is_empty());
432 assert!(!config.production);
433 }
434
435 #[test]
436 fn default_config_rules_are_error() {
437 let config = FallowConfig::default();
438 assert_eq!(config.rules.unused_files, Severity::Error);
439 assert_eq!(config.rules.unused_exports, Severity::Error);
440 assert_eq!(config.rules.unused_dependencies, Severity::Error);
441 }
442
443 #[test]
444 fn default_config_duplicates_enabled() {
445 let config = FallowConfig::default();
446 assert!(config.duplicates.enabled);
447 assert_eq!(config.duplicates.min_tokens, 50);
448 assert_eq!(config.duplicates.min_lines, 5);
449 }
450
451 #[test]
452 fn default_config_health_thresholds() {
453 let config = FallowConfig::default();
454 assert_eq!(config.health.max_cyclomatic, 20);
455 assert_eq!(config.health.max_cognitive, 15);
456 }
457
458 #[test]
461 fn deserialize_empty_json_object() {
462 let config: FallowConfig = serde_json::from_str("{}").unwrap();
463 assert!(config.entry.is_empty());
464 assert!(!config.production);
465 }
466
467 #[test]
468 fn deserialize_json_with_all_top_level_fields() {
469 let json = r#"{
470 "$schema": "https://fallow.dev/schema.json",
471 "entry": ["src/main.ts"],
472 "ignorePatterns": ["generated/**"],
473 "ignoreDependencies": ["postcss"],
474 "production": true,
475 "plugins": ["custom-plugin.toml"],
476 "rules": {"unused-files": "warn"},
477 "duplicates": {"enabled": false},
478 "health": {"maxCyclomatic": 30}
479 }"#;
480 let config: FallowConfig = serde_json::from_str(json).unwrap();
481 assert_eq!(
482 config.schema.as_deref(),
483 Some("https://fallow.dev/schema.json")
484 );
485 assert_eq!(config.entry, vec!["src/main.ts"]);
486 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
487 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
488 assert!(config.production);
489 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
490 assert_eq!(config.rules.unused_files, Severity::Warn);
491 assert!(!config.duplicates.enabled);
492 assert_eq!(config.health.max_cyclomatic, 30);
493 }
494
495 #[test]
496 fn deserialize_json_deny_unknown_fields() {
497 let json = r#"{"unknownField": true}"#;
498 let result: Result<FallowConfig, _> = serde_json::from_str(json);
499 assert!(result.is_err(), "unknown fields should be rejected");
500 }
501
502 #[test]
503 fn deserialize_json_production_mode_default_false() {
504 let config: FallowConfig = serde_json::from_str("{}").unwrap();
505 assert!(!config.production);
506 }
507
508 #[test]
509 fn deserialize_json_production_mode_true() {
510 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
511 assert!(config.production);
512 }
513
514 #[test]
515 fn deserialize_json_per_analysis_production_mode() {
516 let config: FallowConfig = serde_json::from_str(
517 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
518 )
519 .unwrap();
520 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
521 assert!(config.production.for_analysis(ProductionAnalysis::Health));
522 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
523 }
524
525 #[test]
526 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
527 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
528 .unwrap_err();
529 assert!(
530 err.to_string().contains("healthTypo"),
531 "error should name the unknown field: {err}"
532 );
533 }
534
535 #[test]
536 fn deserialize_json_dynamically_loaded() {
537 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
538 let config: FallowConfig = serde_json::from_str(json).unwrap();
539 assert_eq!(
540 config.dynamically_loaded,
541 vec!["plugins/**/*.ts", "locales/**/*.json"]
542 );
543 }
544
545 #[test]
546 fn deserialize_json_dynamically_loaded_defaults_empty() {
547 let config: FallowConfig = serde_json::from_str("{}").unwrap();
548 assert!(config.dynamically_loaded.is_empty());
549 }
550
551 #[test]
552 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
553 let json = r#"{
554 "usedClassMembers": [
555 "agInit",
556 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
557 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
558 ]
559 }"#;
560 let config: FallowConfig = serde_json::from_str(json).unwrap();
561 assert_eq!(
562 config.used_class_members,
563 vec![
564 UsedClassMemberRule::from("agInit"),
565 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
566 extends: None,
567 implements: Some("ICellRendererAngularComp".to_string()),
568 members: vec!["refresh".to_string()],
569 }),
570 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
571 extends: Some("BaseCommand".to_string()),
572 implements: Some("CanActivate".to_string()),
573 members: vec!["execute".to_string()],
574 }),
575 ]
576 );
577 }
578
579 #[test]
582 fn deserialize_toml_minimal() {
583 let toml_str = r#"
584entry = ["src/index.ts"]
585production = true
586"#;
587 let config: FallowConfig = toml::from_str(toml_str).unwrap();
588 assert_eq!(config.entry, vec!["src/index.ts"]);
589 assert!(config.production);
590 }
591
592 #[test]
593 fn deserialize_toml_per_analysis_production_mode() {
594 let toml_str = r"
595[production]
596deadCode = false
597health = true
598dupes = false
599";
600 let config: FallowConfig = toml::from_str(toml_str).unwrap();
601 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
602 assert!(config.production.for_analysis(ProductionAnalysis::Health));
603 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
604 }
605
606 #[test]
607 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
608 let err = toml::from_str::<FallowConfig>(
609 r"
610[production]
611healthTypo = true
612",
613 )
614 .unwrap_err();
615 assert!(
616 err.to_string().contains("healthTypo"),
617 "error should name the unknown field: {err}"
618 );
619 }
620
621 #[test]
622 fn deserialize_toml_with_inline_framework() {
623 let toml_str = r#"
624[[framework]]
625name = "my-framework"
626enablers = ["my-framework-pkg"]
627entryPoints = ["src/routes/**/*.tsx"]
628"#;
629 let config: FallowConfig = toml::from_str(toml_str).unwrap();
630 assert_eq!(config.framework.len(), 1);
631 assert_eq!(config.framework[0].name, "my-framework");
632 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
633 assert_eq!(
634 config.framework[0].entry_points,
635 vec!["src/routes/**/*.tsx"]
636 );
637 }
638
639 #[test]
640 fn deserialize_toml_with_workspace_config() {
641 let toml_str = r#"
642[workspaces]
643patterns = ["packages/*", "apps/*"]
644"#;
645 let config: FallowConfig = toml::from_str(toml_str).unwrap();
646 assert!(config.workspaces.is_some());
647 let ws = config.workspaces.unwrap();
648 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
649 }
650
651 #[test]
652 fn deserialize_toml_with_ignore_exports() {
653 let toml_str = r#"
654[[ignoreExports]]
655file = "src/types/**/*.ts"
656exports = ["*"]
657"#;
658 let config: FallowConfig = toml::from_str(toml_str).unwrap();
659 assert_eq!(config.ignore_exports.len(), 1);
660 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
661 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
662 }
663
664 #[test]
665 fn deserialize_toml_used_class_members_supports_scoped_rules() {
666 let toml_str = r#"
667usedClassMembers = [
668 { implements = "ICellRendererAngularComp", members = ["refresh"] },
669 { extends = "BaseCommand", members = ["execute"] },
670]
671"#;
672 let config: FallowConfig = toml::from_str(toml_str).unwrap();
673 assert_eq!(
674 config.used_class_members,
675 vec![
676 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
677 extends: None,
678 implements: Some("ICellRendererAngularComp".to_string()),
679 members: vec!["refresh".to_string()],
680 }),
681 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
682 extends: Some("BaseCommand".to_string()),
683 implements: None,
684 members: vec!["execute".to_string()],
685 }),
686 ]
687 );
688 }
689
690 #[test]
691 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
692 let result = serde_json::from_str::<FallowConfig>(
693 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
694 );
695 assert!(
696 result.is_err(),
697 "unconstrained scoped rule should be rejected"
698 );
699 }
700
701 #[test]
702 fn deserialize_toml_deny_unknown_fields() {
703 let toml_str = r"bogus_field = true";
704 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
705 assert!(result.is_err(), "unknown fields should be rejected");
706 }
707
708 #[test]
711 fn json_serialize_roundtrip() {
712 let config = FallowConfig {
713 entry: vec!["src/main.ts".to_string()],
714 production: true.into(),
715 ..FallowConfig::default()
716 };
717 let json = serde_json::to_string(&config).unwrap();
718 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
719 assert_eq!(restored.entry, vec!["src/main.ts"]);
720 assert!(restored.production);
721 }
722
723 #[test]
724 fn schema_field_not_serialized() {
725 let config = FallowConfig {
726 schema: Some("https://example.com/schema.json".to_string()),
727 ..FallowConfig::default()
728 };
729 let json = serde_json::to_string(&config).unwrap();
730 assert!(
732 !json.contains("$schema"),
733 "schema field should be skipped in serialization"
734 );
735 }
736
737 #[test]
738 fn extends_field_not_serialized() {
739 let config = FallowConfig {
740 extends: vec!["base.json".to_string()],
741 ..FallowConfig::default()
742 };
743 let json = serde_json::to_string(&config).unwrap();
744 assert!(
745 !json.contains("extends"),
746 "extends field should be skipped in serialization"
747 );
748 }
749
750 #[test]
753 fn regression_config_deserialize_json() {
754 let json = r#"{
755 "regression": {
756 "baseline": {
757 "totalIssues": 42,
758 "unusedFiles": 10,
759 "unusedExports": 5,
760 "circularDependencies": 2
761 }
762 }
763 }"#;
764 let config: FallowConfig = serde_json::from_str(json).unwrap();
765 let regression = config.regression.unwrap();
766 let baseline = regression.baseline.unwrap();
767 assert_eq!(baseline.total_issues, 42);
768 assert_eq!(baseline.unused_files, 10);
769 assert_eq!(baseline.unused_exports, 5);
770 assert_eq!(baseline.circular_dependencies, 2);
771 assert_eq!(baseline.unused_types, 0);
773 assert_eq!(baseline.boundary_violations, 0);
774 }
775
776 #[test]
777 fn regression_config_defaults_to_none() {
778 let config: FallowConfig = serde_json::from_str("{}").unwrap();
779 assert!(config.regression.is_none());
780 }
781
782 #[test]
783 fn regression_baseline_all_zeros_by_default() {
784 let baseline = RegressionBaseline::default();
785 assert_eq!(baseline.total_issues, 0);
786 assert_eq!(baseline.unused_files, 0);
787 assert_eq!(baseline.unused_exports, 0);
788 assert_eq!(baseline.unused_types, 0);
789 assert_eq!(baseline.unused_dependencies, 0);
790 assert_eq!(baseline.unused_dev_dependencies, 0);
791 assert_eq!(baseline.unused_optional_dependencies, 0);
792 assert_eq!(baseline.unused_enum_members, 0);
793 assert_eq!(baseline.unused_class_members, 0);
794 assert_eq!(baseline.unresolved_imports, 0);
795 assert_eq!(baseline.unlisted_dependencies, 0);
796 assert_eq!(baseline.duplicate_exports, 0);
797 assert_eq!(baseline.circular_dependencies, 0);
798 assert_eq!(baseline.type_only_dependencies, 0);
799 assert_eq!(baseline.test_only_dependencies, 0);
800 assert_eq!(baseline.boundary_violations, 0);
801 }
802
803 #[test]
804 fn regression_config_serialize_roundtrip() {
805 let baseline = RegressionBaseline {
806 total_issues: 100,
807 unused_files: 20,
808 unused_exports: 30,
809 ..RegressionBaseline::default()
810 };
811 let regression = RegressionConfig {
812 baseline: Some(baseline),
813 };
814 let config = FallowConfig {
815 regression: Some(regression),
816 ..FallowConfig::default()
817 };
818 let json = serde_json::to_string(&config).unwrap();
819 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
820 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
821 assert_eq!(restored_baseline.total_issues, 100);
822 assert_eq!(restored_baseline.unused_files, 20);
823 assert_eq!(restored_baseline.unused_exports, 30);
824 assert_eq!(restored_baseline.unused_types, 0);
825 }
826
827 #[test]
828 fn regression_config_empty_baseline_deserialize() {
829 let json = r#"{"regression": {}}"#;
830 let config: FallowConfig = serde_json::from_str(json).unwrap();
831 let regression = config.regression.unwrap();
832 assert!(regression.baseline.is_none());
833 }
834
835 #[test]
836 fn regression_baseline_not_serialized_when_none() {
837 let config = FallowConfig {
838 regression: None,
839 ..FallowConfig::default()
840 };
841 let json = serde_json::to_string(&config).unwrap();
842 assert!(
843 !json.contains("regression"),
844 "regression should be skipped when None"
845 );
846 }
847
848 #[test]
851 fn deserialize_json_with_overrides() {
852 let json = r#"{
853 "overrides": [
854 {
855 "files": ["*.test.ts", "*.spec.ts"],
856 "rules": {
857 "unused-exports": "off",
858 "unused-files": "warn"
859 }
860 }
861 ]
862 }"#;
863 let config: FallowConfig = serde_json::from_str(json).unwrap();
864 assert_eq!(config.overrides.len(), 1);
865 assert_eq!(config.overrides[0].files.len(), 2);
866 assert_eq!(
867 config.overrides[0].rules.unused_exports,
868 Some(Severity::Off)
869 );
870 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
871 }
872
873 #[test]
874 fn deserialize_json_with_boundaries() {
875 let json = r#"{
876 "boundaries": {
877 "preset": "layered"
878 }
879 }"#;
880 let config: FallowConfig = serde_json::from_str(json).unwrap();
881 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
882 }
883
884 #[test]
887 fn deserialize_toml_with_regression_baseline() {
888 let toml_str = r"
889[regression.baseline]
890totalIssues = 50
891unusedFiles = 10
892unusedExports = 15
893";
894 let config: FallowConfig = toml::from_str(toml_str).unwrap();
895 let baseline = config.regression.unwrap().baseline.unwrap();
896 assert_eq!(baseline.total_issues, 50);
897 assert_eq!(baseline.unused_files, 10);
898 assert_eq!(baseline.unused_exports, 15);
899 }
900
901 #[test]
904 fn deserialize_toml_with_overrides() {
905 let toml_str = r#"
906[[overrides]]
907files = ["*.test.ts"]
908
909[overrides.rules]
910unused-exports = "off"
911
912[[overrides]]
913files = ["*.stories.tsx"]
914
915[overrides.rules]
916unused-files = "off"
917"#;
918 let config: FallowConfig = toml::from_str(toml_str).unwrap();
919 assert_eq!(config.overrides.len(), 2);
920 assert_eq!(
921 config.overrides[0].rules.unused_exports,
922 Some(Severity::Off)
923 );
924 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
925 }
926
927 #[test]
930 fn regression_config_default_is_none_baseline() {
931 let config = RegressionConfig::default();
932 assert!(config.baseline.is_none());
933 }
934
935 #[test]
938 fn deserialize_json_multiple_ignore_export_rules() {
939 let json = r#"{
940 "ignoreExports": [
941 {"file": "src/types/**/*.ts", "exports": ["*"]},
942 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
943 {"file": "src/index.ts", "exports": ["default"]}
944 ]
945 }"#;
946 let config: FallowConfig = serde_json::from_str(json).unwrap();
947 assert_eq!(config.ignore_exports.len(), 3);
948 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
949 }
950
951 #[test]
954 fn deserialize_json_public_packages_camel_case() {
955 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
956 let config: FallowConfig = serde_json::from_str(json).unwrap();
957 assert_eq!(
958 config.public_packages,
959 vec!["@myorg/shared-lib", "@myorg/utils"]
960 );
961 }
962
963 #[test]
964 fn deserialize_json_public_packages_rejects_snake_case() {
965 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
966 let result: Result<FallowConfig, _> = serde_json::from_str(json);
967 assert!(
968 result.is_err(),
969 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
970 );
971 }
972
973 #[test]
974 fn deserialize_json_public_packages_empty() {
975 let config: FallowConfig = serde_json::from_str("{}").unwrap();
976 assert!(config.public_packages.is_empty());
977 }
978
979 #[test]
980 fn deserialize_toml_public_packages() {
981 let toml_str = r#"
982publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
983"#;
984 let config: FallowConfig = toml::from_str(toml_str).unwrap();
985 assert_eq!(
986 config.public_packages,
987 vec!["@myorg/shared-lib", "@myorg/ui"]
988 );
989 }
990
991 #[test]
992 fn public_packages_serialize_roundtrip() {
993 let config = FallowConfig {
994 public_packages: vec!["@myorg/shared-lib".to_string()],
995 ..FallowConfig::default()
996 };
997 let json = serde_json::to_string(&config).unwrap();
998 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
999 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1000 }
1001}