1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5mod health;
6mod parsing;
7mod resolution;
8mod rules;
9
10pub use boundaries::{
11 BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
12 ResolvedBoundaryRule, ResolvedZone,
13};
14pub use duplicates_config::{
15 DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
16};
17pub use flags::{FlagsConfig, SdkPattern};
18pub use format::OutputFormat;
19pub use health::HealthConfig;
20pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
21pub use rules::{PartialRulesConfig, RulesConfig, Severity};
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::external_plugin::ExternalPluginDef;
27use crate::workspace::WorkspaceConfig;
28
29#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
50#[serde(deny_unknown_fields, rename_all = "camelCase")]
51pub struct FallowConfig {
52 #[serde(rename = "$schema", default, skip_serializing)]
54 pub schema: Option<String>,
55
56 #[serde(default, skip_serializing)]
77 pub extends: Vec<String>,
78
79 #[serde(default)]
81 pub entry: Vec<String>,
82
83 #[serde(default)]
85 pub ignore_patterns: Vec<String>,
86
87 #[serde(default)]
89 pub framework: Vec<ExternalPluginDef>,
90
91 #[serde(default)]
93 pub workspaces: Option<WorkspaceConfig>,
94
95 #[serde(default)]
101 pub ignore_dependencies: Vec<String>,
102
103 #[serde(default)]
105 pub ignore_exports: Vec<IgnoreExportRule>,
106
107 #[serde(default)]
114 pub used_class_members: Vec<String>,
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]
366 fn deserialize_toml_minimal() {
367 let toml_str = r#"
368entry = ["src/index.ts"]
369production = true
370"#;
371 let config: FallowConfig = toml::from_str(toml_str).unwrap();
372 assert_eq!(config.entry, vec!["src/index.ts"]);
373 assert!(config.production);
374 }
375
376 #[test]
377 fn deserialize_toml_with_inline_framework() {
378 let toml_str = r#"
379[[framework]]
380name = "my-framework"
381enablers = ["my-framework-pkg"]
382entryPoints = ["src/routes/**/*.tsx"]
383"#;
384 let config: FallowConfig = toml::from_str(toml_str).unwrap();
385 assert_eq!(config.framework.len(), 1);
386 assert_eq!(config.framework[0].name, "my-framework");
387 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
388 assert_eq!(
389 config.framework[0].entry_points,
390 vec!["src/routes/**/*.tsx"]
391 );
392 }
393
394 #[test]
395 fn deserialize_toml_with_workspace_config() {
396 let toml_str = r#"
397[workspaces]
398patterns = ["packages/*", "apps/*"]
399"#;
400 let config: FallowConfig = toml::from_str(toml_str).unwrap();
401 assert!(config.workspaces.is_some());
402 let ws = config.workspaces.unwrap();
403 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
404 }
405
406 #[test]
407 fn deserialize_toml_with_ignore_exports() {
408 let toml_str = r#"
409[[ignoreExports]]
410file = "src/types/**/*.ts"
411exports = ["*"]
412"#;
413 let config: FallowConfig = toml::from_str(toml_str).unwrap();
414 assert_eq!(config.ignore_exports.len(), 1);
415 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
416 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
417 }
418
419 #[test]
420 fn deserialize_toml_deny_unknown_fields() {
421 let toml_str = r"bogus_field = true";
422 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
423 assert!(result.is_err(), "unknown fields should be rejected");
424 }
425
426 #[test]
429 fn json_serialize_roundtrip() {
430 let config = FallowConfig {
431 entry: vec!["src/main.ts".to_string()],
432 production: true,
433 ..FallowConfig::default()
434 };
435 let json = serde_json::to_string(&config).unwrap();
436 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
437 assert_eq!(restored.entry, vec!["src/main.ts"]);
438 assert!(restored.production);
439 }
440
441 #[test]
442 fn schema_field_not_serialized() {
443 let config = FallowConfig {
444 schema: Some("https://example.com/schema.json".to_string()),
445 ..FallowConfig::default()
446 };
447 let json = serde_json::to_string(&config).unwrap();
448 assert!(
450 !json.contains("$schema"),
451 "schema field should be skipped in serialization"
452 );
453 }
454
455 #[test]
456 fn extends_field_not_serialized() {
457 let config = FallowConfig {
458 extends: vec!["base.json".to_string()],
459 ..FallowConfig::default()
460 };
461 let json = serde_json::to_string(&config).unwrap();
462 assert!(
463 !json.contains("extends"),
464 "extends field should be skipped in serialization"
465 );
466 }
467
468 #[test]
471 fn regression_config_deserialize_json() {
472 let json = r#"{
473 "regression": {
474 "baseline": {
475 "totalIssues": 42,
476 "unusedFiles": 10,
477 "unusedExports": 5,
478 "circularDependencies": 2
479 }
480 }
481 }"#;
482 let config: FallowConfig = serde_json::from_str(json).unwrap();
483 let regression = config.regression.unwrap();
484 let baseline = regression.baseline.unwrap();
485 assert_eq!(baseline.total_issues, 42);
486 assert_eq!(baseline.unused_files, 10);
487 assert_eq!(baseline.unused_exports, 5);
488 assert_eq!(baseline.circular_dependencies, 2);
489 assert_eq!(baseline.unused_types, 0);
491 assert_eq!(baseline.boundary_violations, 0);
492 }
493
494 #[test]
495 fn regression_config_defaults_to_none() {
496 let config: FallowConfig = serde_json::from_str("{}").unwrap();
497 assert!(config.regression.is_none());
498 }
499
500 #[test]
501 fn regression_baseline_all_zeros_by_default() {
502 let baseline = RegressionBaseline::default();
503 assert_eq!(baseline.total_issues, 0);
504 assert_eq!(baseline.unused_files, 0);
505 assert_eq!(baseline.unused_exports, 0);
506 assert_eq!(baseline.unused_types, 0);
507 assert_eq!(baseline.unused_dependencies, 0);
508 assert_eq!(baseline.unused_dev_dependencies, 0);
509 assert_eq!(baseline.unused_optional_dependencies, 0);
510 assert_eq!(baseline.unused_enum_members, 0);
511 assert_eq!(baseline.unused_class_members, 0);
512 assert_eq!(baseline.unresolved_imports, 0);
513 assert_eq!(baseline.unlisted_dependencies, 0);
514 assert_eq!(baseline.duplicate_exports, 0);
515 assert_eq!(baseline.circular_dependencies, 0);
516 assert_eq!(baseline.type_only_dependencies, 0);
517 assert_eq!(baseline.test_only_dependencies, 0);
518 assert_eq!(baseline.boundary_violations, 0);
519 }
520
521 #[test]
522 fn regression_config_serialize_roundtrip() {
523 let baseline = RegressionBaseline {
524 total_issues: 100,
525 unused_files: 20,
526 unused_exports: 30,
527 ..RegressionBaseline::default()
528 };
529 let regression = RegressionConfig {
530 baseline: Some(baseline),
531 };
532 let config = FallowConfig {
533 regression: Some(regression),
534 ..FallowConfig::default()
535 };
536 let json = serde_json::to_string(&config).unwrap();
537 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
538 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
539 assert_eq!(restored_baseline.total_issues, 100);
540 assert_eq!(restored_baseline.unused_files, 20);
541 assert_eq!(restored_baseline.unused_exports, 30);
542 assert_eq!(restored_baseline.unused_types, 0);
543 }
544
545 #[test]
546 fn regression_config_empty_baseline_deserialize() {
547 let json = r#"{"regression": {}}"#;
548 let config: FallowConfig = serde_json::from_str(json).unwrap();
549 let regression = config.regression.unwrap();
550 assert!(regression.baseline.is_none());
551 }
552
553 #[test]
554 fn regression_baseline_not_serialized_when_none() {
555 let config = FallowConfig {
556 regression: None,
557 ..FallowConfig::default()
558 };
559 let json = serde_json::to_string(&config).unwrap();
560 assert!(
561 !json.contains("regression"),
562 "regression should be skipped when None"
563 );
564 }
565
566 #[test]
569 fn deserialize_json_with_overrides() {
570 let json = r#"{
571 "overrides": [
572 {
573 "files": ["*.test.ts", "*.spec.ts"],
574 "rules": {
575 "unused-exports": "off",
576 "unused-files": "warn"
577 }
578 }
579 ]
580 }"#;
581 let config: FallowConfig = serde_json::from_str(json).unwrap();
582 assert_eq!(config.overrides.len(), 1);
583 assert_eq!(config.overrides[0].files.len(), 2);
584 assert_eq!(
585 config.overrides[0].rules.unused_exports,
586 Some(Severity::Off)
587 );
588 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
589 }
590
591 #[test]
592 fn deserialize_json_with_boundaries() {
593 let json = r#"{
594 "boundaries": {
595 "preset": "layered"
596 }
597 }"#;
598 let config: FallowConfig = serde_json::from_str(json).unwrap();
599 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
600 }
601
602 #[test]
605 fn deserialize_toml_with_regression_baseline() {
606 let toml_str = r"
607[regression.baseline]
608totalIssues = 50
609unusedFiles = 10
610unusedExports = 15
611";
612 let config: FallowConfig = toml::from_str(toml_str).unwrap();
613 let baseline = config.regression.unwrap().baseline.unwrap();
614 assert_eq!(baseline.total_issues, 50);
615 assert_eq!(baseline.unused_files, 10);
616 assert_eq!(baseline.unused_exports, 15);
617 }
618
619 #[test]
622 fn deserialize_toml_with_overrides() {
623 let toml_str = r#"
624[[overrides]]
625files = ["*.test.ts"]
626
627[overrides.rules]
628unused-exports = "off"
629
630[[overrides]]
631files = ["*.stories.tsx"]
632
633[overrides.rules]
634unused-files = "off"
635"#;
636 let config: FallowConfig = toml::from_str(toml_str).unwrap();
637 assert_eq!(config.overrides.len(), 2);
638 assert_eq!(
639 config.overrides[0].rules.unused_exports,
640 Some(Severity::Off)
641 );
642 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
643 }
644
645 #[test]
648 fn regression_config_default_is_none_baseline() {
649 let config = RegressionConfig::default();
650 assert!(config.baseline.is_none());
651 }
652
653 #[test]
656 fn deserialize_json_multiple_ignore_export_rules() {
657 let json = r#"{
658 "ignoreExports": [
659 {"file": "src/types/**/*.ts", "exports": ["*"]},
660 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
661 {"file": "src/index.ts", "exports": ["default"]}
662 ]
663 }"#;
664 let config: FallowConfig = serde_json::from_str(json).unwrap();
665 assert_eq!(config.ignore_exports.len(), 3);
666 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
667 }
668
669 #[test]
672 fn deserialize_json_public_packages_camel_case() {
673 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
674 let config: FallowConfig = serde_json::from_str(json).unwrap();
675 assert_eq!(
676 config.public_packages,
677 vec!["@myorg/shared-lib", "@myorg/utils"]
678 );
679 }
680
681 #[test]
682 fn deserialize_json_public_packages_rejects_snake_case() {
683 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
684 let result: Result<FallowConfig, _> = serde_json::from_str(json);
685 assert!(
686 result.is_err(),
687 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
688 );
689 }
690
691 #[test]
692 fn deserialize_json_public_packages_empty() {
693 let config: FallowConfig = serde_json::from_str("{}").unwrap();
694 assert!(config.public_packages.is_empty());
695 }
696
697 #[test]
698 fn deserialize_toml_public_packages() {
699 let toml_str = r#"
700publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
701"#;
702 let config: FallowConfig = toml::from_str(toml_str).unwrap();
703 assert_eq!(
704 config.public_packages,
705 vec!["@myorg/shared-lib", "@myorg/ui"]
706 );
707 }
708
709 #[test]
710 fn public_packages_serialize_roundtrip() {
711 let config = FallowConfig {
712 public_packages: vec!["@myorg/shared-lib".to_string()],
713 ..FallowConfig::default()
714 };
715 let json = serde_json::to_string(&config).unwrap();
716 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
717 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
718 }
719}