Skip to main content

fallow_config/config/
mod.rs

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