Skip to main content

fallow_config/config/
mod.rs

1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5mod health;
6mod parsing;
7mod resolution;
8mod rules;
9
10pub use boundaries::{
11    BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, ResolvedBoundaryConfig,
12    ResolvedBoundaryRule, ResolvedZone,
13};
14pub use duplicates_config::{
15    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
16};
17pub use flags::{FlagsConfig, SdkPattern};
18pub use format::OutputFormat;
19pub use health::HealthConfig;
20pub use resolution::{ConfigOverride, IgnoreExportRule, ResolvedConfig, ResolvedOverride};
21pub use rules::{PartialRulesConfig, RulesConfig, Severity};
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::external_plugin::ExternalPluginDef;
27use crate::workspace::WorkspaceConfig;
28
29/// User-facing configuration loaded from `.fallowrc.json` or `fallow.toml`.
30///
31/// # Examples
32///
33/// ```
34/// use fallow_config::FallowConfig;
35///
36/// // Default config has sensible defaults
37/// let config = FallowConfig::default();
38/// assert!(config.entry.is_empty());
39/// assert!(!config.production);
40///
41/// // Deserialize from JSON
42/// let config: FallowConfig = serde_json::from_str(r#"{
43///     "entry": ["src/main.ts"],
44///     "production": true
45/// }"#).unwrap();
46/// assert_eq!(config.entry, vec!["src/main.ts"]);
47/// assert!(config.production);
48/// ```
49#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
50#[serde(deny_unknown_fields, rename_all = "camelCase")]
51pub struct FallowConfig {
52    /// JSON Schema reference (ignored during deserialization).
53    #[serde(rename = "$schema", default, skip_serializing)]
54    pub schema: Option<String>,
55
56    /// Base config files to extend from.
57    ///
58    /// Supports three resolution strategies:
59    /// - **Relative paths**: `"./base.json"` — resolved relative to the config file.
60    /// - **npm packages**: `"npm:@co/config"` — resolved by walking up `node_modules/`.
61    ///   Package resolution checks `package.json` `exports`/`main` first, then falls back
62    ///   to standard config file names. Subpaths are supported (e.g., `npm:@co/config/strict.json`).
63    /// - **HTTPS URLs**: `"https://example.com/fallow-base.json"` — fetched remotely.
64    ///   Only HTTPS is supported (no plain HTTP). URL-sourced configs may extend other
65    ///   URLs or `npm:` packages, but not relative paths. Only JSON/JSONC format is
66    ///   supported for remote configs. Timeout is configurable via
67    ///   `FALLOW_EXTENDS_TIMEOUT_SECS` (default: 5s).
68    ///
69    /// Base configs are loaded first, then this config's values override them.
70    /// Later entries in the array override earlier ones.
71    ///
72    /// **Note:** `npm:` resolution uses `node_modules/` directory walk-up and is
73    /// incompatible with Yarn Plug'n'Play (PnP), which has no `node_modules/`.
74    /// URL extends fetch on every run (no caching). For reliable CI, prefer `npm:`
75    /// for private or critical configs.
76    #[serde(default, skip_serializing)]
77    pub extends: Vec<String>,
78
79    /// Additional entry point glob patterns.
80    #[serde(default)]
81    pub entry: Vec<String>,
82
83    /// Glob patterns to ignore from analysis.
84    #[serde(default)]
85    pub ignore_patterns: Vec<String>,
86
87    /// Custom framework definitions (inline plugin definitions).
88    #[serde(default)]
89    pub framework: Vec<ExternalPluginDef>,
90
91    /// Workspace overrides.
92    #[serde(default)]
93    pub workspaces: Option<WorkspaceConfig>,
94
95    /// Dependencies to ignore (always considered used and always considered available).
96    ///
97    /// Listed dependencies are excluded from both unused dependency and unlisted
98    /// dependency detection. Useful for runtime-provided packages like `bun:sqlite`
99    /// or implicitly available dependencies.
100    #[serde(default)]
101    pub ignore_dependencies: Vec<String>,
102
103    /// Export ignore rules.
104    #[serde(default)]
105    pub ignore_exports: Vec<IgnoreExportRule>,
106
107    /// Class member method/property names that should never be flagged as
108    /// unused. Extends the built-in lifecycle allowlist (Angular/React) with
109    /// framework-invoked names from libraries that call interface methods at
110    /// runtime (e.g. ag-Grid's `agInit`, `refresh`). Use this at the top level
111    /// for project-wide additions; use a plugin file's `usedClassMembers` when
112    /// the names should only apply when a specific package is installed.
113    #[serde(default)]
114    pub used_class_members: Vec<String>,
115
116    /// Duplication detection settings.
117    #[serde(default)]
118    pub duplicates: DuplicatesConfig,
119
120    /// Complexity health metrics settings.
121    #[serde(default)]
122    pub health: HealthConfig,
123
124    /// Per-issue-type severity rules.
125    #[serde(default)]
126    pub rules: RulesConfig,
127
128    /// Architecture boundary enforcement configuration.
129    #[serde(default)]
130    pub boundaries: BoundaryConfig,
131
132    /// Feature flag detection configuration.
133    #[serde(default)]
134    pub flags: FlagsConfig,
135
136    /// Production mode: exclude test/dev files, only start/build scripts.
137    #[serde(default)]
138    pub production: bool,
139
140    /// Paths to external plugin files or directories containing plugin files.
141    ///
142    /// Supports TOML, JSON, and JSONC formats.
143    ///
144    /// In addition to these explicit paths, fallow automatically discovers:
145    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
146    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
147    #[serde(default)]
148    pub plugins: Vec<String>,
149
150    /// Glob patterns for files that are dynamically loaded at runtime
151    /// (plugin directories, locale files, etc.). These files are treated as
152    /// always-used and will never be flagged as unused.
153    #[serde(default)]
154    pub dynamically_loaded: Vec<String>,
155
156    /// Per-file rule overrides matching oxlint's overrides pattern.
157    #[serde(default)]
158    pub overrides: Vec<ConfigOverride>,
159
160    /// Path to a CODEOWNERS file for `--group-by owner`.
161    ///
162    /// When unset, fallow auto-probes `CODEOWNERS`, `.github/CODEOWNERS`,
163    /// `.gitlab/CODEOWNERS`, and `docs/CODEOWNERS`. Set this to use a
164    /// non-standard location.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub codeowners: Option<String>,
167
168    /// Workspace package name patterns that are public libraries.
169    /// Exports from these packages are not flagged as unused.
170    #[serde(default)]
171    pub public_packages: Vec<String>,
172
173    /// Regression detection baseline embedded in config.
174    /// Stores issue counts from a known-good state for CI regression checks.
175    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub regression: Option<RegressionConfig>,
178
179    /// Mark this config as sealed: `extends` paths must be file-relative and
180    /// resolve within this config's own directory. `npm:` and `https:` extends
181    /// are rejected. Useful for library publishers and monorepo sub-packages
182    /// that want to guarantee their config is self-contained and not subject
183    /// to ancestor configs being injected via `extends`.
184    ///
185    /// Discovery is unaffected (first-match-wins already stops the directory
186    /// walk at the nearest config). This only constrains `extends`.
187    #[serde(default)]
188    pub sealed: bool,
189}
190
191/// Regression baseline counts, embedded in the config file.
192///
193/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
194/// fallow reads the baseline from this config section.
195/// When `--save-regression-baseline` is used without a path argument,
196/// fallow writes the baseline into the config file.
197#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
198#[serde(rename_all = "camelCase")]
199pub struct RegressionConfig {
200    /// Dead code issue counts baseline.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub baseline: Option<RegressionBaseline>,
203}
204
205/// Per-type issue counts for regression comparison.
206#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
207#[serde(rename_all = "camelCase")]
208pub struct RegressionBaseline {
209    #[serde(default)]
210    pub total_issues: usize,
211    #[serde(default)]
212    pub unused_files: usize,
213    #[serde(default)]
214    pub unused_exports: usize,
215    #[serde(default)]
216    pub unused_types: usize,
217    #[serde(default)]
218    pub unused_dependencies: usize,
219    #[serde(default)]
220    pub unused_dev_dependencies: usize,
221    #[serde(default)]
222    pub unused_optional_dependencies: usize,
223    #[serde(default)]
224    pub unused_enum_members: usize,
225    #[serde(default)]
226    pub unused_class_members: usize,
227    #[serde(default)]
228    pub unresolved_imports: usize,
229    #[serde(default)]
230    pub unlisted_dependencies: usize,
231    #[serde(default)]
232    pub duplicate_exports: usize,
233    #[serde(default)]
234    pub circular_dependencies: usize,
235    #[serde(default)]
236    pub type_only_dependencies: usize,
237    #[serde(default)]
238    pub test_only_dependencies: usize,
239    #[serde(default)]
240    pub boundary_violations: usize,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    // ── Default trait ───────────────────────────────────────────────
248
249    #[test]
250    fn default_config_has_empty_collections() {
251        let config = FallowConfig::default();
252        assert!(config.schema.is_none());
253        assert!(config.extends.is_empty());
254        assert!(config.entry.is_empty());
255        assert!(config.ignore_patterns.is_empty());
256        assert!(config.framework.is_empty());
257        assert!(config.workspaces.is_none());
258        assert!(config.ignore_dependencies.is_empty());
259        assert!(config.ignore_exports.is_empty());
260        assert!(config.used_class_members.is_empty());
261        assert!(config.plugins.is_empty());
262        assert!(config.dynamically_loaded.is_empty());
263        assert!(config.overrides.is_empty());
264        assert!(config.public_packages.is_empty());
265        assert!(!config.production);
266    }
267
268    #[test]
269    fn default_config_rules_are_error() {
270        let config = FallowConfig::default();
271        assert_eq!(config.rules.unused_files, Severity::Error);
272        assert_eq!(config.rules.unused_exports, Severity::Error);
273        assert_eq!(config.rules.unused_dependencies, Severity::Error);
274    }
275
276    #[test]
277    fn default_config_duplicates_enabled() {
278        let config = FallowConfig::default();
279        assert!(config.duplicates.enabled);
280        assert_eq!(config.duplicates.min_tokens, 50);
281        assert_eq!(config.duplicates.min_lines, 5);
282    }
283
284    #[test]
285    fn default_config_health_thresholds() {
286        let config = FallowConfig::default();
287        assert_eq!(config.health.max_cyclomatic, 20);
288        assert_eq!(config.health.max_cognitive, 15);
289    }
290
291    // ── JSON deserialization ────────────────────────────────────────
292
293    #[test]
294    fn deserialize_empty_json_object() {
295        let config: FallowConfig = serde_json::from_str("{}").unwrap();
296        assert!(config.entry.is_empty());
297        assert!(!config.production);
298    }
299
300    #[test]
301    fn deserialize_json_with_all_top_level_fields() {
302        let json = r#"{
303            "$schema": "https://fallow.dev/schema.json",
304            "entry": ["src/main.ts"],
305            "ignorePatterns": ["generated/**"],
306            "ignoreDependencies": ["postcss"],
307            "production": true,
308            "plugins": ["custom-plugin.toml"],
309            "rules": {"unused-files": "warn"},
310            "duplicates": {"enabled": false},
311            "health": {"maxCyclomatic": 30}
312        }"#;
313        let config: FallowConfig = serde_json::from_str(json).unwrap();
314        assert_eq!(
315            config.schema.as_deref(),
316            Some("https://fallow.dev/schema.json")
317        );
318        assert_eq!(config.entry, vec!["src/main.ts"]);
319        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
320        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
321        assert!(config.production);
322        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
323        assert_eq!(config.rules.unused_files, Severity::Warn);
324        assert!(!config.duplicates.enabled);
325        assert_eq!(config.health.max_cyclomatic, 30);
326    }
327
328    #[test]
329    fn deserialize_json_deny_unknown_fields() {
330        let json = r#"{"unknownField": true}"#;
331        let result: Result<FallowConfig, _> = serde_json::from_str(json);
332        assert!(result.is_err(), "unknown fields should be rejected");
333    }
334
335    #[test]
336    fn deserialize_json_production_mode_default_false() {
337        let config: FallowConfig = serde_json::from_str("{}").unwrap();
338        assert!(!config.production);
339    }
340
341    #[test]
342    fn deserialize_json_production_mode_true() {
343        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
344        assert!(config.production);
345    }
346
347    #[test]
348    fn deserialize_json_dynamically_loaded() {
349        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
350        let config: FallowConfig = serde_json::from_str(json).unwrap();
351        assert_eq!(
352            config.dynamically_loaded,
353            vec!["plugins/**/*.ts", "locales/**/*.json"]
354        );
355    }
356
357    #[test]
358    fn deserialize_json_dynamically_loaded_defaults_empty() {
359        let config: FallowConfig = serde_json::from_str("{}").unwrap();
360        assert!(config.dynamically_loaded.is_empty());
361    }
362
363    // ── TOML deserialization ────────────────────────────────────────
364
365    #[test]
366    fn deserialize_toml_minimal() {
367        let toml_str = r#"
368entry = ["src/index.ts"]
369production = true
370"#;
371        let config: FallowConfig = toml::from_str(toml_str).unwrap();
372        assert_eq!(config.entry, vec!["src/index.ts"]);
373        assert!(config.production);
374    }
375
376    #[test]
377    fn deserialize_toml_with_inline_framework() {
378        let toml_str = r#"
379[[framework]]
380name = "my-framework"
381enablers = ["my-framework-pkg"]
382entryPoints = ["src/routes/**/*.tsx"]
383"#;
384        let config: FallowConfig = toml::from_str(toml_str).unwrap();
385        assert_eq!(config.framework.len(), 1);
386        assert_eq!(config.framework[0].name, "my-framework");
387        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
388        assert_eq!(
389            config.framework[0].entry_points,
390            vec!["src/routes/**/*.tsx"]
391        );
392    }
393
394    #[test]
395    fn deserialize_toml_with_workspace_config() {
396        let toml_str = r#"
397[workspaces]
398patterns = ["packages/*", "apps/*"]
399"#;
400        let config: FallowConfig = toml::from_str(toml_str).unwrap();
401        assert!(config.workspaces.is_some());
402        let ws = config.workspaces.unwrap();
403        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
404    }
405
406    #[test]
407    fn deserialize_toml_with_ignore_exports() {
408        let toml_str = r#"
409[[ignoreExports]]
410file = "src/types/**/*.ts"
411exports = ["*"]
412"#;
413        let config: FallowConfig = toml::from_str(toml_str).unwrap();
414        assert_eq!(config.ignore_exports.len(), 1);
415        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
416        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
417    }
418
419    #[test]
420    fn deserialize_toml_deny_unknown_fields() {
421        let toml_str = r"bogus_field = true";
422        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
423        assert!(result.is_err(), "unknown fields should be rejected");
424    }
425
426    // ── Serialization roundtrip ─────────────────────────────────────
427
428    #[test]
429    fn json_serialize_roundtrip() {
430        let config = FallowConfig {
431            entry: vec!["src/main.ts".to_string()],
432            production: true,
433            ..FallowConfig::default()
434        };
435        let json = serde_json::to_string(&config).unwrap();
436        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
437        assert_eq!(restored.entry, vec!["src/main.ts"]);
438        assert!(restored.production);
439    }
440
441    #[test]
442    fn schema_field_not_serialized() {
443        let config = FallowConfig {
444            schema: Some("https://example.com/schema.json".to_string()),
445            ..FallowConfig::default()
446        };
447        let json = serde_json::to_string(&config).unwrap();
448        // $schema has skip_serializing, should not appear in output
449        assert!(
450            !json.contains("$schema"),
451            "schema field should be skipped in serialization"
452        );
453    }
454
455    #[test]
456    fn extends_field_not_serialized() {
457        let config = FallowConfig {
458            extends: vec!["base.json".to_string()],
459            ..FallowConfig::default()
460        };
461        let json = serde_json::to_string(&config).unwrap();
462        assert!(
463            !json.contains("extends"),
464            "extends field should be skipped in serialization"
465        );
466    }
467
468    // ── RegressionConfig / RegressionBaseline ──────────────────────
469
470    #[test]
471    fn regression_config_deserialize_json() {
472        let json = r#"{
473            "regression": {
474                "baseline": {
475                    "totalIssues": 42,
476                    "unusedFiles": 10,
477                    "unusedExports": 5,
478                    "circularDependencies": 2
479                }
480            }
481        }"#;
482        let config: FallowConfig = serde_json::from_str(json).unwrap();
483        let regression = config.regression.unwrap();
484        let baseline = regression.baseline.unwrap();
485        assert_eq!(baseline.total_issues, 42);
486        assert_eq!(baseline.unused_files, 10);
487        assert_eq!(baseline.unused_exports, 5);
488        assert_eq!(baseline.circular_dependencies, 2);
489        // Unset fields default to 0
490        assert_eq!(baseline.unused_types, 0);
491        assert_eq!(baseline.boundary_violations, 0);
492    }
493
494    #[test]
495    fn regression_config_defaults_to_none() {
496        let config: FallowConfig = serde_json::from_str("{}").unwrap();
497        assert!(config.regression.is_none());
498    }
499
500    #[test]
501    fn regression_baseline_all_zeros_by_default() {
502        let baseline = RegressionBaseline::default();
503        assert_eq!(baseline.total_issues, 0);
504        assert_eq!(baseline.unused_files, 0);
505        assert_eq!(baseline.unused_exports, 0);
506        assert_eq!(baseline.unused_types, 0);
507        assert_eq!(baseline.unused_dependencies, 0);
508        assert_eq!(baseline.unused_dev_dependencies, 0);
509        assert_eq!(baseline.unused_optional_dependencies, 0);
510        assert_eq!(baseline.unused_enum_members, 0);
511        assert_eq!(baseline.unused_class_members, 0);
512        assert_eq!(baseline.unresolved_imports, 0);
513        assert_eq!(baseline.unlisted_dependencies, 0);
514        assert_eq!(baseline.duplicate_exports, 0);
515        assert_eq!(baseline.circular_dependencies, 0);
516        assert_eq!(baseline.type_only_dependencies, 0);
517        assert_eq!(baseline.test_only_dependencies, 0);
518        assert_eq!(baseline.boundary_violations, 0);
519    }
520
521    #[test]
522    fn regression_config_serialize_roundtrip() {
523        let baseline = RegressionBaseline {
524            total_issues: 100,
525            unused_files: 20,
526            unused_exports: 30,
527            ..RegressionBaseline::default()
528        };
529        let regression = RegressionConfig {
530            baseline: Some(baseline),
531        };
532        let config = FallowConfig {
533            regression: Some(regression),
534            ..FallowConfig::default()
535        };
536        let json = serde_json::to_string(&config).unwrap();
537        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
538        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
539        assert_eq!(restored_baseline.total_issues, 100);
540        assert_eq!(restored_baseline.unused_files, 20);
541        assert_eq!(restored_baseline.unused_exports, 30);
542        assert_eq!(restored_baseline.unused_types, 0);
543    }
544
545    #[test]
546    fn regression_config_empty_baseline_deserialize() {
547        let json = r#"{"regression": {}}"#;
548        let config: FallowConfig = serde_json::from_str(json).unwrap();
549        let regression = config.regression.unwrap();
550        assert!(regression.baseline.is_none());
551    }
552
553    #[test]
554    fn regression_baseline_not_serialized_when_none() {
555        let config = FallowConfig {
556            regression: None,
557            ..FallowConfig::default()
558        };
559        let json = serde_json::to_string(&config).unwrap();
560        assert!(
561            !json.contains("regression"),
562            "regression should be skipped when None"
563        );
564    }
565
566    // ── JSON config with overrides and boundaries ──────────────────
567
568    #[test]
569    fn deserialize_json_with_overrides() {
570        let json = r#"{
571            "overrides": [
572                {
573                    "files": ["*.test.ts", "*.spec.ts"],
574                    "rules": {
575                        "unused-exports": "off",
576                        "unused-files": "warn"
577                    }
578                }
579            ]
580        }"#;
581        let config: FallowConfig = serde_json::from_str(json).unwrap();
582        assert_eq!(config.overrides.len(), 1);
583        assert_eq!(config.overrides[0].files.len(), 2);
584        assert_eq!(
585            config.overrides[0].rules.unused_exports,
586            Some(Severity::Off)
587        );
588        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
589    }
590
591    #[test]
592    fn deserialize_json_with_boundaries() {
593        let json = r#"{
594            "boundaries": {
595                "preset": "layered"
596            }
597        }"#;
598        let config: FallowConfig = serde_json::from_str(json).unwrap();
599        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
600    }
601
602    // ── TOML with regression config ────────────────────────────────
603
604    #[test]
605    fn deserialize_toml_with_regression_baseline() {
606        let toml_str = r"
607[regression.baseline]
608totalIssues = 50
609unusedFiles = 10
610unusedExports = 15
611";
612        let config: FallowConfig = toml::from_str(toml_str).unwrap();
613        let baseline = config.regression.unwrap().baseline.unwrap();
614        assert_eq!(baseline.total_issues, 50);
615        assert_eq!(baseline.unused_files, 10);
616        assert_eq!(baseline.unused_exports, 15);
617    }
618
619    // ── TOML with multiple overrides ───────────────────────────────
620
621    #[test]
622    fn deserialize_toml_with_overrides() {
623        let toml_str = r#"
624[[overrides]]
625files = ["*.test.ts"]
626
627[overrides.rules]
628unused-exports = "off"
629
630[[overrides]]
631files = ["*.stories.tsx"]
632
633[overrides.rules]
634unused-files = "off"
635"#;
636        let config: FallowConfig = toml::from_str(toml_str).unwrap();
637        assert_eq!(config.overrides.len(), 2);
638        assert_eq!(
639            config.overrides[0].rules.unused_exports,
640            Some(Severity::Off)
641        );
642        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
643    }
644
645    // ── Default regression config ──────────────────────────────────
646
647    #[test]
648    fn regression_config_default_is_none_baseline() {
649        let config = RegressionConfig::default();
650        assert!(config.baseline.is_none());
651    }
652
653    // ── Config with multiple ignore export rules ───────────────────
654
655    #[test]
656    fn deserialize_json_multiple_ignore_export_rules() {
657        let json = r#"{
658            "ignoreExports": [
659                {"file": "src/types/**/*.ts", "exports": ["*"]},
660                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
661                {"file": "src/index.ts", "exports": ["default"]}
662            ]
663        }"#;
664        let config: FallowConfig = serde_json::from_str(json).unwrap();
665        assert_eq!(config.ignore_exports.len(), 3);
666        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
667    }
668
669    // ── Public packages ───────────────────────────────────────────
670
671    #[test]
672    fn deserialize_json_public_packages_camel_case() {
673        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
674        let config: FallowConfig = serde_json::from_str(json).unwrap();
675        assert_eq!(
676            config.public_packages,
677            vec!["@myorg/shared-lib", "@myorg/utils"]
678        );
679    }
680
681    #[test]
682    fn deserialize_json_public_packages_rejects_snake_case() {
683        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
684        let result: Result<FallowConfig, _> = serde_json::from_str(json);
685        assert!(
686            result.is_err(),
687            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
688        );
689    }
690
691    #[test]
692    fn deserialize_json_public_packages_empty() {
693        let config: FallowConfig = serde_json::from_str("{}").unwrap();
694        assert!(config.public_packages.is_empty());
695    }
696
697    #[test]
698    fn deserialize_toml_public_packages() {
699        let toml_str = r#"
700publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
701"#;
702        let config: FallowConfig = toml::from_str(toml_str).unwrap();
703        assert_eq!(
704            config.public_packages,
705            vec!["@myorg/shared-lib", "@myorg/ui"]
706        );
707    }
708
709    #[test]
710    fn public_packages_serialize_roundtrip() {
711        let config = FallowConfig {
712            public_packages: vec!["@myorg/shared-lib".to_string()],
713            ..FallowConfig::default()
714        };
715        let json = serde_json::to_string(&config).unwrap();
716        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
717        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
718    }
719}