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