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