1mod boundaries;
2mod duplicates_config;
3mod format;
4mod health;
5mod parsing;
6mod resolution;
7mod rules;
8
9pub use boundaries::{
10 BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
11 ResolvedBoundaryRule, ResolvedZone,
12};
13pub use duplicates_config::{
14 DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
15};
16pub use format::OutputFormat;
17pub use health::HealthConfig;
18pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
19pub use rules::{PartialRulesConfig, RulesConfig, Severity};
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::external_plugin::ExternalPluginDef;
25use crate::workspace::WorkspaceConfig;
26
27#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
48#[serde(deny_unknown_fields, rename_all = "camelCase")]
49pub struct FallowConfig {
50 #[serde(rename = "$schema", default, skip_serializing)]
52 #[schemars(skip)]
53 pub schema: Option<String>,
54
55 #[serde(default, skip_serializing)]
76 pub extends: Vec<String>,
77
78 #[serde(default)]
80 pub entry: Vec<String>,
81
82 #[serde(default)]
84 pub ignore_patterns: Vec<String>,
85
86 #[serde(default)]
88 pub framework: Vec<ExternalPluginDef>,
89
90 #[serde(default)]
92 pub workspaces: Option<WorkspaceConfig>,
93
94 #[serde(default)]
96 pub ignore_dependencies: Vec<String>,
97
98 #[serde(default)]
100 pub ignore_exports: Vec<IgnoreExportRule>,
101
102 #[serde(default)]
104 pub duplicates: DuplicatesConfig,
105
106 #[serde(default)]
108 pub health: HealthConfig,
109
110 #[serde(default)]
112 pub rules: RulesConfig,
113
114 #[serde(default)]
116 pub boundaries: BoundaryConfig,
117
118 #[serde(default)]
120 pub production: bool,
121
122 #[serde(default)]
130 pub plugins: Vec<String>,
131
132 #[serde(default)]
136 pub dynamically_loaded: Vec<String>,
137
138 #[serde(default)]
140 pub overrides: Vec<ConfigOverride>,
141
142 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub codeowners: Option<String>,
149
150 #[serde(default)]
153 pub public_packages: Vec<String>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub regression: Option<RegressionConfig>,
160}
161
162#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
169#[serde(rename_all = "camelCase")]
170pub struct RegressionConfig {
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub baseline: Option<RegressionBaseline>,
174}
175
176#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
178#[serde(rename_all = "camelCase")]
179pub struct RegressionBaseline {
180 #[serde(default)]
181 pub total_issues: usize,
182 #[serde(default)]
183 pub unused_files: usize,
184 #[serde(default)]
185 pub unused_exports: usize,
186 #[serde(default)]
187 pub unused_types: usize,
188 #[serde(default)]
189 pub unused_dependencies: usize,
190 #[serde(default)]
191 pub unused_dev_dependencies: usize,
192 #[serde(default)]
193 pub unused_optional_dependencies: usize,
194 #[serde(default)]
195 pub unused_enum_members: usize,
196 #[serde(default)]
197 pub unused_class_members: usize,
198 #[serde(default)]
199 pub unresolved_imports: usize,
200 #[serde(default)]
201 pub unlisted_dependencies: usize,
202 #[serde(default)]
203 pub duplicate_exports: usize,
204 #[serde(default)]
205 pub circular_dependencies: usize,
206 #[serde(default)]
207 pub type_only_dependencies: usize,
208 #[serde(default)]
209 pub test_only_dependencies: usize,
210 #[serde(default)]
211 pub boundary_violations: usize,
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
221 fn default_config_has_empty_collections() {
222 let config = FallowConfig::default();
223 assert!(config.schema.is_none());
224 assert!(config.extends.is_empty());
225 assert!(config.entry.is_empty());
226 assert!(config.ignore_patterns.is_empty());
227 assert!(config.framework.is_empty());
228 assert!(config.workspaces.is_none());
229 assert!(config.ignore_dependencies.is_empty());
230 assert!(config.ignore_exports.is_empty());
231 assert!(config.plugins.is_empty());
232 assert!(config.dynamically_loaded.is_empty());
233 assert!(config.overrides.is_empty());
234 assert!(config.public_packages.is_empty());
235 assert!(!config.production);
236 }
237
238 #[test]
239 fn default_config_rules_are_error() {
240 let config = FallowConfig::default();
241 assert_eq!(config.rules.unused_files, Severity::Error);
242 assert_eq!(config.rules.unused_exports, Severity::Error);
243 assert_eq!(config.rules.unused_dependencies, Severity::Error);
244 }
245
246 #[test]
247 fn default_config_duplicates_enabled() {
248 let config = FallowConfig::default();
249 assert!(config.duplicates.enabled);
250 assert_eq!(config.duplicates.min_tokens, 50);
251 assert_eq!(config.duplicates.min_lines, 5);
252 }
253
254 #[test]
255 fn default_config_health_thresholds() {
256 let config = FallowConfig::default();
257 assert_eq!(config.health.max_cyclomatic, 20);
258 assert_eq!(config.health.max_cognitive, 15);
259 }
260
261 #[test]
264 fn deserialize_empty_json_object() {
265 let config: FallowConfig = serde_json::from_str("{}").unwrap();
266 assert!(config.entry.is_empty());
267 assert!(!config.production);
268 }
269
270 #[test]
271 fn deserialize_json_with_all_top_level_fields() {
272 let json = r#"{
273 "$schema": "https://fallow.dev/schema.json",
274 "entry": ["src/main.ts"],
275 "ignorePatterns": ["generated/**"],
276 "ignoreDependencies": ["postcss"],
277 "production": true,
278 "plugins": ["custom-plugin.toml"],
279 "rules": {"unused-files": "warn"},
280 "duplicates": {"enabled": false},
281 "health": {"maxCyclomatic": 30}
282 }"#;
283 let config: FallowConfig = serde_json::from_str(json).unwrap();
284 assert_eq!(
285 config.schema.as_deref(),
286 Some("https://fallow.dev/schema.json")
287 );
288 assert_eq!(config.entry, vec!["src/main.ts"]);
289 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
290 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
291 assert!(config.production);
292 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
293 assert_eq!(config.rules.unused_files, Severity::Warn);
294 assert!(!config.duplicates.enabled);
295 assert_eq!(config.health.max_cyclomatic, 30);
296 }
297
298 #[test]
299 fn deserialize_json_deny_unknown_fields() {
300 let json = r#"{"unknownField": true}"#;
301 let result: Result<FallowConfig, _> = serde_json::from_str(json);
302 assert!(result.is_err(), "unknown fields should be rejected");
303 }
304
305 #[test]
306 fn deserialize_json_production_mode_default_false() {
307 let config: FallowConfig = serde_json::from_str("{}").unwrap();
308 assert!(!config.production);
309 }
310
311 #[test]
312 fn deserialize_json_production_mode_true() {
313 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
314 assert!(config.production);
315 }
316
317 #[test]
318 fn deserialize_json_dynamically_loaded() {
319 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
320 let config: FallowConfig = serde_json::from_str(json).unwrap();
321 assert_eq!(
322 config.dynamically_loaded,
323 vec!["plugins/**/*.ts", "locales/**/*.json"]
324 );
325 }
326
327 #[test]
328 fn deserialize_json_dynamically_loaded_defaults_empty() {
329 let config: FallowConfig = serde_json::from_str("{}").unwrap();
330 assert!(config.dynamically_loaded.is_empty());
331 }
332
333 #[test]
336 fn deserialize_toml_minimal() {
337 let toml_str = r#"
338entry = ["src/index.ts"]
339production = true
340"#;
341 let config: FallowConfig = toml::from_str(toml_str).unwrap();
342 assert_eq!(config.entry, vec!["src/index.ts"]);
343 assert!(config.production);
344 }
345
346 #[test]
347 fn deserialize_toml_with_inline_framework() {
348 let toml_str = r#"
349[[framework]]
350name = "my-framework"
351enablers = ["my-framework-pkg"]
352entryPoints = ["src/routes/**/*.tsx"]
353"#;
354 let config: FallowConfig = toml::from_str(toml_str).unwrap();
355 assert_eq!(config.framework.len(), 1);
356 assert_eq!(config.framework[0].name, "my-framework");
357 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
358 assert_eq!(
359 config.framework[0].entry_points,
360 vec!["src/routes/**/*.tsx"]
361 );
362 }
363
364 #[test]
365 fn deserialize_toml_with_workspace_config() {
366 let toml_str = r#"
367[workspaces]
368patterns = ["packages/*", "apps/*"]
369"#;
370 let config: FallowConfig = toml::from_str(toml_str).unwrap();
371 assert!(config.workspaces.is_some());
372 let ws = config.workspaces.unwrap();
373 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
374 }
375
376 #[test]
377 fn deserialize_toml_with_ignore_exports() {
378 let toml_str = r#"
379[[ignoreExports]]
380file = "src/types/**/*.ts"
381exports = ["*"]
382"#;
383 let config: FallowConfig = toml::from_str(toml_str).unwrap();
384 assert_eq!(config.ignore_exports.len(), 1);
385 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
386 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
387 }
388
389 #[test]
390 fn deserialize_toml_deny_unknown_fields() {
391 let toml_str = r"bogus_field = true";
392 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
393 assert!(result.is_err(), "unknown fields should be rejected");
394 }
395
396 #[test]
399 fn json_serialize_roundtrip() {
400 let config = FallowConfig {
401 entry: vec!["src/main.ts".to_string()],
402 production: true,
403 ..FallowConfig::default()
404 };
405 let json = serde_json::to_string(&config).unwrap();
406 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
407 assert_eq!(restored.entry, vec!["src/main.ts"]);
408 assert!(restored.production);
409 }
410
411 #[test]
412 fn schema_field_not_serialized() {
413 let config = FallowConfig {
414 schema: Some("https://example.com/schema.json".to_string()),
415 ..FallowConfig::default()
416 };
417 let json = serde_json::to_string(&config).unwrap();
418 assert!(
420 !json.contains("$schema"),
421 "schema field should be skipped in serialization"
422 );
423 }
424
425 #[test]
426 fn extends_field_not_serialized() {
427 let config = FallowConfig {
428 extends: vec!["base.json".to_string()],
429 ..FallowConfig::default()
430 };
431 let json = serde_json::to_string(&config).unwrap();
432 assert!(
433 !json.contains("extends"),
434 "extends field should be skipped in serialization"
435 );
436 }
437
438 #[test]
441 fn regression_config_deserialize_json() {
442 let json = r#"{
443 "regression": {
444 "baseline": {
445 "totalIssues": 42,
446 "unusedFiles": 10,
447 "unusedExports": 5,
448 "circularDependencies": 2
449 }
450 }
451 }"#;
452 let config: FallowConfig = serde_json::from_str(json).unwrap();
453 let regression = config.regression.unwrap();
454 let baseline = regression.baseline.unwrap();
455 assert_eq!(baseline.total_issues, 42);
456 assert_eq!(baseline.unused_files, 10);
457 assert_eq!(baseline.unused_exports, 5);
458 assert_eq!(baseline.circular_dependencies, 2);
459 assert_eq!(baseline.unused_types, 0);
461 assert_eq!(baseline.boundary_violations, 0);
462 }
463
464 #[test]
465 fn regression_config_defaults_to_none() {
466 let config: FallowConfig = serde_json::from_str("{}").unwrap();
467 assert!(config.regression.is_none());
468 }
469
470 #[test]
471 fn regression_baseline_all_zeros_by_default() {
472 let baseline = RegressionBaseline::default();
473 assert_eq!(baseline.total_issues, 0);
474 assert_eq!(baseline.unused_files, 0);
475 assert_eq!(baseline.unused_exports, 0);
476 assert_eq!(baseline.unused_types, 0);
477 assert_eq!(baseline.unused_dependencies, 0);
478 assert_eq!(baseline.unused_dev_dependencies, 0);
479 assert_eq!(baseline.unused_optional_dependencies, 0);
480 assert_eq!(baseline.unused_enum_members, 0);
481 assert_eq!(baseline.unused_class_members, 0);
482 assert_eq!(baseline.unresolved_imports, 0);
483 assert_eq!(baseline.unlisted_dependencies, 0);
484 assert_eq!(baseline.duplicate_exports, 0);
485 assert_eq!(baseline.circular_dependencies, 0);
486 assert_eq!(baseline.type_only_dependencies, 0);
487 assert_eq!(baseline.test_only_dependencies, 0);
488 assert_eq!(baseline.boundary_violations, 0);
489 }
490
491 #[test]
492 fn regression_config_serialize_roundtrip() {
493 let baseline = RegressionBaseline {
494 total_issues: 100,
495 unused_files: 20,
496 unused_exports: 30,
497 ..RegressionBaseline::default()
498 };
499 let regression = RegressionConfig {
500 baseline: Some(baseline),
501 };
502 let config = FallowConfig {
503 regression: Some(regression),
504 ..FallowConfig::default()
505 };
506 let json = serde_json::to_string(&config).unwrap();
507 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
508 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
509 assert_eq!(restored_baseline.total_issues, 100);
510 assert_eq!(restored_baseline.unused_files, 20);
511 assert_eq!(restored_baseline.unused_exports, 30);
512 assert_eq!(restored_baseline.unused_types, 0);
513 }
514
515 #[test]
516 fn regression_config_empty_baseline_deserialize() {
517 let json = r#"{"regression": {}}"#;
518 let config: FallowConfig = serde_json::from_str(json).unwrap();
519 let regression = config.regression.unwrap();
520 assert!(regression.baseline.is_none());
521 }
522
523 #[test]
524 fn regression_baseline_not_serialized_when_none() {
525 let config = FallowConfig {
526 regression: None,
527 ..FallowConfig::default()
528 };
529 let json = serde_json::to_string(&config).unwrap();
530 assert!(
531 !json.contains("regression"),
532 "regression should be skipped when None"
533 );
534 }
535
536 #[test]
539 fn deserialize_json_with_overrides() {
540 let json = r#"{
541 "overrides": [
542 {
543 "files": ["*.test.ts", "*.spec.ts"],
544 "rules": {
545 "unused-exports": "off",
546 "unused-files": "warn"
547 }
548 }
549 ]
550 }"#;
551 let config: FallowConfig = serde_json::from_str(json).unwrap();
552 assert_eq!(config.overrides.len(), 1);
553 assert_eq!(config.overrides[0].files.len(), 2);
554 assert_eq!(
555 config.overrides[0].rules.unused_exports,
556 Some(Severity::Off)
557 );
558 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
559 }
560
561 #[test]
562 fn deserialize_json_with_boundaries() {
563 let json = r#"{
564 "boundaries": {
565 "preset": "layered"
566 }
567 }"#;
568 let config: FallowConfig = serde_json::from_str(json).unwrap();
569 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
570 }
571
572 #[test]
575 fn deserialize_toml_with_regression_baseline() {
576 let toml_str = r"
577[regression.baseline]
578totalIssues = 50
579unusedFiles = 10
580unusedExports = 15
581";
582 let config: FallowConfig = toml::from_str(toml_str).unwrap();
583 let baseline = config.regression.unwrap().baseline.unwrap();
584 assert_eq!(baseline.total_issues, 50);
585 assert_eq!(baseline.unused_files, 10);
586 assert_eq!(baseline.unused_exports, 15);
587 }
588
589 #[test]
592 fn deserialize_toml_with_overrides() {
593 let toml_str = r#"
594[[overrides]]
595files = ["*.test.ts"]
596
597[overrides.rules]
598unused-exports = "off"
599
600[[overrides]]
601files = ["*.stories.tsx"]
602
603[overrides.rules]
604unused-files = "off"
605"#;
606 let config: FallowConfig = toml::from_str(toml_str).unwrap();
607 assert_eq!(config.overrides.len(), 2);
608 assert_eq!(
609 config.overrides[0].rules.unused_exports,
610 Some(Severity::Off)
611 );
612 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
613 }
614
615 #[test]
618 fn regression_config_default_is_none_baseline() {
619 let config = RegressionConfig::default();
620 assert!(config.baseline.is_none());
621 }
622
623 #[test]
626 fn deserialize_json_multiple_ignore_export_rules() {
627 let json = r#"{
628 "ignoreExports": [
629 {"file": "src/types/**/*.ts", "exports": ["*"]},
630 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
631 {"file": "src/index.ts", "exports": ["default"]}
632 ]
633 }"#;
634 let config: FallowConfig = serde_json::from_str(json).unwrap();
635 assert_eq!(config.ignore_exports.len(), 3);
636 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
637 }
638
639 #[test]
642 fn deserialize_json_public_packages_camel_case() {
643 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
644 let config: FallowConfig = serde_json::from_str(json).unwrap();
645 assert_eq!(
646 config.public_packages,
647 vec!["@myorg/shared-lib", "@myorg/utils"]
648 );
649 }
650
651 #[test]
652 fn deserialize_json_public_packages_rejects_snake_case() {
653 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
654 let result: Result<FallowConfig, _> = serde_json::from_str(json);
655 assert!(
656 result.is_err(),
657 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
658 );
659 }
660
661 #[test]
662 fn deserialize_json_public_packages_empty() {
663 let config: FallowConfig = serde_json::from_str("{}").unwrap();
664 assert!(config.public_packages.is_empty());
665 }
666
667 #[test]
668 fn deserialize_toml_public_packages() {
669 let toml_str = r#"
670publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
671"#;
672 let config: FallowConfig = toml::from_str(toml_str).unwrap();
673 assert_eq!(
674 config.public_packages,
675 vec!["@myorg/shared-lib", "@myorg/ui"]
676 );
677 }
678
679 #[test]
680 fn public_packages_serialize_roundtrip() {
681 let config = FallowConfig {
682 public_packages: vec!["@myorg/shared-lib".to_string()],
683 ..FallowConfig::default()
684 };
685 let json = serde_json::to_string(&config).unwrap();
686 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
687 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
688 }
689}