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