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)]
134 pub overrides: Vec<ConfigOverride>,
135
136 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub regression: Option<RegressionConfig>,
141}
142
143#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
150#[serde(rename_all = "camelCase")]
151pub struct RegressionConfig {
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub baseline: Option<RegressionBaseline>,
155}
156
157#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
159#[serde(rename_all = "camelCase")]
160pub struct RegressionBaseline {
161 #[serde(default)]
162 pub total_issues: usize,
163 #[serde(default)]
164 pub unused_files: usize,
165 #[serde(default)]
166 pub unused_exports: usize,
167 #[serde(default)]
168 pub unused_types: usize,
169 #[serde(default)]
170 pub unused_dependencies: usize,
171 #[serde(default)]
172 pub unused_dev_dependencies: usize,
173 #[serde(default)]
174 pub unused_optional_dependencies: usize,
175 #[serde(default)]
176 pub unused_enum_members: usize,
177 #[serde(default)]
178 pub unused_class_members: usize,
179 #[serde(default)]
180 pub unresolved_imports: usize,
181 #[serde(default)]
182 pub unlisted_dependencies: usize,
183 #[serde(default)]
184 pub duplicate_exports: usize,
185 #[serde(default)]
186 pub circular_dependencies: usize,
187 #[serde(default)]
188 pub type_only_dependencies: usize,
189 #[serde(default)]
190 pub test_only_dependencies: usize,
191 #[serde(default)]
192 pub boundary_violations: usize,
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
202 fn default_config_has_empty_collections() {
203 let config = FallowConfig::default();
204 assert!(config.schema.is_none());
205 assert!(config.extends.is_empty());
206 assert!(config.entry.is_empty());
207 assert!(config.ignore_patterns.is_empty());
208 assert!(config.framework.is_empty());
209 assert!(config.workspaces.is_none());
210 assert!(config.ignore_dependencies.is_empty());
211 assert!(config.ignore_exports.is_empty());
212 assert!(config.plugins.is_empty());
213 assert!(config.overrides.is_empty());
214 assert!(!config.production);
215 }
216
217 #[test]
218 fn default_config_rules_are_error() {
219 let config = FallowConfig::default();
220 assert_eq!(config.rules.unused_files, Severity::Error);
221 assert_eq!(config.rules.unused_exports, Severity::Error);
222 assert_eq!(config.rules.unused_dependencies, Severity::Error);
223 }
224
225 #[test]
226 fn default_config_duplicates_enabled() {
227 let config = FallowConfig::default();
228 assert!(config.duplicates.enabled);
229 assert_eq!(config.duplicates.min_tokens, 50);
230 assert_eq!(config.duplicates.min_lines, 5);
231 }
232
233 #[test]
234 fn default_config_health_thresholds() {
235 let config = FallowConfig::default();
236 assert_eq!(config.health.max_cyclomatic, 20);
237 assert_eq!(config.health.max_cognitive, 15);
238 }
239
240 #[test]
243 fn deserialize_empty_json_object() {
244 let config: FallowConfig = serde_json::from_str("{}").unwrap();
245 assert!(config.entry.is_empty());
246 assert!(!config.production);
247 }
248
249 #[test]
250 fn deserialize_json_with_all_top_level_fields() {
251 let json = r#"{
252 "$schema": "https://fallow.dev/schema.json",
253 "entry": ["src/main.ts"],
254 "ignorePatterns": ["generated/**"],
255 "ignoreDependencies": ["postcss"],
256 "production": true,
257 "plugins": ["custom-plugin.toml"],
258 "rules": {"unused-files": "warn"},
259 "duplicates": {"enabled": false},
260 "health": {"maxCyclomatic": 30}
261 }"#;
262 let config: FallowConfig = serde_json::from_str(json).unwrap();
263 assert_eq!(
264 config.schema.as_deref(),
265 Some("https://fallow.dev/schema.json")
266 );
267 assert_eq!(config.entry, vec!["src/main.ts"]);
268 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
269 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
270 assert!(config.production);
271 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
272 assert_eq!(config.rules.unused_files, Severity::Warn);
273 assert!(!config.duplicates.enabled);
274 assert_eq!(config.health.max_cyclomatic, 30);
275 }
276
277 #[test]
278 fn deserialize_json_deny_unknown_fields() {
279 let json = r#"{"unknownField": true}"#;
280 let result: Result<FallowConfig, _> = serde_json::from_str(json);
281 assert!(result.is_err(), "unknown fields should be rejected");
282 }
283
284 #[test]
285 fn deserialize_json_production_mode_default_false() {
286 let config: FallowConfig = serde_json::from_str("{}").unwrap();
287 assert!(!config.production);
288 }
289
290 #[test]
291 fn deserialize_json_production_mode_true() {
292 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
293 assert!(config.production);
294 }
295
296 #[test]
299 fn deserialize_toml_minimal() {
300 let toml_str = r#"
301entry = ["src/index.ts"]
302production = true
303"#;
304 let config: FallowConfig = toml::from_str(toml_str).unwrap();
305 assert_eq!(config.entry, vec!["src/index.ts"]);
306 assert!(config.production);
307 }
308
309 #[test]
310 fn deserialize_toml_with_inline_framework() {
311 let toml_str = r#"
312[[framework]]
313name = "my-framework"
314enablers = ["my-framework-pkg"]
315entryPoints = ["src/routes/**/*.tsx"]
316"#;
317 let config: FallowConfig = toml::from_str(toml_str).unwrap();
318 assert_eq!(config.framework.len(), 1);
319 assert_eq!(config.framework[0].name, "my-framework");
320 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
321 assert_eq!(
322 config.framework[0].entry_points,
323 vec!["src/routes/**/*.tsx"]
324 );
325 }
326
327 #[test]
328 fn deserialize_toml_with_workspace_config() {
329 let toml_str = r#"
330[workspaces]
331patterns = ["packages/*", "apps/*"]
332"#;
333 let config: FallowConfig = toml::from_str(toml_str).unwrap();
334 assert!(config.workspaces.is_some());
335 let ws = config.workspaces.unwrap();
336 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
337 }
338
339 #[test]
340 fn deserialize_toml_with_ignore_exports() {
341 let toml_str = r#"
342[[ignoreExports]]
343file = "src/types/**/*.ts"
344exports = ["*"]
345"#;
346 let config: FallowConfig = toml::from_str(toml_str).unwrap();
347 assert_eq!(config.ignore_exports.len(), 1);
348 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
349 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
350 }
351
352 #[test]
353 fn deserialize_toml_deny_unknown_fields() {
354 let toml_str = r"bogus_field = true";
355 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
356 assert!(result.is_err(), "unknown fields should be rejected");
357 }
358
359 #[test]
362 fn json_serialize_roundtrip() {
363 let config = FallowConfig {
364 entry: vec!["src/main.ts".to_string()],
365 production: true,
366 ..FallowConfig::default()
367 };
368 let json = serde_json::to_string(&config).unwrap();
369 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
370 assert_eq!(restored.entry, vec!["src/main.ts"]);
371 assert!(restored.production);
372 }
373
374 #[test]
375 fn schema_field_not_serialized() {
376 let config = FallowConfig {
377 schema: Some("https://example.com/schema.json".to_string()),
378 ..FallowConfig::default()
379 };
380 let json = serde_json::to_string(&config).unwrap();
381 assert!(
383 !json.contains("$schema"),
384 "schema field should be skipped in serialization"
385 );
386 }
387
388 #[test]
389 fn extends_field_not_serialized() {
390 let config = FallowConfig {
391 extends: vec!["base.json".to_string()],
392 ..FallowConfig::default()
393 };
394 let json = serde_json::to_string(&config).unwrap();
395 assert!(
396 !json.contains("extends"),
397 "extends field should be skipped in serialization"
398 );
399 }
400
401 #[test]
404 fn regression_config_deserialize_json() {
405 let json = r#"{
406 "regression": {
407 "baseline": {
408 "totalIssues": 42,
409 "unusedFiles": 10,
410 "unusedExports": 5,
411 "circularDependencies": 2
412 }
413 }
414 }"#;
415 let config: FallowConfig = serde_json::from_str(json).unwrap();
416 let regression = config.regression.unwrap();
417 let baseline = regression.baseline.unwrap();
418 assert_eq!(baseline.total_issues, 42);
419 assert_eq!(baseline.unused_files, 10);
420 assert_eq!(baseline.unused_exports, 5);
421 assert_eq!(baseline.circular_dependencies, 2);
422 assert_eq!(baseline.unused_types, 0);
424 assert_eq!(baseline.boundary_violations, 0);
425 }
426
427 #[test]
428 fn regression_config_defaults_to_none() {
429 let config: FallowConfig = serde_json::from_str("{}").unwrap();
430 assert!(config.regression.is_none());
431 }
432
433 #[test]
434 fn regression_baseline_all_zeros_by_default() {
435 let baseline = RegressionBaseline::default();
436 assert_eq!(baseline.total_issues, 0);
437 assert_eq!(baseline.unused_files, 0);
438 assert_eq!(baseline.unused_exports, 0);
439 assert_eq!(baseline.unused_types, 0);
440 assert_eq!(baseline.unused_dependencies, 0);
441 assert_eq!(baseline.unused_dev_dependencies, 0);
442 assert_eq!(baseline.unused_optional_dependencies, 0);
443 assert_eq!(baseline.unused_enum_members, 0);
444 assert_eq!(baseline.unused_class_members, 0);
445 assert_eq!(baseline.unresolved_imports, 0);
446 assert_eq!(baseline.unlisted_dependencies, 0);
447 assert_eq!(baseline.duplicate_exports, 0);
448 assert_eq!(baseline.circular_dependencies, 0);
449 assert_eq!(baseline.type_only_dependencies, 0);
450 assert_eq!(baseline.test_only_dependencies, 0);
451 assert_eq!(baseline.boundary_violations, 0);
452 }
453
454 #[test]
455 fn regression_config_serialize_roundtrip() {
456 let baseline = RegressionBaseline {
457 total_issues: 100,
458 unused_files: 20,
459 unused_exports: 30,
460 ..RegressionBaseline::default()
461 };
462 let regression = RegressionConfig {
463 baseline: Some(baseline),
464 };
465 let config = FallowConfig {
466 regression: Some(regression),
467 ..FallowConfig::default()
468 };
469 let json = serde_json::to_string(&config).unwrap();
470 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
471 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
472 assert_eq!(restored_baseline.total_issues, 100);
473 assert_eq!(restored_baseline.unused_files, 20);
474 assert_eq!(restored_baseline.unused_exports, 30);
475 assert_eq!(restored_baseline.unused_types, 0);
476 }
477
478 #[test]
479 fn regression_config_empty_baseline_deserialize() {
480 let json = r#"{"regression": {}}"#;
481 let config: FallowConfig = serde_json::from_str(json).unwrap();
482 let regression = config.regression.unwrap();
483 assert!(regression.baseline.is_none());
484 }
485
486 #[test]
487 fn regression_baseline_not_serialized_when_none() {
488 let config = FallowConfig {
489 regression: None,
490 ..FallowConfig::default()
491 };
492 let json = serde_json::to_string(&config).unwrap();
493 assert!(
494 !json.contains("regression"),
495 "regression should be skipped when None"
496 );
497 }
498
499 #[test]
502 fn deserialize_json_with_overrides() {
503 let json = r#"{
504 "overrides": [
505 {
506 "files": ["*.test.ts", "*.spec.ts"],
507 "rules": {
508 "unused-exports": "off",
509 "unused-files": "warn"
510 }
511 }
512 ]
513 }"#;
514 let config: FallowConfig = serde_json::from_str(json).unwrap();
515 assert_eq!(config.overrides.len(), 1);
516 assert_eq!(config.overrides[0].files.len(), 2);
517 assert_eq!(
518 config.overrides[0].rules.unused_exports,
519 Some(Severity::Off)
520 );
521 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
522 }
523
524 #[test]
525 fn deserialize_json_with_boundaries() {
526 let json = r#"{
527 "boundaries": {
528 "preset": "layered"
529 }
530 }"#;
531 let config: FallowConfig = serde_json::from_str(json).unwrap();
532 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
533 }
534
535 #[test]
538 fn deserialize_toml_with_regression_baseline() {
539 let toml_str = r"
540[regression.baseline]
541totalIssues = 50
542unusedFiles = 10
543unusedExports = 15
544";
545 let config: FallowConfig = toml::from_str(toml_str).unwrap();
546 let baseline = config.regression.unwrap().baseline.unwrap();
547 assert_eq!(baseline.total_issues, 50);
548 assert_eq!(baseline.unused_files, 10);
549 assert_eq!(baseline.unused_exports, 15);
550 }
551
552 #[test]
555 fn deserialize_toml_with_overrides() {
556 let toml_str = r#"
557[[overrides]]
558files = ["*.test.ts"]
559
560[overrides.rules]
561unused-exports = "off"
562
563[[overrides]]
564files = ["*.stories.tsx"]
565
566[overrides.rules]
567unused-files = "off"
568"#;
569 let config: FallowConfig = toml::from_str(toml_str).unwrap();
570 assert_eq!(config.overrides.len(), 2);
571 assert_eq!(
572 config.overrides[0].rules.unused_exports,
573 Some(Severity::Off)
574 );
575 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
576 }
577
578 #[test]
581 fn regression_config_default_is_none_baseline() {
582 let config = RegressionConfig::default();
583 assert!(config.baseline.is_none());
584 }
585
586 #[test]
589 fn deserialize_json_multiple_ignore_export_rules() {
590 let json = r#"{
591 "ignoreExports": [
592 {"file": "src/types/**/*.ts", "exports": ["*"]},
593 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
594 {"file": "src/index.ts", "exports": ["default"]}
595 ]
596 }"#;
597 let config: FallowConfig = serde_json::from_str(json).unwrap();
598 assert_eq!(config.ignore_exports.len(), 3);
599 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
600 }
601}