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