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