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