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