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, Serialize};
29
30use crate::external_plugin::ExternalPluginDef;
31use crate::workspace::WorkspaceConfig;
32
33#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
54#[serde(deny_unknown_fields, rename_all = "camelCase")]
55pub struct FallowConfig {
56 #[serde(rename = "$schema", default, skip_serializing)]
58 pub schema: Option<String>,
59
60 #[serde(default, skip_serializing)]
81 pub extends: Vec<String>,
82
83 #[serde(default)]
85 pub entry: Vec<String>,
86
87 #[serde(default)]
89 pub ignore_patterns: Vec<String>,
90
91 #[serde(default)]
93 pub framework: Vec<ExternalPluginDef>,
94
95 #[serde(default)]
97 pub workspaces: Option<WorkspaceConfig>,
98
99 #[serde(default)]
105 pub ignore_dependencies: Vec<String>,
106
107 #[serde(default)]
109 pub ignore_exports: Vec<IgnoreExportRule>,
110
111 #[serde(default)]
116 pub used_class_members: Vec<UsedClassMemberRule>,
117
118 #[serde(default)]
120 pub duplicates: DuplicatesConfig,
121
122 #[serde(default)]
124 pub health: HealthConfig,
125
126 #[serde(default)]
128 pub rules: RulesConfig,
129
130 #[serde(default)]
132 pub boundaries: BoundaryConfig,
133
134 #[serde(default)]
136 pub flags: FlagsConfig,
137
138 #[serde(default)]
140 pub resolve: ResolveConfig,
141
142 #[serde(default)]
144 pub production: bool,
145
146 #[serde(default)]
154 pub plugins: Vec<String>,
155
156 #[serde(default)]
160 pub dynamically_loaded: Vec<String>,
161
162 #[serde(default)]
164 pub overrides: Vec<ConfigOverride>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub codeowners: Option<String>,
173
174 #[serde(default)]
177 pub public_packages: Vec<String>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub regression: Option<RegressionConfig>,
184
185 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
192 pub audit: AuditConfig,
193
194 #[serde(default)]
203 pub sealed: bool,
204}
205
206#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
213#[serde(rename_all = "camelCase")]
214pub struct AuditConfig {
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub dead_code_baseline: Option<String>,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub health_baseline: Option<String>,
222
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub dupes_baseline: Option<String>,
226}
227
228impl AuditConfig {
229 #[must_use]
231 pub fn is_empty(&self) -> bool {
232 self.dead_code_baseline.is_none()
233 && self.health_baseline.is_none()
234 && self.dupes_baseline.is_none()
235 }
236}
237
238#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct RegressionConfig {
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub baseline: Option<RegressionBaseline>,
250}
251
252#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
254#[serde(rename_all = "camelCase")]
255pub struct RegressionBaseline {
256 #[serde(default)]
257 pub total_issues: usize,
258 #[serde(default)]
259 pub unused_files: usize,
260 #[serde(default)]
261 pub unused_exports: usize,
262 #[serde(default)]
263 pub unused_types: usize,
264 #[serde(default)]
265 pub unused_dependencies: usize,
266 #[serde(default)]
267 pub unused_dev_dependencies: usize,
268 #[serde(default)]
269 pub unused_optional_dependencies: usize,
270 #[serde(default)]
271 pub unused_enum_members: usize,
272 #[serde(default)]
273 pub unused_class_members: usize,
274 #[serde(default)]
275 pub unresolved_imports: usize,
276 #[serde(default)]
277 pub unlisted_dependencies: usize,
278 #[serde(default)]
279 pub duplicate_exports: usize,
280 #[serde(default)]
281 pub circular_dependencies: usize,
282 #[serde(default)]
283 pub type_only_dependencies: usize,
284 #[serde(default)]
285 pub test_only_dependencies: usize,
286 #[serde(default)]
287 pub boundary_violations: usize,
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
297 fn default_config_has_empty_collections() {
298 let config = FallowConfig::default();
299 assert!(config.schema.is_none());
300 assert!(config.extends.is_empty());
301 assert!(config.entry.is_empty());
302 assert!(config.ignore_patterns.is_empty());
303 assert!(config.framework.is_empty());
304 assert!(config.workspaces.is_none());
305 assert!(config.ignore_dependencies.is_empty());
306 assert!(config.ignore_exports.is_empty());
307 assert!(config.used_class_members.is_empty());
308 assert!(config.plugins.is_empty());
309 assert!(config.dynamically_loaded.is_empty());
310 assert!(config.overrides.is_empty());
311 assert!(config.public_packages.is_empty());
312 assert!(!config.production);
313 }
314
315 #[test]
316 fn default_config_rules_are_error() {
317 let config = FallowConfig::default();
318 assert_eq!(config.rules.unused_files, Severity::Error);
319 assert_eq!(config.rules.unused_exports, Severity::Error);
320 assert_eq!(config.rules.unused_dependencies, Severity::Error);
321 }
322
323 #[test]
324 fn default_config_duplicates_enabled() {
325 let config = FallowConfig::default();
326 assert!(config.duplicates.enabled);
327 assert_eq!(config.duplicates.min_tokens, 50);
328 assert_eq!(config.duplicates.min_lines, 5);
329 }
330
331 #[test]
332 fn default_config_health_thresholds() {
333 let config = FallowConfig::default();
334 assert_eq!(config.health.max_cyclomatic, 20);
335 assert_eq!(config.health.max_cognitive, 15);
336 }
337
338 #[test]
341 fn deserialize_empty_json_object() {
342 let config: FallowConfig = serde_json::from_str("{}").unwrap();
343 assert!(config.entry.is_empty());
344 assert!(!config.production);
345 }
346
347 #[test]
348 fn deserialize_json_with_all_top_level_fields() {
349 let json = r#"{
350 "$schema": "https://fallow.dev/schema.json",
351 "entry": ["src/main.ts"],
352 "ignorePatterns": ["generated/**"],
353 "ignoreDependencies": ["postcss"],
354 "production": true,
355 "plugins": ["custom-plugin.toml"],
356 "rules": {"unused-files": "warn"},
357 "duplicates": {"enabled": false},
358 "health": {"maxCyclomatic": 30}
359 }"#;
360 let config: FallowConfig = serde_json::from_str(json).unwrap();
361 assert_eq!(
362 config.schema.as_deref(),
363 Some("https://fallow.dev/schema.json")
364 );
365 assert_eq!(config.entry, vec!["src/main.ts"]);
366 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
367 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
368 assert!(config.production);
369 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
370 assert_eq!(config.rules.unused_files, Severity::Warn);
371 assert!(!config.duplicates.enabled);
372 assert_eq!(config.health.max_cyclomatic, 30);
373 }
374
375 #[test]
376 fn deserialize_json_deny_unknown_fields() {
377 let json = r#"{"unknownField": true}"#;
378 let result: Result<FallowConfig, _> = serde_json::from_str(json);
379 assert!(result.is_err(), "unknown fields should be rejected");
380 }
381
382 #[test]
383 fn deserialize_json_production_mode_default_false() {
384 let config: FallowConfig = serde_json::from_str("{}").unwrap();
385 assert!(!config.production);
386 }
387
388 #[test]
389 fn deserialize_json_production_mode_true() {
390 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
391 assert!(config.production);
392 }
393
394 #[test]
395 fn deserialize_json_dynamically_loaded() {
396 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
397 let config: FallowConfig = serde_json::from_str(json).unwrap();
398 assert_eq!(
399 config.dynamically_loaded,
400 vec!["plugins/**/*.ts", "locales/**/*.json"]
401 );
402 }
403
404 #[test]
405 fn deserialize_json_dynamically_loaded_defaults_empty() {
406 let config: FallowConfig = serde_json::from_str("{}").unwrap();
407 assert!(config.dynamically_loaded.is_empty());
408 }
409
410 #[test]
411 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
412 let json = r#"{
413 "usedClassMembers": [
414 "agInit",
415 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
416 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
417 ]
418 }"#;
419 let config: FallowConfig = serde_json::from_str(json).unwrap();
420 assert_eq!(
421 config.used_class_members,
422 vec![
423 UsedClassMemberRule::from("agInit"),
424 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
425 extends: None,
426 implements: Some("ICellRendererAngularComp".to_string()),
427 members: vec!["refresh".to_string()],
428 }),
429 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
430 extends: Some("BaseCommand".to_string()),
431 implements: Some("CanActivate".to_string()),
432 members: vec!["execute".to_string()],
433 }),
434 ]
435 );
436 }
437
438 #[test]
441 fn deserialize_toml_minimal() {
442 let toml_str = r#"
443entry = ["src/index.ts"]
444production = true
445"#;
446 let config: FallowConfig = toml::from_str(toml_str).unwrap();
447 assert_eq!(config.entry, vec!["src/index.ts"]);
448 assert!(config.production);
449 }
450
451 #[test]
452 fn deserialize_toml_with_inline_framework() {
453 let toml_str = r#"
454[[framework]]
455name = "my-framework"
456enablers = ["my-framework-pkg"]
457entryPoints = ["src/routes/**/*.tsx"]
458"#;
459 let config: FallowConfig = toml::from_str(toml_str).unwrap();
460 assert_eq!(config.framework.len(), 1);
461 assert_eq!(config.framework[0].name, "my-framework");
462 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
463 assert_eq!(
464 config.framework[0].entry_points,
465 vec!["src/routes/**/*.tsx"]
466 );
467 }
468
469 #[test]
470 fn deserialize_toml_with_workspace_config() {
471 let toml_str = r#"
472[workspaces]
473patterns = ["packages/*", "apps/*"]
474"#;
475 let config: FallowConfig = toml::from_str(toml_str).unwrap();
476 assert!(config.workspaces.is_some());
477 let ws = config.workspaces.unwrap();
478 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
479 }
480
481 #[test]
482 fn deserialize_toml_with_ignore_exports() {
483 let toml_str = r#"
484[[ignoreExports]]
485file = "src/types/**/*.ts"
486exports = ["*"]
487"#;
488 let config: FallowConfig = toml::from_str(toml_str).unwrap();
489 assert_eq!(config.ignore_exports.len(), 1);
490 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
491 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
492 }
493
494 #[test]
495 fn deserialize_toml_used_class_members_supports_scoped_rules() {
496 let toml_str = r#"
497usedClassMembers = [
498 { implements = "ICellRendererAngularComp", members = ["refresh"] },
499 { extends = "BaseCommand", members = ["execute"] },
500]
501"#;
502 let config: FallowConfig = toml::from_str(toml_str).unwrap();
503 assert_eq!(
504 config.used_class_members,
505 vec![
506 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
507 extends: None,
508 implements: Some("ICellRendererAngularComp".to_string()),
509 members: vec!["refresh".to_string()],
510 }),
511 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
512 extends: Some("BaseCommand".to_string()),
513 implements: None,
514 members: vec!["execute".to_string()],
515 }),
516 ]
517 );
518 }
519
520 #[test]
521 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
522 let result = serde_json::from_str::<FallowConfig>(
523 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
524 );
525 assert!(
526 result.is_err(),
527 "unconstrained scoped rule should be rejected"
528 );
529 }
530
531 #[test]
532 fn deserialize_toml_deny_unknown_fields() {
533 let toml_str = r"bogus_field = true";
534 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
535 assert!(result.is_err(), "unknown fields should be rejected");
536 }
537
538 #[test]
541 fn json_serialize_roundtrip() {
542 let config = FallowConfig {
543 entry: vec!["src/main.ts".to_string()],
544 production: true,
545 ..FallowConfig::default()
546 };
547 let json = serde_json::to_string(&config).unwrap();
548 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
549 assert_eq!(restored.entry, vec!["src/main.ts"]);
550 assert!(restored.production);
551 }
552
553 #[test]
554 fn schema_field_not_serialized() {
555 let config = FallowConfig {
556 schema: Some("https://example.com/schema.json".to_string()),
557 ..FallowConfig::default()
558 };
559 let json = serde_json::to_string(&config).unwrap();
560 assert!(
562 !json.contains("$schema"),
563 "schema field should be skipped in serialization"
564 );
565 }
566
567 #[test]
568 fn extends_field_not_serialized() {
569 let config = FallowConfig {
570 extends: vec!["base.json".to_string()],
571 ..FallowConfig::default()
572 };
573 let json = serde_json::to_string(&config).unwrap();
574 assert!(
575 !json.contains("extends"),
576 "extends field should be skipped in serialization"
577 );
578 }
579
580 #[test]
583 fn regression_config_deserialize_json() {
584 let json = r#"{
585 "regression": {
586 "baseline": {
587 "totalIssues": 42,
588 "unusedFiles": 10,
589 "unusedExports": 5,
590 "circularDependencies": 2
591 }
592 }
593 }"#;
594 let config: FallowConfig = serde_json::from_str(json).unwrap();
595 let regression = config.regression.unwrap();
596 let baseline = regression.baseline.unwrap();
597 assert_eq!(baseline.total_issues, 42);
598 assert_eq!(baseline.unused_files, 10);
599 assert_eq!(baseline.unused_exports, 5);
600 assert_eq!(baseline.circular_dependencies, 2);
601 assert_eq!(baseline.unused_types, 0);
603 assert_eq!(baseline.boundary_violations, 0);
604 }
605
606 #[test]
607 fn regression_config_defaults_to_none() {
608 let config: FallowConfig = serde_json::from_str("{}").unwrap();
609 assert!(config.regression.is_none());
610 }
611
612 #[test]
613 fn regression_baseline_all_zeros_by_default() {
614 let baseline = RegressionBaseline::default();
615 assert_eq!(baseline.total_issues, 0);
616 assert_eq!(baseline.unused_files, 0);
617 assert_eq!(baseline.unused_exports, 0);
618 assert_eq!(baseline.unused_types, 0);
619 assert_eq!(baseline.unused_dependencies, 0);
620 assert_eq!(baseline.unused_dev_dependencies, 0);
621 assert_eq!(baseline.unused_optional_dependencies, 0);
622 assert_eq!(baseline.unused_enum_members, 0);
623 assert_eq!(baseline.unused_class_members, 0);
624 assert_eq!(baseline.unresolved_imports, 0);
625 assert_eq!(baseline.unlisted_dependencies, 0);
626 assert_eq!(baseline.duplicate_exports, 0);
627 assert_eq!(baseline.circular_dependencies, 0);
628 assert_eq!(baseline.type_only_dependencies, 0);
629 assert_eq!(baseline.test_only_dependencies, 0);
630 assert_eq!(baseline.boundary_violations, 0);
631 }
632
633 #[test]
634 fn regression_config_serialize_roundtrip() {
635 let baseline = RegressionBaseline {
636 total_issues: 100,
637 unused_files: 20,
638 unused_exports: 30,
639 ..RegressionBaseline::default()
640 };
641 let regression = RegressionConfig {
642 baseline: Some(baseline),
643 };
644 let config = FallowConfig {
645 regression: Some(regression),
646 ..FallowConfig::default()
647 };
648 let json = serde_json::to_string(&config).unwrap();
649 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
650 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
651 assert_eq!(restored_baseline.total_issues, 100);
652 assert_eq!(restored_baseline.unused_files, 20);
653 assert_eq!(restored_baseline.unused_exports, 30);
654 assert_eq!(restored_baseline.unused_types, 0);
655 }
656
657 #[test]
658 fn regression_config_empty_baseline_deserialize() {
659 let json = r#"{"regression": {}}"#;
660 let config: FallowConfig = serde_json::from_str(json).unwrap();
661 let regression = config.regression.unwrap();
662 assert!(regression.baseline.is_none());
663 }
664
665 #[test]
666 fn regression_baseline_not_serialized_when_none() {
667 let config = FallowConfig {
668 regression: None,
669 ..FallowConfig::default()
670 };
671 let json = serde_json::to_string(&config).unwrap();
672 assert!(
673 !json.contains("regression"),
674 "regression should be skipped when None"
675 );
676 }
677
678 #[test]
681 fn deserialize_json_with_overrides() {
682 let json = r#"{
683 "overrides": [
684 {
685 "files": ["*.test.ts", "*.spec.ts"],
686 "rules": {
687 "unused-exports": "off",
688 "unused-files": "warn"
689 }
690 }
691 ]
692 }"#;
693 let config: FallowConfig = serde_json::from_str(json).unwrap();
694 assert_eq!(config.overrides.len(), 1);
695 assert_eq!(config.overrides[0].files.len(), 2);
696 assert_eq!(
697 config.overrides[0].rules.unused_exports,
698 Some(Severity::Off)
699 );
700 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
701 }
702
703 #[test]
704 fn deserialize_json_with_boundaries() {
705 let json = r#"{
706 "boundaries": {
707 "preset": "layered"
708 }
709 }"#;
710 let config: FallowConfig = serde_json::from_str(json).unwrap();
711 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
712 }
713
714 #[test]
717 fn deserialize_toml_with_regression_baseline() {
718 let toml_str = r"
719[regression.baseline]
720totalIssues = 50
721unusedFiles = 10
722unusedExports = 15
723";
724 let config: FallowConfig = toml::from_str(toml_str).unwrap();
725 let baseline = config.regression.unwrap().baseline.unwrap();
726 assert_eq!(baseline.total_issues, 50);
727 assert_eq!(baseline.unused_files, 10);
728 assert_eq!(baseline.unused_exports, 15);
729 }
730
731 #[test]
734 fn deserialize_toml_with_overrides() {
735 let toml_str = r#"
736[[overrides]]
737files = ["*.test.ts"]
738
739[overrides.rules]
740unused-exports = "off"
741
742[[overrides]]
743files = ["*.stories.tsx"]
744
745[overrides.rules]
746unused-files = "off"
747"#;
748 let config: FallowConfig = toml::from_str(toml_str).unwrap();
749 assert_eq!(config.overrides.len(), 2);
750 assert_eq!(
751 config.overrides[0].rules.unused_exports,
752 Some(Severity::Off)
753 );
754 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
755 }
756
757 #[test]
760 fn regression_config_default_is_none_baseline() {
761 let config = RegressionConfig::default();
762 assert!(config.baseline.is_none());
763 }
764
765 #[test]
768 fn deserialize_json_multiple_ignore_export_rules() {
769 let json = r#"{
770 "ignoreExports": [
771 {"file": "src/types/**/*.ts", "exports": ["*"]},
772 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
773 {"file": "src/index.ts", "exports": ["default"]}
774 ]
775 }"#;
776 let config: FallowConfig = serde_json::from_str(json).unwrap();
777 assert_eq!(config.ignore_exports.len(), 3);
778 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
779 }
780
781 #[test]
784 fn deserialize_json_public_packages_camel_case() {
785 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
786 let config: FallowConfig = serde_json::from_str(json).unwrap();
787 assert_eq!(
788 config.public_packages,
789 vec!["@myorg/shared-lib", "@myorg/utils"]
790 );
791 }
792
793 #[test]
794 fn deserialize_json_public_packages_rejects_snake_case() {
795 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
796 let result: Result<FallowConfig, _> = serde_json::from_str(json);
797 assert!(
798 result.is_err(),
799 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
800 );
801 }
802
803 #[test]
804 fn deserialize_json_public_packages_empty() {
805 let config: FallowConfig = serde_json::from_str("{}").unwrap();
806 assert!(config.public_packages.is_empty());
807 }
808
809 #[test]
810 fn deserialize_toml_public_packages() {
811 let toml_str = r#"
812publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
813"#;
814 let config: FallowConfig = toml::from_str(toml_str).unwrap();
815 assert_eq!(
816 config.public_packages,
817 vec!["@myorg/shared-lib", "@myorg/ui"]
818 );
819 }
820
821 #[test]
822 fn public_packages_serialize_roundtrip() {
823 let config = FallowConfig {
824 public_packages: vec!["@myorg/shared-lib".to_string()],
825 ..FallowConfig::default()
826 };
827 let json = serde_json::to_string(&config).unwrap();
828 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
829 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
830 }
831}