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