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