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