1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5pub mod glob_validation;
6mod health;
7mod parsing;
8mod resolution;
9mod resolve;
10mod rules;
11mod used_class_members;
12
13#[expect(
14 clippy::redundant_pub_crate,
15 reason = "this module is glob re-exported from lib.rs, so `pub` would leak the helper into the public API; pub(crate) keeps it internal to the crate"
16)]
17pub(crate) use boundaries::wildcard_placement_error;
18pub use boundaries::{
19 AuthoredRule, BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryPreset,
20 BoundaryRule, BoundaryZone, ForbiddenCallRule, ForbiddenCallee, InvalidForbiddenCallee,
21 LogicalGroup, LogicalGroupStatus, RedundantRootPrefix, ResolvedBoundaryConfig,
22 ResolvedBoundaryCoverageConfig, ResolvedBoundaryRule, ResolvedZone, UnknownZoneRef,
23 ZoneReferenceKind, ZoneValidationError,
24};
25pub use duplicates_config::{
26 DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
27};
28pub use flags::{FlagsConfig, SdkPattern};
29pub use format::OutputFormat;
30pub use health::{EmailMode, HealthConfig, HealthThresholdOverride, OwnershipConfig};
31pub use resolution::{
32 CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
33 CompiledIgnoreExportRule, ConfigOverride, DEFAULT_MAX_FILE_SIZE_BYTES,
34 DEFAULT_MAX_FILE_SIZE_MB, IgnoreCatalogReferenceRule, IgnoreDependencyOverrideRule,
35 IgnoreExportRule, ResolvedConfig, ResolvedOverride, resolve_max_file_size_bytes,
36};
37pub use resolve::ResolveConfig;
38pub use rules::{
39 KNOWN_RULE_NAMES, PartialRulesConfig, RulesConfig, Severity, closest_known_rule_name,
40 default_severity_for_kind, is_opt_in_kind,
41};
42pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
43
44use schemars::JsonSchema;
45use serde::{Deserialize, Deserializer, Serialize};
46use std::ops::Not;
47use std::path::PathBuf;
48
49use crate::external_plugin::ExternalPluginDef;
50use crate::workspace::WorkspaceConfig;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
53#[serde(untagged, rename_all = "camelCase")]
54pub enum IgnoreExportsUsedInFileConfig {
55 Bool(bool),
56 ByKind(IgnoreExportsUsedInFileByKind),
57}
58
59impl Default for IgnoreExportsUsedInFileConfig {
60 fn default() -> Self {
61 Self::Bool(false)
62 }
63}
64
65impl From<bool> for IgnoreExportsUsedInFileConfig {
66 fn from(value: bool) -> Self {
67 Self::Bool(value)
68 }
69}
70
71impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
72 fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
73 Self::ByKind(value)
74 }
75}
76
77impl IgnoreExportsUsedInFileConfig {
78 #[must_use]
79 pub const fn is_enabled(self) -> bool {
80 match self {
81 Self::Bool(value) => value,
82 Self::ByKind(kind) => kind.type_ || kind.interface,
83 }
84 }
85
86 #[must_use]
87 pub const fn suppresses(self, is_type_only: bool) -> bool {
88 match self {
89 Self::Bool(value) => value,
90 Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
91 }
92 }
93}
94
95#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
96#[serde(rename_all = "camelCase")]
97pub struct IgnoreExportsUsedInFileByKind {
98 #[serde(default, rename = "type")]
100 pub type_: bool,
101 #[serde(default)]
103 pub interface: bool,
104}
105
106#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
116#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
117pub struct UnusedComponentPropsConfig {
118 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub ignore_pattern: Option<String>,
128}
129
130impl UnusedComponentPropsConfig {
131 #[must_use]
132 pub fn is_default(&self) -> bool {
133 self.ignore_pattern.is_none()
134 }
135}
136
137#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
138#[serde(rename_all = "camelCase")]
139pub struct FixConfig {
140 #[serde(default)]
142 pub catalog: CatalogFixConfig,
143}
144
145#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
146#[serde(rename_all = "camelCase")]
147pub struct CatalogFixConfig {
148 #[serde(default)]
150 pub delete_preceding_comments: CatalogPrecedingCommentPolicy,
151}
152
153#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
154#[serde(rename_all = "lowercase")]
155pub enum CatalogPrecedingCommentPolicy {
156 #[default]
157 Auto,
158 Always,
159 Never,
160}
161
162#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
163#[serde(deny_unknown_fields, rename_all = "camelCase")]
164pub struct FallowConfig {
165 #[serde(rename = "$schema", default, skip_serializing)]
167 pub schema: Option<String>,
168
169 #[serde(default, skip_serializing)]
171 pub extends: Vec<String>,
172
173 #[serde(default)]
175 pub entry: Vec<String>,
176
177 #[serde(default)]
179 pub ignore_patterns: Vec<String>,
180
181 #[serde(default)]
183 pub framework: Vec<ExternalPluginDef>,
184
185 #[serde(default)]
187 pub workspaces: Option<WorkspaceConfig>,
188
189 #[serde(default)]
191 pub ignore_dependencies: Vec<String>,
192
193 #[serde(default)]
195 pub ignore_unresolved_imports: Vec<String>,
196
197 #[serde(default)]
199 pub ignore_exports: Vec<IgnoreExportRule>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
204
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
208
209 #[serde(default)]
211 pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
212
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub ignore_decorators: Vec<String>,
216
217 #[serde(default)]
219 pub used_class_members: Vec<UsedClassMemberRule>,
220
221 #[serde(default)]
223 pub duplicates: DuplicatesConfig,
224
225 #[serde(default)]
227 pub health: HealthConfig,
228
229 #[serde(default)]
231 pub rules: RulesConfig,
232
233 #[serde(
234 default,
235 skip_serializing_if = "UnusedComponentPropsConfig::is_default"
236 )]
237 pub unused_component_props: UnusedComponentPropsConfig,
239
240 #[serde(default)]
242 pub boundaries: BoundaryConfig,
243
244 #[serde(default)]
246 pub flags: FlagsConfig,
247
248 #[serde(default)]
250 pub security: SecurityConfig,
251
252 #[serde(default)]
254 pub fix: FixConfig,
255
256 #[serde(default)]
258 pub resolve: ResolveConfig,
259
260 #[serde(default)]
262 pub production: ProductionConfig,
263
264 #[serde(default)]
266 pub plugins: Vec<String>,
267
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
274 pub rule_packs: Vec<String>,
275
276 #[serde(default)]
278 pub dynamically_loaded: Vec<String>,
279
280 #[serde(default)]
282 pub overrides: Vec<ConfigOverride>,
283
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub codeowners: Option<String>,
287
288 #[serde(default)]
290 pub public_packages: Vec<String>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub regression: Option<RegressionConfig>,
295
296 #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
298 pub audit: AuditConfig,
299
300 #[serde(default)]
302 pub sealed: bool,
303
304 #[serde(default)]
306 pub include_entry_exports: bool,
307
308 #[serde(default)]
310 pub auto_imports: bool,
311
312 #[serde(default, skip_serializing_if = "CacheConfig::is_default")]
314 pub cache: CacheConfig,
315}
316
317#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
321#[serde(deny_unknown_fields, rename_all = "camelCase")]
322pub struct SecurityConfig {
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub categories: Option<SecurityCategories>,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub request_receivers: Vec<String>,
332}
333
334impl SecurityConfig {
335 #[must_use]
336 pub fn normalized_request_receivers(&self) -> Vec<String> {
337 let mut receivers = Vec::new();
338 for receiver in &self.request_receivers {
339 let normalized = receiver.trim().to_ascii_lowercase();
340 if !normalized.is_empty() && !receivers.contains(&normalized) {
341 receivers.push(normalized);
342 }
343 }
344 receivers
345 }
346
347 #[must_use]
348 pub fn request_receivers_are_valid(&self) -> bool {
349 self.request_receivers
350 .iter()
351 .all(|receiver| !receiver.trim().is_empty())
352 }
353}
354
355#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
360#[serde(deny_unknown_fields, rename_all = "camelCase")]
361pub struct SecurityCategories {
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub include: Option<Vec<String>>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub exclude: Option<Vec<String>>,
368}
369
370#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
371#[serde(deny_unknown_fields, rename_all = "camelCase")]
372pub struct CacheConfig {
373 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub dir: Option<PathBuf>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub max_size_mb: Option<u32>,
380}
381
382impl CacheConfig {
383 #[must_use]
384 pub fn is_default(&self) -> bool {
385 self.dir.is_none() && self.max_size_mb.is_none()
386 }
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub enum ProductionAnalysis {
391 DeadCode,
392 Health,
393 Dupes,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
397#[serde(untagged)]
398pub enum ProductionConfig {
399 Global(bool),
400 PerAnalysis(PerAnalysisProductionConfig),
401}
402
403impl<'de> Deserialize<'de> for ProductionConfig {
404 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
405 where
406 D: Deserializer<'de>,
407 {
408 struct ProductionConfigVisitor;
409
410 impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
411 type Value = ProductionConfig;
412
413 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414 formatter.write_str("a boolean or per-analysis production config object")
415 }
416
417 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
418 where
419 E: serde::de::Error,
420 {
421 Ok(ProductionConfig::Global(value))
422 }
423
424 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
425 where
426 A: serde::de::MapAccess<'de>,
427 {
428 PerAnalysisProductionConfig::deserialize(
429 serde::de::value::MapAccessDeserializer::new(map),
430 )
431 .map(ProductionConfig::PerAnalysis)
432 }
433 }
434
435 deserializer.deserialize_any(ProductionConfigVisitor)
436 }
437}
438
439impl Default for ProductionConfig {
440 fn default() -> Self {
441 Self::Global(false)
442 }
443}
444
445impl From<bool> for ProductionConfig {
446 fn from(value: bool) -> Self {
447 Self::Global(value)
448 }
449}
450
451impl Not for ProductionConfig {
452 type Output = bool;
453
454 fn not(self) -> Self::Output {
455 !self.any_enabled()
456 }
457}
458
459impl ProductionConfig {
460 #[must_use]
461 pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
462 match self {
463 Self::Global(value) => value,
464 Self::PerAnalysis(config) => match analysis {
465 ProductionAnalysis::DeadCode => config.dead_code,
466 ProductionAnalysis::Health => config.health,
467 ProductionAnalysis::Dupes => config.dupes,
468 },
469 }
470 }
471
472 #[must_use]
473 pub const fn global(self) -> bool {
474 match self {
475 Self::Global(value) => value,
476 Self::PerAnalysis(_) => false,
477 }
478 }
479
480 #[must_use]
481 pub const fn any_enabled(self) -> bool {
482 match self {
483 Self::Global(value) => value,
484 Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
485 }
486 }
487}
488
489#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
490#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
491pub struct PerAnalysisProductionConfig {
492 pub dead_code: bool,
494 pub health: bool,
496 pub dupes: bool,
498}
499
500#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
501#[serde(rename_all = "camelCase")]
502pub struct AuditConfig {
503 #[serde(default, skip_serializing_if = "AuditGate::is_default")]
505 pub gate: AuditGate,
506
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub css: Option<bool>,
510
511 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub css_deep: Option<bool>,
514
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub dead_code_baseline: Option<String>,
518
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub health_baseline: Option<String>,
522
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub dupes_baseline: Option<String>,
526
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub cache_max_age_days: Option<u32>,
530}
531
532impl AuditConfig {
533 #[must_use]
534 pub fn is_empty(&self) -> bool {
535 self.gate.is_default()
536 && self.css.is_none()
537 && self.css_deep.is_none()
538 && self.dead_code_baseline.is_none()
539 && self.health_baseline.is_none()
540 && self.dupes_baseline.is_none()
541 && self.cache_max_age_days.is_none()
542 }
543}
544
545#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
546#[serde(rename_all = "kebab-case")]
547pub enum AuditGate {
548 #[default]
549 NewOnly,
550 All,
551}
552
553impl AuditGate {
554 #[must_use]
555 pub const fn is_default(&self) -> bool {
556 matches!(self, Self::NewOnly)
557 }
558}
559
560#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
561#[serde(rename_all = "camelCase")]
562pub struct RegressionConfig {
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub baseline: Option<RegressionBaseline>,
566}
567
568#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
569#[serde(rename_all = "camelCase")]
570pub struct RegressionBaseline {
571 #[serde(default)]
572 pub total_issues: usize,
573 #[serde(default)]
574 pub unused_files: usize,
575 #[serde(default)]
576 pub unused_exports: usize,
577 #[serde(default)]
578 pub unused_types: usize,
579 #[serde(default)]
580 pub unused_dependencies: usize,
581 #[serde(default)]
582 pub unused_dev_dependencies: usize,
583 #[serde(default)]
584 pub unused_optional_dependencies: usize,
585 #[serde(default)]
586 pub unused_enum_members: usize,
587 #[serde(default)]
588 pub unused_class_members: usize,
589 #[serde(default)]
590 pub unresolved_imports: usize,
591 #[serde(default)]
592 pub unlisted_dependencies: usize,
593 #[serde(default)]
594 pub duplicate_exports: usize,
595 #[serde(default)]
596 pub circular_dependencies: usize,
597 #[serde(default)]
598 pub re_export_cycles: usize,
599 #[serde(default)]
600 pub type_only_dependencies: usize,
601 #[serde(default)]
602 pub test_only_dependencies: usize,
603 #[serde(default)]
604 pub dev_dependencies_in_production: usize,
605 #[serde(default)]
606 pub boundary_violations: usize,
607 #[serde(default)]
608 pub boundary_coverage_violations: usize,
609 #[serde(default)]
610 pub boundary_call_violations: usize,
611 #[serde(default)]
612 pub policy_violations: usize,
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn default_config_has_empty_collections() {
621 let config = FallowConfig::default();
622 assert!(config.schema.is_none());
623 assert!(config.extends.is_empty());
624 assert!(config.entry.is_empty());
625 assert!(config.ignore_patterns.is_empty());
626 assert!(config.framework.is_empty());
627 assert!(config.workspaces.is_none());
628 assert!(config.ignore_dependencies.is_empty());
629 assert!(config.ignore_exports.is_empty());
630 assert!(config.used_class_members.is_empty());
631 assert!(config.plugins.is_empty());
632 assert!(config.dynamically_loaded.is_empty());
633 assert!(config.overrides.is_empty());
634 assert!(config.public_packages.is_empty());
635 assert_eq!(
636 config.fix.catalog.delete_preceding_comments,
637 CatalogPrecedingCommentPolicy::Auto
638 );
639 assert!(!config.production);
640 }
641
642 #[test]
643 fn default_config_rules_are_error() {
644 let config = FallowConfig::default();
645 assert_eq!(config.rules.unused_files, Severity::Error);
646 assert_eq!(config.rules.unused_exports, Severity::Error);
647 assert_eq!(config.rules.unused_dependencies, Severity::Error);
648 }
649
650 #[test]
651 fn default_config_duplicates_enabled() {
652 let config = FallowConfig::default();
653 assert!(config.duplicates.enabled);
654 assert_eq!(config.duplicates.min_tokens, 50);
655 assert_eq!(config.duplicates.min_lines, 5);
656 }
657
658 #[test]
659 fn default_config_health_thresholds() {
660 let config = FallowConfig::default();
661 assert_eq!(config.health.max_cyclomatic, 20);
662 assert_eq!(config.health.max_cognitive, 15);
663 }
664
665 #[test]
666 fn deserialize_empty_json_object() {
667 let config: FallowConfig = serde_json::from_str("{}").unwrap();
668 assert!(config.entry.is_empty());
669 assert!(!config.production);
670 }
671
672 #[test]
673 fn deserialize_json_with_all_top_level_fields() {
674 let json = r#"{
675 "$schema": "https://fallow.dev/schema.json",
676 "entry": ["src/main.ts"],
677 "ignorePatterns": ["generated/**"],
678 "ignoreDependencies": ["postcss"],
679 "production": true,
680 "plugins": ["custom-plugin.toml"],
681 "rules": {"unused-files": "warn"},
682 "duplicates": {"enabled": false},
683 "health": {"maxCyclomatic": 30}
684 }"#;
685 let config: FallowConfig = serde_json::from_str(json).unwrap();
686 assert_eq!(
687 config.schema.as_deref(),
688 Some("https://fallow.dev/schema.json")
689 );
690 assert_eq!(config.entry, vec!["src/main.ts"]);
691 assert_eq!(config.ignore_patterns, vec!["generated/**"]);
692 assert_eq!(config.ignore_dependencies, vec!["postcss"]);
693 assert!(config.production);
694 assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
695 assert_eq!(config.rules.unused_files, Severity::Warn);
696 assert!(!config.duplicates.enabled);
697 assert_eq!(config.health.max_cyclomatic, 30);
698 }
699
700 #[test]
701 fn deserialize_json_deny_unknown_fields() {
702 let json = r#"{"unknownField": true}"#;
703 let result: Result<FallowConfig, _> = serde_json::from_str(json);
704 assert!(result.is_err(), "unknown fields should be rejected");
705 }
706
707 #[test]
708 fn deserialize_json_production_mode_default_false() {
709 let config: FallowConfig = serde_json::from_str("{}").unwrap();
710 assert!(!config.production);
711 }
712
713 #[test]
714 fn deserialize_json_production_mode_true() {
715 let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
716 assert!(config.production);
717 }
718
719 #[test]
720 fn deserialize_json_per_analysis_production_mode() {
721 let config: FallowConfig = serde_json::from_str(
722 r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
723 )
724 .unwrap();
725 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
726 assert!(config.production.for_analysis(ProductionAnalysis::Health));
727 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
728 }
729
730 #[test]
731 fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
732 let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
733 .unwrap_err();
734 assert!(
735 err.to_string().contains("healthTypo"),
736 "error should name the unknown field: {err}"
737 );
738 }
739
740 #[test]
741 fn deserialize_json_dynamically_loaded() {
742 let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
743 let config: FallowConfig = serde_json::from_str(json).unwrap();
744 assert_eq!(
745 config.dynamically_loaded,
746 vec!["plugins/**/*.ts", "locales/**/*.json"]
747 );
748 }
749
750 #[test]
751 fn deserialize_json_dynamically_loaded_defaults_empty() {
752 let config: FallowConfig = serde_json::from_str("{}").unwrap();
753 assert!(config.dynamically_loaded.is_empty());
754 }
755
756 #[test]
757 fn deserialize_json_fix_catalog_delete_preceding_comments() {
758 let config: FallowConfig =
759 serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
760 .unwrap();
761 assert_eq!(
762 config.fix.catalog.delete_preceding_comments,
763 CatalogPrecedingCommentPolicy::Always
764 );
765 }
766
767 #[test]
768 fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
769 let err = serde_json::from_str::<FallowConfig>(
770 r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
771 )
772 .unwrap_err();
773 assert!(
774 err.to_string().contains("sometimes"),
775 "error should name the bad policy: {err}"
776 );
777 }
778
779 #[test]
780 fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
781 let json = r#"{
782 "usedClassMembers": [
783 "agInit",
784 { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
785 { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
786 ]
787 }"#;
788 let config: FallowConfig = serde_json::from_str(json).unwrap();
789 assert_eq!(
790 config.used_class_members,
791 vec![
792 UsedClassMemberRule::from("agInit"),
793 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
794 extends: None,
795 implements: Some("ICellRendererAngularComp".to_string()),
796 members: vec!["refresh".to_string()],
797 }),
798 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
799 extends: Some("BaseCommand".to_string()),
800 implements: Some("CanActivate".to_string()),
801 members: vec!["execute".to_string()],
802 }),
803 ]
804 );
805 }
806
807 #[test]
808 fn deserialize_toml_minimal() {
809 let toml_str = r#"
810entry = ["src/index.ts"]
811production = true
812"#;
813 let config: FallowConfig = toml::from_str(toml_str).unwrap();
814 assert_eq!(config.entry, vec!["src/index.ts"]);
815 assert!(config.production);
816 }
817
818 #[test]
819 fn workspaces_packages_key_is_accepted_as_patterns_alias() {
820 let config: FallowConfig =
824 toml::from_str("[workspaces]\npackages = [\"packages/*\", \"apps/*\"]").unwrap();
825 assert_eq!(
826 config.workspaces.map(|w| w.patterns).unwrap_or_default(),
827 vec!["packages/*".to_string(), "apps/*".to_string()],
828 "the `packages` alias must populate `patterns`"
829 );
830 }
831
832 #[test]
833 fn deserialize_toml_per_analysis_production_mode() {
834 let toml_str = r"
835[production]
836deadCode = false
837health = true
838dupes = false
839";
840 let config: FallowConfig = toml::from_str(toml_str).unwrap();
841 assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
842 assert!(config.production.for_analysis(ProductionAnalysis::Health));
843 assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
844 }
845
846 #[test]
847 fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
848 let err = toml::from_str::<FallowConfig>(
849 r"
850[production]
851healthTypo = true
852",
853 )
854 .unwrap_err();
855 assert!(
856 err.to_string().contains("healthTypo"),
857 "error should name the unknown field: {err}"
858 );
859 }
860
861 #[test]
862 fn deserialize_toml_with_inline_framework() {
863 let toml_str = r#"
864[[framework]]
865name = "my-framework"
866enablers = ["my-framework-pkg"]
867entryPoints = ["src/routes/**/*.tsx"]
868"#;
869 let config: FallowConfig = toml::from_str(toml_str).unwrap();
870 assert_eq!(config.framework.len(), 1);
871 assert_eq!(config.framework[0].name, "my-framework");
872 assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
873 assert_eq!(
874 config.framework[0].entry_points,
875 vec!["src/routes/**/*.tsx"]
876 );
877 }
878
879 #[test]
880 fn deserialize_toml_fix_catalog_delete_preceding_comments() {
881 let toml_str = r#"
882[fix.catalog]
883deletePrecedingComments = "never"
884"#;
885 let config: FallowConfig = toml::from_str(toml_str).unwrap();
886 assert_eq!(
887 config.fix.catalog.delete_preceding_comments,
888 CatalogPrecedingCommentPolicy::Never
889 );
890 }
891
892 #[test]
893 fn deserialize_toml_with_workspace_config() {
894 let toml_str = r#"
895[workspaces]
896patterns = ["packages/*", "apps/*"]
897"#;
898 let config: FallowConfig = toml::from_str(toml_str).unwrap();
899 assert!(config.workspaces.is_some());
900 let ws = config.workspaces.unwrap();
901 assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
902 }
903
904 #[test]
905 fn deserialize_toml_with_ignore_exports() {
906 let toml_str = r#"
907[[ignoreExports]]
908file = "src/types/**/*.ts"
909exports = ["*"]
910"#;
911 let config: FallowConfig = toml::from_str(toml_str).unwrap();
912 assert_eq!(config.ignore_exports.len(), 1);
913 assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
914 assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
915 }
916
917 #[test]
918 fn deserialize_toml_used_class_members_supports_scoped_rules() {
919 let toml_str = r#"
920usedClassMembers = [
921 { implements = "ICellRendererAngularComp", members = ["refresh"] },
922 { extends = "BaseCommand", members = ["execute"] },
923]
924"#;
925 let config: FallowConfig = toml::from_str(toml_str).unwrap();
926 assert_eq!(
927 config.used_class_members,
928 vec![
929 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
930 extends: None,
931 implements: Some("ICellRendererAngularComp".to_string()),
932 members: vec!["refresh".to_string()],
933 }),
934 UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
935 extends: Some("BaseCommand".to_string()),
936 implements: None,
937 members: vec!["execute".to_string()],
938 }),
939 ]
940 );
941 }
942
943 #[test]
944 fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
945 let result = serde_json::from_str::<FallowConfig>(
946 r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
947 );
948 assert!(
949 result.is_err(),
950 "unconstrained scoped rule should be rejected"
951 );
952 }
953
954 #[test]
955 fn deserialize_ignore_exports_used_in_file_bool() {
956 let config: FallowConfig =
957 serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
958
959 assert!(config.ignore_exports_used_in_file.suppresses(false));
960 assert!(config.ignore_exports_used_in_file.suppresses(true));
961 }
962
963 #[test]
964 fn deserialize_ignore_exports_used_in_file_kind_form() {
965 let config: FallowConfig =
966 serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
967
968 assert!(!config.ignore_exports_used_in_file.suppresses(false));
969 assert!(config.ignore_exports_used_in_file.suppresses(true));
970 }
971
972 #[test]
973 fn deserialize_toml_deny_unknown_fields() {
974 let toml_str = r"bogus_field = true";
975 let result: Result<FallowConfig, _> = toml::from_str(toml_str);
976 assert!(result.is_err(), "unknown fields should be rejected");
977 }
978
979 #[test]
980 fn json_serialize_roundtrip() {
981 let config = FallowConfig {
982 entry: vec!["src/main.ts".to_string()],
983 production: true.into(),
984 ..FallowConfig::default()
985 };
986 let json = serde_json::to_string(&config).unwrap();
987 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
988 assert_eq!(restored.entry, vec!["src/main.ts"]);
989 assert!(restored.production);
990 }
991
992 #[test]
993 fn schema_field_not_serialized() {
994 let config = FallowConfig {
995 schema: Some("https://example.com/schema.json".to_string()),
996 ..FallowConfig::default()
997 };
998 let json = serde_json::to_string(&config).unwrap();
999 assert!(
1000 !json.contains("$schema"),
1001 "schema field should be skipped in serialization"
1002 );
1003 }
1004
1005 #[test]
1006 fn extends_field_not_serialized() {
1007 let config = FallowConfig {
1008 extends: vec!["base.json".to_string()],
1009 ..FallowConfig::default()
1010 };
1011 let json = serde_json::to_string(&config).unwrap();
1012 assert!(
1013 !json.contains("extends"),
1014 "extends field should be skipped in serialization"
1015 );
1016 }
1017
1018 #[test]
1019 fn regression_config_deserialize_json() {
1020 let json = r#"{
1021 "regression": {
1022 "baseline": {
1023 "totalIssues": 42,
1024 "unusedFiles": 10,
1025 "unusedExports": 5,
1026 "circularDependencies": 2
1027 }
1028 }
1029 }"#;
1030 let config: FallowConfig = serde_json::from_str(json).unwrap();
1031 let regression = config.regression.unwrap();
1032 let baseline = regression.baseline.unwrap();
1033 assert_eq!(baseline.total_issues, 42);
1034 assert_eq!(baseline.unused_files, 10);
1035 assert_eq!(baseline.unused_exports, 5);
1036 assert_eq!(baseline.circular_dependencies, 2);
1037 assert_eq!(baseline.unused_types, 0);
1038 assert_eq!(baseline.boundary_violations, 0);
1039 }
1040
1041 #[test]
1042 fn regression_config_defaults_to_none() {
1043 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1044 assert!(config.regression.is_none());
1045 }
1046
1047 #[test]
1048 fn regression_baseline_all_zeros_by_default() {
1049 let baseline = RegressionBaseline::default();
1050 assert_eq!(baseline.total_issues, 0);
1051 assert_eq!(baseline.unused_files, 0);
1052 assert_eq!(baseline.unused_exports, 0);
1053 assert_eq!(baseline.unused_types, 0);
1054 assert_eq!(baseline.unused_dependencies, 0);
1055 assert_eq!(baseline.unused_dev_dependencies, 0);
1056 assert_eq!(baseline.unused_optional_dependencies, 0);
1057 assert_eq!(baseline.unused_enum_members, 0);
1058 assert_eq!(baseline.unused_class_members, 0);
1059 assert_eq!(baseline.unresolved_imports, 0);
1060 assert_eq!(baseline.unlisted_dependencies, 0);
1061 assert_eq!(baseline.duplicate_exports, 0);
1062 assert_eq!(baseline.circular_dependencies, 0);
1063 assert_eq!(baseline.type_only_dependencies, 0);
1064 assert_eq!(baseline.test_only_dependencies, 0);
1065 assert_eq!(baseline.boundary_violations, 0);
1066 }
1067
1068 #[test]
1069 fn regression_config_serialize_roundtrip() {
1070 let baseline = RegressionBaseline {
1071 total_issues: 100,
1072 unused_files: 20,
1073 unused_exports: 30,
1074 ..RegressionBaseline::default()
1075 };
1076 let regression = RegressionConfig {
1077 baseline: Some(baseline),
1078 };
1079 let config = FallowConfig {
1080 regression: Some(regression),
1081 ..FallowConfig::default()
1082 };
1083 let json = serde_json::to_string(&config).unwrap();
1084 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1085 let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1086 assert_eq!(restored_baseline.total_issues, 100);
1087 assert_eq!(restored_baseline.unused_files, 20);
1088 assert_eq!(restored_baseline.unused_exports, 30);
1089 assert_eq!(restored_baseline.unused_types, 0);
1090 }
1091
1092 #[test]
1093 fn regression_config_empty_baseline_deserialize() {
1094 let json = r#"{"regression": {}}"#;
1095 let config: FallowConfig = serde_json::from_str(json).unwrap();
1096 let regression = config.regression.unwrap();
1097 assert!(regression.baseline.is_none());
1098 }
1099
1100 #[test]
1101 fn regression_baseline_not_serialized_when_none() {
1102 let config = FallowConfig {
1103 regression: None,
1104 ..FallowConfig::default()
1105 };
1106 let json = serde_json::to_string(&config).unwrap();
1107 assert!(
1108 !json.contains("regression"),
1109 "regression should be skipped when None"
1110 );
1111 }
1112
1113 #[test]
1114 fn deserialize_json_with_overrides() {
1115 let json = r#"{
1116 "overrides": [
1117 {
1118 "files": ["*.test.ts", "*.spec.ts"],
1119 "rules": {
1120 "unused-exports": "off",
1121 "unused-files": "warn"
1122 }
1123 }
1124 ]
1125 }"#;
1126 let config: FallowConfig = serde_json::from_str(json).unwrap();
1127 assert_eq!(config.overrides.len(), 1);
1128 assert_eq!(config.overrides[0].files.len(), 2);
1129 assert_eq!(
1130 config.overrides[0].rules.unused_exports,
1131 Some(Severity::Off)
1132 );
1133 assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1134 }
1135
1136 #[test]
1137 fn deserialize_json_with_boundaries() {
1138 let json = r#"{
1139 "boundaries": {
1140 "preset": "layered"
1141 }
1142 }"#;
1143 let config: FallowConfig = serde_json::from_str(json).unwrap();
1144 assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1145 }
1146
1147 #[test]
1148 fn deserialize_toml_with_regression_baseline() {
1149 let toml_str = r"
1150[regression.baseline]
1151totalIssues = 50
1152unusedFiles = 10
1153unusedExports = 15
1154";
1155 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1156 let baseline = config.regression.unwrap().baseline.unwrap();
1157 assert_eq!(baseline.total_issues, 50);
1158 assert_eq!(baseline.unused_files, 10);
1159 assert_eq!(baseline.unused_exports, 15);
1160 }
1161
1162 #[test]
1163 fn deserialize_toml_with_overrides() {
1164 let toml_str = r#"
1165[[overrides]]
1166files = ["*.test.ts"]
1167
1168[overrides.rules]
1169unused-exports = "off"
1170
1171[[overrides]]
1172files = ["*.stories.tsx"]
1173
1174[overrides.rules]
1175unused-files = "off"
1176"#;
1177 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1178 assert_eq!(config.overrides.len(), 2);
1179 assert_eq!(
1180 config.overrides[0].rules.unused_exports,
1181 Some(Severity::Off)
1182 );
1183 assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1184 }
1185
1186 #[test]
1187 fn regression_config_default_is_none_baseline() {
1188 let config = RegressionConfig::default();
1189 assert!(config.baseline.is_none());
1190 }
1191
1192 #[test]
1193 fn deserialize_json_multiple_ignore_export_rules() {
1194 let json = r#"{
1195 "ignoreExports": [
1196 {"file": "src/types/**/*.ts", "exports": ["*"]},
1197 {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1198 {"file": "src/index.ts", "exports": ["default"]}
1199 ]
1200 }"#;
1201 let config: FallowConfig = serde_json::from_str(json).unwrap();
1202 assert_eq!(config.ignore_exports.len(), 3);
1203 assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1204 }
1205
1206 #[test]
1207 fn deserialize_json_public_packages_camel_case() {
1208 let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1209 let config: FallowConfig = serde_json::from_str(json).unwrap();
1210 assert_eq!(
1211 config.public_packages,
1212 vec!["@myorg/shared-lib", "@myorg/utils"]
1213 );
1214 }
1215
1216 #[test]
1217 fn deserialize_json_public_packages_rejects_snake_case() {
1218 let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1219 let result: Result<FallowConfig, _> = serde_json::from_str(json);
1220 assert!(
1221 result.is_err(),
1222 "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1223 );
1224 }
1225
1226 #[test]
1227 fn deserialize_json_public_packages_empty() {
1228 let config: FallowConfig = serde_json::from_str("{}").unwrap();
1229 assert!(config.public_packages.is_empty());
1230 }
1231
1232 #[test]
1233 fn deserialize_toml_public_packages() {
1234 let toml_str = r#"
1235publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1236"#;
1237 let config: FallowConfig = toml::from_str(toml_str).unwrap();
1238 assert_eq!(
1239 config.public_packages,
1240 vec!["@myorg/shared-lib", "@myorg/ui"]
1241 );
1242 }
1243
1244 #[test]
1245 fn public_packages_serialize_roundtrip() {
1246 let config = FallowConfig {
1247 public_packages: vec!["@myorg/shared-lib".to_string()],
1248 ..FallowConfig::default()
1249 };
1250 let json = serde_json::to_string(&config).unwrap();
1251 let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1252 assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1253 }
1254}