mod boundaries;
mod duplicates_config;
mod flags;
mod format;
mod health;
mod parsing;
mod resolution;
mod rules;
pub use boundaries::{
BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
ResolvedBoundaryRule, ResolvedZone,
};
pub use duplicates_config::{
DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
};
pub use flags::{FlagsConfig, SdkPattern};
pub use format::OutputFormat;
pub use health::HealthConfig;
pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
pub use rules::{PartialRulesConfig, RulesConfig, Severity};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::external_plugin::ExternalPluginDef;
use crate::workspace::WorkspaceConfig;
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct FallowConfig {
#[serde(rename = "$schema", default, skip_serializing)]
pub schema: Option<String>,
#[serde(default, skip_serializing)]
pub extends: Vec<String>,
#[serde(default)]
pub entry: Vec<String>,
#[serde(default)]
pub ignore_patterns: Vec<String>,
#[serde(default)]
pub framework: Vec<ExternalPluginDef>,
#[serde(default)]
pub workspaces: Option<WorkspaceConfig>,
#[serde(default)]
pub ignore_dependencies: Vec<String>,
#[serde(default)]
pub ignore_exports: Vec<IgnoreExportRule>,
#[serde(default)]
pub used_class_members: Vec<String>,
#[serde(default)]
pub duplicates: DuplicatesConfig,
#[serde(default)]
pub health: HealthConfig,
#[serde(default)]
pub rules: RulesConfig,
#[serde(default)]
pub boundaries: BoundaryConfig,
#[serde(default)]
pub flags: FlagsConfig,
#[serde(default)]
pub production: bool,
#[serde(default)]
pub plugins: Vec<String>,
#[serde(default)]
pub dynamically_loaded: Vec<String>,
#[serde(default)]
pub overrides: Vec<ConfigOverride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codeowners: Option<String>,
#[serde(default)]
pub public_packages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regression: Option<RegressionConfig>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegressionConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<RegressionBaseline>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegressionBaseline {
#[serde(default)]
pub total_issues: usize,
#[serde(default)]
pub unused_files: usize,
#[serde(default)]
pub unused_exports: usize,
#[serde(default)]
pub unused_types: usize,
#[serde(default)]
pub unused_dependencies: usize,
#[serde(default)]
pub unused_dev_dependencies: usize,
#[serde(default)]
pub unused_optional_dependencies: usize,
#[serde(default)]
pub unused_enum_members: usize,
#[serde(default)]
pub unused_class_members: usize,
#[serde(default)]
pub unresolved_imports: usize,
#[serde(default)]
pub unlisted_dependencies: usize,
#[serde(default)]
pub duplicate_exports: usize,
#[serde(default)]
pub circular_dependencies: usize,
#[serde(default)]
pub type_only_dependencies: usize,
#[serde(default)]
pub test_only_dependencies: usize,
#[serde(default)]
pub boundary_violations: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_empty_collections() {
let config = FallowConfig::default();
assert!(config.schema.is_none());
assert!(config.extends.is_empty());
assert!(config.entry.is_empty());
assert!(config.ignore_patterns.is_empty());
assert!(config.framework.is_empty());
assert!(config.workspaces.is_none());
assert!(config.ignore_dependencies.is_empty());
assert!(config.ignore_exports.is_empty());
assert!(config.used_class_members.is_empty());
assert!(config.plugins.is_empty());
assert!(config.dynamically_loaded.is_empty());
assert!(config.overrides.is_empty());
assert!(config.public_packages.is_empty());
assert!(!config.production);
}
#[test]
fn default_config_rules_are_error() {
let config = FallowConfig::default();
assert_eq!(config.rules.unused_files, Severity::Error);
assert_eq!(config.rules.unused_exports, Severity::Error);
assert_eq!(config.rules.unused_dependencies, Severity::Error);
}
#[test]
fn default_config_duplicates_enabled() {
let config = FallowConfig::default();
assert!(config.duplicates.enabled);
assert_eq!(config.duplicates.min_tokens, 50);
assert_eq!(config.duplicates.min_lines, 5);
}
#[test]
fn default_config_health_thresholds() {
let config = FallowConfig::default();
assert_eq!(config.health.max_cyclomatic, 20);
assert_eq!(config.health.max_cognitive, 15);
}
#[test]
fn deserialize_empty_json_object() {
let config: FallowConfig = serde_json::from_str("{}").unwrap();
assert!(config.entry.is_empty());
assert!(!config.production);
}
#[test]
fn deserialize_json_with_all_top_level_fields() {
let json = r#"{
"$schema": "https://fallow.dev/schema.json",
"entry": ["src/main.ts"],
"ignorePatterns": ["generated/**"],
"ignoreDependencies": ["postcss"],
"production": true,
"plugins": ["custom-plugin.toml"],
"rules": {"unused-files": "warn"},
"duplicates": {"enabled": false},
"health": {"maxCyclomatic": 30}
}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.schema.as_deref(),
Some("https://fallow.dev/schema.json")
);
assert_eq!(config.entry, vec!["src/main.ts"]);
assert_eq!(config.ignore_patterns, vec!["generated/**"]);
assert_eq!(config.ignore_dependencies, vec!["postcss"]);
assert!(config.production);
assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
assert_eq!(config.rules.unused_files, Severity::Warn);
assert!(!config.duplicates.enabled);
assert_eq!(config.health.max_cyclomatic, 30);
}
#[test]
fn deserialize_json_deny_unknown_fields() {
let json = r#"{"unknownField": true}"#;
let result: Result<FallowConfig, _> = serde_json::from_str(json);
assert!(result.is_err(), "unknown fields should be rejected");
}
#[test]
fn deserialize_json_production_mode_default_false() {
let config: FallowConfig = serde_json::from_str("{}").unwrap();
assert!(!config.production);
}
#[test]
fn deserialize_json_production_mode_true() {
let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
assert!(config.production);
}
#[test]
fn deserialize_json_dynamically_loaded() {
let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.dynamically_loaded,
vec!["plugins/**/*.ts", "locales/**/*.json"]
);
}
#[test]
fn deserialize_json_dynamically_loaded_defaults_empty() {
let config: FallowConfig = serde_json::from_str("{}").unwrap();
assert!(config.dynamically_loaded.is_empty());
}
#[test]
fn deserialize_toml_minimal() {
let toml_str = r#"
entry = ["src/index.ts"]
production = true
"#;
let config: FallowConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.entry, vec!["src/index.ts"]);
assert!(config.production);
}
#[test]
fn deserialize_toml_with_inline_framework() {
let toml_str = r#"
[[framework]]
name = "my-framework"
enablers = ["my-framework-pkg"]
entryPoints = ["src/routes/**/*.tsx"]
"#;
let config: FallowConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.framework.len(), 1);
assert_eq!(config.framework[0].name, "my-framework");
assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
assert_eq!(
config.framework[0].entry_points,
vec!["src/routes/**/*.tsx"]
);
}
#[test]
fn deserialize_toml_with_workspace_config() {
let toml_str = r#"
[workspaces]
patterns = ["packages/*", "apps/*"]
"#;
let config: FallowConfig = toml::from_str(toml_str).unwrap();
assert!(config.workspaces.is_some());
let ws = config.workspaces.unwrap();
assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
}
#[test]
fn deserialize_toml_with_ignore_exports() {
let toml_str = r#"
[[ignoreExports]]
file = "src/types/**/*.ts"
exports = ["*"]
"#;
let config: FallowConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.ignore_exports.len(), 1);
assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
}
#[test]
fn deserialize_toml_deny_unknown_fields() {
let toml_str = r"bogus_field = true";
let result: Result<FallowConfig, _> = toml::from_str(toml_str);
assert!(result.is_err(), "unknown fields should be rejected");
}
#[test]
fn json_serialize_roundtrip() {
let config = FallowConfig {
entry: vec!["src/main.ts".to_string()],
production: true,
..FallowConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let restored: FallowConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.entry, vec!["src/main.ts"]);
assert!(restored.production);
}
#[test]
fn schema_field_not_serialized() {
let config = FallowConfig {
schema: Some("https://example.com/schema.json".to_string()),
..FallowConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
!json.contains("$schema"),
"schema field should be skipped in serialization"
);
}
#[test]
fn extends_field_not_serialized() {
let config = FallowConfig {
extends: vec!["base.json".to_string()],
..FallowConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
!json.contains("extends"),
"extends field should be skipped in serialization"
);
}
#[test]
fn regression_config_deserialize_json() {
let json = r#"{
"regression": {
"baseline": {
"totalIssues": 42,
"unusedFiles": 10,
"unusedExports": 5,
"circularDependencies": 2
}
}
}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
let regression = config.regression.unwrap();
let baseline = regression.baseline.unwrap();
assert_eq!(baseline.total_issues, 42);
assert_eq!(baseline.unused_files, 10);
assert_eq!(baseline.unused_exports, 5);
assert_eq!(baseline.circular_dependencies, 2);
assert_eq!(baseline.unused_types, 0);
assert_eq!(baseline.boundary_violations, 0);
}
#[test]
fn regression_config_defaults_to_none() {
let config: FallowConfig = serde_json::from_str("{}").unwrap();
assert!(config.regression.is_none());
}
#[test]
fn regression_baseline_all_zeros_by_default() {
let baseline = RegressionBaseline::default();
assert_eq!(baseline.total_issues, 0);
assert_eq!(baseline.unused_files, 0);
assert_eq!(baseline.unused_exports, 0);
assert_eq!(baseline.unused_types, 0);
assert_eq!(baseline.unused_dependencies, 0);
assert_eq!(baseline.unused_dev_dependencies, 0);
assert_eq!(baseline.unused_optional_dependencies, 0);
assert_eq!(baseline.unused_enum_members, 0);
assert_eq!(baseline.unused_class_members, 0);
assert_eq!(baseline.unresolved_imports, 0);
assert_eq!(baseline.unlisted_dependencies, 0);
assert_eq!(baseline.duplicate_exports, 0);
assert_eq!(baseline.circular_dependencies, 0);
assert_eq!(baseline.type_only_dependencies, 0);
assert_eq!(baseline.test_only_dependencies, 0);
assert_eq!(baseline.boundary_violations, 0);
}
#[test]
fn regression_config_serialize_roundtrip() {
let baseline = RegressionBaseline {
total_issues: 100,
unused_files: 20,
unused_exports: 30,
..RegressionBaseline::default()
};
let regression = RegressionConfig {
baseline: Some(baseline),
};
let config = FallowConfig {
regression: Some(regression),
..FallowConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let restored: FallowConfig = serde_json::from_str(&json).unwrap();
let restored_baseline = restored.regression.unwrap().baseline.unwrap();
assert_eq!(restored_baseline.total_issues, 100);
assert_eq!(restored_baseline.unused_files, 20);
assert_eq!(restored_baseline.unused_exports, 30);
assert_eq!(restored_baseline.unused_types, 0);
}
#[test]
fn regression_config_empty_baseline_deserialize() {
let json = r#"{"regression": {}}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
let regression = config.regression.unwrap();
assert!(regression.baseline.is_none());
}
#[test]
fn regression_baseline_not_serialized_when_none() {
let config = FallowConfig {
regression: None,
..FallowConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
!json.contains("regression"),
"regression should be skipped when None"
);
}
#[test]
fn deserialize_json_with_overrides() {
let json = r#"{
"overrides": [
{
"files": ["*.test.ts", "*.spec.ts"],
"rules": {
"unused-exports": "off",
"unused-files": "warn"
}
}
]
}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.overrides.len(), 1);
assert_eq!(config.overrides[0].files.len(), 2);
assert_eq!(
config.overrides[0].rules.unused_exports,
Some(Severity::Off)
);
assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
}
#[test]
fn deserialize_json_with_boundaries() {
let json = r#"{
"boundaries": {
"preset": "layered"
}
}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
}
#[test]
fn deserialize_toml_with_regression_baseline() {
let toml_str = r"
[regression.baseline]
totalIssues = 50
unusedFiles = 10
unusedExports = 15
";
let config: FallowConfig = toml::from_str(toml_str).unwrap();
let baseline = config.regression.unwrap().baseline.unwrap();
assert_eq!(baseline.total_issues, 50);
assert_eq!(baseline.unused_files, 10);
assert_eq!(baseline.unused_exports, 15);
}
#[test]
fn deserialize_toml_with_overrides() {
let toml_str = r#"
[[overrides]]
files = ["*.test.ts"]
[overrides.rules]
unused-exports = "off"
[[overrides]]
files = ["*.stories.tsx"]
[overrides.rules]
unused-files = "off"
"#;
let config: FallowConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.overrides.len(), 2);
assert_eq!(
config.overrides[0].rules.unused_exports,
Some(Severity::Off)
);
assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
}
#[test]
fn regression_config_default_is_none_baseline() {
let config = RegressionConfig::default();
assert!(config.baseline.is_none());
}
#[test]
fn deserialize_json_multiple_ignore_export_rules() {
let json = r#"{
"ignoreExports": [
{"file": "src/types/**/*.ts", "exports": ["*"]},
{"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
{"file": "src/index.ts", "exports": ["default"]}
]
}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ignore_exports.len(), 3);
assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
}
#[test]
fn deserialize_json_public_packages_camel_case() {
let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
let config: FallowConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.public_packages,
vec!["@myorg/shared-lib", "@myorg/utils"]
);
}
#[test]
fn deserialize_json_public_packages_rejects_snake_case() {
let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
let result: Result<FallowConfig, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
);
}
#[test]
fn deserialize_json_public_packages_empty() {
let config: FallowConfig = serde_json::from_str("{}").unwrap();
assert!(config.public_packages.is_empty());
}
#[test]
fn deserialize_toml_public_packages() {
let toml_str = r#"
publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
"#;
let config: FallowConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.public_packages,
vec!["@myorg/shared-lib", "@myorg/ui"]
);
}
#[test]
fn public_packages_serialize_roundtrip() {
let config = FallowConfig {
public_packages: vec!["@myorg/shared-lib".to_string()],
..FallowConfig::default()
};
let json = serde_json::to_string(&config).unwrap();
let restored: FallowConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
}
}