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