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
180/// Regression baseline counts, embedded in the config file.
181///
182/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
183/// fallow reads the baseline from this config section.
184/// When `--save-regression-baseline` is used without a path argument,
185/// fallow writes the baseline into the config file.
186#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
187#[serde(rename_all = "camelCase")]
188pub struct RegressionConfig {
189    /// Dead code issue counts baseline.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub baseline: Option<RegressionBaseline>,
192}
193
194/// Per-type issue counts for regression comparison.
195#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
196#[serde(rename_all = "camelCase")]
197pub struct RegressionBaseline {
198    #[serde(default)]
199    pub total_issues: usize,
200    #[serde(default)]
201    pub unused_files: usize,
202    #[serde(default)]
203    pub unused_exports: usize,
204    #[serde(default)]
205    pub unused_types: usize,
206    #[serde(default)]
207    pub unused_dependencies: usize,
208    #[serde(default)]
209    pub unused_dev_dependencies: usize,
210    #[serde(default)]
211    pub unused_optional_dependencies: usize,
212    #[serde(default)]
213    pub unused_enum_members: usize,
214    #[serde(default)]
215    pub unused_class_members: usize,
216    #[serde(default)]
217    pub unresolved_imports: usize,
218    #[serde(default)]
219    pub unlisted_dependencies: usize,
220    #[serde(default)]
221    pub duplicate_exports: usize,
222    #[serde(default)]
223    pub circular_dependencies: usize,
224    #[serde(default)]
225    pub type_only_dependencies: usize,
226    #[serde(default)]
227    pub test_only_dependencies: usize,
228    #[serde(default)]
229    pub boundary_violations: usize,
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    // ── Default trait ───────────────────────────────────────────────
237
238    #[test]
239    fn default_config_has_empty_collections() {
240        let config = FallowConfig::default();
241        assert!(config.schema.is_none());
242        assert!(config.extends.is_empty());
243        assert!(config.entry.is_empty());
244        assert!(config.ignore_patterns.is_empty());
245        assert!(config.framework.is_empty());
246        assert!(config.workspaces.is_none());
247        assert!(config.ignore_dependencies.is_empty());
248        assert!(config.ignore_exports.is_empty());
249        assert!(config.used_class_members.is_empty());
250        assert!(config.plugins.is_empty());
251        assert!(config.dynamically_loaded.is_empty());
252        assert!(config.overrides.is_empty());
253        assert!(config.public_packages.is_empty());
254        assert!(!config.production);
255    }
256
257    #[test]
258    fn default_config_rules_are_error() {
259        let config = FallowConfig::default();
260        assert_eq!(config.rules.unused_files, Severity::Error);
261        assert_eq!(config.rules.unused_exports, Severity::Error);
262        assert_eq!(config.rules.unused_dependencies, Severity::Error);
263    }
264
265    #[test]
266    fn default_config_duplicates_enabled() {
267        let config = FallowConfig::default();
268        assert!(config.duplicates.enabled);
269        assert_eq!(config.duplicates.min_tokens, 50);
270        assert_eq!(config.duplicates.min_lines, 5);
271    }
272
273    #[test]
274    fn default_config_health_thresholds() {
275        let config = FallowConfig::default();
276        assert_eq!(config.health.max_cyclomatic, 20);
277        assert_eq!(config.health.max_cognitive, 15);
278    }
279
280    // ── JSON deserialization ────────────────────────────────────────
281
282    #[test]
283    fn deserialize_empty_json_object() {
284        let config: FallowConfig = serde_json::from_str("{}").unwrap();
285        assert!(config.entry.is_empty());
286        assert!(!config.production);
287    }
288
289    #[test]
290    fn deserialize_json_with_all_top_level_fields() {
291        let json = r#"{
292            "$schema": "https://fallow.dev/schema.json",
293            "entry": ["src/main.ts"],
294            "ignorePatterns": ["generated/**"],
295            "ignoreDependencies": ["postcss"],
296            "production": true,
297            "plugins": ["custom-plugin.toml"],
298            "rules": {"unused-files": "warn"},
299            "duplicates": {"enabled": false},
300            "health": {"maxCyclomatic": 30}
301        }"#;
302        let config: FallowConfig = serde_json::from_str(json).unwrap();
303        assert_eq!(
304            config.schema.as_deref(),
305            Some("https://fallow.dev/schema.json")
306        );
307        assert_eq!(config.entry, vec!["src/main.ts"]);
308        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
309        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
310        assert!(config.production);
311        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
312        assert_eq!(config.rules.unused_files, Severity::Warn);
313        assert!(!config.duplicates.enabled);
314        assert_eq!(config.health.max_cyclomatic, 30);
315    }
316
317    #[test]
318    fn deserialize_json_deny_unknown_fields() {
319        let json = r#"{"unknownField": true}"#;
320        let result: Result<FallowConfig, _> = serde_json::from_str(json);
321        assert!(result.is_err(), "unknown fields should be rejected");
322    }
323
324    #[test]
325    fn deserialize_json_production_mode_default_false() {
326        let config: FallowConfig = serde_json::from_str("{}").unwrap();
327        assert!(!config.production);
328    }
329
330    #[test]
331    fn deserialize_json_production_mode_true() {
332        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
333        assert!(config.production);
334    }
335
336    #[test]
337    fn deserialize_json_dynamically_loaded() {
338        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
339        let config: FallowConfig = serde_json::from_str(json).unwrap();
340        assert_eq!(
341            config.dynamically_loaded,
342            vec!["plugins/**/*.ts", "locales/**/*.json"]
343        );
344    }
345
346    #[test]
347    fn deserialize_json_dynamically_loaded_defaults_empty() {
348        let config: FallowConfig = serde_json::from_str("{}").unwrap();
349        assert!(config.dynamically_loaded.is_empty());
350    }
351
352    // ── TOML deserialization ────────────────────────────────────────
353
354    #[test]
355    fn deserialize_toml_minimal() {
356        let toml_str = r#"
357entry = ["src/index.ts"]
358production = true
359"#;
360        let config: FallowConfig = toml::from_str(toml_str).unwrap();
361        assert_eq!(config.entry, vec!["src/index.ts"]);
362        assert!(config.production);
363    }
364
365    #[test]
366    fn deserialize_toml_with_inline_framework() {
367        let toml_str = r#"
368[[framework]]
369name = "my-framework"
370enablers = ["my-framework-pkg"]
371entryPoints = ["src/routes/**/*.tsx"]
372"#;
373        let config: FallowConfig = toml::from_str(toml_str).unwrap();
374        assert_eq!(config.framework.len(), 1);
375        assert_eq!(config.framework[0].name, "my-framework");
376        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
377        assert_eq!(
378            config.framework[0].entry_points,
379            vec!["src/routes/**/*.tsx"]
380        );
381    }
382
383    #[test]
384    fn deserialize_toml_with_workspace_config() {
385        let toml_str = r#"
386[workspaces]
387patterns = ["packages/*", "apps/*"]
388"#;
389        let config: FallowConfig = toml::from_str(toml_str).unwrap();
390        assert!(config.workspaces.is_some());
391        let ws = config.workspaces.unwrap();
392        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
393    }
394
395    #[test]
396    fn deserialize_toml_with_ignore_exports() {
397        let toml_str = r#"
398[[ignoreExports]]
399file = "src/types/**/*.ts"
400exports = ["*"]
401"#;
402        let config: FallowConfig = toml::from_str(toml_str).unwrap();
403        assert_eq!(config.ignore_exports.len(), 1);
404        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
405        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
406    }
407
408    #[test]
409    fn deserialize_toml_deny_unknown_fields() {
410        let toml_str = r"bogus_field = true";
411        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
412        assert!(result.is_err(), "unknown fields should be rejected");
413    }
414
415    // ── Serialization roundtrip ─────────────────────────────────────
416
417    #[test]
418    fn json_serialize_roundtrip() {
419        let config = FallowConfig {
420            entry: vec!["src/main.ts".to_string()],
421            production: true,
422            ..FallowConfig::default()
423        };
424        let json = serde_json::to_string(&config).unwrap();
425        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
426        assert_eq!(restored.entry, vec!["src/main.ts"]);
427        assert!(restored.production);
428    }
429
430    #[test]
431    fn schema_field_not_serialized() {
432        let config = FallowConfig {
433            schema: Some("https://example.com/schema.json".to_string()),
434            ..FallowConfig::default()
435        };
436        let json = serde_json::to_string(&config).unwrap();
437        // $schema has skip_serializing, should not appear in output
438        assert!(
439            !json.contains("$schema"),
440            "schema field should be skipped in serialization"
441        );
442    }
443
444    #[test]
445    fn extends_field_not_serialized() {
446        let config = FallowConfig {
447            extends: vec!["base.json".to_string()],
448            ..FallowConfig::default()
449        };
450        let json = serde_json::to_string(&config).unwrap();
451        assert!(
452            !json.contains("extends"),
453            "extends field should be skipped in serialization"
454        );
455    }
456
457    // ── RegressionConfig / RegressionBaseline ──────────────────────
458
459    #[test]
460    fn regression_config_deserialize_json() {
461        let json = r#"{
462            "regression": {
463                "baseline": {
464                    "totalIssues": 42,
465                    "unusedFiles": 10,
466                    "unusedExports": 5,
467                    "circularDependencies": 2
468                }
469            }
470        }"#;
471        let config: FallowConfig = serde_json::from_str(json).unwrap();
472        let regression = config.regression.unwrap();
473        let baseline = regression.baseline.unwrap();
474        assert_eq!(baseline.total_issues, 42);
475        assert_eq!(baseline.unused_files, 10);
476        assert_eq!(baseline.unused_exports, 5);
477        assert_eq!(baseline.circular_dependencies, 2);
478        // Unset fields default to 0
479        assert_eq!(baseline.unused_types, 0);
480        assert_eq!(baseline.boundary_violations, 0);
481    }
482
483    #[test]
484    fn regression_config_defaults_to_none() {
485        let config: FallowConfig = serde_json::from_str("{}").unwrap();
486        assert!(config.regression.is_none());
487    }
488
489    #[test]
490    fn regression_baseline_all_zeros_by_default() {
491        let baseline = RegressionBaseline::default();
492        assert_eq!(baseline.total_issues, 0);
493        assert_eq!(baseline.unused_files, 0);
494        assert_eq!(baseline.unused_exports, 0);
495        assert_eq!(baseline.unused_types, 0);
496        assert_eq!(baseline.unused_dependencies, 0);
497        assert_eq!(baseline.unused_dev_dependencies, 0);
498        assert_eq!(baseline.unused_optional_dependencies, 0);
499        assert_eq!(baseline.unused_enum_members, 0);
500        assert_eq!(baseline.unused_class_members, 0);
501        assert_eq!(baseline.unresolved_imports, 0);
502        assert_eq!(baseline.unlisted_dependencies, 0);
503        assert_eq!(baseline.duplicate_exports, 0);
504        assert_eq!(baseline.circular_dependencies, 0);
505        assert_eq!(baseline.type_only_dependencies, 0);
506        assert_eq!(baseline.test_only_dependencies, 0);
507        assert_eq!(baseline.boundary_violations, 0);
508    }
509
510    #[test]
511    fn regression_config_serialize_roundtrip() {
512        let baseline = RegressionBaseline {
513            total_issues: 100,
514            unused_files: 20,
515            unused_exports: 30,
516            ..RegressionBaseline::default()
517        };
518        let regression = RegressionConfig {
519            baseline: Some(baseline),
520        };
521        let config = FallowConfig {
522            regression: Some(regression),
523            ..FallowConfig::default()
524        };
525        let json = serde_json::to_string(&config).unwrap();
526        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
527        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
528        assert_eq!(restored_baseline.total_issues, 100);
529        assert_eq!(restored_baseline.unused_files, 20);
530        assert_eq!(restored_baseline.unused_exports, 30);
531        assert_eq!(restored_baseline.unused_types, 0);
532    }
533
534    #[test]
535    fn regression_config_empty_baseline_deserialize() {
536        let json = r#"{"regression": {}}"#;
537        let config: FallowConfig = serde_json::from_str(json).unwrap();
538        let regression = config.regression.unwrap();
539        assert!(regression.baseline.is_none());
540    }
541
542    #[test]
543    fn regression_baseline_not_serialized_when_none() {
544        let config = FallowConfig {
545            regression: None,
546            ..FallowConfig::default()
547        };
548        let json = serde_json::to_string(&config).unwrap();
549        assert!(
550            !json.contains("regression"),
551            "regression should be skipped when None"
552        );
553    }
554
555    // ── JSON config with overrides and boundaries ──────────────────
556
557    #[test]
558    fn deserialize_json_with_overrides() {
559        let json = r#"{
560            "overrides": [
561                {
562                    "files": ["*.test.ts", "*.spec.ts"],
563                    "rules": {
564                        "unused-exports": "off",
565                        "unused-files": "warn"
566                    }
567                }
568            ]
569        }"#;
570        let config: FallowConfig = serde_json::from_str(json).unwrap();
571        assert_eq!(config.overrides.len(), 1);
572        assert_eq!(config.overrides[0].files.len(), 2);
573        assert_eq!(
574            config.overrides[0].rules.unused_exports,
575            Some(Severity::Off)
576        );
577        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
578    }
579
580    #[test]
581    fn deserialize_json_with_boundaries() {
582        let json = r#"{
583            "boundaries": {
584                "preset": "layered"
585            }
586        }"#;
587        let config: FallowConfig = serde_json::from_str(json).unwrap();
588        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
589    }
590
591    // ── TOML with regression config ────────────────────────────────
592
593    #[test]
594    fn deserialize_toml_with_regression_baseline() {
595        let toml_str = r"
596[regression.baseline]
597totalIssues = 50
598unusedFiles = 10
599unusedExports = 15
600";
601        let config: FallowConfig = toml::from_str(toml_str).unwrap();
602        let baseline = config.regression.unwrap().baseline.unwrap();
603        assert_eq!(baseline.total_issues, 50);
604        assert_eq!(baseline.unused_files, 10);
605        assert_eq!(baseline.unused_exports, 15);
606    }
607
608    // ── TOML with multiple overrides ───────────────────────────────
609
610    #[test]
611    fn deserialize_toml_with_overrides() {
612        let toml_str = r#"
613[[overrides]]
614files = ["*.test.ts"]
615
616[overrides.rules]
617unused-exports = "off"
618
619[[overrides]]
620files = ["*.stories.tsx"]
621
622[overrides.rules]
623unused-files = "off"
624"#;
625        let config: FallowConfig = toml::from_str(toml_str).unwrap();
626        assert_eq!(config.overrides.len(), 2);
627        assert_eq!(
628            config.overrides[0].rules.unused_exports,
629            Some(Severity::Off)
630        );
631        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
632    }
633
634    // ── Default regression config ──────────────────────────────────
635
636    #[test]
637    fn regression_config_default_is_none_baseline() {
638        let config = RegressionConfig::default();
639        assert!(config.baseline.is_none());
640    }
641
642    // ── Config with multiple ignore export rules ───────────────────
643
644    #[test]
645    fn deserialize_json_multiple_ignore_export_rules() {
646        let json = r#"{
647            "ignoreExports": [
648                {"file": "src/types/**/*.ts", "exports": ["*"]},
649                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
650                {"file": "src/index.ts", "exports": ["default"]}
651            ]
652        }"#;
653        let config: FallowConfig = serde_json::from_str(json).unwrap();
654        assert_eq!(config.ignore_exports.len(), 3);
655        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
656    }
657
658    // ── Public packages ───────────────────────────────────────────
659
660    #[test]
661    fn deserialize_json_public_packages_camel_case() {
662        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
663        let config: FallowConfig = serde_json::from_str(json).unwrap();
664        assert_eq!(
665            config.public_packages,
666            vec!["@myorg/shared-lib", "@myorg/utils"]
667        );
668    }
669
670    #[test]
671    fn deserialize_json_public_packages_rejects_snake_case() {
672        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
673        let result: Result<FallowConfig, _> = serde_json::from_str(json);
674        assert!(
675            result.is_err(),
676            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
677        );
678    }
679
680    #[test]
681    fn deserialize_json_public_packages_empty() {
682        let config: FallowConfig = serde_json::from_str("{}").unwrap();
683        assert!(config.public_packages.is_empty());
684    }
685
686    #[test]
687    fn deserialize_toml_public_packages() {
688        let toml_str = r#"
689publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
690"#;
691        let config: FallowConfig = toml::from_str(toml_str).unwrap();
692        assert_eq!(
693            config.public_packages,
694            vec!["@myorg/shared-lib", "@myorg/ui"]
695        );
696    }
697
698    #[test]
699    fn public_packages_serialize_roundtrip() {
700        let config = FallowConfig {
701            public_packages: vec!["@myorg/shared-lib".to_string()],
702            ..FallowConfig::default()
703        };
704        let json = serde_json::to_string(&config).unwrap();
705        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
706        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
707    }
708}