Skip to main content

deps_lsp/
config.rs

1use serde::Deserialize;
2use tower_lsp_server::ls_types::DiagnosticSeverity;
3
4/// Root configuration for the deps-lsp server.
5///
6/// This configuration can be provided by the LSP client via initialization options
7/// or workspace settings. All fields use sensible defaults if not specified.
8///
9/// # Examples
10///
11/// ```
12/// use deps_lsp::config::DepsConfig;
13///
14/// let json = r#"{
15///     "inlay_hints": {
16///         "enabled": true,
17///         "up_to_date_text": "✅",
18///         "needs_update_text": "❌ {}"
19///     }
20/// }"#;
21///
22/// let config: DepsConfig = serde_json::from_str(json).unwrap();
23/// assert!(config.inlay_hints.enabled);
24/// ```
25/// `deny_unknown_fields` on this top-level struct only (never on the section structs
26/// below, to preserve forward-compat for keys added inside a known section): any key that
27/// isn't one of `DepsConfig`'s own fields makes the whole payload fail to parse, so
28/// `parse_config` (`server.rs`) can react by keeping the previous configuration rather
29/// than silently substituting an all-defaults one. Without this, a single recognized key
30/// in an otherwise-unrelated blob (e.g. a client that flattens its whole settings tree)
31/// would deserialize successfully and reset every unrecognized section to its default —
32/// issue #227 C2.
33#[derive(Debug, Deserialize, Default)]
34#[serde(deny_unknown_fields)]
35pub struct DepsConfig {
36    #[serde(default)]
37    pub inlay_hints: InlayHintsConfig,
38    #[serde(default)]
39    pub diagnostics: DiagnosticsConfig,
40    #[serde(default)]
41    pub cache: CacheConfig,
42    #[serde(default)]
43    pub cold_start: ColdStartConfig,
44    #[serde(default)]
45    pub loading_indicator: LoadingIndicatorConfig,
46    #[serde(default)]
47    pub code_lens: CodeLensConfig,
48    #[serde(default)]
49    pub freshness: FreshnessConfig,
50    #[serde(default)]
51    pub supply_chain: SupplyChainConfig,
52    #[serde(default)]
53    pub registries: RegistriesConfig,
54    #[serde(default)]
55    pub network: NetworkConfig,
56}
57
58/// Configuration for inlay hints (inline version annotations).
59///
60/// Controls whether inlay hints are displayed and customizes their appearance.
61/// Inlay hints show version information next to dependency declarations.
62///
63/// # Defaults
64///
65/// - `enabled`: `true`
66/// - `up_to_date_text`: `"✅"`
67/// - `needs_update_text`: `"❌ {}"` (where `{}` is replaced with the latest version)
68///
69/// # Examples
70///
71/// ```
72/// use deps_lsp::config::InlayHintsConfig;
73///
74/// let config = InlayHintsConfig {
75///     enabled: true,
76///     up_to_date_text: "OK".into(),
77///     needs_update_text: "UPDATE {}".into(),
78/// };
79///
80/// assert_eq!(config.up_to_date_text, "OK");
81/// ```
82#[derive(Debug, Clone, Deserialize)]
83pub struct InlayHintsConfig {
84    #[serde(default = "default_true")]
85    pub enabled: bool,
86    #[serde(default = "default_up_to_date")]
87    pub up_to_date_text: String,
88    #[serde(default = "default_needs_update")]
89    pub needs_update_text: String,
90}
91
92impl Default for InlayHintsConfig {
93    fn default() -> Self {
94        Self {
95            enabled: true,
96            up_to_date_text: default_up_to_date(),
97            needs_update_text: default_needs_update(),
98        }
99    }
100}
101
102/// Configuration for diagnostic severity levels.
103///
104/// Controls the severity level reported for different types of dependency issues.
105/// This allows users to customize whether issues appear as errors, warnings, hints, etc.
106///
107/// # Defaults
108///
109/// - `outdated_severity`: `HINT` - Dependencies with available updates
110/// - `unknown_severity`: `WARNING` - Dependencies not found in registry
111/// - `yanked_severity`: `WARNING` - Dependencies using yanked versions
112/// - `unsatisfiable_severity`: `WARNING` - Dependencies whose requirement matches zero published versions
113/// - `deprecated_severity`: `WARNING` - Dependencies on a package the registry reports as deprecated/abandoned
114/// - `mutable_ref_pin_severity`: `HINT` - GitHub Actions `uses:` steps pinned to a mutable ref (tag) instead of a commit SHA
115/// - `mutable_ref_pin_enabled`: `true` - Whether the mutable-ref-pin diagnostic runs at all
116///
117/// # Examples
118///
119/// ```
120/// use deps_lsp::config::DiagnosticsConfig;
121/// use tower_lsp_server::ls_types::DiagnosticSeverity;
122///
123/// let config = DiagnosticsConfig {
124///     outdated_severity: DiagnosticSeverity::INFORMATION,
125///     unknown_severity: DiagnosticSeverity::ERROR,
126///     yanked_severity: DiagnosticSeverity::ERROR,
127///     unsatisfiable_severity: DiagnosticSeverity::ERROR,
128///     deprecated_severity: DiagnosticSeverity::ERROR,
129///     mutable_ref_pin_severity: DiagnosticSeverity::ERROR,
130///     mutable_ref_pin_enabled: true,
131///     vulnerabilities_enabled: true,
132/// };
133///
134/// assert_eq!(config.unknown_severity, DiagnosticSeverity::ERROR);
135/// ```
136#[derive(Debug, Clone, Deserialize)]
137pub struct DiagnosticsConfig {
138    #[serde(default = "default_outdated_severity")]
139    pub outdated_severity: DiagnosticSeverity,
140    #[serde(default = "default_unknown_severity")]
141    pub unknown_severity: DiagnosticSeverity,
142    #[serde(default = "default_yanked_severity")]
143    pub yanked_severity: DiagnosticSeverity,
144    #[serde(default = "default_unsatisfiable_severity")]
145    pub unsatisfiable_severity: DiagnosticSeverity,
146    /// Severity for a dependency on a package the registry reports as
147    /// deprecated/abandoned (issue #205). No corresponding `deprecated_enabled`
148    /// toggle: unlike `vulnerabilities_enabled`, this signal is derived from
149    /// already-fetched data (zero new registry requests — see #205's plan §1
150    /// D2), so a boolean would gate only string formatting, not a network
151    /// call. Matches the severity-only precedent set by the four fields above.
152    #[serde(default = "default_deprecated_severity")]
153    pub deprecated_severity: DiagnosticSeverity,
154    /// Severity for a GitHub Actions `uses:` step pinned to a mutable ref (a tag)
155    /// instead of a full commit SHA (issue #473). Tunes loudness only; see
156    /// `mutable_ref_pin_enabled` for the on/off toggle.
157    #[serde(default = "default_mutable_ref_pin_severity")]
158    pub mutable_ref_pin_severity: DiagnosticSeverity,
159    /// Whether the mutable-ref-pin diagnostic (issue #473) runs at all. Default
160    /// `true`. **Corrected during implementation review (spec 031 FR-009)**: unlike
161    /// `deprecated_severity`, this diagnostic *does* need a real `_enabled` toggle —
162    /// `DiagnosticSeverity` has no suppression value, and severity is never treated
163    /// as a suppression input anywhere in this codebase, so without this boolean the
164    /// diagnostic would be permanent and unremovable on every tag-pinned `uses:` step
165    /// (the dominant pinning style), even for teams that intentionally reject
166    /// SHA-pinning. Mirrors `vulnerabilities_enabled`'s exact shape.
167    #[serde(default = "default_true")]
168    pub mutable_ref_pin_enabled: bool,
169    /// Whether to run the OSV.dev vulnerability scan and render its
170    /// diagnostics/hover content. Default `true` (opt-out): `cargo audit`/
171    /// `npm audit` run by default, and an opt-in gate would undercut the
172    /// feature (approved Q5).
173    #[serde(default = "default_true")]
174    pub vulnerabilities_enabled: bool,
175}
176
177impl Default for DiagnosticsConfig {
178    fn default() -> Self {
179        Self {
180            outdated_severity: default_outdated_severity(),
181            unknown_severity: default_unknown_severity(),
182            yanked_severity: default_yanked_severity(),
183            unsatisfiable_severity: default_unsatisfiable_severity(),
184            deprecated_severity: default_deprecated_severity(),
185            mutable_ref_pin_severity: default_mutable_ref_pin_severity(),
186            mutable_ref_pin_enabled: true,
187            vulnerabilities_enabled: true,
188        }
189    }
190}
191
192impl DiagnosticsConfig {
193    /// Converts this LSP-facing config into the `deps-core` DTO threaded
194    /// through `Ecosystem::generate_diagnostics`.
195    ///
196    /// # Examples
197    ///
198    /// ```
199    /// use deps_lsp::config::DiagnosticsConfig;
200    ///
201    /// let config = DiagnosticsConfig::default();
202    /// let severities = config.to_severities();
203    /// assert_eq!(severities.outdated, config.outdated_severity);
204    /// assert_eq!(severities.unknown, config.unknown_severity);
205    /// assert_eq!(severities.yanked, config.yanked_severity);
206    /// assert_eq!(severities.unsatisfiable, config.unsatisfiable_severity);
207    /// assert_eq!(severities.deprecated, config.deprecated_severity);
208    /// assert_eq!(severities.mutable_ref_pin, config.mutable_ref_pin_severity);
209    /// assert_eq!(severities.mutable_ref_pin_enabled, config.mutable_ref_pin_enabled);
210    /// ```
211    #[must_use]
212    pub const fn to_severities(&self) -> deps_core::DiagnosticSeverities {
213        deps_core::DiagnosticSeverities {
214            outdated: self.outdated_severity,
215            unknown: self.unknown_severity,
216            yanked: self.yanked_severity,
217            unsatisfiable: self.unsatisfiable_severity,
218            deprecated: self.deprecated_severity,
219            mutable_ref_pin: self.mutable_ref_pin_severity,
220            mutable_ref_pin_enabled: self.mutable_ref_pin_enabled,
221        }
222    }
223}
224
225/// Configuration for HTTP caching behavior.
226///
227/// Controls cache settings for registry requests. The cache uses ETag and
228/// Last-Modified headers for validation, minimizing network traffic.
229///
230/// # Defaults
231///
232/// - `enabled`: `true`
233/// - `fetch_timeout_secs`: `10` (10 seconds per package)
234/// - `max_concurrent_fetches`: `20` (20 concurrent requests)
235///
236/// # Examples
237///
238/// ```
239/// use deps_lsp::config::CacheConfig;
240///
241/// let config = CacheConfig {
242///     enabled: true,
243///     fetch_timeout_secs: 5,
244///     max_concurrent_fetches: 20,
245/// };
246///
247/// assert_eq!(config.fetch_timeout_secs, 5);
248/// ```
249#[derive(Debug, Clone, Deserialize)]
250pub struct CacheConfig {
251    /// Whether `deps_core::cache::HttpCache`'s entry map is used at all (issue #482):
252    /// `false` bypasses it entirely (fetch fresh every time, never store).
253    ///
254    /// **Offline override**: while `network.offline` (see [`NetworkConfig::offline`]) is
255    /// set, this flag's `false` value is overridden and treated as `true` — otherwise a
256    /// warm entry fetched before going offline could never survive an online→offline
257    /// transition, since nothing would have been stored while online in the first place.
258    ///
259    /// **Maven exception**: `deps-maven`'s `peek_cached`-based stale-data fallback
260    /// (`crates/deps-maven/src/registry.rs`) behaves differently from every other
261    /// ecosystem under `enabled: false`, which has no equivalent second-layer fallback to
262    /// diverge on. This only "always misses" for a process that started cold with the
263    /// flag already off — `peek_cached` reads the entry map directly and
264    /// `set_cache_enabled` never clears it, so a *live* `true` -> `false` toggle leaves
265    /// every already-stored entry servable through this fallback indefinitely.
266    #[serde(default = "default_true")]
267    pub enabled: bool,
268    /// Timeout for fetching a single package's versions (default: 10 seconds)
269    #[serde(
270        default = "default_fetch_timeout_secs",
271        deserialize_with = "deserialize_fetch_timeout"
272    )]
273    pub fetch_timeout_secs: u64,
274    /// Maximum concurrent package fetches (default: 20)
275    #[serde(
276        default = "default_max_concurrent_fetches",
277        deserialize_with = "deserialize_max_concurrent"
278    )]
279    pub max_concurrent_fetches: usize,
280}
281
282impl Default for CacheConfig {
283    fn default() -> Self {
284        Self {
285            enabled: true,
286            fetch_timeout_secs: default_fetch_timeout_secs(),
287            max_concurrent_fetches: default_max_concurrent_fetches(),
288        }
289    }
290}
291
292/// Configuration for loading indicator behavior.
293///
294/// Controls how the server shows loading feedback when fetching registry data.
295///
296/// # Defaults
297///
298/// - `enabled`: `true`
299/// - `fallback_to_hints`: `true`
300/// - `loading_text`: `"⏳"`
301#[derive(Debug, Clone, Deserialize)]
302pub struct LoadingIndicatorConfig {
303    /// Enable loading indicators (default: true)
304    #[serde(default = "default_true")]
305    pub enabled: bool,
306
307    /// Show progress in inlay hints if LSP progress not supported (default: true)
308    #[serde(default = "default_true")]
309    pub fallback_to_hints: bool,
310
311    /// Loading text to show in inlay hints (default: "⏳")
312    /// Maximum length: 100 characters (truncated with warning if exceeded)
313    #[serde(
314        default = "default_loading_text",
315        deserialize_with = "deserialize_loading_text"
316    )]
317    pub loading_text: String,
318}
319
320impl Default for LoadingIndicatorConfig {
321    fn default() -> Self {
322        Self {
323            enabled: true,
324            fallback_to_hints: true,
325            loading_text: default_loading_text(),
326        }
327    }
328}
329
330// Default value functions
331const fn default_true() -> bool {
332    true
333}
334
335fn default_up_to_date() -> String {
336    "✅".to_string()
337}
338
339fn default_needs_update() -> String {
340    "❌ {}".to_string()
341}
342
343fn default_loading_text() -> String {
344    "⏳".to_string()
345}
346
347/// Maximum length for loading_text (security limit)
348const MAX_LOADING_TEXT_LENGTH: usize = 100;
349
350/// Truncates and validates loading_text to prevent abuse
351fn validate_loading_text(text: String) -> String {
352    if text.len() > MAX_LOADING_TEXT_LENGTH {
353        tracing::warn!(
354            "loading_text exceeded max length of {} chars, truncating from {} to {}",
355            MAX_LOADING_TEXT_LENGTH,
356            text.len(),
357            MAX_LOADING_TEXT_LENGTH
358        );
359        text.chars().take(MAX_LOADING_TEXT_LENGTH).collect()
360    } else {
361        text
362    }
363}
364
365/// Custom deserializer for loading_text that validates length
366fn deserialize_loading_text<'de, D>(deserializer: D) -> Result<String, D::Error>
367where
368    D: serde::Deserializer<'de>,
369{
370    let text = String::deserialize(deserializer)?;
371    Ok(validate_loading_text(text))
372}
373
374const fn default_outdated_severity() -> DiagnosticSeverity {
375    DiagnosticSeverity::HINT
376}
377
378const fn default_unknown_severity() -> DiagnosticSeverity {
379    DiagnosticSeverity::WARNING
380}
381
382const fn default_yanked_severity() -> DiagnosticSeverity {
383    DiagnosticSeverity::WARNING
384}
385
386const fn default_unsatisfiable_severity() -> DiagnosticSeverity {
387    DiagnosticSeverity::WARNING
388}
389
390const fn default_deprecated_severity() -> DiagnosticSeverity {
391    DiagnosticSeverity::WARNING
392}
393
394const fn default_mutable_ref_pin_severity() -> DiagnosticSeverity {
395    DiagnosticSeverity::HINT
396}
397
398const fn default_fetch_timeout_secs() -> u64 {
399    5
400}
401
402const fn default_max_concurrent_fetches() -> usize {
403    20
404}
405
406/// Minimum timeout (seconds) to prevent zero-timeout edge case
407const MIN_FETCH_TIMEOUT_SECS: u64 = 1;
408/// Maximum timeout (seconds) - 5 minutes is generous
409const MAX_FETCH_TIMEOUT_SECS: u64 = 300;
410
411/// Minimum concurrent fetches (must be at least 1)
412const MIN_CONCURRENT_FETCHES: usize = 1;
413/// Maximum concurrent fetches
414const MAX_CONCURRENT_FETCHES: usize = 100;
415
416/// Custom deserializer for fetch_timeout_secs that validates bounds
417fn deserialize_fetch_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
418where
419    D: serde::Deserializer<'de>,
420{
421    let secs = u64::deserialize(deserializer)?;
422    let clamped = secs.clamp(MIN_FETCH_TIMEOUT_SECS, MAX_FETCH_TIMEOUT_SECS);
423    if clamped != secs {
424        tracing::warn!(
425            "fetch_timeout_secs {} clamped to {} (valid range: {}-{})",
426            secs,
427            clamped,
428            MIN_FETCH_TIMEOUT_SECS,
429            MAX_FETCH_TIMEOUT_SECS
430        );
431    }
432    Ok(clamped)
433}
434
435/// Custom deserializer for max_concurrent_fetches that validates bounds
436fn deserialize_max_concurrent<'de, D>(deserializer: D) -> Result<usize, D::Error>
437where
438    D: serde::Deserializer<'de>,
439{
440    let count = usize::deserialize(deserializer)?;
441    let clamped = count.clamp(MIN_CONCURRENT_FETCHES, MAX_CONCURRENT_FETCHES);
442    if clamped != count {
443        tracing::warn!(
444            "max_concurrent_fetches {} clamped to {} (valid range: {}-{})",
445            count,
446            clamped,
447            MIN_CONCURRENT_FETCHES,
448            MAX_CONCURRENT_FETCHES
449        );
450    }
451    Ok(clamped)
452}
453
454/// Configuration for cold start behavior.
455///
456/// Controls how the server handles loading documents from disk when
457/// they haven't been explicitly opened via didOpen notifications.
458///
459/// # Defaults
460///
461/// - `enabled`: `true`
462/// - `rate_limit_ms`: `100` (10 req/sec per URI)
463///
464/// # Security
465///
466/// File size limit (10MB) is hardcoded and NOT configurable for security reasons.
467/// See `loader::MAX_FILE_SIZE` constant.
468///
469/// # Examples
470///
471/// ```
472/// use deps_lsp::config::ColdStartConfig;
473///
474/// let config = ColdStartConfig {
475///     enabled: true,
476///     rate_limit_ms: 200,
477/// };
478///
479/// assert_eq!(config.rate_limit_ms, 200);
480/// ```
481#[derive(Debug, Clone, Deserialize)]
482pub struct ColdStartConfig {
483    #[serde(default = "default_true")]
484    pub enabled: bool,
485    #[serde(default = "default_rate_limit_ms")]
486    pub rate_limit_ms: u64,
487}
488
489impl Default for ColdStartConfig {
490    fn default() -> Self {
491        Self {
492            enabled: true,
493            rate_limit_ms: default_rate_limit_ms(),
494        }
495    }
496}
497
498const fn default_rate_limit_ms() -> u64 {
499    100 // 10 req/sec per URI
500}
501
502/// Configuration for the "Update N outdated dependencies" code lens.
503///
504/// # Defaults
505///
506/// - `enabled`: `true`
507///
508/// # Examples
509///
510/// ```
511/// use deps_lsp::config::CodeLensConfig;
512///
513/// let config = CodeLensConfig::default();
514/// assert!(config.enabled);
515/// ```
516#[derive(Debug, Clone, Deserialize)]
517pub struct CodeLensConfig {
518    #[serde(default = "default_true")]
519    pub enabled: bool,
520}
521
522// Deliberately hand-written rather than `#[derive(Default)]`: `DepsConfig` derives
523// `Default` for its own `code_lens` field, so a derived `Default` here (`enabled: false`)
524// would silently ship the feature disabled.
525impl Default for CodeLensConfig {
526    fn default() -> Self {
527        Self { enabled: true }
528    }
529}
530
531/// Configuration for the release-freshness signal (issue #145).
532///
533/// Controls whether a recently published "latest" version is flagged as
534/// still within a cooldown window, mirroring GitHub Dependabot's default
535/// 3-day package cooldown. Applied uniformly across all ecosystems — no
536/// per-ecosystem override.
537///
538/// # Defaults
539///
540/// - `enabled`: `true`
541/// - `cooldown_secs`: `259200` (3 days)
542///
543/// # Examples
544///
545/// ```
546/// use deps_lsp::config::FreshnessConfig;
547///
548/// let config = FreshnessConfig {
549///     enabled: true,
550///     cooldown_secs: 3600,
551/// };
552///
553/// assert_eq!(config.cooldown_secs, 3600);
554/// ```
555#[derive(Debug, Clone, Deserialize)]
556pub struct FreshnessConfig {
557    #[serde(default = "default_true")]
558    pub enabled: bool,
559    /// Cooldown window in seconds, clamped to 0..=30 days (default: 3 days)
560    #[serde(
561        default = "default_cooldown_secs",
562        deserialize_with = "deserialize_cooldown_secs"
563    )]
564    pub cooldown_secs: u64,
565}
566
567impl Default for FreshnessConfig {
568    fn default() -> Self {
569        Self {
570            enabled: true,
571            cooldown_secs: default_cooldown_secs(),
572        }
573    }
574}
575
576impl FreshnessConfig {
577    /// Converts this LSP-facing config into the `deps-core` DTO threaded
578    /// through `Ecosystem::generate_hover`/`generate_diagnostics`.
579    ///
580    /// # Examples
581    ///
582    /// ```
583    /// use deps_lsp::config::FreshnessConfig;
584    ///
585    /// let config = FreshnessConfig::default();
586    /// let settings = config.to_settings();
587    /// assert!(settings.enabled);
588    /// ```
589    #[must_use]
590    pub const fn to_settings(&self) -> deps_core::FreshnessSettings {
591        deps_core::FreshnessSettings {
592            enabled: self.enabled,
593            cooldown_secs: self.cooldown_secs,
594        }
595    }
596}
597
598const fn default_cooldown_secs() -> u64 {
599    deps_core::DEFAULT_COOLDOWN_SECS
600}
601
602/// Configuration for the supply-chain trust signal (spec 037, issue #543).
603///
604/// Controls whether hover attempts a deps.dev OpenSSF Scorecard / SLSA
605/// provenance lookup for the hovered dependency. This is the first feature
606/// to send package names to a third party that is not the package's own
607/// registry, so it gets an off switch like every other opt-out-able signal
608/// in this server (`diagnostics.vulnerabilities_enabled`), rather than
609/// requiring a user on a locked-down network to go fully offline.
610///
611/// # Defaults
612///
613/// - `enabled`: `true`
614///
615/// # Examples
616///
617/// ```
618/// use deps_lsp::config::SupplyChainConfig;
619///
620/// let config = SupplyChainConfig::default();
621/// assert!(config.enabled);
622/// ```
623#[derive(Debug, Clone, Deserialize)]
624pub struct SupplyChainConfig {
625    #[serde(default = "default_true")]
626    pub enabled: bool,
627}
628
629// Deliberately hand-written, mirroring `CodeLensConfig`'s rationale: a derived
630// `Default` would silently ship the feature disabled.
631impl Default for SupplyChainConfig {
632    fn default() -> Self {
633        Self { enabled: true }
634    }
635}
636
637/// Minimum cooldown window (seconds) — 0 disables the cooldown callout
638/// while keeping age display.
639const MIN_COOLDOWN_SECS: u64 = 0;
640/// Maximum cooldown window (seconds) — 30 days.
641const MAX_COOLDOWN_SECS: u64 = 30 * 24 * 60 * 60;
642
643/// Custom deserializer for `cooldown_secs` that validates bounds.
644fn deserialize_cooldown_secs<'de, D>(deserializer: D) -> Result<u64, D::Error>
645where
646    D: serde::Deserializer<'de>,
647{
648    let secs = u64::deserialize(deserializer)?;
649    let clamped = secs.clamp(MIN_COOLDOWN_SECS, MAX_COOLDOWN_SECS);
650    if clamped != secs {
651        tracing::warn!(
652            "freshness.cooldown_secs {} clamped to {} (valid range: {}-{})",
653            secs,
654            clamped,
655            MIN_COOLDOWN_SECS,
656            MAX_COOLDOWN_SECS
657        );
658    }
659    Ok(clamped)
660}
661
662/// Cross-ecosystem workspace-declared registry settings (spec #443/plan-1b §1.7, renamed
663/// from `cargo.workspace_registries` by `032-npm-npmrc-registry-support` FR-008/C2).
664///
665/// **Breaking, pre-1.0, no alias.** `HttpCache` holds exactly one global
666/// `Arc<RegistryAccessPolicy>`, so this setting was never actually Cargo-scoped — it already
667/// governed every ecosystem's workspace-declared registry fetches (the npm `.npmrc`
668/// `registry=`/`@scope:registry=` path included, once that feature also reads it). A client
669/// still sending the old `cargo` key fails `DepsConfig`'s top-level `deny_unknown_fields`
670/// parse — since that attribute sits on `DepsConfig` itself, not on this section, the
671/// rejection takes the **whole** settings payload with it, not just this one setting. Sent at
672/// `initialize` (the common case) that means every setting reverts to its default — safely,
673/// for the security-relevant one here, since `WorkspaceRegistriesSetting::default()` is
674/// `PublicOnly` and `HttpCache::new` already starts there; sent later via
675/// `workspace/didChangeConfiguration` the previously applied configuration is kept instead.
676/// Either way the failure is logged (`tracing::warn!`), just not surfaced by most editors.
677///
678/// # Examples
679///
680/// ```
681/// use deps_lsp::config::{RegistriesConfig, WorkspaceRegistriesSetting};
682///
683/// let config = RegistriesConfig::default();
684/// assert_eq!(config.workspace_registries, WorkspaceRegistriesSetting::PublicOnly);
685/// ```
686#[derive(Debug, Clone, Deserialize, Default)]
687pub struct RegistriesConfig {
688    #[serde(default)]
689    pub workspace_registries: WorkspaceRegistriesSetting,
690    /// Issue #561, FR-006: whether a NuGet user-profile-tier `NuGet.Config` `<add>` with no
691    /// repo-declared counterpart becomes a routing hop (`AlternateRegistry`-sourced, so
692    /// OSV/deps.dev/hover-trust are suppressed for it — spec 035 §5a), not just a credential
693    /// source for a repo-declared entry at the same URL. `#[serde(default)]`: additive-safe,
694    /// since `RegistriesConfig` itself is not under `DepsConfig`'s top-level
695    /// `deny_unknown_fields`. Default `false` — zero routing effect from any user-profile file
696    /// unless explicitly opted in.
697    #[serde(default)]
698    pub nuget_user_profile_sources: bool,
699    /// Issue #466, spec FR-005a/FR-011a: the GitLab instance host that `project:` includes
700    /// and `$CI_SERVER_FQDN`-relative `component:` includes resolve against, and — replacing,
701    /// not joined with, `gitlab.com` — the *only* host `GITLAB_TOKEN` may be sent to.
702    /// `#[serde(default)]`: additive-safe, same rationale as `nuget_user_profile_sources`
703    /// above. Default `""` (unset); an empty string is written through as `None` into the
704    /// shared `Arc<RwLock<Option<String>>>` handle. **No validation happens here** —
705    /// `deps-lsp` must not depend on `deps-gitlab-ci` for host semantics; an invalid value is
706    /// rejected on read by `deps_gitlab_ci::host::GitlabInstanceHost::get`, which also
707    /// documents the already-open-document limitation of a live change to this setting.
708    #[serde(default)]
709    pub gitlab_instance_host: String,
710}
711
712/// Controls which workspace-declared registry index hosts this LSP will ever fetch.
713///
714/// Shared by every ecosystem with a workspace-declared-registry concept (spec #443,
715/// plan-1b §1.1/§1.7; `032-npm-npmrc-registry-support` FR-008 widened this from Cargo-only
716/// to cross-ecosystem).
717///
718/// Applies to Cargo's `registry`/`registry-index` alias path (#440), a
719/// `[source.crates-io] replace-with` chain (1b), and npm's `.npmrc` `registry=`/
720/// `@scope:registry=` resolution alike. Never affects a `$CARGO_HOME/config.toml`-configured
721/// Cargo registry, which is the user's own trusted configuration, not something a cloned
722/// repository controls — npm's `.npmrc` has no equivalent always-trusted tier (both its
723/// project and user tiers are policy-symmetric, since phase 1 carries no credential
724/// provenance to protect).
725///
726/// # Defaults
727///
728/// `"public_only"` — blocking the observed attack shape (an IP literal in a metadata/RFC1918
729/// range) without breaking a legitimate corporate `https://index.mycorp.dev` registry (a DNS
730/// name cannot be classified as internal without resolving it — see
731/// [`deps_core::net_policy`]'s module docs for the residual risk this leaves and why `off`
732/// is the only complete boundary).
733///
734/// # Examples
735///
736/// ```
737/// use deps_lsp::config::WorkspaceRegistriesSetting;
738///
739/// let setting: WorkspaceRegistriesSetting = serde_json::from_str("\"off\"").unwrap();
740/// assert_eq!(setting, WorkspaceRegistriesSetting::Off);
741/// ```
742#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
743#[serde(rename_all = "snake_case")]
744pub enum WorkspaceRegistriesSetting {
745    /// Block every workspace-declared registry index — the only complete boundary. This
746    /// blocks the `registry`/`registry-index` alias path as well as `[source]`; it does
747    /// **not** affect `$CARGO_HOME`-configured registries, which keep working.
748    Off,
749    /// Allow only a host classified as public (spec `deps_core::net_policy::HostClass::Global`).
750    #[default]
751    PublicOnly,
752    /// Allow every workspace-declared index, including loopback/RFC1918/metadata-range
753    /// hosts — today's pre-#443 behavior, the escape hatch for a workspace that legitimately
754    /// points at one.
755    All,
756}
757
758impl WorkspaceRegistriesSetting {
759    /// Converts this LSP-facing setting into the `deps-core` policy value threaded through
760    /// `deps_cargo::config::RegistryIndex::new`.
761    ///
762    /// # Examples
763    ///
764    /// ```
765    /// use deps_core::net_policy::WorkspaceRegistryAccess;
766    /// use deps_lsp::config::WorkspaceRegistriesSetting;
767    ///
768    /// assert_eq!(
769    ///     WorkspaceRegistriesSetting::Off.to_policy(),
770    ///     WorkspaceRegistryAccess::Off
771    /// );
772    /// ```
773    #[must_use]
774    pub const fn to_policy(self) -> deps_core::net_policy::WorkspaceRegistryAccess {
775        match self {
776            Self::Off => deps_core::net_policy::WorkspaceRegistryAccess::Off,
777            Self::PublicOnly => deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly,
778            Self::All => deps_core::net_policy::WorkspaceRegistryAccess::All,
779        }
780    }
781}
782
783/// Configuration for outbound network access (issue #483).
784///
785/// # Defaults
786///
787/// - `offline`: `false`
788///
789/// # Examples
790///
791/// ```
792/// use deps_lsp::config::NetworkConfig;
793///
794/// let config = NetworkConfig::default();
795/// assert!(!config.offline);
796/// ```
797#[derive(Debug, Clone, Deserialize, Default)]
798pub struct NetworkConfig {
799    /// When `true`, blocks every *new* outbound registry/OSV/GitHub-tags request
800    /// (`deps_core::cache::HttpCache`'s 4 send sites) instead of making it, serving
801    /// already-cached data where available and returning `deps_core::DepsError::Offline`
802    /// otherwise. Also forces `cache.enabled` semantics to `true` for the duration (see
803    /// [`CacheConfig::enabled`]'s doc comment), so a warm entry keeps serving through an
804    /// online→offline transition even if caching was explicitly disabled.
805    ///
806    /// `HttpCache::set_offline` is a bare atomic store: a request already past its
807    /// `ensure_online` check and awaiting a response completes normally, and toggling
808    /// this flag never cancels in-flight requests.
809    #[serde(default)]
810    pub offline: bool,
811}
812
813/// Which open documents a config change invalidates and must reparse (issue #592).
814///
815/// `All` and a named `Ecosystems` set both exist so a change with a narrow, known blast
816/// radius (e.g. `registries.nuget_user_profile_sources` only ever affects NuGet's parse
817/// context) doesn't force a workspace-wide reparse.
818///
819/// **On `reparse_scope`'s actual safety property (security review correction)**: in
820/// production, [`reparse_scope`] never returns `All` today — every currently-classified
821/// field maps to a specific `Ecosystems` scope, not the fail-open `All` branch. The real
822/// safety mechanism is that function's exhaustive destructuring: adding a field to
823/// `DepsConfig` (or one of its sections) without updating `reparse_scope` is a compile error
824/// (E0027), not a silent gap — verified empirically. That compile error does **not** itself
825/// pick a safe branch, though: rustc's own suggested fix for it is `field: _`, which is
826/// exactly the not-parse-affecting shape every existing field already uses. A developer
827/// adding a genuinely security-relevant future field (e.g. a hypothetical
828/// `network.proxy_url`) who follows that suggestion mechanically would make it silently
829/// non-parse-affecting — the compile error forces *a* decision, it does not make the *safe*
830/// decision for you. Whoever adds such a field must consciously map it to
831/// [`ReparseScope::All`] (or a narrower scope) instead of reaching for `_`.
832#[derive(Debug, Clone, PartialEq)]
833pub(crate) enum ReparseScope {
834    /// Reparse every open document, regardless of ecosystem.
835    All,
836    /// Reparse only open documents whose `ecosystem_id()` is one of these.
837    Ecosystems(Vec<&'static str>),
838}
839
840impl ReparseScope {
841    /// Whether a document of this ecosystem falls within scope.
842    pub(crate) fn matches(&self, ecosystem_id: &str) -> bool {
843        match self {
844            Self::All => true,
845            Self::Ecosystems(ids) => ids.contains(&ecosystem_id),
846        }
847    }
848
849    /// Unions two scopes together (issue #592: coalescing a burst of config changes must
850    /// not lose an earlier change's scope to a later, narrower one). `All` absorbs
851    /// anything; two `Ecosystems` sets are deduplicated-merged.
852    pub(crate) fn union(self, other: Self) -> Self {
853        match (self, other) {
854            (Self::All, _) | (_, Self::All) => Self::All,
855            (Self::Ecosystems(mut a), Self::Ecosystems(b)) => {
856                for id in b {
857                    if !a.contains(&id) {
858                        a.push(id);
859                    }
860                }
861                Self::Ecosystems(a)
862            }
863        }
864    }
865}
866
867/// The only ecosystem `registries.nuget_user_profile_sources` affects.
868const NUGET_USER_PROFILE_SOURCES_ECOSYSTEMS: &[&str] = &["nuget"];
869
870/// The only ecosystem `registries.gitlab_instance_host` affects.
871///
872/// Same single-ecosystem-scoped shape as [`NUGET_USER_PROFILE_SOURCES_ECOSYSTEMS`], not a
873/// member of `workspace_registry_ecosystems`: `deps_gitlab_ci::parser::parse_gitlab_ci_yaml`
874/// resolves this setting into a [`deps_gitlab_ci::types::HostRef`] once, at parse time (see
875/// `resolve_project_host`/`resolve_component_host`), so a changed instance host leaves
876/// already-open documents' cached `HostRef`s stale until they are re-parsed. Listed
877/// unconditionally here even though the `gitlab-ci` feature can be compiled out — `ReparseScope`
878/// only ever narrows an *already-registered* ecosystem, so naming an absent one is a no-op, not
879/// a hazard.
880const GITLAB_INSTANCE_HOST_ECOSYSTEMS: &[&str] = &["gitlab-ci"];
881
882/// Diffs `old` against `new` and returns the [`ReparseScope`] of open documents a
883/// live-reloaded config change invalidates, or `None` if nothing parse-affecting changed
884/// (issue #592).
885///
886/// Both `DepsConfig` and each of its section structs are destructured exhaustively here —
887/// **no `..` rest pattern**, at either level — so a field added to `DepsConfig` in the
888/// future is a compile error in this function until it is explicitly classified as either
889/// not parse-affecting (bound to `_`, its value never read) or mapped to a scope. A section
890/// classified not parse-affecting is still destructured field-by-field, for the same
891/// reason: classifying a whole section once would silently swallow a future field added to
892/// it. This can't catch a field whose *documented meaning* changes without changing its
893/// type (e.g. a hypothetical `network.proxy_url`) — destructuring forces a human to look at
894/// every field, it cannot make the classification decision by itself.
895///
896/// `workspace_registry_ecosystems` — the ecosystem ids to scope a `registries.workspace_registries`
897/// change to — is a caller-supplied parameter rather than a hardcoded list in this module
898/// (issue #592 security M1): the true set is whatever `register_ecosystems` (`lib.rs`)
899/// actually threads the live `RegistryAccessPolicy` handle into, returned by that same
900/// function and stored on `ServerState::workspace_registry_ecosystems`. Hardcoding a second,
901/// independently-maintained copy here would let the two drift — a 6th policy-consuming
902/// ecosystem added to `register_ecosystems` without updating a duplicate list would fail
903/// *closed*: exactly the scenario #592 exists to close, since that ecosystem would keep
904/// silently showing versions resolved under a revoked policy.
905pub(crate) fn reparse_scope(
906    old: &DepsConfig,
907    new: &DepsConfig,
908    workspace_registry_ecosystems: &[&'static str],
909) -> Option<ReparseScope> {
910    let DepsConfig {
911        inlay_hints: new_inlay_hints,
912        diagnostics: new_diagnostics,
913        cache: new_cache,
914        cold_start: new_cold_start,
915        loading_indicator: new_loading_indicator,
916        code_lens: new_code_lens,
917        freshness: new_freshness,
918        supply_chain: new_supply_chain,
919        registries: new_registries,
920        network: new_network,
921    } = new;
922
923    // Not parse-affecting: every field is named (never `..`), so its value is simply
924    // unused here rather than compared, but a new field on any of these sections still
925    // forces a decision at this line.
926    let InlayHintsConfig {
927        enabled: _,
928        up_to_date_text: _,
929        needs_update_text: _,
930    } = new_inlay_hints;
931    let DiagnosticsConfig {
932        outdated_severity: _,
933        unknown_severity: _,
934        yanked_severity: _,
935        unsatisfiable_severity: _,
936        deprecated_severity: _,
937        mutable_ref_pin_severity: _,
938        mutable_ref_pin_enabled: _,
939        vulnerabilities_enabled: _,
940    } = new_diagnostics;
941    let CacheConfig {
942        enabled: _,
943        fetch_timeout_secs: _,
944        max_concurrent_fetches: _,
945    } = new_cache;
946    let ColdStartConfig {
947        enabled: _,
948        rate_limit_ms: _,
949    } = new_cold_start;
950    let LoadingIndicatorConfig {
951        enabled: _,
952        fallback_to_hints: _,
953        loading_text: _,
954    } = new_loading_indicator;
955    let CodeLensConfig { enabled: _ } = new_code_lens;
956    let FreshnessConfig {
957        enabled: _,
958        cooldown_secs: _,
959    } = new_freshness;
960    let SupplyChainConfig { enabled: _ } = new_supply_chain;
961    let NetworkConfig { offline: _ } = new_network;
962
963    // Parse-affecting.
964    let RegistriesConfig {
965        workspace_registries: new_workspace_registries,
966        nuget_user_profile_sources: new_nuget_user_profile_sources,
967        gitlab_instance_host: new_gitlab_instance_host,
968    } = new_registries;
969    let RegistriesConfig {
970        workspace_registries: old_workspace_registries,
971        nuget_user_profile_sources: old_nuget_user_profile_sources,
972        gitlab_instance_host: old_gitlab_instance_host,
973    } = &old.registries;
974
975    let mut scope: Option<ReparseScope> = None;
976    let union_in = |scope: &mut Option<ReparseScope>, addition: ReparseScope| {
977        *scope = Some(match scope.take() {
978            Some(existing) => existing.union(addition),
979            None => addition,
980        });
981    };
982
983    if old_workspace_registries != new_workspace_registries {
984        union_in(
985            &mut scope,
986            ReparseScope::Ecosystems(workspace_registry_ecosystems.to_vec()),
987        );
988    }
989    if old_nuget_user_profile_sources != new_nuget_user_profile_sources {
990        union_in(
991            &mut scope,
992            ReparseScope::Ecosystems(NUGET_USER_PROFILE_SOURCES_ECOSYSTEMS.to_vec()),
993        );
994    }
995    if old_gitlab_instance_host != new_gitlab_instance_host {
996        union_in(
997            &mut scope,
998            ReparseScope::Ecosystems(GITLAB_INSTANCE_HOST_ECOSYSTEMS.to_vec()),
999        );
1000    }
1001
1002    scope
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    #[test]
1010    fn test_default_config() {
1011        let config = DepsConfig::default();
1012        assert!(config.inlay_hints.enabled);
1013        assert_eq!(config.inlay_hints.up_to_date_text, "✅");
1014        assert_eq!(config.inlay_hints.needs_update_text, "❌ {}");
1015        assert_eq!(
1016            config.registries.workspace_registries,
1017            WorkspaceRegistriesSetting::PublicOnly
1018        );
1019    }
1020
1021    #[test]
1022    fn test_workspace_registries_setting_deserializes_all_variants() {
1023        assert_eq!(
1024            serde_json::from_str::<WorkspaceRegistriesSetting>("\"off\"").unwrap(),
1025            WorkspaceRegistriesSetting::Off
1026        );
1027        assert_eq!(
1028            serde_json::from_str::<WorkspaceRegistriesSetting>("\"public_only\"").unwrap(),
1029            WorkspaceRegistriesSetting::PublicOnly
1030        );
1031        assert_eq!(
1032            serde_json::from_str::<WorkspaceRegistriesSetting>("\"all\"").unwrap(),
1033            WorkspaceRegistriesSetting::All
1034        );
1035    }
1036
1037    #[test]
1038    fn test_registries_config_section_deserialization() {
1039        let json = r#"{"registries": {"workspace_registries": "off"}}"#;
1040        let config: DepsConfig = serde_json::from_str(json).unwrap();
1041        assert_eq!(
1042            config.registries.workspace_registries,
1043            WorkspaceRegistriesSetting::Off
1044        );
1045    }
1046
1047    /// Issue #466: `gitlab_instance_host` defaults to `""` (unset), and a payload omitting
1048    /// it still parses (additive-safety, mirroring the section above).
1049    #[test]
1050    fn test_gitlab_instance_host_defaults_to_empty_and_deserializes() {
1051        let default_config: DepsConfig = serde_json::from_str("{}").unwrap();
1052        assert_eq!(default_config.registries.gitlab_instance_host, "");
1053
1054        let json = r#"{"registries": {"gitlab_instance_host": "gitlab.mycorp.dev"}}"#;
1055        let config: DepsConfig = serde_json::from_str(json).unwrap();
1056        assert_eq!(config.registries.gitlab_instance_host, "gitlab.mycorp.dev");
1057    }
1058
1059    /// The renamed key: a client still sending the old `cargo` section fails the whole
1060    /// settings payload's `deny_unknown_fields` parse (N-S2) — never silently accepted as a
1061    /// no-op, and never partially applied.
1062    #[test]
1063    fn test_old_cargo_config_key_is_rejected_not_silently_ignored() {
1064        let json = r#"{"cargo": {"workspace_registries": "off"}}"#;
1065        assert!(serde_json::from_str::<DepsConfig>(json).is_err());
1066    }
1067
1068    #[test]
1069    fn test_workspace_registries_setting_to_policy() {
1070        use deps_core::net_policy::WorkspaceRegistryAccess;
1071
1072        assert_eq!(
1073            WorkspaceRegistriesSetting::Off.to_policy(),
1074            WorkspaceRegistryAccess::Off
1075        );
1076        assert_eq!(
1077            WorkspaceRegistriesSetting::PublicOnly.to_policy(),
1078            WorkspaceRegistryAccess::PublicOnly
1079        );
1080        assert_eq!(
1081            WorkspaceRegistriesSetting::All.to_policy(),
1082            WorkspaceRegistryAccess::All
1083        );
1084    }
1085
1086    #[test]
1087    fn test_inlay_hints_config_deserialization() {
1088        let json = r#"{
1089            "enabled": false,
1090            "up_to_date_text": "OK",
1091            "needs_update_text": "UPDATE {}"
1092        }"#;
1093
1094        let config: InlayHintsConfig = serde_json::from_str(json).unwrap();
1095        assert!(!config.enabled);
1096        assert_eq!(config.up_to_date_text, "OK");
1097        assert_eq!(config.needs_update_text, "UPDATE {}");
1098    }
1099
1100    #[test]
1101    fn test_diagnostics_config_deserialization() {
1102        let json = r#"{
1103            "outdated_severity": 1,
1104            "unknown_severity": 2,
1105            "yanked_severity": 2,
1106            "unsatisfiable_severity": 1,
1107            "deprecated_severity": 1
1108        }"#;
1109
1110        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1111        assert_eq!(config.outdated_severity, DiagnosticSeverity::ERROR);
1112        assert_eq!(config.unknown_severity, DiagnosticSeverity::WARNING);
1113        assert_eq!(config.yanked_severity, DiagnosticSeverity::WARNING);
1114        assert_eq!(config.unsatisfiable_severity, DiagnosticSeverity::ERROR);
1115        assert_eq!(config.deprecated_severity, DiagnosticSeverity::ERROR);
1116    }
1117
1118    #[test]
1119    fn test_diagnostics_config_unsatisfiable_severity_defaults_warning() {
1120        let config = DiagnosticsConfig::default();
1121        assert_eq!(config.unsatisfiable_severity, DiagnosticSeverity::WARNING);
1122
1123        let json = r"{}";
1124        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1125        assert_eq!(config.unsatisfiable_severity, DiagnosticSeverity::WARNING);
1126    }
1127
1128    /// D8/O3: no `deprecated_enabled` toggle exists — severity is the only knob, matching
1129    /// the other four fields' precedent (deprecation adds no network call).
1130    #[test]
1131    fn test_diagnostics_config_deprecated_severity_defaults_warning() {
1132        let config = DiagnosticsConfig::default();
1133        assert_eq!(config.deprecated_severity, DiagnosticSeverity::WARNING);
1134
1135        let json = r"{}";
1136        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1137        assert_eq!(config.deprecated_severity, DiagnosticSeverity::WARNING);
1138    }
1139
1140    /// Severity default (issue #473) — see `mutable_ref_pin_enabled` tests below for the
1141    /// separate on/off toggle, unlike `deprecated_severity`'s severity-only precedent.
1142    #[test]
1143    fn test_diagnostics_config_mutable_ref_pin_severity_defaults_hint() {
1144        let config = DiagnosticsConfig::default();
1145        assert_eq!(config.mutable_ref_pin_severity, DiagnosticSeverity::HINT);
1146
1147        let json = r"{}";
1148        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1149        assert_eq!(config.mutable_ref_pin_severity, DiagnosticSeverity::HINT);
1150    }
1151
1152    /// FR-009 (corrected during implementation review): mirrors
1153    /// `test_diagnostics_config_vulnerabilities_enabled_defaults_true` — this diagnostic
1154    /// does need a real `_enabled` toggle, since severity alone cannot suppress it.
1155    #[test]
1156    fn test_diagnostics_config_mutable_ref_pin_enabled_defaults_true() {
1157        let config = DiagnosticsConfig::default();
1158        assert!(config.mutable_ref_pin_enabled);
1159
1160        let json = r"{}";
1161        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1162        assert!(config.mutable_ref_pin_enabled);
1163    }
1164
1165    #[test]
1166    fn test_diagnostics_config_mutable_ref_pin_enabled_can_be_disabled() {
1167        let json = r#"{ "mutable_ref_pin_enabled": false }"#;
1168        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1169        assert!(!config.mutable_ref_pin_enabled);
1170    }
1171
1172    #[test]
1173    fn test_diagnostics_config_vulnerabilities_enabled_defaults_true() {
1174        let config = DiagnosticsConfig::default();
1175        assert!(config.vulnerabilities_enabled);
1176
1177        let json = r"{}";
1178        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1179        assert!(config.vulnerabilities_enabled);
1180    }
1181
1182    #[test]
1183    fn test_diagnostics_config_vulnerabilities_enabled_can_be_disabled() {
1184        let json = r#"{ "vulnerabilities_enabled": false }"#;
1185        let config: DiagnosticsConfig = serde_json::from_str(json).unwrap();
1186        assert!(!config.vulnerabilities_enabled);
1187    }
1188
1189    #[test]
1190    fn test_cache_config_deserialization() {
1191        let json = r#"{
1192            "enabled": false
1193        }"#;
1194
1195        let config: CacheConfig = serde_json::from_str(json).unwrap();
1196        assert!(!config.enabled);
1197    }
1198
1199    #[test]
1200    fn test_cache_config_defaults() {
1201        let config = CacheConfig::default();
1202        assert!(config.enabled);
1203        assert_eq!(config.fetch_timeout_secs, 5);
1204        assert_eq!(config.max_concurrent_fetches, 20);
1205    }
1206
1207    #[test]
1208    fn test_cache_config_with_timeout_and_concurrency() {
1209        let json = r#"{
1210            "enabled": true,
1211            "fetch_timeout_secs": 10,
1212            "max_concurrent_fetches": 50
1213        }"#;
1214
1215        let config: CacheConfig = serde_json::from_str(json).unwrap();
1216        assert!(config.enabled);
1217        assert_eq!(config.fetch_timeout_secs, 10);
1218        assert_eq!(config.max_concurrent_fetches, 50);
1219    }
1220
1221    #[test]
1222    fn test_full_config_deserialization() {
1223        let json = r#"{
1224            "inlay_hints": {
1225                "enabled": true,
1226                "up_to_date_text": "✅",
1227                "needs_update_text": "❌ {}"
1228            },
1229            "diagnostics": {
1230                "outdated_severity": 4,
1231                "unknown_severity": 2,
1232                "yanked_severity": 2
1233            },
1234            "cache": {
1235                "enabled": true
1236            }
1237        }"#;
1238
1239        let config: DepsConfig = serde_json::from_str(json).unwrap();
1240        assert!(config.inlay_hints.enabled);
1241        assert_eq!(
1242            config.diagnostics.outdated_severity,
1243            DiagnosticSeverity::HINT
1244        );
1245        assert!(config.cache.enabled);
1246    }
1247
1248    #[test]
1249    fn test_partial_config_deserialization() {
1250        let json = r#"{
1251            "inlay_hints": {
1252                "enabled": false
1253            }
1254        }"#;
1255
1256        let config: DepsConfig = serde_json::from_str(json).unwrap();
1257        assert!(!config.inlay_hints.enabled);
1258        // Other fields should use defaults
1259        assert_eq!(config.inlay_hints.up_to_date_text, "✅");
1260        assert_eq!(
1261            config.diagnostics.outdated_severity,
1262            DiagnosticSeverity::HINT
1263        );
1264    }
1265
1266    #[test]
1267    fn test_empty_config_deserialization() {
1268        let json = r"{}";
1269        let config: DepsConfig = serde_json::from_str(json).unwrap();
1270        // All fields should use defaults
1271        assert!(config.inlay_hints.enabled);
1272        assert!(config.cache.enabled);
1273    }
1274
1275    #[test]
1276    fn test_cold_start_config_defaults() {
1277        let config = ColdStartConfig::default();
1278        assert!(config.enabled);
1279        assert_eq!(config.rate_limit_ms, 100);
1280    }
1281
1282    #[test]
1283    fn test_cold_start_config_deserialization() {
1284        let json = r#"{
1285            "enabled": false,
1286            "rate_limit_ms": 200
1287        }"#;
1288
1289        let config: ColdStartConfig = serde_json::from_str(json).unwrap();
1290        assert!(!config.enabled);
1291        assert_eq!(config.rate_limit_ms, 200);
1292    }
1293
1294    #[test]
1295    fn test_full_config_with_cold_start() {
1296        let json = r#"{
1297            "cold_start": {
1298                "enabled": true,
1299                "rate_limit_ms": 150
1300            }
1301        }"#;
1302
1303        let config: DepsConfig = serde_json::from_str(json).unwrap();
1304        assert!(config.cold_start.enabled);
1305        assert_eq!(config.cold_start.rate_limit_ms, 150);
1306    }
1307
1308    #[test]
1309    fn test_loading_indicator_config_defaults() {
1310        let config = LoadingIndicatorConfig::default();
1311        assert!(config.enabled);
1312        assert!(config.fallback_to_hints);
1313        assert_eq!(config.loading_text, "⏳");
1314    }
1315
1316    #[test]
1317    fn test_loading_indicator_config_deserialization() {
1318        let json = r#"{
1319            "enabled": false,
1320            "fallback_to_hints": false,
1321            "loading_text": "Loading..."
1322        }"#;
1323
1324        let config: LoadingIndicatorConfig = serde_json::from_str(json).unwrap();
1325        assert!(!config.enabled);
1326        assert!(!config.fallback_to_hints);
1327        assert_eq!(config.loading_text, "Loading...");
1328    }
1329
1330    #[test]
1331    fn test_loading_text_truncation() {
1332        let long_text = "a".repeat(150);
1333        let json = format!(
1334            r#"{{
1335            "enabled": true,
1336            "fallback_to_hints": true,
1337            "loading_text": "{}"
1338        }}"#,
1339            long_text
1340        );
1341
1342        let config: LoadingIndicatorConfig = serde_json::from_str(&json).unwrap();
1343        assert_eq!(config.loading_text.len(), 100);
1344        assert_eq!(config.loading_text, "a".repeat(100));
1345    }
1346
1347    #[test]
1348    fn test_loading_text_exactly_100_chars() {
1349        let text = "a".repeat(100);
1350        let json = format!(
1351            r#"{{
1352            "enabled": true,
1353            "fallback_to_hints": true,
1354            "loading_text": "{}"
1355        }}"#,
1356            text
1357        );
1358
1359        let config: LoadingIndicatorConfig = serde_json::from_str(&json).unwrap();
1360        assert_eq!(config.loading_text.len(), 100);
1361        assert_eq!(config.loading_text, text);
1362    }
1363
1364    #[test]
1365    fn test_loading_text_under_limit() {
1366        let json = r#"{
1367            "enabled": true,
1368            "fallback_to_hints": true,
1369            "loading_text": "⏳ Loading dependencies..."
1370        }"#;
1371
1372        let config: LoadingIndicatorConfig = serde_json::from_str(json).unwrap();
1373        assert_eq!(config.loading_text, "⏳ Loading dependencies...");
1374        assert!(config.loading_text.len() < 100);
1375    }
1376
1377    #[test]
1378    fn test_loading_text_default() {
1379        let json = r#"{
1380            "enabled": true,
1381            "fallback_to_hints": true
1382        }"#;
1383
1384        let config: LoadingIndicatorConfig = serde_json::from_str(json).unwrap();
1385        assert_eq!(config.loading_text, "⏳");
1386    }
1387
1388    #[test]
1389    fn test_cache_config_fetch_timeout_clamped_min() {
1390        let json = r#"{"fetch_timeout_secs": 0}"#;
1391        let config: CacheConfig = serde_json::from_str(json).unwrap();
1392        assert_eq!(config.fetch_timeout_secs, 1, "Should clamp 0 to MIN");
1393    }
1394
1395    #[test]
1396    fn test_cache_config_fetch_timeout_clamped_max() {
1397        let json = r#"{"fetch_timeout_secs": 999999}"#;
1398        let config: CacheConfig = serde_json::from_str(json).unwrap();
1399        assert_eq!(config.fetch_timeout_secs, 300, "Should clamp to MAX");
1400    }
1401
1402    #[test]
1403    fn test_cache_config_fetch_timeout_valid_range() {
1404        let json = r#"{"fetch_timeout_secs": 10}"#;
1405        let config: CacheConfig = serde_json::from_str(json).unwrap();
1406        assert_eq!(
1407            config.fetch_timeout_secs, 10,
1408            "Valid value should not be clamped"
1409        );
1410    }
1411
1412    #[test]
1413    fn test_cache_config_max_concurrent_clamped_min() {
1414        let json = r#"{"max_concurrent_fetches": 0}"#;
1415        let config: CacheConfig = serde_json::from_str(json).unwrap();
1416        assert_eq!(config.max_concurrent_fetches, 1, "Should clamp 0 to MIN");
1417    }
1418
1419    #[test]
1420    fn test_cache_config_max_concurrent_clamped_max() {
1421        let json = r#"{"max_concurrent_fetches": 100000}"#;
1422        let config: CacheConfig = serde_json::from_str(json).unwrap();
1423        assert_eq!(config.max_concurrent_fetches, 100, "Should clamp to MAX");
1424    }
1425
1426    #[test]
1427    fn test_cache_config_max_concurrent_valid_range() {
1428        let json = r#"{"max_concurrent_fetches": 50}"#;
1429        let config: CacheConfig = serde_json::from_str(json).unwrap();
1430        assert_eq!(
1431            config.max_concurrent_fetches, 50,
1432            "Valid value should not be clamped"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_code_lens_config_defaults() {
1438        let config = CodeLensConfig::default();
1439        assert!(config.enabled);
1440    }
1441
1442    #[test]
1443    fn test_code_lens_config_deserialization() {
1444        let json = r#"{"enabled": false}"#;
1445        let config: CodeLensConfig = serde_json::from_str(json).unwrap();
1446        assert!(!config.enabled);
1447    }
1448
1449    #[test]
1450    fn test_code_lens_config_empty_object_defaults_to_enabled() {
1451        let config: CodeLensConfig = serde_json::from_str("{}").unwrap();
1452        assert!(config.enabled);
1453    }
1454
1455    #[test]
1456    fn test_deps_config_default_has_code_lens_enabled() {
1457        // Regression guard: DepsConfig derives Default, which would silently produce
1458        // `enabled: false` if CodeLensConfig ever switched to a derived Default.
1459        let config = DepsConfig::default();
1460        assert!(config.code_lens.enabled);
1461    }
1462
1463    #[test]
1464    fn test_supply_chain_config_defaults() {
1465        let config = SupplyChainConfig::default();
1466        assert!(config.enabled);
1467    }
1468
1469    #[test]
1470    fn test_supply_chain_config_deserialization() {
1471        let json = r#"{"enabled": false}"#;
1472        let config: SupplyChainConfig = serde_json::from_str(json).unwrap();
1473        assert!(!config.enabled);
1474    }
1475
1476    #[test]
1477    fn test_supply_chain_config_empty_object_defaults_to_enabled() {
1478        let config: SupplyChainConfig = serde_json::from_str("{}").unwrap();
1479        assert!(config.enabled);
1480    }
1481
1482    #[test]
1483    fn test_deps_config_default_has_supply_chain_enabled() {
1484        // Regression guard: DepsConfig derives Default, which would silently produce
1485        // `enabled: false` if SupplyChainConfig ever switched to a derived Default.
1486        let config = DepsConfig::default();
1487        assert!(config.supply_chain.enabled);
1488    }
1489
1490    #[test]
1491    fn test_freshness_config_defaults() {
1492        let config = FreshnessConfig::default();
1493        assert!(config.enabled);
1494        assert_eq!(config.cooldown_secs, 259_200);
1495    }
1496
1497    #[test]
1498    fn test_freshness_config_partial_deserialization() {
1499        let json = r#"{"enabled": false}"#;
1500        let config: FreshnessConfig = serde_json::from_str(json).unwrap();
1501        assert!(!config.enabled);
1502        assert_eq!(config.cooldown_secs, 259_200, "Should use default");
1503    }
1504
1505    #[test]
1506    fn test_freshness_config_custom_cooldown() {
1507        let json = r#"{"cooldown_secs": 3600}"#;
1508        let config: FreshnessConfig = serde_json::from_str(json).unwrap();
1509        assert!(config.enabled, "Should use default");
1510        assert_eq!(config.cooldown_secs, 3600);
1511    }
1512
1513    #[test]
1514    fn test_freshness_config_cooldown_clamped_min() {
1515        let json = r#"{"cooldown_secs": 0}"#;
1516        let config: FreshnessConfig = serde_json::from_str(json).unwrap();
1517        assert_eq!(config.cooldown_secs, 0, "0 disables the cooldown callout");
1518    }
1519
1520    #[test]
1521    fn test_freshness_config_cooldown_clamped_max() {
1522        let json = r#"{"cooldown_secs": 99999999}"#;
1523        let config: FreshnessConfig = serde_json::from_str(json).unwrap();
1524        assert_eq!(
1525            config.cooldown_secs,
1526            30 * 24 * 60 * 60,
1527            "Should clamp to 30 days"
1528        );
1529    }
1530
1531    #[test]
1532    fn test_freshness_config_to_settings() {
1533        let config = FreshnessConfig {
1534            enabled: false,
1535            cooldown_secs: 1800,
1536        };
1537        let settings = config.to_settings();
1538        assert!(!settings.enabled);
1539        assert_eq!(settings.cooldown_secs, 1800);
1540    }
1541
1542    #[test]
1543    fn test_deps_config_includes_freshness_default() {
1544        let config = DepsConfig::default();
1545        assert!(config.freshness.enabled);
1546        assert_eq!(config.freshness.cooldown_secs, 259_200);
1547    }
1548
1549    #[test]
1550    fn test_deps_config_empty_json_includes_freshness_default() {
1551        let config: DepsConfig = serde_json::from_str("{}").unwrap();
1552        assert!(config.freshness.enabled);
1553        assert_eq!(config.freshness.cooldown_secs, 259_200);
1554    }
1555
1556    #[test]
1557    fn test_network_config_defaults_to_online() {
1558        let config = NetworkConfig::default();
1559        assert!(!config.offline);
1560
1561        let config: DepsConfig = serde_json::from_str("{}").unwrap();
1562        assert!(!config.network.offline);
1563    }
1564
1565    #[test]
1566    fn test_network_config_accepts_offline_true() {
1567        let json = r#"{"network":{"offline":true}}"#;
1568        let config: DepsConfig = serde_json::from_str(json).unwrap();
1569        assert!(config.network.offline);
1570    }
1571
1572    // =========================================================================
1573    // `reparse_scope` / `ReparseScope` tests (issue #592)
1574    // =========================================================================
1575
1576    mod reparse_scope_tests {
1577        use super::*;
1578
1579        /// A small, test-local stand-in for the real ecosystem list `reparse_scope` now
1580        /// takes as a parameter (issue #592 security M1) — these tests exercise
1581        /// `reparse_scope`'s diff/union *logic*, not the production ecosystem set, which is
1582        /// covered separately by `lib.rs`'s `register_ecosystems`-drift test.
1583        const TEST_WORKSPACE_REGISTRY_ECOSYSTEMS: &[&str] = &["cargo", "npm", "pypi", "go"];
1584
1585        #[test]
1586        fn test_no_change_returns_none() {
1587            let config = DepsConfig::default();
1588            assert!(reparse_scope(&config, &config, TEST_WORKSPACE_REGISTRY_ECOSYSTEMS).is_none());
1589        }
1590
1591        #[test]
1592        fn test_inert_field_change_returns_none() {
1593            let old = DepsConfig::default();
1594            let mut new = DepsConfig::default();
1595            new.freshness.cooldown_secs = 60;
1596            new.network.offline = true;
1597            new.cold_start.rate_limit_ms = 0;
1598            assert!(
1599                reparse_scope(&old, &new, TEST_WORKSPACE_REGISTRY_ECOSYSTEMS).is_none(),
1600                "freshness/network/cold_start changes must not trigger a reparse"
1601            );
1602        }
1603
1604        #[test]
1605        fn test_workspace_registries_change_scopes_to_workspace_ecosystems() {
1606            let old = DepsConfig::default();
1607            let mut new = DepsConfig::default();
1608            new.registries.workspace_registries = WorkspaceRegistriesSetting::Off;
1609
1610            let scope = reparse_scope(&old, &new, TEST_WORKSPACE_REGISTRY_ECOSYSTEMS)
1611                .expect("must trigger a reparse");
1612            assert_eq!(
1613                scope,
1614                ReparseScope::Ecosystems(TEST_WORKSPACE_REGISTRY_ECOSYSTEMS.to_vec())
1615            );
1616            for id in TEST_WORKSPACE_REGISTRY_ECOSYSTEMS {
1617                assert!(scope.matches(id));
1618            }
1619            assert!(!scope.matches("bundler"));
1620        }
1621
1622        /// The scope must come from the caller-supplied list, not a value baked into
1623        /// `reparse_scope` itself (security M1) — passing a different list for the same
1624        /// config diff must change the result.
1625        #[test]
1626        fn test_workspace_registries_change_scope_reflects_caller_supplied_list() {
1627            let old = DepsConfig::default();
1628            let mut new = DepsConfig::default();
1629            new.registries.workspace_registries = WorkspaceRegistriesSetting::Off;
1630
1631            let scope =
1632                reparse_scope(&old, &new, &["only-this-one"]).expect("must trigger a reparse");
1633            assert_eq!(scope, ReparseScope::Ecosystems(vec!["only-this-one"]));
1634            assert!(!scope.matches("cargo"));
1635        }
1636
1637        #[test]
1638        fn test_nuget_user_profile_sources_change_scopes_to_nuget_only() {
1639            let old = DepsConfig::default();
1640            let mut new = DepsConfig::default();
1641            new.registries.nuget_user_profile_sources = true;
1642
1643            let scope = reparse_scope(&old, &new, TEST_WORKSPACE_REGISTRY_ECOSYSTEMS)
1644                .expect("must trigger a reparse");
1645            assert_eq!(
1646                scope,
1647                ReparseScope::Ecosystems(NUGET_USER_PROFILE_SOURCES_ECOSYSTEMS.to_vec())
1648            );
1649            assert!(scope.matches("nuget"));
1650            assert!(!scope.matches("cargo"));
1651        }
1652
1653        #[test]
1654        fn test_gitlab_instance_host_change_scopes_to_gitlab_ci_only() {
1655            let old = DepsConfig::default();
1656            let mut new = DepsConfig::default();
1657            new.registries.gitlab_instance_host = "gitlab.mycorp.dev".to_string();
1658
1659            let scope = reparse_scope(&old, &new, TEST_WORKSPACE_REGISTRY_ECOSYSTEMS)
1660                .expect("must trigger a reparse");
1661            assert_eq!(
1662                scope,
1663                ReparseScope::Ecosystems(GITLAB_INSTANCE_HOST_ECOSYSTEMS.to_vec())
1664            );
1665            assert!(scope.matches("gitlab-ci"));
1666            assert!(!scope.matches("cargo"));
1667            assert!(!scope.matches("nuget"));
1668        }
1669
1670        #[test]
1671        fn test_both_registry_fields_changed_unions_scopes() {
1672            let old = DepsConfig::default();
1673            let mut new = DepsConfig::default();
1674            new.registries.workspace_registries = WorkspaceRegistriesSetting::Off;
1675            new.registries.nuget_user_profile_sources = true;
1676
1677            let scope = reparse_scope(&old, &new, TEST_WORKSPACE_REGISTRY_ECOSYSTEMS)
1678                .expect("must trigger a reparse");
1679            for id in TEST_WORKSPACE_REGISTRY_ECOSYSTEMS {
1680                assert!(scope.matches(id), "must still cover {id}");
1681            }
1682            assert!(scope.matches("nuget"));
1683        }
1684
1685        #[test]
1686        fn test_scope_union_all_absorbs_ecosystems() {
1687            let all = ReparseScope::All;
1688            let ecosystems = ReparseScope::Ecosystems(vec!["cargo"]);
1689            assert_eq!(all.clone().union(ecosystems.clone()), ReparseScope::All);
1690            assert_eq!(ecosystems.union(all), ReparseScope::All);
1691        }
1692
1693        #[test]
1694        fn test_scope_union_ecosystems_dedups() {
1695            let a = ReparseScope::Ecosystems(vec!["cargo", "npm"]);
1696            let b = ReparseScope::Ecosystems(vec!["npm", "pypi"]);
1697            let ReparseScope::Ecosystems(union) = a.union(b) else {
1698                panic!("expected Ecosystems variant");
1699            };
1700            assert_eq!(union.len(), 3, "npm must not be duplicated: {union:?}");
1701            for id in ["cargo", "npm", "pypi"] {
1702                assert!(union.contains(&id));
1703            }
1704        }
1705
1706        #[test]
1707        fn test_scope_matches_all_matches_any_ecosystem() {
1708            assert!(ReparseScope::All.matches("anything"));
1709        }
1710
1711        /// Every ecosystem id named in the `nuget_user_profile_sources` scope literal must
1712        /// actually resolve in the registered ecosystem set (critic Q1: a typo here fails
1713        /// silently closed — matching no document, no warning). The `workspace_registries`
1714        /// scope's ids are no longer a literal in this module (security M1) — their
1715        /// validity is covered by `lib.rs`'s `register_ecosystems`-drift test instead.
1716        #[test]
1717        fn test_nuget_user_profile_sources_ecosystem_ids_are_valid_ecosystem_ids() {
1718            for id in NUGET_USER_PROFILE_SOURCES_ECOSYSTEMS {
1719                id.parse::<deps_core::EcosystemId>()
1720                    .unwrap_or_else(|_| panic!("{id:?} is not a valid EcosystemId"));
1721            }
1722        }
1723    }
1724}