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