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