Skip to main content

fallow_config/config/
mod.rs

1mod boundaries;
2mod duplicates_config;
3mod flags;
4mod format;
5pub mod glob_validation;
6mod health;
7mod parsing;
8mod resolution;
9mod resolve;
10mod rules;
11mod used_class_members;
12
13pub use boundaries::{
14    AuthoredRule, BoundaryConfig, BoundaryPreset, BoundaryRule, BoundaryZone, LogicalGroup,
15    LogicalGroupStatus, RedundantRootPrefix, ResolvedBoundaryConfig, ResolvedBoundaryRule,
16    ResolvedZone, UnknownZoneRef, ZoneReferenceKind, ZoneValidationError,
17};
18pub use duplicates_config::{
19    DetectionMode, DuplicatesConfig, NormalizationConfig, ResolvedNormalization,
20};
21pub use flags::{FlagsConfig, SdkPattern};
22pub use format::OutputFormat;
23pub use health::{EmailMode, HealthConfig, OwnershipConfig};
24pub use resolution::{
25    CompiledIgnoreCatalogReferenceRule, CompiledIgnoreDependencyOverrideRule,
26    CompiledIgnoreExportRule, ConfigOverride, IgnoreCatalogReferenceRule,
27    IgnoreDependencyOverrideRule, IgnoreExportRule, ResolvedConfig, ResolvedOverride,
28};
29pub use resolve::ResolveConfig;
30pub use rules::{PartialRulesConfig, RulesConfig, Severity};
31pub use used_class_members::{ScopedUsedClassMemberRule, UsedClassMemberRule};
32
33use schemars::JsonSchema;
34use serde::{Deserialize, Deserializer, Serialize};
35use std::ops::Not;
36
37use crate::external_plugin::ExternalPluginDef;
38use crate::workspace::WorkspaceConfig;
39
40/// Controls whether exports referenced only inside their defining file are
41/// reported as unused exports.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
43#[serde(untagged, rename_all = "camelCase")]
44pub enum IgnoreExportsUsedInFileConfig {
45    /// `true` suppresses both value and type exports that are referenced in
46    /// their defining file. `false` preserves the default cross-file behavior.
47    Bool(bool),
48    /// Knip-compatible fine-grained form. Fallow groups type aliases and
49    /// interfaces under `unused_types`, so either field enables type-export
50    /// suppression for same-file references.
51    ByKind(IgnoreExportsUsedInFileByKind),
52}
53
54impl Default for IgnoreExportsUsedInFileConfig {
55    fn default() -> Self {
56        Self::Bool(false)
57    }
58}
59
60impl From<bool> for IgnoreExportsUsedInFileConfig {
61    fn from(value: bool) -> Self {
62        Self::Bool(value)
63    }
64}
65
66impl From<IgnoreExportsUsedInFileByKind> for IgnoreExportsUsedInFileConfig {
67    fn from(value: IgnoreExportsUsedInFileByKind) -> Self {
68        Self::ByKind(value)
69    }
70}
71
72impl IgnoreExportsUsedInFileConfig {
73    /// Whether this option can suppress at least one kind of export.
74    #[must_use]
75    pub const fn is_enabled(self) -> bool {
76        match self {
77            Self::Bool(value) => value,
78            Self::ByKind(kind) => kind.type_ || kind.interface,
79        }
80    }
81
82    /// Whether same-file references should suppress this export kind.
83    #[must_use]
84    pub const fn suppresses(self, is_type_only: bool) -> bool {
85        match self {
86            Self::Bool(value) => value,
87            Self::ByKind(kind) => is_type_only && (kind.type_ || kind.interface),
88        }
89    }
90}
91
92/// Knip-compatible `ignoreExportsUsedInFile` object form.
93#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
94#[serde(rename_all = "camelCase")]
95pub struct IgnoreExportsUsedInFileByKind {
96    /// Suppress same-file references for exported type aliases.
97    #[serde(default, rename = "type")]
98    pub type_: bool,
99    /// Suppress same-file references for exported interfaces.
100    #[serde(default)]
101    pub interface: bool,
102}
103
104/// Auto-fix behavior settings.
105#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
106#[serde(rename_all = "camelCase")]
107pub struct FixConfig {
108    /// Auto-fix behavior for pnpm catalog edits.
109    #[serde(default)]
110    pub catalog: CatalogFixConfig,
111}
112
113/// Auto-fix behavior for pnpm catalog entries.
114#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
115#[serde(rename_all = "camelCase")]
116pub struct CatalogFixConfig {
117    /// Whether removing an unused catalog entry also removes the contiguous
118    /// YAML comment block immediately above it.
119    #[serde(default)]
120    pub delete_preceding_comments: CatalogPrecedingCommentPolicy,
121}
122
123/// Policy for deleting comments immediately above removed catalog entries.
124#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
125#[serde(rename_all = "lowercase")]
126pub enum CatalogPrecedingCommentPolicy {
127    /// Delete the comment block when it is separated from previous siblings by
128    /// a blank line, or when it directly follows the parent catalog header.
129    #[default]
130    Auto,
131    /// Always delete the contiguous comment block immediately above the entry.
132    Always,
133    /// Never delete leading comments; leave them in place as orphan comments.
134    Never,
135}
136
137/// User-facing configuration loaded from `.fallowrc.json`, `.fallowrc.jsonc`, `fallow.toml`, or `.fallow.toml`.
138///
139/// # Examples
140///
141/// ```
142/// use fallow_config::FallowConfig;
143///
144/// // Default config has sensible defaults
145/// let config = FallowConfig::default();
146/// assert!(config.entry.is_empty());
147/// assert!(!config.production);
148///
149/// // Deserialize from JSON
150/// let config: FallowConfig = serde_json::from_str(r#"{
151///     "entry": ["src/main.ts"],
152///     "production": true
153/// }"#).unwrap();
154/// assert_eq!(config.entry, vec!["src/main.ts"]);
155/// assert!(config.production);
156/// ```
157#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
158#[serde(deny_unknown_fields, rename_all = "camelCase")]
159pub struct FallowConfig {
160    /// JSON Schema reference (ignored during deserialization).
161    #[serde(rename = "$schema", default, skip_serializing)]
162    pub schema: Option<String>,
163
164    /// Base config files to extend from.
165    ///
166    /// Supports three resolution strategies:
167    /// - **Relative paths**: `"./base.json"` — resolved relative to the config file.
168    /// - **npm packages**: `"npm:@co/config"` — resolved by walking up `node_modules/`.
169    ///   Package resolution checks `package.json` `exports`/`main` first, then falls back
170    ///   to standard config file names. Subpaths are supported (e.g., `npm:@co/config/strict.json`).
171    /// - **HTTPS URLs**: `"https://example.com/fallow-base.json"` — fetched remotely.
172    ///   Only HTTPS is supported (no plain HTTP). URL-sourced configs may extend other
173    ///   URLs or `npm:` packages, but not relative paths. Only JSON/JSONC format is
174    ///   supported for remote configs. Timeout is configurable via
175    ///   `FALLOW_EXTENDS_TIMEOUT_SECS` (default: 5s).
176    ///
177    /// Base configs are loaded first, then this config's values override them.
178    /// Later entries in the array override earlier ones.
179    ///
180    /// **Note:** `npm:` resolution uses `node_modules/` directory walk-up and is
181    /// incompatible with Yarn Plug'n'Play (PnP), which has no `node_modules/`.
182    /// URL extends fetch on every run (no caching). For reliable CI, prefer `npm:`
183    /// for private or critical configs.
184    #[serde(default, skip_serializing)]
185    pub extends: Vec<String>,
186
187    /// Additional entry point glob patterns.
188    #[serde(default)]
189    pub entry: Vec<String>,
190
191    /// Glob patterns to ignore from analysis.
192    #[serde(default)]
193    pub ignore_patterns: Vec<String>,
194
195    /// Custom framework definitions (inline plugin definitions).
196    #[serde(default)]
197    pub framework: Vec<ExternalPluginDef>,
198
199    /// Workspace overrides.
200    #[serde(default)]
201    pub workspaces: Option<WorkspaceConfig>,
202
203    /// Dependencies to ignore (always considered used and always considered available).
204    ///
205    /// Listed dependencies are excluded from both unused dependency and unlisted
206    /// dependency detection. Useful for runtime-provided packages like `bun:sqlite`
207    /// or implicitly available dependencies.
208    #[serde(default)]
209    pub ignore_dependencies: Vec<String>,
210
211    /// Export ignore rules.
212    #[serde(default)]
213    pub ignore_exports: Vec<IgnoreExportRule>,
214
215    /// Rules for suppressing `unresolved-catalog-reference` findings.
216    ///
217    /// Each rule matches by package name, optionally scoped to a specific
218    /// catalog and/or consumer `package.json` glob. Useful for staged catalog
219    /// migrations where the catalog edit lands separately from the consumer
220    /// edit, and for library-internal placeholder packages whose target
221    /// catalog isn't ready yet.
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub ignore_catalog_references: Vec<IgnoreCatalogReferenceRule>,
224
225    /// Rules for suppressing `unused-dependency-override` and
226    /// `misconfigured-dependency-override` findings.
227    ///
228    /// Each rule matches by override target package, optionally scoped to the
229    /// declaring source file (`pnpm-workspace.yaml` or `package.json`). Useful
230    /// for overrides targeting purely-transitive packages (CVE-fix pattern)
231    /// where the conservative static algorithm would otherwise cry wolf.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub ignore_dependency_overrides: Vec<IgnoreDependencyOverrideRule>,
234
235    /// Suppress unused-export findings when the exported symbol is referenced
236    /// inside the file that declares it. This mirrors Knip's
237    /// `ignoreExportsUsedInFile` option while still reporting exports that have
238    /// no references at all.
239    #[serde(default)]
240    pub ignore_exports_used_in_file: IgnoreExportsUsedInFileConfig,
241
242    /// Decorators that fallow should NOT treat as evidence of reflective use.
243    /// Members carrying only these decorators are checked for usage as if they
244    /// were undecorated. Members carrying any decorator NOT in this list stay
245    /// skipped (frameworks like NestJS, Angular, TypeORM rely on reflection so
246    /// the conservative default is to keep skipping).
247    ///
248    /// Matching rule: entries containing `.` (e.g. `"decorators.log"`) match
249    /// the full dotted path of a decorator. Bare entries (e.g. `"step"` or
250    /// `"decorators"`) match the leftmost segment; a bare `"decorators"` entry
251    /// thus collapses every `@decorators.*` decorator. Both `"@step"` and
252    /// `"step"` round-trip equivalently (a leading `@` is stripped before
253    /// matching).
254    ///
255    /// Entries that never match a decorator in the analyzed codebase produce
256    /// a one-time warning at end of run, mirroring the existing
257    /// `usedClassMembers` warn-on-unmatched-pattern behavior. See issue #471.
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub ignore_decorators: Vec<String>,
260
261    /// Class member method/property rules that should never be flagged as
262    /// unused. Supports plain member names for global suppression and scoped
263    /// objects with `extends` / `implements` constraints for framework-invoked
264    /// methods that should only be suppressed on matching classes.
265    #[serde(default)]
266    pub used_class_members: Vec<UsedClassMemberRule>,
267
268    /// Duplication detection settings.
269    #[serde(default)]
270    pub duplicates: DuplicatesConfig,
271
272    /// Complexity health metrics settings.
273    #[serde(default)]
274    pub health: HealthConfig,
275
276    /// Per-issue-type severity rules.
277    #[serde(default)]
278    pub rules: RulesConfig,
279
280    /// Architecture boundary enforcement configuration.
281    #[serde(default)]
282    pub boundaries: BoundaryConfig,
283
284    /// Feature flag detection configuration.
285    #[serde(default)]
286    pub flags: FlagsConfig,
287
288    /// Auto-fix behavior settings.
289    #[serde(default)]
290    pub fix: FixConfig,
291
292    /// Module resolver configuration (custom conditions, etc.).
293    #[serde(default)]
294    pub resolve: ResolveConfig,
295
296    /// Production mode: exclude test/dev files, only start/build scripts.
297    ///
298    /// Accepts the legacy boolean form (`true` applies to all analyses) or a
299    /// per-analysis object (`{ "deadCode": false, "health": true, "dupes": false }`).
300    #[serde(default)]
301    pub production: ProductionConfig,
302
303    /// Paths to external plugin files or directories containing plugin files.
304    ///
305    /// Supports TOML, JSON, and JSONC formats.
306    ///
307    /// In addition to these explicit paths, fallow automatically discovers:
308    /// - `*.toml`, `*.json`, `*.jsonc` files in `.fallow/plugins/`
309    /// - `fallow-plugin-*.{toml,json,jsonc}` files in the project root
310    #[serde(default)]
311    pub plugins: Vec<String>,
312
313    /// Glob patterns for files that are dynamically loaded at runtime
314    /// (plugin directories, locale files, etc.). These files are treated as
315    /// always-used and will never be flagged as unused.
316    #[serde(default)]
317    pub dynamically_loaded: Vec<String>,
318
319    /// Per-file rule overrides matching oxlint's overrides pattern.
320    #[serde(default)]
321    pub overrides: Vec<ConfigOverride>,
322
323    /// Path to a CODEOWNERS file for `--group-by owner`.
324    ///
325    /// When unset, fallow auto-probes `CODEOWNERS`, `.github/CODEOWNERS`,
326    /// `.gitlab/CODEOWNERS`, and `docs/CODEOWNERS`. Set this to use a
327    /// non-standard location.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub codeowners: Option<String>,
330
331    /// Workspace package name patterns that are public libraries.
332    /// Exported API surface from these packages is not flagged as unused.
333    #[serde(default)]
334    pub public_packages: Vec<String>,
335
336    /// Regression detection baseline embedded in config.
337    /// Stores issue counts from a known-good state for CI regression checks.
338    /// Populated by `--save-regression-baseline` (no path), read by `--fail-on-regression`.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub regression: Option<RegressionConfig>,
341
342    /// Audit command baseline paths (one per analysis: dead-code, health, dupes).
343    ///
344    /// `fallow audit` runs three analyses and each has its own baseline format.
345    /// Paths in this section are resolved relative to the project root. CLI flags
346    /// (`--dead-code-baseline`, `--health-baseline`, `--dupes-baseline`) override
347    /// these values when provided.
348    #[serde(default, skip_serializing_if = "AuditConfig::is_empty")]
349    pub audit: AuditConfig,
350
351    /// Mark this config as sealed: `extends` paths must be file-relative and
352    /// resolve within this config's own directory. `npm:` and `https:` extends
353    /// are rejected. Useful for library publishers and monorepo sub-packages
354    /// that want to guarantee their config is self-contained and not subject
355    /// to ancestor configs being injected via `extends`.
356    ///
357    /// Discovery is unaffected (first-match-wins already stops the directory
358    /// walk at the nearest config). This only constrains `extends`.
359    #[serde(default)]
360    pub sealed: bool,
361
362    /// Report unused exports in entry files instead of auto-marking them as
363    /// used. Catches typos in framework exports (e.g. `meatdata` instead of
364    /// `metadata`). The CLI flag `--include-entry-exports` (global) overrides
365    /// this when set; otherwise the config value is used.
366    #[serde(default)]
367    pub include_entry_exports: bool,
368
369    /// Incremental cache tuning. Today the only knob is `maxSizeMb`, which
370    /// caps the on-disk cache and triggers LRU eviction during save. See
371    /// [`CacheConfig`].
372    #[serde(default, skip_serializing_if = "CacheConfig::is_default")]
373    pub cache: CacheConfig,
374}
375
376/// Incremental cache configuration.
377///
378/// Today only `maxSizeMb` is exposed. The env var `FALLOW_CACHE_MAX_SIZE`
379/// (also in MB) wins over this field when both are set. The default cap is
380/// 256 MB; values are interpreted as whole megabytes.
381#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
382#[serde(deny_unknown_fields, rename_all = "camelCase")]
383pub struct CacheConfig {
384    /// Maximum on-disk cache size in megabytes. When the serialized cache
385    /// exceeds 80% of this cap during save, the oldest entries are evicted
386    /// down to 60% of the cap. Default: 256 MB.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub max_size_mb: Option<u32>,
389}
390
391impl CacheConfig {
392    /// Whether the config carries no overrides (used to suppress serialization
393    /// of the `cache` field when the user has not customized it).
394    #[must_use]
395    pub fn is_default(&self) -> bool {
396        self.max_size_mb.is_none()
397    }
398}
399
400/// Analysis-specific production-mode selector.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum ProductionAnalysis {
403    DeadCode,
404    Health,
405    Dupes,
406}
407
408/// Production-mode defaults.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
410#[serde(untagged)]
411pub enum ProductionConfig {
412    /// Legacy/global form: `production = true` or `"production": true`.
413    Global(bool),
414    /// Per-analysis form.
415    PerAnalysis(PerAnalysisProductionConfig),
416}
417
418impl<'de> Deserialize<'de> for ProductionConfig {
419    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
420    where
421        D: Deserializer<'de>,
422    {
423        struct ProductionConfigVisitor;
424
425        impl<'de> serde::de::Visitor<'de> for ProductionConfigVisitor {
426            type Value = ProductionConfig;
427
428            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429                formatter.write_str("a boolean or per-analysis production config object")
430            }
431
432            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
433            where
434                E: serde::de::Error,
435            {
436                Ok(ProductionConfig::Global(value))
437            }
438
439            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
440            where
441                A: serde::de::MapAccess<'de>,
442            {
443                PerAnalysisProductionConfig::deserialize(
444                    serde::de::value::MapAccessDeserializer::new(map),
445                )
446                .map(ProductionConfig::PerAnalysis)
447            }
448        }
449
450        deserializer.deserialize_any(ProductionConfigVisitor)
451    }
452}
453
454impl Default for ProductionConfig {
455    fn default() -> Self {
456        Self::Global(false)
457    }
458}
459
460impl From<bool> for ProductionConfig {
461    fn from(value: bool) -> Self {
462        Self::Global(value)
463    }
464}
465
466impl Not for ProductionConfig {
467    type Output = bool;
468
469    fn not(self) -> Self::Output {
470        !self.any_enabled()
471    }
472}
473
474impl ProductionConfig {
475    #[must_use]
476    pub const fn for_analysis(self, analysis: ProductionAnalysis) -> bool {
477        match self {
478            Self::Global(value) => value,
479            Self::PerAnalysis(config) => match analysis {
480                ProductionAnalysis::DeadCode => config.dead_code,
481                ProductionAnalysis::Health => config.health,
482                ProductionAnalysis::Dupes => config.dupes,
483            },
484        }
485    }
486
487    #[must_use]
488    pub const fn global(self) -> bool {
489        match self {
490            Self::Global(value) => value,
491            Self::PerAnalysis(_) => false,
492        }
493    }
494
495    #[must_use]
496    pub const fn any_enabled(self) -> bool {
497        match self {
498            Self::Global(value) => value,
499            Self::PerAnalysis(config) => config.dead_code || config.health || config.dupes,
500        }
501    }
502}
503
504/// Per-analysis production-mode defaults.
505#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
506#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
507pub struct PerAnalysisProductionConfig {
508    /// Production mode for dead-code analysis.
509    pub dead_code: bool,
510    /// Production mode for health analysis.
511    pub health: bool,
512    /// Production mode for duplication analysis.
513    pub dupes: bool,
514}
515
516/// Per-analysis baseline paths for the `audit` command.
517///
518/// Each field points to a baseline file produced by the corresponding
519/// subcommand (`fallow dead-code --save-baseline`, `fallow health --save-baseline`,
520/// `fallow dupes --save-baseline`). `audit` passes each baseline through to its
521/// underlying analysis; baseline-matched issues are excluded from the verdict.
522#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
523#[serde(rename_all = "camelCase")]
524pub struct AuditConfig {
525    /// Which findings should make `fallow audit` fail.
526    #[serde(default, skip_serializing_if = "AuditGate::is_default")]
527    pub gate: AuditGate,
528
529    /// Path to the dead-code baseline (produced by `fallow dead-code --save-baseline`).
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub dead_code_baseline: Option<String>,
532
533    /// Path to the health baseline (produced by `fallow health --save-baseline`).
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub health_baseline: Option<String>,
536
537    /// Path to the duplication baseline (produced by `fallow dupes --save-baseline`).
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub dupes_baseline: Option<String>,
540
541    /// Maximum age (in days since last reuse or fresh create) of a persistent
542    /// reusable base-snapshot worktree cache entry. Older entries are removed
543    /// at the top of the next `fallow audit` invocation. The env var
544    /// `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` wins over this field. Unset on both
545    /// sides defaults to 30 days. Setting either source to `0` disables the
546    /// sweep entirely (escape hatch for CI runners that prune caches
547    /// out-of-band). Invalid env var values (non-integer, negative) silently
548    /// fall back to this field / default rather than failing the audit.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub cache_max_age_days: Option<u32>,
551}
552
553impl AuditConfig {
554    /// True when all baseline paths are unset.
555    #[must_use]
556    pub fn is_empty(&self) -> bool {
557        self.gate.is_default()
558            && self.dead_code_baseline.is_none()
559            && self.health_baseline.is_none()
560            && self.dupes_baseline.is_none()
561            && self.cache_max_age_days.is_none()
562    }
563}
564
565/// Gating mode for `fallow audit`.
566#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
567#[serde(rename_all = "kebab-case")]
568pub enum AuditGate {
569    /// Only findings introduced by the current changeset affect the verdict.
570    #[default]
571    NewOnly,
572    /// All findings in changed files affect the verdict.
573    All,
574}
575
576impl AuditGate {
577    #[must_use]
578    pub const fn is_default(&self) -> bool {
579        matches!(self, Self::NewOnly)
580    }
581}
582
583/// Regression baseline counts, embedded in the config file.
584///
585/// When `--fail-on-regression` is used without `--regression-baseline <PATH>`,
586/// fallow reads the baseline from this config section.
587/// When `--save-regression-baseline` is used without a path argument,
588/// fallow writes the baseline into the config file.
589#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
590#[serde(rename_all = "camelCase")]
591pub struct RegressionConfig {
592    /// Dead code issue counts baseline.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub baseline: Option<RegressionBaseline>,
595}
596
597/// Per-type issue counts for regression comparison.
598#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
599#[serde(rename_all = "camelCase")]
600pub struct RegressionBaseline {
601    #[serde(default)]
602    pub total_issues: usize,
603    #[serde(default)]
604    pub unused_files: usize,
605    #[serde(default)]
606    pub unused_exports: usize,
607    #[serde(default)]
608    pub unused_types: usize,
609    #[serde(default)]
610    pub unused_dependencies: usize,
611    #[serde(default)]
612    pub unused_dev_dependencies: usize,
613    #[serde(default)]
614    pub unused_optional_dependencies: usize,
615    #[serde(default)]
616    pub unused_enum_members: usize,
617    #[serde(default)]
618    pub unused_class_members: usize,
619    #[serde(default)]
620    pub unresolved_imports: usize,
621    #[serde(default)]
622    pub unlisted_dependencies: usize,
623    #[serde(default)]
624    pub duplicate_exports: usize,
625    #[serde(default)]
626    pub circular_dependencies: usize,
627    #[serde(default)]
628    pub re_export_cycles: usize,
629    #[serde(default)]
630    pub type_only_dependencies: usize,
631    #[serde(default)]
632    pub test_only_dependencies: usize,
633    #[serde(default)]
634    pub boundary_violations: usize,
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    // ── Default trait ───────────────────────────────────────────────
642
643    #[test]
644    fn default_config_has_empty_collections() {
645        let config = FallowConfig::default();
646        assert!(config.schema.is_none());
647        assert!(config.extends.is_empty());
648        assert!(config.entry.is_empty());
649        assert!(config.ignore_patterns.is_empty());
650        assert!(config.framework.is_empty());
651        assert!(config.workspaces.is_none());
652        assert!(config.ignore_dependencies.is_empty());
653        assert!(config.ignore_exports.is_empty());
654        assert!(config.used_class_members.is_empty());
655        assert!(config.plugins.is_empty());
656        assert!(config.dynamically_loaded.is_empty());
657        assert!(config.overrides.is_empty());
658        assert!(config.public_packages.is_empty());
659        assert_eq!(
660            config.fix.catalog.delete_preceding_comments,
661            CatalogPrecedingCommentPolicy::Auto
662        );
663        assert!(!config.production);
664    }
665
666    #[test]
667    fn default_config_rules_are_error() {
668        let config = FallowConfig::default();
669        assert_eq!(config.rules.unused_files, Severity::Error);
670        assert_eq!(config.rules.unused_exports, Severity::Error);
671        assert_eq!(config.rules.unused_dependencies, Severity::Error);
672    }
673
674    #[test]
675    fn default_config_duplicates_enabled() {
676        let config = FallowConfig::default();
677        assert!(config.duplicates.enabled);
678        assert_eq!(config.duplicates.min_tokens, 50);
679        assert_eq!(config.duplicates.min_lines, 5);
680    }
681
682    #[test]
683    fn default_config_health_thresholds() {
684        let config = FallowConfig::default();
685        assert_eq!(config.health.max_cyclomatic, 20);
686        assert_eq!(config.health.max_cognitive, 15);
687    }
688
689    // ── JSON deserialization ────────────────────────────────────────
690
691    #[test]
692    fn deserialize_empty_json_object() {
693        let config: FallowConfig = serde_json::from_str("{}").unwrap();
694        assert!(config.entry.is_empty());
695        assert!(!config.production);
696    }
697
698    #[test]
699    fn deserialize_json_with_all_top_level_fields() {
700        let json = r#"{
701            "$schema": "https://fallow.dev/schema.json",
702            "entry": ["src/main.ts"],
703            "ignorePatterns": ["generated/**"],
704            "ignoreDependencies": ["postcss"],
705            "production": true,
706            "plugins": ["custom-plugin.toml"],
707            "rules": {"unused-files": "warn"},
708            "duplicates": {"enabled": false},
709            "health": {"maxCyclomatic": 30}
710        }"#;
711        let config: FallowConfig = serde_json::from_str(json).unwrap();
712        assert_eq!(
713            config.schema.as_deref(),
714            Some("https://fallow.dev/schema.json")
715        );
716        assert_eq!(config.entry, vec!["src/main.ts"]);
717        assert_eq!(config.ignore_patterns, vec!["generated/**"]);
718        assert_eq!(config.ignore_dependencies, vec!["postcss"]);
719        assert!(config.production);
720        assert_eq!(config.plugins, vec!["custom-plugin.toml"]);
721        assert_eq!(config.rules.unused_files, Severity::Warn);
722        assert!(!config.duplicates.enabled);
723        assert_eq!(config.health.max_cyclomatic, 30);
724    }
725
726    #[test]
727    fn deserialize_json_deny_unknown_fields() {
728        let json = r#"{"unknownField": true}"#;
729        let result: Result<FallowConfig, _> = serde_json::from_str(json);
730        assert!(result.is_err(), "unknown fields should be rejected");
731    }
732
733    #[test]
734    fn deserialize_json_production_mode_default_false() {
735        let config: FallowConfig = serde_json::from_str("{}").unwrap();
736        assert!(!config.production);
737    }
738
739    #[test]
740    fn deserialize_json_production_mode_true() {
741        let config: FallowConfig = serde_json::from_str(r#"{"production": true}"#).unwrap();
742        assert!(config.production);
743    }
744
745    #[test]
746    fn deserialize_json_per_analysis_production_mode() {
747        let config: FallowConfig = serde_json::from_str(
748            r#"{"production": {"deadCode": false, "health": true, "dupes": false}}"#,
749        )
750        .unwrap();
751        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
752        assert!(config.production.for_analysis(ProductionAnalysis::Health));
753        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
754    }
755
756    #[test]
757    fn deserialize_json_per_analysis_production_mode_rejects_unknown_fields() {
758        let err = serde_json::from_str::<FallowConfig>(r#"{"production": {"healthTypo": true}}"#)
759            .unwrap_err();
760        assert!(
761            err.to_string().contains("healthTypo"),
762            "error should name the unknown field: {err}"
763        );
764    }
765
766    #[test]
767    fn deserialize_json_dynamically_loaded() {
768        let json = r#"{"dynamicallyLoaded": ["plugins/**/*.ts", "locales/**/*.json"]}"#;
769        let config: FallowConfig = serde_json::from_str(json).unwrap();
770        assert_eq!(
771            config.dynamically_loaded,
772            vec!["plugins/**/*.ts", "locales/**/*.json"]
773        );
774    }
775
776    #[test]
777    fn deserialize_json_dynamically_loaded_defaults_empty() {
778        let config: FallowConfig = serde_json::from_str("{}").unwrap();
779        assert!(config.dynamically_loaded.is_empty());
780    }
781
782    #[test]
783    fn deserialize_json_fix_catalog_delete_preceding_comments() {
784        let config: FallowConfig =
785            serde_json::from_str(r#"{"fix": {"catalog": {"deletePrecedingComments": "always"}}}"#)
786                .unwrap();
787        assert_eq!(
788            config.fix.catalog.delete_preceding_comments,
789            CatalogPrecedingCommentPolicy::Always
790        );
791    }
792
793    #[test]
794    fn deserialize_json_fix_catalog_delete_preceding_comments_rejects_unknown_policy() {
795        let err = serde_json::from_str::<FallowConfig>(
796            r#"{"fix": {"catalog": {"deletePrecedingComments": "sometimes"}}}"#,
797        )
798        .unwrap_err();
799        assert!(
800            err.to_string().contains("sometimes"),
801            "error should name the bad policy: {err}"
802        );
803    }
804
805    #[test]
806    fn deserialize_json_used_class_members_supports_strings_and_scoped_rules() {
807        let json = r#"{
808            "usedClassMembers": [
809                "agInit",
810                { "implements": "ICellRendererAngularComp", "members": ["refresh"] },
811                { "extends": "BaseCommand", "implements": "CanActivate", "members": ["execute"] }
812            ]
813        }"#;
814        let config: FallowConfig = serde_json::from_str(json).unwrap();
815        assert_eq!(
816            config.used_class_members,
817            vec![
818                UsedClassMemberRule::from("agInit"),
819                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
820                    extends: None,
821                    implements: Some("ICellRendererAngularComp".to_string()),
822                    members: vec!["refresh".to_string()],
823                }),
824                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
825                    extends: Some("BaseCommand".to_string()),
826                    implements: Some("CanActivate".to_string()),
827                    members: vec!["execute".to_string()],
828                }),
829            ]
830        );
831    }
832
833    // ── TOML deserialization ────────────────────────────────────────
834
835    #[test]
836    fn deserialize_toml_minimal() {
837        let toml_str = r#"
838entry = ["src/index.ts"]
839production = true
840"#;
841        let config: FallowConfig = toml::from_str(toml_str).unwrap();
842        assert_eq!(config.entry, vec!["src/index.ts"]);
843        assert!(config.production);
844    }
845
846    #[test]
847    fn deserialize_toml_per_analysis_production_mode() {
848        let toml_str = r"
849[production]
850deadCode = false
851health = true
852dupes = false
853";
854        let config: FallowConfig = toml::from_str(toml_str).unwrap();
855        assert!(!config.production.for_analysis(ProductionAnalysis::DeadCode));
856        assert!(config.production.for_analysis(ProductionAnalysis::Health));
857        assert!(!config.production.for_analysis(ProductionAnalysis::Dupes));
858    }
859
860    #[test]
861    fn deserialize_toml_per_analysis_production_mode_rejects_unknown_fields() {
862        let err = toml::from_str::<FallowConfig>(
863            r"
864[production]
865healthTypo = true
866",
867        )
868        .unwrap_err();
869        assert!(
870            err.to_string().contains("healthTypo"),
871            "error should name the unknown field: {err}"
872        );
873    }
874
875    #[test]
876    fn deserialize_toml_with_inline_framework() {
877        let toml_str = r#"
878[[framework]]
879name = "my-framework"
880enablers = ["my-framework-pkg"]
881entryPoints = ["src/routes/**/*.tsx"]
882"#;
883        let config: FallowConfig = toml::from_str(toml_str).unwrap();
884        assert_eq!(config.framework.len(), 1);
885        assert_eq!(config.framework[0].name, "my-framework");
886        assert_eq!(config.framework[0].enablers, vec!["my-framework-pkg"]);
887        assert_eq!(
888            config.framework[0].entry_points,
889            vec!["src/routes/**/*.tsx"]
890        );
891    }
892
893    #[test]
894    fn deserialize_toml_fix_catalog_delete_preceding_comments() {
895        let toml_str = r#"
896[fix.catalog]
897deletePrecedingComments = "never"
898"#;
899        let config: FallowConfig = toml::from_str(toml_str).unwrap();
900        assert_eq!(
901            config.fix.catalog.delete_preceding_comments,
902            CatalogPrecedingCommentPolicy::Never
903        );
904    }
905
906    #[test]
907    fn deserialize_toml_with_workspace_config() {
908        let toml_str = r#"
909[workspaces]
910patterns = ["packages/*", "apps/*"]
911"#;
912        let config: FallowConfig = toml::from_str(toml_str).unwrap();
913        assert!(config.workspaces.is_some());
914        let ws = config.workspaces.unwrap();
915        assert_eq!(ws.patterns, vec!["packages/*", "apps/*"]);
916    }
917
918    #[test]
919    fn deserialize_toml_with_ignore_exports() {
920        let toml_str = r#"
921[[ignoreExports]]
922file = "src/types/**/*.ts"
923exports = ["*"]
924"#;
925        let config: FallowConfig = toml::from_str(toml_str).unwrap();
926        assert_eq!(config.ignore_exports.len(), 1);
927        assert_eq!(config.ignore_exports[0].file, "src/types/**/*.ts");
928        assert_eq!(config.ignore_exports[0].exports, vec!["*"]);
929    }
930
931    #[test]
932    fn deserialize_toml_used_class_members_supports_scoped_rules() {
933        let toml_str = r#"
934usedClassMembers = [
935  { implements = "ICellRendererAngularComp", members = ["refresh"] },
936  { extends = "BaseCommand", members = ["execute"] },
937]
938"#;
939        let config: FallowConfig = toml::from_str(toml_str).unwrap();
940        assert_eq!(
941            config.used_class_members,
942            vec![
943                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
944                    extends: None,
945                    implements: Some("ICellRendererAngularComp".to_string()),
946                    members: vec!["refresh".to_string()],
947                }),
948                UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
949                    extends: Some("BaseCommand".to_string()),
950                    implements: None,
951                    members: vec!["execute".to_string()],
952                }),
953            ]
954        );
955    }
956
957    #[test]
958    fn deserialize_json_used_class_members_rejects_unconstrained_scoped_rules() {
959        let result = serde_json::from_str::<FallowConfig>(
960            r#"{"usedClassMembers":[{"members":["refresh"]}]}"#,
961        );
962        assert!(
963            result.is_err(),
964            "unconstrained scoped rule should be rejected"
965        );
966    }
967
968    #[test]
969    fn deserialize_ignore_exports_used_in_file_bool() {
970        let config: FallowConfig =
971            serde_json::from_str(r#"{"ignoreExportsUsedInFile":true}"#).unwrap();
972
973        assert!(config.ignore_exports_used_in_file.suppresses(false));
974        assert!(config.ignore_exports_used_in_file.suppresses(true));
975    }
976
977    #[test]
978    fn deserialize_ignore_exports_used_in_file_kind_form() {
979        let config: FallowConfig =
980            serde_json::from_str(r#"{"ignoreExportsUsedInFile":{"type":true}}"#).unwrap();
981
982        assert!(!config.ignore_exports_used_in_file.suppresses(false));
983        assert!(config.ignore_exports_used_in_file.suppresses(true));
984    }
985
986    #[test]
987    fn deserialize_toml_deny_unknown_fields() {
988        let toml_str = r"bogus_field = true";
989        let result: Result<FallowConfig, _> = toml::from_str(toml_str);
990        assert!(result.is_err(), "unknown fields should be rejected");
991    }
992
993    // ── Serialization roundtrip ─────────────────────────────────────
994
995    #[test]
996    fn json_serialize_roundtrip() {
997        let config = FallowConfig {
998            entry: vec!["src/main.ts".to_string()],
999            production: true.into(),
1000            ..FallowConfig::default()
1001        };
1002        let json = serde_json::to_string(&config).unwrap();
1003        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1004        assert_eq!(restored.entry, vec!["src/main.ts"]);
1005        assert!(restored.production);
1006    }
1007
1008    #[test]
1009    fn schema_field_not_serialized() {
1010        let config = FallowConfig {
1011            schema: Some("https://example.com/schema.json".to_string()),
1012            ..FallowConfig::default()
1013        };
1014        let json = serde_json::to_string(&config).unwrap();
1015        // $schema has skip_serializing, should not appear in output
1016        assert!(
1017            !json.contains("$schema"),
1018            "schema field should be skipped in serialization"
1019        );
1020    }
1021
1022    #[test]
1023    fn extends_field_not_serialized() {
1024        let config = FallowConfig {
1025            extends: vec!["base.json".to_string()],
1026            ..FallowConfig::default()
1027        };
1028        let json = serde_json::to_string(&config).unwrap();
1029        assert!(
1030            !json.contains("extends"),
1031            "extends field should be skipped in serialization"
1032        );
1033    }
1034
1035    // ── RegressionConfig / RegressionBaseline ──────────────────────
1036
1037    #[test]
1038    fn regression_config_deserialize_json() {
1039        let json = r#"{
1040            "regression": {
1041                "baseline": {
1042                    "totalIssues": 42,
1043                    "unusedFiles": 10,
1044                    "unusedExports": 5,
1045                    "circularDependencies": 2
1046                }
1047            }
1048        }"#;
1049        let config: FallowConfig = serde_json::from_str(json).unwrap();
1050        let regression = config.regression.unwrap();
1051        let baseline = regression.baseline.unwrap();
1052        assert_eq!(baseline.total_issues, 42);
1053        assert_eq!(baseline.unused_files, 10);
1054        assert_eq!(baseline.unused_exports, 5);
1055        assert_eq!(baseline.circular_dependencies, 2);
1056        // Unset fields default to 0
1057        assert_eq!(baseline.unused_types, 0);
1058        assert_eq!(baseline.boundary_violations, 0);
1059    }
1060
1061    #[test]
1062    fn regression_config_defaults_to_none() {
1063        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1064        assert!(config.regression.is_none());
1065    }
1066
1067    #[test]
1068    fn regression_baseline_all_zeros_by_default() {
1069        let baseline = RegressionBaseline::default();
1070        assert_eq!(baseline.total_issues, 0);
1071        assert_eq!(baseline.unused_files, 0);
1072        assert_eq!(baseline.unused_exports, 0);
1073        assert_eq!(baseline.unused_types, 0);
1074        assert_eq!(baseline.unused_dependencies, 0);
1075        assert_eq!(baseline.unused_dev_dependencies, 0);
1076        assert_eq!(baseline.unused_optional_dependencies, 0);
1077        assert_eq!(baseline.unused_enum_members, 0);
1078        assert_eq!(baseline.unused_class_members, 0);
1079        assert_eq!(baseline.unresolved_imports, 0);
1080        assert_eq!(baseline.unlisted_dependencies, 0);
1081        assert_eq!(baseline.duplicate_exports, 0);
1082        assert_eq!(baseline.circular_dependencies, 0);
1083        assert_eq!(baseline.type_only_dependencies, 0);
1084        assert_eq!(baseline.test_only_dependencies, 0);
1085        assert_eq!(baseline.boundary_violations, 0);
1086    }
1087
1088    #[test]
1089    fn regression_config_serialize_roundtrip() {
1090        let baseline = RegressionBaseline {
1091            total_issues: 100,
1092            unused_files: 20,
1093            unused_exports: 30,
1094            ..RegressionBaseline::default()
1095        };
1096        let regression = RegressionConfig {
1097            baseline: Some(baseline),
1098        };
1099        let config = FallowConfig {
1100            regression: Some(regression),
1101            ..FallowConfig::default()
1102        };
1103        let json = serde_json::to_string(&config).unwrap();
1104        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1105        let restored_baseline = restored.regression.unwrap().baseline.unwrap();
1106        assert_eq!(restored_baseline.total_issues, 100);
1107        assert_eq!(restored_baseline.unused_files, 20);
1108        assert_eq!(restored_baseline.unused_exports, 30);
1109        assert_eq!(restored_baseline.unused_types, 0);
1110    }
1111
1112    #[test]
1113    fn regression_config_empty_baseline_deserialize() {
1114        let json = r#"{"regression": {}}"#;
1115        let config: FallowConfig = serde_json::from_str(json).unwrap();
1116        let regression = config.regression.unwrap();
1117        assert!(regression.baseline.is_none());
1118    }
1119
1120    #[test]
1121    fn regression_baseline_not_serialized_when_none() {
1122        let config = FallowConfig {
1123            regression: None,
1124            ..FallowConfig::default()
1125        };
1126        let json = serde_json::to_string(&config).unwrap();
1127        assert!(
1128            !json.contains("regression"),
1129            "regression should be skipped when None"
1130        );
1131    }
1132
1133    // ── JSON config with overrides and boundaries ──────────────────
1134
1135    #[test]
1136    fn deserialize_json_with_overrides() {
1137        let json = r#"{
1138            "overrides": [
1139                {
1140                    "files": ["*.test.ts", "*.spec.ts"],
1141                    "rules": {
1142                        "unused-exports": "off",
1143                        "unused-files": "warn"
1144                    }
1145                }
1146            ]
1147        }"#;
1148        let config: FallowConfig = serde_json::from_str(json).unwrap();
1149        assert_eq!(config.overrides.len(), 1);
1150        assert_eq!(config.overrides[0].files.len(), 2);
1151        assert_eq!(
1152            config.overrides[0].rules.unused_exports,
1153            Some(Severity::Off)
1154        );
1155        assert_eq!(config.overrides[0].rules.unused_files, Some(Severity::Warn));
1156    }
1157
1158    #[test]
1159    fn deserialize_json_with_boundaries() {
1160        let json = r#"{
1161            "boundaries": {
1162                "preset": "layered"
1163            }
1164        }"#;
1165        let config: FallowConfig = serde_json::from_str(json).unwrap();
1166        assert_eq!(config.boundaries.preset, Some(BoundaryPreset::Layered));
1167    }
1168
1169    // ── TOML with regression config ────────────────────────────────
1170
1171    #[test]
1172    fn deserialize_toml_with_regression_baseline() {
1173        let toml_str = r"
1174[regression.baseline]
1175totalIssues = 50
1176unusedFiles = 10
1177unusedExports = 15
1178";
1179        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1180        let baseline = config.regression.unwrap().baseline.unwrap();
1181        assert_eq!(baseline.total_issues, 50);
1182        assert_eq!(baseline.unused_files, 10);
1183        assert_eq!(baseline.unused_exports, 15);
1184    }
1185
1186    // ── TOML with multiple overrides ───────────────────────────────
1187
1188    #[test]
1189    fn deserialize_toml_with_overrides() {
1190        let toml_str = r#"
1191[[overrides]]
1192files = ["*.test.ts"]
1193
1194[overrides.rules]
1195unused-exports = "off"
1196
1197[[overrides]]
1198files = ["*.stories.tsx"]
1199
1200[overrides.rules]
1201unused-files = "off"
1202"#;
1203        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1204        assert_eq!(config.overrides.len(), 2);
1205        assert_eq!(
1206            config.overrides[0].rules.unused_exports,
1207            Some(Severity::Off)
1208        );
1209        assert_eq!(config.overrides[1].rules.unused_files, Some(Severity::Off));
1210    }
1211
1212    // ── Default regression config ──────────────────────────────────
1213
1214    #[test]
1215    fn regression_config_default_is_none_baseline() {
1216        let config = RegressionConfig::default();
1217        assert!(config.baseline.is_none());
1218    }
1219
1220    // ── Config with multiple ignore export rules ───────────────────
1221
1222    #[test]
1223    fn deserialize_json_multiple_ignore_export_rules() {
1224        let json = r#"{
1225            "ignoreExports": [
1226                {"file": "src/types/**/*.ts", "exports": ["*"]},
1227                {"file": "src/constants.ts", "exports": ["FOO", "BAR"]},
1228                {"file": "src/index.ts", "exports": ["default"]}
1229            ]
1230        }"#;
1231        let config: FallowConfig = serde_json::from_str(json).unwrap();
1232        assert_eq!(config.ignore_exports.len(), 3);
1233        assert_eq!(config.ignore_exports[2].exports, vec!["default"]);
1234    }
1235
1236    // ── Public packages ───────────────────────────────────────────
1237
1238    #[test]
1239    fn deserialize_json_public_packages_camel_case() {
1240        let json = r#"{"publicPackages": ["@myorg/shared-lib", "@myorg/utils"]}"#;
1241        let config: FallowConfig = serde_json::from_str(json).unwrap();
1242        assert_eq!(
1243            config.public_packages,
1244            vec!["@myorg/shared-lib", "@myorg/utils"]
1245        );
1246    }
1247
1248    #[test]
1249    fn deserialize_json_public_packages_rejects_snake_case() {
1250        let json = r#"{"public_packages": ["@myorg/shared-lib"]}"#;
1251        let result: Result<FallowConfig, _> = serde_json::from_str(json);
1252        assert!(
1253            result.is_err(),
1254            "snake_case should be rejected by deny_unknown_fields + rename_all camelCase"
1255        );
1256    }
1257
1258    #[test]
1259    fn deserialize_json_public_packages_empty() {
1260        let config: FallowConfig = serde_json::from_str("{}").unwrap();
1261        assert!(config.public_packages.is_empty());
1262    }
1263
1264    #[test]
1265    fn deserialize_toml_public_packages() {
1266        let toml_str = r#"
1267publicPackages = ["@myorg/shared-lib", "@myorg/ui"]
1268"#;
1269        let config: FallowConfig = toml::from_str(toml_str).unwrap();
1270        assert_eq!(
1271            config.public_packages,
1272            vec!["@myorg/shared-lib", "@myorg/ui"]
1273        );
1274    }
1275
1276    #[test]
1277    fn public_packages_serialize_roundtrip() {
1278        let config = FallowConfig {
1279            public_packages: vec!["@myorg/shared-lib".to_string()],
1280            ..FallowConfig::default()
1281        };
1282        let json = serde_json::to_string(&config).unwrap();
1283        let restored: FallowConfig = serde_json::from_str(&json).unwrap();
1284        assert_eq!(restored.public_packages, vec!["@myorg/shared-lib"]);
1285    }
1286}