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