Skip to main content

deps_engine/
setup.rs

1//! Ecosystem registration.
2//!
3//! [`EcosystemRuntime`] bundles the live-updatable settings every adapter threads into the
4//! ecosystems that need them, and [`register_ecosystems`] wires every feature-enabled
5//! `deps-<ecosystem>` crate into a [`deps_core::EcosystemRegistry`].
6//!
7//! Moved verbatim from `deps-lsp/src/lib.rs` (issue #1058) so `deps-cli`/`deps-mcp` share the
8//! exact same registration instead of each maintaining an independent, non-uniform copy — see
9//! `specs/062-cli-check-mode/architecture-decision.md` §5.2 for why per-adapter re-registration
10//! was rejected.
11
12use std::sync::Arc;
13use std::sync::atomic::AtomicBool;
14
15use deps_core::policy_config::PolicyConfig;
16use deps_core::{EcosystemRegistry, HttpCache};
17
18/// Live-updatable settings [`register_ecosystems`] threads into every ecosystem that needs them.
19///
20/// Bundled into one struct (issue #561, M3) rather than growing that function's arity again
21/// for each new cross-ecosystem live flag.
22#[non_exhaustive]
23#[derive(Debug, Clone)]
24pub struct EcosystemRuntime {
25    /// Gates every workspace-declared registry index host (spec #443,
26    /// `registries.workspace_registries`).
27    pub policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
28    /// `registries.nuget_user_profile_sources` (issue #561, FR-006) — whether a NuGet
29    /// user-profile-tier `NuGet.Config` source with no repo-declared counterpart becomes a
30    /// routing hop, not just a credential source. Default `false`.
31    pub nuget_user_profile_sources: Arc<AtomicBool>,
32    /// `registries.gitlab_instance_host` (issue #466, spec FR-005a/FR-011a) — the raw
33    /// configured GitLab instance host string, or `None` when unset. A feature-agnostic
34    /// `Arc<RwLock<Option<String>>>` (not a `deps-gitlab-ci` type) since this struct is
35    /// un-`cfg`'d — see `deps_gitlab_ci::host::GitlabInstanceHost`'s docs for why host
36    /// validation lives in that crate instead, applied on read.
37    pub gitlab_instance_host: Arc<std::sync::RwLock<Option<String>>>,
38}
39
40impl EcosystemRuntime {
41    /// Constructs the runtime from its three live-updatable settings handles.
42    ///
43    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
44    /// this crate, so every other crate — including anything embedding [`register_ecosystems`],
45    /// this module's advertised entry point — must use this constructor instead. Unlike some
46    /// config-DTO builders elsewhere in this workspace (e.g. `deps_lsp::config::InlayHintsConfig::new`),
47    /// all three fields are required here: none has a sensible default, so there is no
48    /// accompanying `with_*` chain — a future field can still add one without breaking this
49    /// signature.
50    ///
51    /// # Examples
52    ///
53    /// ```
54    /// use deps_core::net_policy::RegistryAccessPolicy;
55    /// use deps_engine::setup::EcosystemRuntime;
56    /// use std::sync::atomic::AtomicBool;
57    /// use std::sync::{Arc, RwLock};
58    ///
59    /// let runtime = EcosystemRuntime::new(
60    ///     Arc::new(RegistryAccessPolicy::default()),
61    ///     Arc::new(AtomicBool::new(false)),
62    ///     Arc::new(RwLock::new(None)),
63    /// );
64    /// assert!(!runtime.nuget_user_profile_sources.load(std::sync::atomic::Ordering::Relaxed));
65    /// ```
66    #[must_use]
67    pub fn new(
68        policy: Arc<deps_core::net_policy::RegistryAccessPolicy>,
69        nuget_user_profile_sources: Arc<AtomicBool>,
70        gitlab_instance_host: Arc<std::sync::RwLock<Option<String>>>,
71    ) -> Self {
72        Self {
73            policy,
74            nuget_user_profile_sources,
75            gitlab_instance_host,
76        }
77    }
78
79    /// Builds the runtime's three live-updatable handles from a [`PolicyConfig`] snapshot.
80    ///
81    /// Construction only — this does not touch any adapter-side state (e.g. `deps-lsp`'s
82    /// `ServerState::cache`/`cold_start_limiter`, or its
83    /// `warn_if_gitlab_instance_host_invalid` notification call): those remain the caller's
84    /// responsibility, since they carry their own ordering constraints relative to the
85    /// adapter's own config-reload lifecycle (issue #1058; see
86    /// `specs/062-cli-check-mode/architecture-decision.md` §8, PR 1b-ii).
87    ///
88    /// The three values themselves come from
89    /// [`RegistriesConfig::resolve`](deps_core::policy_config::RegistriesConfig::resolve),
90    /// shared with `deps-lsp`'s `initialize`/`did_change_configuration` config-reload sites so
91    /// this derivation exists in exactly one place (issue #1058, T009).
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use deps_core::policy_config::PolicyConfig;
97    /// use deps_engine::setup::EcosystemRuntime;
98    ///
99    /// let runtime = EcosystemRuntime::from_policy(&PolicyConfig::default());
100    /// assert!(!runtime.nuget_user_profile_sources.load(std::sync::atomic::Ordering::Relaxed));
101    /// ```
102    #[must_use]
103    pub fn from_policy(policy: &PolicyConfig) -> Self {
104        let resolved = policy.registries.resolve();
105        Self::new(
106            Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
107                resolved.workspace_registries,
108            )),
109            Arc::new(AtomicBool::new(resolved.nuget_user_profile_sources)),
110            Arc::new(std::sync::RwLock::new(resolved.gitlab_instance_host)),
111        )
112    }
113}
114
115/// Validates a `registries.gitlab_instance_host` value against the live registry-access
116/// policy, without the caller needing to name `deps_gitlab_ci` directly.
117///
118/// Exists so `deps-lsp` (and any future adapter) can validate this value while depending only
119/// on `deps-engine` — per `specs/062-cli-check-mode/architecture-decision.md` §3.3's placement
120/// rule, code that must name a concrete ecosystem type belongs in `deps-engine`, not in an
121/// adapter crate.
122///
123/// # Errors
124///
125/// Returns [`deps_core::net_policy::IndexUrlError`] under the same conditions as
126/// [`deps_gitlab_ci::GitlabHost::parse`] — `raw` contains a URL-structural character, fails to
127/// parse, is not `https`-eligible, carries userinfo, round-trips to a different host, or
128/// resolves to a [`deps_core::net_policy::HostClass`] the current policy blocks.
129///
130/// # Examples
131///
132/// ```
133/// use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
134/// use deps_engine::setup::validate_gitlab_instance_host;
135///
136/// let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
137/// assert!(validate_gitlab_instance_host("gitlab.com", &policy).is_ok());
138/// assert!(validate_gitlab_instance_host("gitlab.com/evil", &policy).is_err());
139/// ```
140#[cfg(feature = "gitlab-ci")]
141pub fn validate_gitlab_instance_host(
142    raw: &str,
143    policy: &deps_core::net_policy::RegistryAccessPolicy,
144) -> Result<(), deps_core::net_policy::IndexUrlError> {
145    deps_gitlab_ci::GitlabHost::parse(raw, policy).map(|_| ())
146}
147
148/// Declares an ecosystem: re-exports types and registers at runtime.
149///
150/// `$types` re-export rule (#834 §6 last bullet — the list used to be asymmetric with no
151/// stated rule: `*Formatter` re-exported for 8/14 ecosystems, `*LockParser` for 4/14,
152/// `*Registry` for 13/14): **always list every `*Registry`/`*Formatter`/`*LockParser`
153/// (or ecosystem-specific-named lock parser, e.g. `GoSumParser`) type the ecosystem crate
154/// actually defines and re-exports from its own crate root.** Omit only when the crate
155/// genuinely has none — e.g. `deps-gradle` has no `GradleRegistry` (reuses
156/// `deps_maven::MavenCentralRegistry` directly), and several ecosystems omit a
157/// `*LockParser` entry because the crate has no `lockfile` module at all (Maven, Gradle,
158/// GitHub Actions, GitLab CI — no lock file format exists for these; Deno — `deno.lock`
159/// exists as a format, but this crate has no parser for it yet, a real coverage gap rather
160/// than "no format" like the other four).
161macro_rules! ecosystem {
162    ($feature:literal, $crate_name:ident, $ecosystem:ident, [$($types:ident),* $(,)?]) => {
163        #[cfg(feature = $feature)]
164        pub use $crate_name::{$ecosystem, $($types),*};
165    };
166}
167
168/// Registers ecosystem if feature is enabled.
169macro_rules! register {
170    ($feature:literal, $ecosystem:ident, $registry:expr, $cache:expr) => {
171        #[cfg(feature = $feature)]
172        $registry.register(Arc::new($ecosystem::new(Arc::clone($cache))));
173    };
174}
175
176// =============================================================================
177// Ecosystems — to add new: 1) feature in Cargo.toml  2) add ecosystem!() + register!()
178// =============================================================================
179
180ecosystem!(
181    "cargo",
182    deps_cargo,
183    CargoEcosystem,
184    [
185        CargoDependency,
186        CargoDependencySection,
187        CargoFormatter,
188        CargoLockParser,
189        CargoParseResult,
190        CargoParser,
191        CargoRegistry,
192        CargoVersion,
193        CrateInfo,
194        CratesIoRegistry,
195        parse_cargo_toml,
196    ]
197);
198
199ecosystem!(
200    "npm",
201    deps_npm,
202    NpmEcosystem,
203    [
204        NpmDependency,
205        NpmDependencySection,
206        NpmFormatter,
207        NpmLockParser,
208        NpmPackage,
209        NpmParseResult,
210        NpmRegistry,
211        NpmVersion,
212        parse_package_json,
213    ]
214);
215
216ecosystem!(
217    "pypi",
218    deps_pypi,
219    PypiEcosystem,
220    [
221        PypiDependency,
222        PypiDependencySection,
223        PypiFormatter,
224        PypiLockParser,
225        PypiParser,
226        PypiRegistry,
227        PypiVersion,
228    ]
229);
230
231ecosystem!(
232    "go",
233    deps_go,
234    GoEcosystem,
235    [
236        GoDependency,
237        GoDirective,
238        GoFormatter,
239        GoParseResult,
240        GoRegistry,
241        GoSumParser,
242        GoVersion,
243        parse_go_mod,
244    ]
245);
246
247ecosystem!(
248    "bundler",
249    deps_bundler,
250    BundlerEcosystem,
251    [
252        BundlerDependency,
253        BundlerFormatter,
254        BundlerParseResult,
255        BundlerVersion,
256        DependencyGroup,
257        GemInfo,
258        GemfileLockParser,
259        RubyGemsRegistry,
260        parse_gemfile,
261    ]
262);
263
264ecosystem!(
265    "dart",
266    deps_dart,
267    DartEcosystem,
268    [
269        DartDependency,
270        DartParseResult,
271        DartVersion,
272        DartFormatter,
273        PackageInfo,
274        PubDevRegistry,
275        PubspecLockParser,
276        parse_pubspec_yaml,
277    ]
278);
279
280ecosystem!(
281    "maven",
282    deps_maven,
283    MavenEcosystem,
284    [
285        MavenDependency,
286        MavenParseResult,
287        MavenVersion,
288        MavenFormatter,
289        ArtifactInfo,
290        MavenCentralRegistry,
291        parse_pom_xml,
292    ]
293);
294
295ecosystem!(
296    "gradle",
297    deps_gradle,
298    GradleEcosystem,
299    [
300        GradleDependency,
301        GradleParseResult,
302        GradleVersion,
303        GradleFormatter,
304        parse_gradle,
305    ]
306);
307
308ecosystem!(
309    "swift",
310    deps_swift,
311    SwiftEcosystem,
312    [
313        SwiftDependency,
314        SwiftParseResult,
315        SwiftVersion,
316        SwiftPackage,
317        SwiftFormatter,
318        SwiftRegistry,
319        SwiftLockParser,
320        parse_package_swift,
321    ]
322);
323
324ecosystem!(
325    "composer",
326    deps_composer,
327    ComposerEcosystem,
328    [
329        ComposerDependency,
330        ComposerFormatter,
331        ComposerLockParser,
332        ComposerSection,
333        ComposerPackage,
334        ComposerParseResult,
335        PackagistRegistry,
336        ComposerVersion,
337        parse_composer_json,
338    ]
339);
340
341// Note: `PackageInfo` is deliberately omitted from this re-export list — it collides with
342// `deps_dart::PackageInfo`, already re-exported above. Reachable directly as
343// `deps_nuget::PackageInfo` for anything that needs it.
344ecosystem!(
345    "nuget",
346    deps_nuget,
347    NuGetEcosystem,
348    [
349        NuGetDependency,
350        NuGetParseResult,
351        NuGetVersion,
352        NuGetFormatter,
353        NuGetRegistry,
354        NuGetLockParser,
355        parse_project_file,
356    ]
357);
358
359ecosystem!(
360    "deno",
361    deps_deno,
362    DenoEcosystem,
363    [
364        DenoDependency,
365        DenoDependencySection,
366        DenoFormatter,
367        DenoMetadata,
368        DenoParseResult,
369        DenoRegistry,
370        JsrPackage,
371        JsrRegistry,
372        JsrVersion,
373        parse_deno_json,
374    ]
375);
376
377ecosystem!(
378    "github-actions",
379    deps_github_actions,
380    GithubActionsEcosystem,
381    [
382        GithubActionsDependency,
383        GithubActionsFormatter,
384        GithubActionsParseResult,
385        GithubActionsRegistry,
386        GithubActionsVersion,
387        parse_workflow_yaml,
388    ]
389);
390
391ecosystem!(
392    "gitlab-ci",
393    deps_gitlab_ci,
394    GitlabCiEcosystem,
395    [
396        GitlabCiDependency,
397        GitlabCiFormatter,
398        GitlabCiParseResult,
399        GitlabCiRegistry,
400        GitlabCiVersion,
401        parse_gitlab_ci_yaml,
402    ]
403);
404
405/// Registers all enabled ecosystems.
406///
407/// `cargo` is special-cased (spec #443/#441, plan-1b §1.6): unlike `register!`'s generic
408/// `Ecosystem::new(cache)` call, `CargoEcosystem` needs `policy` threaded through
409/// `CargoEcosystem::with_context` so the calling adapter's live-updatable
410/// `Arc<RegistryAccessPolicy>` (e.g. `deps-lsp`'s `document::state::ServerState::registry_policy`)
411/// is the exact same handle every Cargo parse reads — the adapter updating it then takes
412/// effect immediately, with no need to reconstruct the ecosystem.
413///
414/// `npm` and `deno` are special-cased (#312): when both features are enabled, they share
415/// one `NpmRegistry` instance — built once here and handed to both `NpmEcosystem` and
416/// `DenoEcosystem`'s `npm:`-scheme half via `with_registry`/`with_npm` — instead of each
417/// constructing its own. `NpmRegistry` is cheaply `Clone` (its `HttpCache` and
418/// freshness-path publish-time map are both `Arc`-wrapped internally), so this dedupes the
419/// freshness path's full-packument fetch and its publish-time cache for a package
420/// appearing in both `package.json` and a `deno.json` `npm:`-specifier dependency, on top
421/// of the plain cached GETs the shared `cache` already dedupes.
422/// Returns every ecosystem id this call threaded the live `RegistryAccessPolicy` handle
423/// into (issue #592 security M1) — the single source of truth `deps_lsp::config::reparse_scope`'s
424/// caller consults to scope a `registries.workspace_registries` reparse, so that set can
425/// never drift from what this function actually wires up. Adding a 6th policy-consuming
426/// ecosystem means editing this function anyway (to thread `policy` through its parse
427/// context); pushing its id onto the returned list at that same call site keeps the two
428/// facts — "receives the policy" and "is in the reparse scope" — physically inseparable,
429/// rather than duplicated across two independently-editable places.
430///
431/// # Examples
432///
433/// ```
434/// use deps_core::policy_config::PolicyConfig;
435/// use deps_core::{EcosystemRegistry, HttpCache};
436/// use deps_engine::setup::{EcosystemRuntime, register_ecosystems};
437/// use std::sync::Arc;
438///
439/// let registry = EcosystemRegistry::new();
440/// let cache = Arc::new(HttpCache::new());
441/// let runtime = EcosystemRuntime::from_policy(&PolicyConfig::default());
442/// let workspace_registry_ecosystems = register_ecosystems(&registry, cache, &runtime);
443///
444/// // Every id this call reports as policy-consuming is actually registered.
445/// for id in &workspace_registry_ecosystems {
446///     assert!(registry.get(id).is_some());
447/// }
448/// ```
449pub fn register_ecosystems(
450    registry: &EcosystemRegistry,
451    cache: Arc<HttpCache>,
452    runtime: &EcosystemRuntime,
453) -> Vec<&'static str> {
454    let policy = Arc::clone(&runtime.policy);
455    // Keeps `policy` used even when none of its consumers (cargo, npm, pypi, go, nuget,
456    // gitlab-ci) are compiled in.
457    let _ = &policy;
458    // Keeps `registry`/`cache` used even when every ecosystem feature is compiled out.
459    let _ = (&registry, &cache);
460    let mut workspace_registry_ecosystems = Vec::new();
461    // Keeps `mut` used even when none of the five features that push into this vec below
462    // (cargo, npm, pypi, go, nuget) are enabled.
463    let _ = &mut workspace_registry_ecosystems;
464
465    #[cfg(feature = "cargo")]
466    {
467        let context = deps_cargo::parser::CargoParseContext::new(
468            Arc::clone(&policy),
469            Arc::new(deps_cargo::config::ConfigFileCache::new()),
470        );
471        registry.register(Arc::new(CargoEcosystem::with_context(
472            Arc::clone(&cache),
473            context,
474        )));
475        workspace_registry_ecosystems.push("cargo");
476    }
477
478    #[cfg(all(feature = "npm", feature = "deno"))]
479    {
480        let npm_context = deps_npm::config::NpmParseContext::new(
481            Arc::clone(&policy),
482            Arc::new(deps_npm::config::NpmConfigCache::new()),
483            Arc::new(deps_npm::catalog::PnpmWorkspaceCache::new()),
484        );
485        let npm_registry = Arc::new(NpmRegistry::new(Arc::clone(&cache)));
486        registry.register(Arc::new(NpmEcosystem::with_context(
487            Arc::clone(&npm_registry),
488            npm_context,
489        )));
490        workspace_registry_ecosystems.push("npm");
491        // `DenoEcosystem::with_npm` shares the registry above but is never handed `policy`
492        // itself (its own `.npmrc`-style workspace registry concept doesn't exist yet), so
493        // "deno" deliberately never joins this list.
494        registry.register(Arc::new(DenoEcosystem::with_npm(
495            Arc::clone(&cache),
496            npm_registry.as_ref().clone(),
497        )));
498    }
499    // npm is explicit, not via `register!` (spec 032, S3): the macro's default
500    // `NpmParseContext` would never see a live `initialize`/`didChangeConfiguration` update.
501    #[cfg(all(feature = "npm", not(feature = "deno")))]
502    {
503        let npm_context = deps_npm::config::NpmParseContext::new(
504            Arc::clone(&policy),
505            Arc::new(deps_npm::config::NpmConfigCache::new()),
506            Arc::new(deps_npm::catalog::PnpmWorkspaceCache::new()),
507        );
508        registry.register(Arc::new(NpmEcosystem::with_context(
509            Arc::new(NpmRegistry::new(Arc::clone(&cache))),
510            npm_context,
511        )));
512        workspace_registry_ecosystems.push("npm");
513    }
514    #[cfg(all(feature = "deno", not(feature = "npm")))]
515    register!("deno", DenoEcosystem, registry, &cache);
516
517    // pypi is explicit, not via `register!` (spec 033, mirrors npm's spec 032 S3): the
518    // macro's default `RegistryAccessPolicy` would never see a live config update.
519    #[cfg(feature = "pypi")]
520    {
521        registry.register(Arc::new(PypiEcosystem::with_policy(
522            Arc::new(PypiRegistry::new(Arc::clone(&cache))),
523            Arc::clone(&policy),
524        )));
525        workspace_registry_ecosystems.push("pypi");
526    }
527
528    // go is explicit, not via `register!` (spec 034, mirrors npm's spec 032 S3): the macro's
529    // default `GoParseContext` would never see a live `$GOENV` policy update.
530    #[cfg(feature = "go")]
531    {
532        let go_context = deps_go::config::GoParseContext::new(
533            Arc::clone(&policy),
534            Arc::new(deps_go::config::GoEnvCache::new()),
535            deps_go::config::goenv_path(),
536        );
537        registry.register(Arc::new(GoEcosystem::with_context(
538            Arc::new(GoRegistry::new(Arc::clone(&cache))),
539            go_context,
540        )));
541        workspace_registry_ecosystems.push("go");
542    }
543    register!("bundler", BundlerEcosystem, registry, &cache);
544    register!("dart", DartEcosystem, registry, &cache);
545    register!("maven", MavenEcosystem, registry, &cache);
546    register!("gradle", GradleEcosystem, registry, &cache);
547    register!("swift", SwiftEcosystem, registry, &cache);
548    register!("composer", ComposerEcosystem, registry, &cache);
549
550    // nuget is explicit, not via `register!` (#523, mirrors npm's/pypi's precedent): the
551    // macro's default `RegistryAccessPolicy` would never see a live config update.
552    #[cfg(feature = "nuget")]
553    {
554        let nuget_context = deps_nuget::config::NuGetParseContext::new(
555            Arc::clone(&policy),
556            Arc::new(deps_nuget::config::NuGetConfigCache::new()),
557            Arc::clone(&runtime.nuget_user_profile_sources),
558        );
559        registry.register(Arc::new(NuGetEcosystem::with_context(
560            Arc::new(NuGetRegistry::new(Arc::clone(&cache))),
561            nuget_context,
562        )));
563        workspace_registry_ecosystems.push("nuget");
564    }
565
566    register!("github-actions", GithubActionsEcosystem, registry, &cache);
567
568    // gitlab-ci is explicit, not via `register!` (#466, mirrors github-actions'/nuget's
569    // precedent): the macro's default config would never see a live self-hosted-instance
570    // or single-token-host update (spec FR-005a/FR-011a).
571    #[cfg(feature = "gitlab-ci")]
572    registry.register(Arc::new(GitlabCiEcosystem::with_context(
573        Arc::clone(&cache),
574        Arc::clone(&policy),
575        Arc::clone(&runtime.gitlab_instance_host),
576    )));
577
578    workspace_registry_ecosystems
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    fn test_runtime() -> EcosystemRuntime {
586        EcosystemRuntime::new(
587            Arc::new(deps_core::net_policy::RegistryAccessPolicy::default()),
588            Arc::new(AtomicBool::new(false)),
589            Arc::new(std::sync::RwLock::new(None)),
590        )
591    }
592
593    /// The doctest on [`EcosystemRuntime::from_policy`] only exercises `PolicyConfig::default()`
594    /// (empty `gitlab_instance_host` -> `None`, `nuget_user_profile_sources` -> `false`). This
595    /// covers its other branches: a non-empty `gitlab_instance_host`, `nuget_user_profile_sources
596    /// = true`, and a non-default `workspace_registries` setting.
597    #[test]
598    fn test_from_policy_non_default_branches() {
599        use deps_core::net_policy::WorkspaceRegistryAccess;
600        use deps_core::policy_config::{
601            PolicyConfig, RegistriesConfig, WorkspaceRegistriesSetting,
602        };
603
604        let policy = PolicyConfig {
605            registries: RegistriesConfig::new()
606                .with_workspace_registries(WorkspaceRegistriesSetting::All)
607                .with_nuget_user_profile_sources(true)
608                .with_gitlab_instance_host("gitlab.corp"),
609            ..PolicyConfig::default()
610        };
611
612        let runtime = EcosystemRuntime::from_policy(&policy);
613
614        assert_eq!(runtime.policy.get(), WorkspaceRegistryAccess::All);
615        assert!(
616            runtime
617                .nuget_user_profile_sources
618                .load(std::sync::atomic::Ordering::Relaxed)
619        );
620        assert_eq!(
621            runtime
622                .gitlab_instance_host
623                .read()
624                .unwrap_or_else(std::sync::PoisonError::into_inner)
625                .as_deref(),
626            Some("gitlab.corp")
627        );
628    }
629
630    /// Direct `cargo nextest` coverage for [`validate_gitlab_instance_host`] — its doctest
631    /// exercises the same two cases inline, but only as a doctest (tester finding, issue
632    /// #1073). Also checks the rejected-host error never leaks the raw credential, mirroring
633    /// `deps-lsp`'s `test_gitlab_instance_host_invalid_message_redacts_credential`, since
634    /// [`deps_core::net_policy::IndexUrlError::InvalidUrl`] wraps an already-redacted
635    /// [`deps_core::net_policy::RedactedUrl`].
636    #[cfg(feature = "gitlab-ci")]
637    #[test]
638    fn test_validate_gitlab_instance_host_accepts_valid_rejects_invalid() {
639        use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
640
641        let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly);
642
643        assert!(validate_gitlab_instance_host("gitlab.com", &policy).is_ok());
644
645        let raw = "user:hunter2@gitlab.corp";
646        let error = validate_gitlab_instance_host(raw, &policy)
647            .expect_err("credential-shaped host must be rejected");
648        assert!(
649            !error.to_string().contains("hunter2"),
650            "rejected-host error must not leak the raw credential: {error}"
651        );
652    }
653
654    /// Smoke test: `register_ecosystems` must not panic under any feature combination.
655    /// Per-ecosystem "is it actually registered" coverage moved to
656    /// [`test_ecosystem_id_all_registered`] (#758) — driven by
657    /// [`deps_core::EcosystemId::ALL`] instead of this hand-written, drift-prone 14-line list.
658    #[test]
659    fn test_register_ecosystems() {
660        let registry = Arc::new(EcosystemRegistry::new());
661        let cache = Arc::new(HttpCache::new());
662        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
663    }
664
665    /// Issue #592 security M1: every id `register_ecosystems` returns must actually be a
666    /// registered ecosystem (catches a typo'd `push` literal) and, for this feature set,
667    /// must exactly match the five ecosystems known to thread `RegistryAccessPolicy` through
668    /// their parse context — a regression here means either a policy-consuming ecosystem
669    /// was added without pushing its id (fails closed for `deps_lsp::config::reparse_scope`), or
670    /// an id was pushed for an ecosystem that no longer receives the policy (harmless
671    /// over-scoping, but signals the two facts drifted anyway).
672    #[test]
673    #[allow(
674        clippy::vec_init_then_push,
675        reason = "each push is independently feature-gated, so a `vec![]` literal can't \
676                  express the feature-conditional membership"
677    )]
678    fn test_register_ecosystems_workspace_registry_list_matches_registered_ecosystems() {
679        let registry = Arc::new(EcosystemRegistry::new());
680        let cache = Arc::new(HttpCache::new());
681        let workspace_registry_ecosystems =
682            register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
683
684        for id in &workspace_registry_ecosystems {
685            assert!(
686                registry.get(id).is_some(),
687                "{id:?} was returned as policy-consuming but is not a registered ecosystem"
688            );
689        }
690
691        let mut expected: Vec<&str> = Vec::new();
692        #[cfg(feature = "cargo")]
693        expected.push("cargo");
694        #[cfg(feature = "npm")]
695        expected.push("npm");
696        #[cfg(feature = "pypi")]
697        expected.push("pypi");
698        #[cfg(feature = "go")]
699        expected.push("go");
700        #[cfg(feature = "nuget")]
701        expected.push("nuget");
702        expected.sort_unstable();
703        let mut actual = workspace_registry_ecosystems.clone();
704        actual.sort_unstable();
705        assert_eq!(
706            actual, expected,
707            "workspace-registry-policy ecosystem set changed — update this test's `expected` \
708             list alongside whatever registration change caused it"
709        );
710    }
711
712    /// Layer 1a (#758): completeness — every [`deps_core::EcosystemId::ALL`] variant must
713    /// actually be registered by [`register_ecosystems`]. Driven by `ALL` itself, not
714    /// `registry.ecosystem_ids()`, so an ecosystem declared in the enum but never wired in
715    /// fails this test instead of silently vanishing from coverage.
716    ///
717    /// Gated on every ecosystem feature at once: `ALL` always lists 14 variants regardless of
718    /// which features are enabled for this build, so this specific claim — "all 14 are
719    /// present" — is only meaningful, and only makes sense to check, in an all-features build.
720    /// The per-ecosystem invariants that don't depend on all 14 being present live in the
721    /// ungated [`test_registered_ecosystems_universal_invariants`] instead (#758 impl-critic
722    /// S2): unlike this completeness check, those must keep working under any feature subset,
723    /// the way the two hand-written per-feature lists this pair replaces used to.
724    #[cfg(all(
725        feature = "cargo",
726        feature = "npm",
727        feature = "pypi",
728        feature = "go",
729        feature = "bundler",
730        feature = "dart",
731        feature = "maven",
732        feature = "gradle",
733        feature = "swift",
734        feature = "composer",
735        feature = "nuget",
736        feature = "deno",
737        feature = "github-actions",
738        feature = "gitlab-ci"
739    ))]
740    #[test]
741    fn test_ecosystem_id_all_registered() {
742        let registry = Arc::new(EcosystemRegistry::new());
743        let cache = Arc::new(HttpCache::new());
744        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
745
746        for id in deps_core::EcosystemId::ALL {
747            assert!(
748                registry.get(id.id()).is_some(),
749                "{id:?} is in EcosystemId::ALL but was not registered by register_ecosystems"
750            );
751        }
752    }
753
754    /// Layer 1b (#758): universal, offline invariants every ecosystem this build actually
755    /// registers must satisfy. Deliberately **ungated** — unlike
756    /// [`test_ecosystem_id_all_registered`]'s completeness claim, none of these invariants
757    /// depend on all 14 ecosystems being present, so this iterates whatever
758    /// `registry.ecosystem_ids()` this build's feature set produced (#758 impl-critic S2):
759    /// under `--no-default-features --features npm`, it still covers `npm` alone; under the
760    /// default (all 14) build, it covers all 14, same as before the split.
761    ///
762    /// Per ecosystem: id round-trip; `display_name()` non-empty and unique across the set; at
763    /// least one routing surface non-empty; `lockfile_filenames().is_empty() ==
764    /// lockfile_provider().is_none()`; `package_url` hostile-input safety (display sink only —
765    /// see `deps_core::conformance`'s doc for the display-vs-fetch sink split; does **not**
766    /// prove the URL is non-degenerate, that is `formatter_conformance!`'s job per ecosystem);
767    /// `completion_insert_text` does not panic.
768    #[test]
769    fn test_registered_ecosystems_universal_invariants() {
770        let registry = Arc::new(EcosystemRegistry::new());
771        let cache = Arc::new(HttpCache::new());
772        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
773
774        let mut display_names = std::collections::HashSet::new();
775
776        for id in registry.ecosystem_ids() {
777            let ecosystem = registry
778                .get(id)
779                .unwrap_or_else(|| panic!("{id:?} came from registry.ecosystem_ids() itself"));
780
781            let parsed_id: deps_core::EcosystemId = id.parse().unwrap_or_else(|_| {
782                panic!("{id:?} has no matching EcosystemId variant (see issue #118)")
783            });
784            assert_eq!(
785                parsed_id.id(),
786                id,
787                "{id:?}: EcosystemId round-trip mismatch"
788            );
789            assert_eq!(ecosystem.id(), id, "{id:?}: Ecosystem::id() mismatch");
790
791            let display_name = ecosystem.display_name();
792            assert!(!display_name.is_empty(), "{id:?} has an empty display_name");
793            assert!(
794                display_names.insert(display_name),
795                "{id:?}'s display_name {display_name:?} collides with another ecosystem's"
796            );
797
798            assert!(
799                !ecosystem.manifest_filenames().is_empty()
800                    || !ecosystem.manifest_patterns().is_empty()
801                    || !ecosystem.manifest_extensions().is_empty()
802                    || !ecosystem.manifest_directory_patterns().is_empty(),
803                "{id:?} has no routing surface at all (manifest_filenames/patterns/extensions/directory_patterns)"
804            );
805
806            assert_eq!(
807                ecosystem.lockfile_filenames().is_empty(),
808                ecosystem.lockfile_provider().is_none(),
809                "{id:?}: lockfile_filenames()/lockfile_provider() disagree on whether a lock file format exists"
810            );
811
812            // Hostile-input safety for the `package_url` display sink (#758 security-review),
813            // shared with `formatter_conformance!`'s per-crate check (#782 gap 1) — see
814            // `assert_package_url_hostile_input_safe`'s doc for the full rationale.
815            deps_core::conformance::assert_package_url_hostile_input_safe(
816                ecosystem.formatter(),
817                &format!("{id:?}"),
818            );
819
820            let metadata = deps_core::test_util::MockMetadata::new("conformance-probe", "1.0.0");
821            let _ = ecosystem.completion_insert_text(&metadata);
822        }
823    }
824
825    /// CRITICAL regression (issue #706 review): GitHub Actions' `action.yml`/`action.yaml`
826    /// bare-basename routing and GitLab CI's `.gitlab/ci/*.yml` directory-pattern routing
827    /// can both match `.gitlab/ci/action.yml` — before `EcosystemRegistry::for_uri`'s
828    /// fix (deps-core), the basename match was checked first and always won, silently
829    /// routing a real GitLab CI file to `github-actions` (which would then, on top of
830    /// that, degrade it to zero dependencies since it lacks a top-level `runs:` key —
831    /// total, silent loss of hover/diagnostics/completions for the file). Exercises the
832    /// real production registry both real ecosystem crates are wired into, not a mock.
833    #[cfg(all(feature = "github-actions", feature = "gitlab-ci"))]
834    #[test]
835    fn test_gitlab_ci_directory_pattern_wins_over_github_actions_basename_match() {
836        let registry = Arc::new(EcosystemRegistry::new());
837        let cache = Arc::new(HttpCache::new());
838        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
839
840        let uri = deps_core::test_util::test_uri("/repo/.gitlab/ci/action.yml");
841        assert_eq!(
842            registry.for_uri(&uri).map(|e| e.id()),
843            Some(deps_core::EcosystemId::GitlabCi.id()),
844            "a real .gitlab/ci/action.yml file must route to gitlab-ci, not github-actions"
845        );
846
847        // Non-conflicting action.yml locations must be unaffected.
848        let root_action = deps_core::test_util::test_uri("/repo/action.yml");
849        assert_eq!(
850            registry.for_uri(&root_action).map(|e| e.id()),
851            Some(deps_core::EcosystemId::GithubActions.id())
852        );
853        let nested_action =
854            deps_core::test_util::test_uri("/repo/.github/actions/my-action/action.yml");
855        assert_eq!(
856            registry.for_uri(&nested_action).map(|e| e.id()),
857            Some(deps_core::EcosystemId::GithubActions.id())
858        );
859    }
860
861    /// Whether `formatter`'s own comparator (preferring
862    /// [`deps_core::lsp_helpers::RequirementResolution::compile_requirement`], falling back to
863    /// [`deps_core::lsp_helpers::RequirementResolution::version_satisfies_requirement`] when it
864    /// declines to compile) treats a bare `requirement` as an exact pin: it must match
865    /// `requirement` itself but reject both a higher patch (`"1.2.9"`) and a higher
866    /// minor/major (`"9.9.9"`) — the same "matches only this one version" shape
867    /// [`deps_core::lsp_helpers::concrete_pin_version`] asserts. Also correctly says `false`
868    /// for a genuine partial-version range (e.g. `"1.2"`), since that legitimately matches
869    /// more than one candidate.
870    #[cfg(any(
871        feature = "cargo",
872        feature = "npm",
873        feature = "go",
874        feature = "bundler",
875        feature = "dart",
876        feature = "maven",
877        feature = "composer",
878        feature = "gradle",
879        feature = "nuget",
880        feature = "deno",
881        feature = "github-actions",
882        feature = "gitlab-ci",
883        feature = "swift",
884        feature = "pypi"
885    ))]
886    fn formatter_treats_bare_version_as_exact_pin(
887        formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
888        bare: &str,
889    ) -> bool {
890        use deps_core::{ConcreteVersion, VersionReq};
891
892        let requirement = VersionReq::new(bare);
893        let matches = |candidate: &str| -> bool {
894            let version = ConcreteVersion::from(candidate);
895            if let Some(matcher) = formatter.compile_requirement(&requirement) {
896                matcher.matches(&version) == Some(true)
897            } else {
898                formatter.version_satisfies_requirement(&version, bare)
899            }
900        };
901
902        matches(bare) && !matches("9.9.9") && !matches("1.2.9")
903    }
904
905    /// How a given ecosystem's bare-version-is-a-pin verdict relates to its own formatter's
906    /// comparator — see [`bare_version_agreement_expectation`].
907    #[cfg(any(
908        feature = "cargo",
909        feature = "npm",
910        feature = "go",
911        feature = "bundler",
912        feature = "dart",
913        feature = "maven",
914        feature = "composer",
915        feature = "gradle",
916        feature = "nuget",
917        feature = "deno",
918        feature = "github-actions",
919        feature = "gitlab-ci",
920        feature = "swift",
921        feature = "pypi"
922    ))]
923    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
924    enum BareVersionAgreementExpectation {
925        /// `deps-core`'s `concrete_pin_version` and the ecosystem's own comparator must
926        /// agree on whether a bare full version (`"1.2.3"`) is an exact pin. When `true`,
927        /// also checked against a bare *partial* version (`"1.2"`, impl-critic M1) —
928        /// gated per-ecosystem because a partial-version *requirement* is not a concept
929        /// every `Concrete`-policy ecosystem actually has: Go's own comparator, for
930        /// instance, treats a bare string as a version *prefix* to support pseudo-version
931        /// and `+incompatible`-suffix matching (`go_version_matches`), which makes it
932        /// (correctly, for its real purpose) accept `"1.2"` against a candidate `"1.2.9"`
933        /// — a false "divergence" against `deps-core` if compared as though `"1.2"` were
934        /// a genuine partial-version requirement, which go.mod's `require` directive
935        /// never actually contains (it is always a complete version). Only the
936        /// ecosystems with a real bare-partial-is-a-range grammar (`AlwaysRange`'s Cargo
937        /// caret, `ConcreteIfFullVersion`'s X-range/moving-tag ecosystems) get `true`.
938        Checked { test_partial: bool },
939        /// The ecosystem's own comparator would disagree with `deps-core`'s verdict, but
940        /// the parser can never actually emit a bare requirement in the first place, so the
941        /// divergence never reaches `concrete_pin_version` in practice (Swift, PyPI).
942        LatentOnly,
943        /// `deps-core` deliberately reports a bare requirement as a pin even though the
944        /// ecosystem's own comparator disagrees — a documented approximation, not an
945        /// oversight (NuGet).
946        DeliberateApproximation,
947    }
948
949    /// #669 regression guard: `deps-core`'s `bare_requirement_policy` hand-maintains a
950    /// per-ecosystem model of "is a bare version requirement a pin or a range", but every
951    /// ecosystem's own formatter already has the authoritative answer via
952    /// `compile_requirement`/`version_satisfies_requirement`, and nothing kept the two in
953    /// sync — this already caused two shipped bugs (#664 npm/Composer, #667 Deno). For every
954    /// ecosystem this crate can register, classifies it via [`BareVersionAgreementExpectation`]
955    /// and checks the matching invariant: `Checked` ecosystems must agree on both a bare full
956    /// version (`"1.2.3"`) and a bare partial version (`"1.2"`, impl-critic M1); `LatentOnly`
957    /// and `DeliberateApproximation` ecosystems must instead still exhibit the disagreement
958    /// their exemption relies on (impl-critic M2) — so an alignment on either side (a parser
959    /// change, a comparator change) fails this test instead of silently going stale.
960    ///
961    /// The `match` in [`bare_version_agreement_expectation`] is deliberately exhaustive
962    /// (`.claude/CLAUDE.md`'s bug-class-#118 rule): a future 15th ecosystem must get an
963    /// explicit arm — added to `Checked` or listed as a commented, reviewed exemption —
964    /// rather than silently falling through a wildcard.
965    #[cfg(any(
966        feature = "cargo",
967        feature = "npm",
968        feature = "go",
969        feature = "bundler",
970        feature = "dart",
971        feature = "maven",
972        feature = "composer",
973        feature = "gradle",
974        feature = "nuget",
975        feature = "deno",
976        feature = "github-actions",
977        feature = "gitlab-ci",
978        feature = "swift",
979        feature = "pypi"
980    ))]
981    fn bare_version_agreement_expectation(
982        id: deps_core::EcosystemId,
983    ) -> BareVersionAgreementExpectation {
984        use BareVersionAgreementExpectation::{Checked, DeliberateApproximation, LatentOnly};
985
986        match id {
987            // Cargo and the `ConcreteIfFullVersion` ecosystems: a bare partial version is a
988            // real, distinct requirement shape from a bare full version, so both are checked.
989            deps_core::EcosystemId::Cargo
990            | deps_core::EcosystemId::Npm
991            | deps_core::EcosystemId::Composer
992            | deps_core::EcosystemId::Deno
993            | deps_core::EcosystemId::GithubActions
994            | deps_core::EcosystemId::GitlabCi => Checked { test_partial: true },
995            // Go/Bundler/Dart/Maven/Gradle: no partial-version requirement concept exists (a
996            // bare version is always complete), so only the full-version case is checked. Go's
997            // comparator specifically treats a bare string as a version prefix, not a
998            // partial-range, so testing `"1.2"` there would produce a false divergence.
999            deps_core::EcosystemId::Go
1000            | deps_core::EcosystemId::Bundler
1001            | deps_core::EcosystemId::Dart
1002            | deps_core::EcosystemId::Maven
1003            | deps_core::EcosystemId::Gradle => Checked {
1004                test_partial: false,
1005            },
1006            // Swift: `compile_requirement`'s `semver::VersionReq` has the same bare-string
1007            // caret-range divergence as NuGet, but `deps-swift`'s parser always emits an
1008            // explicit range or exact pin, never a bare `"X.Y.Z"` — can't fire today.
1009            // Re-review if the parser ever changes to emit a bare form.
1010            deps_core::EcosystemId::Swift => LatentOnly,
1011            // PyPI: the parser retains the pep440 comparator on every requirement (an exact
1012            // pin parses to `"==1.2.3"`, never bare), so a bare requirement never reaches
1013            // this check. Re-review if the parser ever changes to emit a bare form.
1014            deps_core::EcosystemId::Pypi => LatentOnly,
1015            // NuGet (#669): a bare `Version="X"` is really an unbounded minimum floor under
1016            // `NuGetFormatter`'s comparator, but `deps-core` deliberately still reports it as
1017            // a pin (mirrors `is_requirement_up_to_date`) — see `bare_requirement_policy`'s
1018            // doc for why the always-range alternative was tried and reverted.
1019            deps_core::EcosystemId::NuGet => DeliberateApproximation,
1020        }
1021    }
1022
1023    #[cfg(any(
1024        feature = "cargo",
1025        feature = "npm",
1026        feature = "go",
1027        feature = "bundler",
1028        feature = "dart",
1029        feature = "maven",
1030        feature = "composer",
1031        feature = "gradle",
1032        feature = "nuget",
1033        feature = "deno",
1034        feature = "github-actions",
1035        feature = "gitlab-ci",
1036        feature = "swift",
1037        feature = "pypi"
1038    ))]
1039    #[test]
1040    fn test_concrete_pin_version_agrees_with_formatter_for_bare_version() {
1041        use deps_core::{ConcreteVersion, VersionReq};
1042
1043        let registry = Arc::new(EcosystemRegistry::new());
1044        let cache = Arc::new(HttpCache::new());
1045        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
1046
1047        const BARE_FULL_VERSION: &str = "1.2.3";
1048        const BARE_PARTIAL_VERSION: &str = "1.2";
1049
1050        for str_id in registry.ecosystem_ids() {
1051            let id: deps_core::EcosystemId = str_id.parse().unwrap_or_else(|_| {
1052                panic!("registered ecosystem id {str_id:?} has no matching EcosystemId variant")
1053            });
1054            let ecosystem = registry
1055                .get(str_id)
1056                .unwrap_or_else(|| panic!("{str_id:?} just parsed from the registry's own ids"));
1057            let formatter = ecosystem.formatter();
1058
1059            match bare_version_agreement_expectation(id) {
1060                BareVersionAgreementExpectation::Checked { test_partial } => {
1061                    let bare_inputs: &[&str] = if test_partial {
1062                        &[BARE_FULL_VERSION, BARE_PARTIAL_VERSION]
1063                    } else {
1064                        &[BARE_FULL_VERSION]
1065                    };
1066                    for &bare in bare_inputs {
1067                        let deps_core_says_pin =
1068                            deps_core::lsp_helpers::concrete_pin_version(bare, id).is_some();
1069                        let formatter_says_pin =
1070                            formatter_treats_bare_version_as_exact_pin(formatter, bare);
1071
1072                        assert_eq!(
1073                            deps_core_says_pin, formatter_says_pin,
1074                            "{id:?} ({bare:?}): deps-core's concrete_pin_version and the \
1075                             ecosystem's own compile_requirement/version_satisfies_requirement \
1076                             disagree on whether this bare requirement is an exact pin"
1077                        );
1078                    }
1079                }
1080                BareVersionAgreementExpectation::LatentOnly => {
1081                    let deps_core_says_pin =
1082                        deps_core::lsp_helpers::concrete_pin_version(BARE_FULL_VERSION, id)
1083                            .is_some();
1084                    let formatter_says_pin =
1085                        formatter_treats_bare_version_as_exact_pin(formatter, BARE_FULL_VERSION);
1086
1087                    assert_ne!(
1088                        deps_core_says_pin, formatter_says_pin,
1089                        "{id:?}: this ecosystem is exempted as a latent-only mismatch, but its \
1090                         comparator no longer disagrees with deps-core's verdict — either the \
1091                         parser started emitting a bare requirement (making this a live bug, \
1092                         not a latent one) or the comparator changed; re-review this exemption \
1093                         in bare_version_agreement_expectation"
1094                    );
1095                }
1096                BareVersionAgreementExpectation::DeliberateApproximation => {
1097                    assert!(
1098                        deps_core::lsp_helpers::concrete_pin_version(BARE_FULL_VERSION, id)
1099                            .is_some(),
1100                        "{id:?}: deps-core should still report a bare full version as a pin \
1101                         (the deliberate approximation this exemption documents)"
1102                    );
1103                    assert!(
1104                        !formatter_treats_bare_version_as_exact_pin(formatter, BARE_FULL_VERSION),
1105                        "{id:?}: the ecosystem's own comparator no longer disagrees with a \
1106                         strict pin verdict — re-review whether this exemption (and the \
1107                         Concrete-policy approximation it documents) is still needed"
1108                    );
1109
1110                    let requirement = VersionReq::new(BARE_FULL_VERSION);
1111                    assert!(
1112                        formatter.is_requirement_up_to_date(
1113                            &requirement,
1114                            &ConcreteVersion::from(BARE_FULL_VERSION)
1115                        ),
1116                        "{id:?}: is_requirement_up_to_date should treat a bare floor as \
1117                         up to date when latest equals the floor — the pin-like precedent \
1118                         this exemption relies on"
1119                    );
1120                    assert!(
1121                        !formatter.is_requirement_up_to_date(
1122                            &requirement,
1123                            &ConcreteVersion::from("9.9.9")
1124                        ),
1125                        "{id:?}: is_requirement_up_to_date should treat a bare floor as \
1126                         outdated once latest moves past it — confirming it is handled as a \
1127                         pin, not an auto-following range"
1128                    );
1129                }
1130            }
1131        }
1132    }
1133
1134    /// #348 regression: `select_latest_matching` must resolve an all-`AdvisoryDeprecated`
1135    /// version list under a wildcard requirement for every registered ecosystem — an
1136    /// advisory-only flag (npm `deprecated`, Composer `abandoned`, ...) must never make an
1137    /// existing package look unresolvable (#347). Iterates every id `register_ecosystems`
1138    /// wires up via `EcosystemRegistry::ecosystem_ids`, so a 12th ecosystem is covered
1139    /// automatically without a new test. The paired `Available` control guards against a
1140    /// `None` result that has nothing to do with the advisory flag (e.g. the fixture
1141    /// version strings not fitting this ecosystem's matcher).
1142    ///
1143    /// This assertion is only genuinely discriminating for an ecosystem whose
1144    /// `select_latest_matching` actually consults `removal_status()` when filtering under
1145    /// a wildcard requirement (currently Composer, npm, and Deno-via-npm) — for an
1146    /// ecosystem that doesn't filter on it at all, or that only maps a real per-version
1147    /// yank (not the advisory case), `subject.is_some()` is trivially true regardless of
1148    /// whether the ecosystem maps its advisory flag correctly.
1149    #[test]
1150    fn test_select_latest_matching_resolves_advisory_deprecated_for_every_ecosystem() {
1151        use deps_core::{RemovalStatus, Version, VersionReq};
1152        use std::any::Any;
1153
1154        struct StatusVersion {
1155            version: deps_core::ConcreteVersion,
1156            status: RemovalStatus,
1157        }
1158
1159        impl Version for StatusVersion {
1160            fn version_string(&self) -> &deps_core::ConcreteVersion {
1161                &self.version
1162            }
1163
1164            fn removal_status(&self) -> RemovalStatus {
1165                self.status
1166            }
1167
1168            fn as_any(&self) -> &dyn Any {
1169                self
1170            }
1171        }
1172
1173        fn fixture(status: RemovalStatus) -> Vec<Box<dyn Version>> {
1174            vec![
1175                Box::new(StatusVersion {
1176                    version: "2.0.0".into(),
1177                    status,
1178                }),
1179                Box::new(StatusVersion {
1180                    version: "1.2.3".into(),
1181                    status,
1182                }),
1183            ]
1184        }
1185
1186        // #421 S2: a package whose only releases are prerelease must still resolve under a
1187        // wildcard requirement — prerelease is a ranking preference, not a removal from
1188        // existence. `is_prerelease()` is overridden directly so this fixture stays
1189        // unambiguous regardless of ecosystem-specific version-string parsing.
1190        struct PrereleaseOnlyVersion {
1191            version: deps_core::ConcreteVersion,
1192        }
1193
1194        impl Version for PrereleaseOnlyVersion {
1195            fn version_string(&self) -> &deps_core::ConcreteVersion {
1196                &self.version
1197            }
1198
1199            fn is_prerelease(&self) -> bool {
1200                true
1201            }
1202
1203            fn as_any(&self) -> &dyn Any {
1204                self
1205            }
1206        }
1207
1208        fn prerelease_only_fixture() -> Vec<Box<dyn Version>> {
1209            vec![
1210                Box::new(PrereleaseOnlyVersion {
1211                    version: "2.0.0-beta2".into(),
1212                }),
1213                Box::new(PrereleaseOnlyVersion {
1214                    version: "2.0.0-beta1".into(),
1215                }),
1216            ]
1217        }
1218
1219        let registry = Arc::new(EcosystemRegistry::new());
1220        let cache = Arc::new(HttpCache::new());
1221        register_ecosystems(&registry, Arc::clone(&cache), &test_runtime());
1222
1223        let req = VersionReq::new("*");
1224        for id in registry.ecosystem_ids() {
1225            let ecosystem = registry.get(id).expect("id came from ecosystem_ids()");
1226            let ecosystem_registry = ecosystem.registry();
1227
1228            let control =
1229                ecosystem_registry.select_latest_matching(&fixture(RemovalStatus::Available), &req);
1230            assert!(
1231                control.is_some(),
1232                "{id}: control fixture (all Available) must resolve under a wildcard \
1233                 requirement — a `None` here means the fixture itself doesn't fit this \
1234                 ecosystem's matcher, not that the advisory flag broke anything"
1235            );
1236
1237            let subject = ecosystem_registry
1238                .select_latest_matching(&fixture(RemovalStatus::AdvisoryDeprecated), &req);
1239            assert!(
1240                subject.is_some(),
1241                "{id}: an advisory-only flag must not hide an existing package under a \
1242                 wildcard requirement (#347)"
1243            );
1244
1245            // Go is a deliberate exception, not an #421-class bug (#364): `select_latest_matching`
1246            // unconditionally excludes prerelease pseudo-versions with no wildcard fallback, so
1247            // the `/@v/list` pick never shadows the `/@latest` fallback a prerelease-only module
1248            // needs. Asserting this invariant for Go would "fix" intentional behavior.
1249            //
1250            // NuGet used to be excluded too, but #423 added a fallback rung to
1251            // `select_latest_matching` so a prerelease-only package now resolves under a bare
1252            // wildcard too — no exception needed anymore.
1253            if matches!(id, "go") {
1254                continue;
1255            }
1256
1257            let prerelease_subject =
1258                ecosystem_registry.select_latest_matching(&prerelease_only_fixture(), &req);
1259            assert!(
1260                prerelease_subject.is_some(),
1261                "{id}: a package whose only releases so far are prerelease must still \
1262                 resolve under a wildcard requirement (#421)"
1263            );
1264        }
1265    }
1266}