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