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)]
188 pub sealed: bool,
189}
190
191#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
198#[serde(rename_all = "camelCase")]
199pub struct RegressionConfig {
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub baseline: Option<RegressionBaseline>,
203}
204
205#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct RegressionBaseline {
209 #[serde(default)]
210 pub total_issues: usize,
211 #[serde(default)]
212 pub unused_files: usize,
213 #[serde(default)]
214 pub unused_exports: usize,
215 #[serde(default)]
216 pub unused_types: usize,
217 #[serde(default)]
218 pub unused_dependencies: usize,
219 #[serde(default)]
220 pub unused_dev_dependencies: usize,
221 #[serde(default)]
222 pub unused_optional_dependencies: usize,
223 #[serde(default)]
224 pub unused_enum_members: usize,
225 #[serde(default)]
226 pub unused_class_members: usize,
227 #[serde(default)]
228 pub unresolved_imports: usize,
229 #[serde(default)]
230 pub unlisted_dependencies: usize,
231 #[serde(default)]
232 pub duplicate_exports: usize,
233 #[serde(default)]
234 pub circular_dependencies: usize,
235 #[serde(default)]
236 pub type_only_dependencies: usize,
237 #[serde(default)]
238 pub test_only_dependencies: usize,
239 #[serde(default)]
240 pub boundary_violations: usize,
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
250 fn default_config_has_empty_collections() {
251 let config = FallowConfig::default();
252 assert!(config.schema.is_none());
253 assert!(config.extends.is_empty());
254 assert!(config.entry.is_empty());
255 assert!(config.ignore_patterns.is_empty());
256 assert!(config.framework.is_empty());
257 assert!(config.workspaces.is_none());
258 assert!(config.ignore_dependencies.is_empty());
259 assert!(config.ignore_exports.is_empty());
260 assert!(config.used_class_members.is_empty());
261 assert!(config.plugins.is_empty());
262 assert!(config.dynamically_loaded.is_empty());
263 assert!(config.overrides.is_empty());
264 assert!(config.public_packages.is_empty());
265 assert!(!config.production);
266 }
267
268 #[test]
269 fn default_config_rules_are_error() {
270 let config = FallowConfig::default();
271 assert_eq!(config.rules.unused_files, Severity::Error);
272 assert_eq!(config.rules.unused_exports, Severity::Error);
273 assert_eq!(config.rules.unused_dependencies, Severity::Error);
274 }
275
276 #[test]
277 fn default_config_duplicates_enabled() {
278 let config = FallowConfig::default();
279 assert!(config.duplicates.enabled);
280 assert_eq!(config.duplicates.min_tokens, 50);
281 assert_eq!(config.duplicates.min_lines, 5);
282 }
283
284 #[test]
285 fn default_config_health_thresholds() {
286 let config = FallowConfig::default();
287 assert_eq!(config.health.max_cyclomatic, 20);
288 assert_eq!(config.health.max_cognitive, 15);
289 }
290
291 #[test]
294 fn deserialize_empty_json_object() {
295 let config: FallowConfig = serde_json::from_str("{}").unwrap();
296 assert!(config.entry.is_empty());
297 assert!(!config.production);
298 }
299
300 #[test]
301 fn deserialize_json_with_all_top_level_fields() {
302 let json = r#"{
303 "$schema": "https://fallow.dev/schema.json",
304 "entry": ["src/main.ts"],
305 "ignorePatterns": ["generated/**"],
306 "ignoreDependencies": ["postcss"],
307 "production": true,
308 "plugins": ["custom-plugin.toml"],
309 "rules": {"unused-files": "warn"},
310 "duplicates": {"enabled": false},
311 "health": {"maxCyclomatic": 30}
312 }"#;
313 let config: FallowConfig = serde_json::from_str(json).unwrap();
314 assert_eq!(
315 config.schema.as_deref(),
316 Some("https://fallow.dev/schema.json")
317 );
318 assert_eq!(config.entry, vec!["src/main.ts"]);
319 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
320 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
321 assert!(config.production);
322 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
323 assert_eq!(config.rules.unused_files, Severity::Warn);
324 assert!(!config.duplicates.enabled);
325 assert_eq!(config.health.max_cyclomatic, 30);
326 }
327
328 #[test]
329 fn deserialize_json_deny_unknown_fields() {
330 let json = r#"{"unknownField": true}"#;
331 let result: Result<FallowConfig, _> = serde_json::from_str(json);
332 assert!(result.is_err(), "unknown fields should be rejected");
333 }
334
335 #[test]
336 fn deserialize_json_production_mode_default_false() {
337 let config: FallowConfig = serde_json::from_str("{}").unwrap();
338 assert!(!config.production);
339 }
340
341 #[test]
342 fn deserialize_json_production_mode_true() {
343 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
344 assert!(config.production);
345 }
346
347 #[test]
348 fn deserialize_json_dynamically_loaded() {
349 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
350 let config: FallowConfig = serde_json::from_str(json).unwrap();
351 assert_eq!(
352 config.dynamically_loaded,
353 vec!["plugins/**/*.ts", "locales/**/*.json"]
354 );
355 }
356
357 #[test]
358 fn deserialize_json_dynamically_loaded_defaults_empty() {
359 let config: FallowConfig = serde_json::from_str("{}").unwrap();
360 assert!(config.dynamically_loaded.is_empty());
361 }
362
363 #[test]
364 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
365 let json = r#"{
366 "usedClassMembers": [
367 "agInit",
368 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
369 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
370 ]
371 }"#;
372 let config: FallowConfig = serde_json::from_str(json).unwrap();
373 assert_eq!(
374 config.used_class_members,
375 vec![
376 UsedClassMemberRule::from("agInit"),
377 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
378 extends: None,
379 implements: Some("ICellRendererAngularComp".to_string()),
380 members: vec!["refresh".to_string()],
381 }),
382 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
383 extends: Some("BaseCommand".to_string()),
384 implements: Some("CanActivate".to_string()),
385 members: vec!["execute".to_string()],
386 }),
387 ]
388 );
389 }
390
391 #[test]
394 fn deserialize_toml_minimal() {
395 let toml_str = r#"
396entry = ["src/index.ts"]
397production = true
398"#;
399 let config: FallowConfig = toml::from_str(toml_str).unwrap();
400 assert_eq!(config.entry, vec!["src/index.ts"]);
401 assert!(config.production);
402 }
403
404 #[test]
405 fn deserialize_toml_with_inline_framework() {
406 let toml_str = r#"
407[[framework]]
408name = "my-framework"
409enablers = ["my-framework-pkg"]
410entryPoints = ["src/routes/**/*.tsx"]
411"#;
412 let config: FallowConfig = toml::from_str(toml_str).unwrap();
413 assert_eq!(config.framework.len(), 1);
414 assert_eq!(config.framework[0].name, "my-framework");
415 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
416 assert_eq!(
417 config.framework[0].entry_points,
418 vec!["src/routes/**/*.tsx"]
419 );
420 }
421
422 #[test]
423 fn deserialize_toml_with_workspace_config() {
424 let toml_str = r#"
425[workspaces]
426patterns = ["packages/*", "apps/*"]
427"#;
428 let config: FallowConfig = toml::from_str(toml_str).unwrap();
429 assert!(config.workspaces.is_some());
430 let ws = config.workspaces.unwrap();
431 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
432 }
433
434 #[test]
435 fn deserialize_toml_with_ignore_exports() {
436 let toml_str = r#"
437[[ignoreExports]]
438file = "src/types/**/*.ts"
439exports = ["*"]
440"#;
441 let config: FallowConfig = toml::from_str(toml_str).unwrap();
442 assert_eq!(config.ignore_exports.len(), 1);
443 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
444 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
445 }
446
447 #[test]
448 fn deserialize_toml_used_class_members_supports_scoped_rules() {
449 let toml_str = r#"
450usedClassMembers = [
451 { implements = "ICellRendererAngularComp", members = ["refresh"] },
452 { extends = "BaseCommand", members = ["execute"] },
453]
454"#;
455 let config: FallowConfig = toml::from_str(toml_str).unwrap();
456 assert_eq!(
457 config.used_class_members,
458 vec![
459 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
460 extends: None,
461 implements: Some("ICellRendererAngularComp".to_string()),
462 members: vec!["refresh".to_string()],
463 }),
464 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
465 extends: Some("BaseCommand".to_string()),
466 implements: None,
467 members: vec!["execute".to_string()],
468 }),
469 ]
470 );
471 }
472
473 #[test]
474 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
475 let result = serde_json::from_str::<FallowConfig>(
476 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
477 );
478 assert!(
479 result.is_err(),
480 "unconstrained scoped rule should be rejected"
481 );
482 }
483
484 #[test]
485 fn deserialize_toml_deny_unknown_fields() {
486 let toml_str = r"bogus_field = true";
487 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
488 assert!(result.is_err(), "unknown fields should be rejected");
489 }
490
491 #[test]
494 fn json_serialize_roundtrip() {
495 let config = FallowConfig {
496 entry: vec!["src/main.ts".to_string()],
497 production: true,
498 ..FallowConfig::default()
499 };
500 let json = serde_json::to_string(&config).unwrap();
501 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
502 assert_eq!(restored.entry, vec!["src/main.ts"]);
503 assert!(restored.production);
504 }
505
506 #[test]
507 fn schema_field_not_serialized() {
508 let config = FallowConfig {
509 schema: Some("https://example.com/schema.json".to_string()),
510 ..FallowConfig::default()
511 };
512 let json = serde_json::to_string(&config).unwrap();
513 assert!(
515 !json.contains("$schema"),
516 "schema field should be skipped in serialization"
517 );
518 }
519
520 #[test]
521 fn extends_field_not_serialized() {
522 let config = FallowConfig {
523 extends: vec!["base.json".to_string()],
524 ..FallowConfig::default()
525 };
526 let json = serde_json::to_string(&config).unwrap();
527 assert!(
528 !json.contains("extends"),
529 "extends field should be skipped in serialization"
530 );
531 }
532
533 #[test]
536 fn regression_config_deserialize_json() {
537 let json = r#"{
538 "regression": {
539 "baseline": {
540 "totalIssues": 42,
541 "unusedFiles": 10,
542 "unusedExports": 5,
543 "circularDependencies": 2
544 }
545 }
546 }"#;
547 let config: FallowConfig = serde_json::from_str(json).unwrap();
548 let regression = config.regression.unwrap();
549 let baseline = regression.baseline.unwrap();
550 assert_eq!(baseline.total_issues, 42);
551 assert_eq!(baseline.unused_files, 10);
552 assert_eq!(baseline.unused_exports, 5);
553 assert_eq!(baseline.circular_dependencies, 2);
554 assert_eq!(baseline.unused_types, 0);
556 assert_eq!(baseline.boundary_violations, 0);
557 }
558
559 #[test]
560 fn regression_config_defaults_to_none() {
561 let config: FallowConfig = serde_json::from_str("{}").unwrap();
562 assert!(config.regression.is_none());
563 }
564
565 #[test]
566 fn regression_baseline_all_zeros_by_default() {
567 let baseline = RegressionBaseline::default();
568 assert_eq!(baseline.total_issues, 0);
569 assert_eq!(baseline.unused_files, 0);
570 assert_eq!(baseline.unused_exports, 0);
571 assert_eq!(baseline.unused_types, 0);
572 assert_eq!(baseline.unused_dependencies, 0);
573 assert_eq!(baseline.unused_dev_dependencies, 0);
574 assert_eq!(baseline.unused_optional_dependencies, 0);
575 assert_eq!(baseline.unused_enum_members, 0);
576 assert_eq!(baseline.unused_class_members, 0);
577 assert_eq!(baseline.unresolved_imports, 0);
578 assert_eq!(baseline.unlisted_dependencies, 0);
579 assert_eq!(baseline.duplicate_exports, 0);
580 assert_eq!(baseline.circular_dependencies, 0);
581 assert_eq!(baseline.type_only_dependencies, 0);
582 assert_eq!(baseline.test_only_dependencies, 0);
583 assert_eq!(baseline.boundary_violations, 0);
584 }
585
586 #[test]
587 fn regression_config_serialize_roundtrip() {
588 let baseline = RegressionBaseline {
589 total_issues: 100,
590 unused_files: 20,
591 unused_exports: 30,
592 ..RegressionBaseline::default()
593 };
594 let regression = RegressionConfig {
595 baseline: Some(baseline),
596 };
597 let config = FallowConfig {
598 regression: Some(regression),
599 ..FallowConfig::default()
600 };
601 let json = serde_json::to_string(&config).unwrap();
602 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
603 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
604 assert_eq!(restored_baseline.total_issues, 100);
605 assert_eq!(restored_baseline.unused_files, 20);
606 assert_eq!(restored_baseline.unused_exports, 30);
607 assert_eq!(restored_baseline.unused_types, 0);
608 }
609
610 #[test]
611 fn regression_config_empty_baseline_deserialize() {
612 let json = r#"{"regression": {}}"#;
613 let config: FallowConfig = serde_json::from_str(json).unwrap();
614 let regression = config.regression.unwrap();
615 assert!(regression.baseline.is_none());
616 }
617
618 #[test]
619 fn regression_baseline_not_serialized_when_none() {
620 let config = FallowConfig {
621 regression: None,
622 ..FallowConfig::default()
623 };
624 let json = serde_json::to_string(&config).unwrap();
625 assert!(
626 !json.contains("regression"),
627 "regression should be skipped when None"
628 );
629 }
630
631 #[test]
634 fn deserialize_json_with_overrides() {
635 let json = r#"{
636 "overrides": [
637 {
638 "files": ["*.test.ts", "*.spec.ts"],
639 "rules": {
640 "unused-exports": "off",
641 "unused-files": "warn"
642 }
643 }
644 ]
645 }"#;
646 let config: FallowConfig = serde_json::from_str(json).unwrap();
647 assert_eq!(config.overrides.len(), 1);
648 assert_eq!(config.overrides[0].files.len(), 2);
649 assert_eq!(
650 config.overrides[0].rules.unused_exports,
651 Some(Severity::Off)
652 );
653 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
654 }
655
656 #[test]
657 fn deserialize_json_with_boundaries() {
658 let json = r#"{
659 "boundaries": {
660 "preset": "layered"
661 }
662 }"#;
663 let config: FallowConfig = serde_json::from_str(json).unwrap();
664 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
665 }
666
667 #[test]
670 fn deserialize_toml_with_regression_baseline() {
671 let toml_str = r"
672[regression.baseline]
673totalIssues = 50
674unusedFiles = 10
675unusedExports = 15
676";
677 let config: FallowConfig = toml::from_str(toml_str).unwrap();
678 let baseline = config.regression.unwrap().baseline.unwrap();
679 assert_eq!(baseline.total_issues, 50);
680 assert_eq!(baseline.unused_files, 10);
681 assert_eq!(baseline.unused_exports, 15);
682 }
683
684 #[test]
687 fn deserialize_toml_with_overrides() {
688 let toml_str = r#"
689[[overrides]]
690files = ["*.test.ts"]
691
692[overrides.rules]
693unused-exports = "off"
694
695[[overrides]]
696files = ["*.stories.tsx"]
697
698[overrides.rules]
699unused-files = "off"
700"#;
701 let config: FallowConfig = toml::from_str(toml_str).unwrap();
702 assert_eq!(config.overrides.len(), 2);
703 assert_eq!(
704 config.overrides[0].rules.unused_exports,
705 Some(Severity::Off)
706 );
707 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
708 }
709
710 #[test]
713 fn regression_config_default_is_none_baseline() {
714 let config = RegressionConfig::default();
715 assert!(config.baseline.is_none());
716 }
717
718 #[test]
721 fn deserialize_json_multiple_ignore_export_rules() {
722 let json = r#"{
723 "ignoreExports": [
724 {"file": "src/types/**/*.ts", "exports": ["*"]},
725 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
726 {"file": "src/index.ts", "exports": ["default"]}
727 ]
728 }"#;
729 let config: FallowConfig = serde_json::from_str(json).unwrap();
730 assert_eq!(config.ignore_exports.len(), 3);
731 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
732 }
733
734 #[test]
737 fn deserialize_json_public_packages_camel_case() {
738 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
739 let config: FallowConfig = serde_json::from_str(json).unwrap();
740 assert_eq!(
741 config.public_packages,
742 vec!["@myorg/shared-lib", "@myorg/utils"]
743 );
744 }
745
746 #[test]
747 fn deserialize_json_public_packages_rejects_snake_case() {
748 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
749 let result: Result<FallowConfig, _> = serde_json::from_str(json);
750 assert!(
751 result.is_err(),
752 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
753 );
754 }
755
756 #[test]
757 fn deserialize_json_public_packages_empty() {
758 let config: FallowConfig = serde_json::from_str("{}").unwrap();
759 assert!(config.public_packages.is_empty());
760 }
761
762 #[test]
763 fn deserialize_toml_public_packages() {
764 let toml_str = r#"
765publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
766"#;
767 let config: FallowConfig = toml::from_str(toml_str).unwrap();
768 assert_eq!(
769 config.public_packages,
770 vec!["@myorg/shared-lib", "@myorg/ui"]
771 );
772 }
773
774 #[test]
775 fn public_packages_serialize_roundtrip() {
776 let config = FallowConfig {
777 public_packages: vec!["@myorg/shared-lib".to_string()],
778 ..FallowConfig::default()
779 };
780 let json = serde_json::to_string(&config).unwrap();
781 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
782 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
783 }
784}