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