Skip to main content

deps_nuget/
ecosystem.rs

1//! NuGet ecosystem implementation for deps-lsp.
2//!
3//! # Private/custom feeds (issue #523)
4//!
5//! `NuGetEcosystem::parse_manifest` resolves the manifest directory's in-repo `NuGet.Config`
6//! ancestor chain (`crate::config::resolve_with_context`) after parsing, stamps each dependency's
7//! [`deps_core::parser::DependencySource`] from it, and registers every implied routing chain
8//! against the shared [`NuGetRegistry`] — see `crate::config`'s module doc for the full
9//! `<packageSources>`/`<packageSourceMapping>` resolution model. Parsers in `parser.rs` stay
10//! config-blind (always construct `DependencySource::Registry`); only this module threads
11//! config resolution in, mirroring `deps-pypi`'s/`deps-cargo`'s split.
12//!
13//! # Unknown/unresolvable packages
14//!
15//! A 404 or otherwise-unknown package must degrade to **no diagnostic, no inlay hint, no
16//! error marker** (S4) — this already falls out of `deps-lsp`'s generic error handling around
17//! `Registry::get_latest_matching` (a fetch error or `Ok(None)` both simply omit the
18//! package from `cached_versions`), so no special-casing is needed here.
19
20use std::any::Any;
21use std::collections::HashSet;
22use std::sync::Arc;
23use tower_lsp_server::ls_types::{CompletionItem, Hover, HoverContents, Position, Range, Uri};
24
25use deps_core::{
26    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
27    lsp_helpers::EcosystemFormatter, parser::DependencySource,
28};
29
30use crate::config::NuGetParseContext;
31use crate::formatter::NuGetFormatter;
32use crate::lockfile::NuGetLockParser;
33use crate::registry::NuGetRegistry;
34use crate::types::NuGetParseResult;
35
36/// Bounds `NuGetEcosystem::generate_hover`'s `unlisted_versions_for_hover` fetch (S4, #451
37/// follow-up) — mirrors `deps_core::lsp_helpers::hover`'s own private `HOVER_FALLBACK_TIMEOUT`
38/// for its analogous fallback fetch: hover responses must return quickly, and without this
39/// bound a pathological feed's registration-hive walk could run unbounded.
40const HOVER_UNLISTED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
41
42/// NuGet/.NET ecosystem implementation.
43///
44/// Provides LSP functionality for `.csproj`/`.fsproj`/`.vbproj`, `Directory.Packages.props`,
45/// and `packages.config` files, backed by the NuGet V3 registry API.
46pub struct NuGetEcosystem {
47    registry: Arc<NuGetRegistry>,
48    formatter: NuGetFormatter,
49    lockfile_provider: Arc<NuGetLockParser>,
50    context: NuGetParseContext,
51}
52
53impl NuGetEcosystem {
54    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
55        Self::with_context(
56            Arc::new(NuGetRegistry::new(cache)),
57            NuGetParseContext::default(),
58        )
59    }
60
61    /// Creates a new NuGet ecosystem sharing `context`'s reachability policy and
62    /// `NuGet.Config` memoization cache, around an existing [`NuGetRegistry`] instance — the
63    /// production constructor, used by `deps-lsp`'s `register_ecosystems` so
64    /// `initialize`/`workspace/didChangeConfiguration` can update the same
65    /// `Arc<RegistryAccessPolicy>` this ecosystem's every parse reads (mirrors `deps-npm`'s/
66    /// `deps-pypi`'s identical split — `Self::new`'s default, disconnected policy would never
67    /// see a live update).
68    #[must_use]
69    pub fn with_context(registry: Arc<NuGetRegistry>, context: NuGetParseContext) -> Self {
70        Self {
71            registry,
72            formatter: NuGetFormatter,
73            lockfile_provider: Arc::new(NuGetLockParser),
74            context,
75        }
76    }
77
78    /// Completes package names by searching the NuGet registry.
79    ///
80    /// Deliberately source-blind (mirrors `deps-npm::ecosystem::NpmEcosystem::
81    /// complete_package_names`'s identical rationale): the string here is a prefix the user
82    /// typed into the name field, not a resolved private dependency name, so it is safe to
83    /// send to api.nuget.org unconditionally — unlike [`Self::complete_versions`].
84    async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
85        deps_core::completion::complete_package_names_generic(
86            self.registry.as_ref(),
87            prefix,
88            20,
89            range,
90        )
91        .await
92    }
93
94    /// Completes version requirements for the dependency at `position`, resolved by cursor
95    /// position rather than by name (issue #593) — delegates to
96    /// [`deps_core::completion::complete_versions_at_position`], which mirrors
97    /// `deps_gitlab_ci::ecosystem::GitLabCiEcosystem::generate_completions`'s reference
98    /// pattern. Position-based lookup also fixes a residual gap in the old name-based
99    /// routing (issue #523): two dependencies sharing one `PackageName` but resolving to
100    /// different sources used to collapse into an ambiguous, empty result for both
101    /// occurrences, even though the cursor position unambiguously identifies which one the
102    /// user is editing.
103    ///
104    /// An unresolvable source still offers no completions rather than risking a private
105    /// package name lookup against api.nuget.org — the shared helper's gate is what keeps
106    /// `Registry::get_versions_from`'s permissive routing of an unrecognized source to the
107    /// default public client (matching hover/diagnostics/code-actions' identical gate) from
108    /// leaking one for completions too.
109    async fn complete_versions(
110        &self,
111        parse_result: &dyn ParseResultTrait,
112        position: Position,
113        prefix: &str,
114        freshness: deps_core::FreshnessSettings,
115    ) -> Vec<CompletionItem> {
116        deps_core::completion::complete_versions_at_position(
117            self.registry.as_ref(),
118            &self.formatter,
119            parse_result,
120            position,
121            prefix,
122            &[],
123            freshness,
124        )
125        .await
126    }
127
128    /// Test-only hook to inject a [`NuGetRegistry`] pointed at a mock service index
129    /// (`NuGetRegistry::new`/`Self::new` always resolve the real `api.nuget.org`).
130    #[cfg(test)]
131    fn with_registry(registry: NuGetRegistry) -> Self {
132        Self::with_context(Arc::new(registry), NuGetParseContext::default())
133    }
134
135    /// Dispatches to the manifest-kind-specific parser based on the URI's basename.
136    /// `.csproj`/`.fsproj`/`.vbproj` (routed to this ecosystem via `manifest_extensions`)
137    /// all share the `PackageReference` MSBuild schema, so anything not matching one of the
138    /// two fixed filenames falls through to the project-file parser.
139    fn parse_by_filename(content: &str, uri: &Uri) -> Result<NuGetParseResult> {
140        let path = uri.path().as_str();
141        let filename = path.rsplit('/').next().unwrap_or(path);
142
143        match filename.to_lowercase().as_str() {
144            "directory.packages.props" => {
145                crate::parser::parse_directory_packages_props(content, uri)
146            }
147            "packages.config" => crate::parser::parse_packages_config(content, uri),
148            _ => crate::parser::parse_project_file(content, uri),
149        }
150    }
151}
152
153impl deps_core::ecosystem::private::Sealed for NuGetEcosystem {}
154
155impl Ecosystem for NuGetEcosystem {
156    fn id(&self) -> &'static str {
157        "nuget"
158    }
159
160    fn display_name(&self) -> &'static str {
161        "NuGet (.NET)"
162    }
163
164    fn manifest_filenames(&self) -> &[&'static str] {
165        &["Directory.Packages.props", "packages.config"]
166    }
167
168    fn manifest_extensions(&self) -> &[&'static str] {
169        &[".csproj", ".fsproj", ".vbproj"]
170    }
171
172    fn lockfile_filenames(&self) -> &[&'static str] {
173        // `packages.*.lock.json` isn't a real filename `EcosystemRegistry::get_for_lockfile`
174        // can exact-match — it exists here only so `all_lockfile_patterns()` registers a
175        // glob watcher for per-project lock files with the LSP client (D3, #451).
176        // `NuGetLockParser::locate_lockfile` is what actually finds them, independent of
177        // this list, via its own directory scan.
178        &["packages.lock.json", "packages.*.lock.json"]
179    }
180
181    fn parse_manifest<'a>(
182        &'a self,
183        content: &'a str,
184        uri: &'a Uri,
185    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
186        Box::pin(async move {
187            let mut result = Self::parse_by_filename(content, uri)?;
188
189            // Issue #523: a non-file URI (or one `Uri::to_file_path` cannot resolve) has no
190            // directory to walk `NuGet.Config` discovery from — falls back to the default
191            // (empty) `NuGetConfig`, which resolves every dependency to
192            // `DependencySource::Registry` (byte-identical to pre-feature behavior), rather
193            // than failing the whole parse.
194            let config = uri
195                .to_file_path()
196                .and_then(|path| path.parent().map(std::path::Path::to_path_buf))
197                .map(|dir| {
198                    crate::config::resolve_with_context(
199                        &dir,
200                        &self.context.config_cache,
201                        &self.context.policy,
202                        self.context.user_profile_config.as_deref(),
203                        &self.context.user_profile_sources,
204                    )
205                })
206                .unwrap_or_default();
207
208            for dep in &mut result.dependencies {
209                dep.source = config.resolve_source_for(&dep.name);
210            }
211            result.resolved_chains = config.resolved_chains();
212            for chain in &result.resolved_chains {
213                NuGetRegistry::register_chain(&self.registry, chain, &self.context.policy);
214            }
215
216            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
217        })
218    }
219
220    fn registry(&self) -> Arc<dyn Registry> {
221        self.registry.clone() as Arc<dyn Registry>
222    }
223
224    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
225        Some(self.lockfile_provider.clone() as Arc<dyn deps_core::lockfile::LockFileProvider>)
226    }
227
228    fn formatter(&self) -> &dyn EcosystemFormatter {
229        &self.formatter
230    }
231
232    fn generate_completions<'a>(
233        &'a self,
234        parse_result: &'a dyn ParseResultTrait,
235        position: Position,
236        content: &'a str,
237        freshness: deps_core::FreshnessSettings,
238    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
239        Box::pin(async move {
240            use deps_core::completion::{CompletionContext, detect_completion_context};
241
242            match detect_completion_context(parse_result, position, content) {
243                CompletionContext::PackageName { prefix, range } => {
244                    self.complete_package_names(&prefix, range).await
245                }
246                CompletionContext::Version { prefix, .. } => {
247                    self.complete_versions(parse_result, position, &prefix, freshness)
248                        .await
249                }
250                CompletionContext::Feature { .. } | CompletionContext::None => vec![],
251            }
252            .into()
253        })
254    }
255
256    /// Overrides the default (`lsp_helpers::generate_hover`) to add a hover-only unlisted
257    /// marker (D1, #451): alongside the ordinary hover render, a separate
258    /// [`NuGetRegistry::unlisted_versions_for_hover`] fetch decorates each unlisted entry
259    /// in the "Recent versions" list with `*(unlisted)*`, matching how other ecosystems in
260    /// this codebase mark a `*(yanked)*` version — see that method's doc comment for why
261    /// this enrichment is deliberately kept out of `Version::removal_status` (and so out of
262    /// completion/inlay hints/diagnostics, which all share the cached version list hover
263    /// itself renders via the *ordinary*, non-enriched path above).
264    ///
265    /// The two fetches run concurrently via `tokio::join!`, not sequentially (S4, #451
266    /// follow-up): serializing them would double hover's worst-case latency, and the
267    /// unlisted fetch alone is additionally bounded by `HOVER_UNLISTED_TIMEOUT` — without
268    /// it, a pathological feed's registration-hive walk (up to `MAX_EXTERNAL_PAGE_FETCHES`
269    /// sequential page fetches, each with its own HTTP-client timeout) could run
270    /// unbounded, exactly the failure mode `deps_core::lsp_helpers::hover`'s own
271    /// `HOVER_FALLBACK_TIMEOUT` exists to prevent for its analogous fallback fetch. `dep` is
272    /// resolved once, up front, and reused for the unlisted lookup rather than re-derived
273    /// from the rendered hover afterward.
274    ///
275    /// **Issue #523 fix**: the unlisted fetch is only issued when `dep.source()` is plain
276    /// `DependencySource::Registry` — `self.registry` here is always the `Public`-tier root,
277    /// so calling `unlisted_versions_for_hover` unconditionally would send a private-feed
278    /// dependency's real package name to `api.nuget.org`, defeating the entire feature
279    /// (`NuGetRegistry::unlisted_versions_for_hover`'s own tier gate only fires when the
280    /// *callee* instance is `WorkspaceDeclared`, which the root never is). A private-feed
281    /// dependency simply renders without the `*(unlisted)*` decoration — registration-hive
282    /// enrichment is already skipped entirely for alternate feeds in phase 1 (see that
283    /// method's own tier gate), so this loses nothing a private-feed hover would have shown
284    /// anyway.
285    fn generate_hover<'a>(
286        &'a self,
287        parse_result: &'a dyn ParseResultTrait,
288        position: Position,
289        versions: deps_core::VersionData<'a>,
290        freshness: deps_core::FreshnessSettings,
291    ) -> deps_core::ecosystem::BoxFuture<'a, Option<Hover>> {
292        Box::pin(async move {
293            let base_hover = deps_core::lsp_generate_hover(
294                parse_result,
295                position,
296                versions,
297                self.registry.as_ref(),
298                self.formatter(),
299                freshness,
300                deps_core::PublishTime::now(),
301            );
302
303            // Same predicate `lsp_generate_hover` uses internally to pick a dependency: if
304            // it finds none, the render above resolves to `None` too, so the unlisted fetch
305            // is skipped rather than issued for a hover that will be empty anyway.
306            let dep = parse_result.dependencies().into_iter().find(|d| {
307                deps_core::position_in_range(position, d.name_range())
308                    || d.version_range()
309                        .is_some_and(|r| deps_core::position_in_range(position, r))
310            });
311            let Some(dep) = dep else {
312                return base_hover.await;
313            };
314
315            // C1 fix (issue #523), widened by #562/FR-012: an `AlternateRegistry` dependency
316            // now routes the unlisted-versions fetch through its own registered alternate
317            // client (registration-hive enrichment is no longer skipped for alternate feeds) —
318            // never against `self.registry` (the `Public`-tier root), which would send a
319            // private-feed dependency's real package name to `api.nuget.org`. An unregistered
320            // (unresolvable) alternate index, or any other source kind, skips the fetch
321            // entirely rather than risk that leak.
322            let unlisted_client: Option<Arc<NuGetRegistry>> = match dep.source() {
323                DependencySource::Registry => Some(Arc::clone(&self.registry)),
324                DependencySource::AlternateRegistry { index, .. } => {
325                    self.registry.alternate_client(&index)
326                }
327                _ => None,
328            };
329            let Some(unlisted_client) = unlisted_client else {
330                return base_hover.await;
331            };
332
333            let unlisted_fetch = tokio::time::timeout(
334                HOVER_UNLISTED_TIMEOUT,
335                unlisted_client.unlisted_versions_for_hover(dep.name().as_str()),
336            );
337
338            let (hover, unlisted_result) = tokio::join!(base_hover, unlisted_fetch);
339            let mut hover = hover?;
340
341            let unlisted = match unlisted_result {
342                Ok(Ok(unlisted)) => unlisted,
343                Ok(Err(error)) => {
344                    tracing::debug!(package = %dep.name(), %error, "hover unlisted-versions fetch failed");
345                    return Some(hover);
346                }
347                Err(_) => {
348                    tracing::warn!(
349                        package = %dep.name(),
350                        timeout_secs = HOVER_UNLISTED_TIMEOUT.as_secs(),
351                        "hover unlisted-versions fetch timed out"
352                    );
353                    return Some(hover);
354                }
355            };
356            if unlisted.is_empty() {
357                return Some(hover);
358            }
359
360            if let HoverContents::Markup(content) = &mut hover.contents {
361                content.value = annotate_unlisted_versions(&content.value, &unlisted);
362            }
363
364            Some(hover)
365        })
366    }
367
368    fn as_any(&self) -> &dyn Any {
369        self
370    }
371}
372
373/// Injects a `*(unlisted)*` marker into each `"- \`VERSION\` ..."` "Recent versions" bullet
374/// line whose version is in `unlisted`, right after the version literal and before any
375/// existing tag (`*(latest)*`, an age suffix, ...) — matching the position/spacing
376/// `formatter.yanked_label()` occupies for other ecosystems' `*(yanked)*` markers. Lines
377/// that don't match the bullet format (the `**Latest**`/`**Requirement**` lines, the footer)
378/// pass through unchanged.
379fn annotate_unlisted_versions(markdown: &str, unlisted: &HashSet<String>) -> String {
380    let mut out = String::with_capacity(markdown.len() + unlisted.len() * 14);
381    for line in markdown.split_inclusive('\n') {
382        let body = line.strip_suffix('\n').unwrap_or(line);
383        let matched = body.strip_prefix("- `").and_then(|rest| {
384            let tick = rest.find('`')?;
385            Some((&rest[..tick], &rest[tick + 1..]))
386        });
387        match matched {
388            Some((version, rest)) if unlisted.contains(version) => {
389                out.push_str("- `");
390                out.push_str(version);
391                out.push_str("` *(unlisted)*");
392                out.push_str(rest);
393            }
394            _ => out.push_str(body),
395        }
396        if line.ends_with('\n') {
397            out.push('\n');
398        }
399    }
400    out
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::types::NuGetDependency;
407
408    #[test]
409    fn test_ecosystem_id() {
410        let cache = Arc::new(deps_core::HttpCache::new());
411        let eco = NuGetEcosystem::new(cache);
412        assert_eq!(eco.id(), "nuget");
413    }
414
415    #[test]
416    fn test_ecosystem_display_name() {
417        let cache = Arc::new(deps_core::HttpCache::new());
418        let eco = NuGetEcosystem::new(cache);
419        assert_eq!(eco.display_name(), "NuGet (.NET)");
420    }
421
422    #[test]
423    fn test_manifest_filenames_and_extensions() {
424        let cache = Arc::new(deps_core::HttpCache::new());
425        let eco = NuGetEcosystem::new(cache);
426        assert_eq!(
427            eco.manifest_filenames(),
428            &["Directory.Packages.props", "packages.config"]
429        );
430        assert_eq!(
431            eco.manifest_extensions(),
432            &[".csproj", ".fsproj", ".vbproj"]
433        );
434    }
435
436    #[test]
437    fn test_lockfile_filenames() {
438        let cache = Arc::new(deps_core::HttpCache::new());
439        let eco = NuGetEcosystem::new(cache);
440        assert_eq!(
441            eco.lockfile_filenames(),
442            &["packages.lock.json", "packages.*.lock.json"]
443        );
444    }
445
446    #[test]
447    fn test_lockfile_provider_some() {
448        let cache = Arc::new(deps_core::HttpCache::new());
449        let eco = NuGetEcosystem::new(cache);
450        assert!(eco.lockfile_provider().is_some());
451    }
452
453    #[test]
454    fn test_as_any() {
455        let cache = Arc::new(deps_core::HttpCache::new());
456        let eco = NuGetEcosystem::new(cache);
457        assert!(eco.as_any().is::<NuGetEcosystem>());
458    }
459
460    #[tokio::test]
461    async fn test_package_name_completion_context_has_real_range() {
462        // Regression test for #232: the textEdit range for a package-name completion
463        // must be the real name token span, not the (0,0)-(0,0) placeholder.
464        let cache = Arc::new(deps_core::HttpCache::new());
465        let eco = NuGetEcosystem::new(cache);
466        let content = "<Project>\n  <ItemGroup>\n    <PackageReference Include=\"Foo\" Version=\"1.0.0\" />\n  </ItemGroup>\n</Project>";
467        let uri = deps_core::test_util::test_uri("/test/App.csproj");
468
469        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
470        let position = Position::new(2, 33); // cursor after "Fo" in "Foo"
471
472        let context = deps_core::completion::detect_completion_context(
473            parse_result.as_ref(),
474            position,
475            content,
476        );
477
478        match context {
479            deps_core::completion::CompletionContext::PackageName { prefix, range } => {
480                assert_eq!(prefix, "Fo");
481                assert_ne!(range, Range::default());
482                assert_eq!(
483                    range,
484                    Range::new(Position::new(2, 31), Position::new(2, 34))
485                );
486            }
487            other => panic!("Expected PackageName context, got {other:?}"),
488        }
489    }
490
491    #[tokio::test]
492    async fn test_parse_manifest_csproj() {
493        let cache = Arc::new(deps_core::HttpCache::new());
494        let eco = NuGetEcosystem::new(cache);
495        let uri = deps_core::test_util::test_uri("/test/App.csproj");
496        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
497
498        let result = eco.parse_manifest(content, &uri).await.unwrap();
499        assert_eq!(result.dependencies().len(), 1);
500    }
501
502    #[tokio::test]
503    async fn test_parse_manifest_fsproj_routes_as_project_file() {
504        let cache = Arc::new(deps_core::HttpCache::new());
505        let eco = NuGetEcosystem::new(cache);
506        let uri = deps_core::test_util::test_uri("/test/App.fsproj");
507        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
508
509        let result = eco.parse_manifest(content, &uri).await.unwrap();
510        assert_eq!(result.dependencies().len(), 1);
511    }
512
513    #[tokio::test]
514    async fn test_parse_manifest_directory_packages_props() {
515        let cache = Arc::new(deps_core::HttpCache::new());
516        let eco = NuGetEcosystem::new(cache);
517        let uri = deps_core::test_util::test_uri("/test/Directory.Packages.props");
518        let content = r#"<Project><ItemGroup><PackageVersion Include="Serilog" Version="3.1.1" /></ItemGroup></Project>"#;
519
520        let result = eco.parse_manifest(content, &uri).await.unwrap();
521        assert_eq!(result.dependencies().len(), 1);
522    }
523
524    #[tokio::test]
525    async fn test_parse_manifest_packages_config() {
526        let cache = Arc::new(deps_core::HttpCache::new());
527        let eco = NuGetEcosystem::new(cache);
528        let uri = deps_core::test_util::test_uri("/test/packages.config");
529        let content = r#"<packages><package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" /></packages>"#;
530
531        let result = eco.parse_manifest(content, &uri).await.unwrap();
532        assert_eq!(result.dependencies().len(), 1);
533    }
534
535    #[tokio::test]
536    async fn test_parse_manifest_invalid_xml_errors() {
537        let cache = Arc::new(deps_core::HttpCache::new());
538        let eco = NuGetEcosystem::new(cache);
539        let uri = deps_core::test_util::test_uri("/test/App.csproj");
540        let content = r#"<Project attr="unclosed></Project>"#;
541
542        let result = eco.parse_manifest(content, &uri).await;
543        assert!(result.is_err());
544    }
545
546    #[tokio::test]
547    async fn test_complete_package_names_min_prefix() {
548        let cache = Arc::new(deps_core::HttpCache::new());
549        let eco = NuGetEcosystem::new(cache);
550        assert!(
551            eco.complete_package_names("", Range::default())
552                .await
553                .is_empty()
554        );
555    }
556
557    // --- complete_versions: position-based dependency lookup + can_resolve_source gate (issue #593) ---
558
559    /// A dependency on `line`, with a `version_range` there so position-based lookup (issue
560    /// #593) can find it — mirrors `deps_go::ecosystem::tests::dep_with_source`.
561    fn dep_with_source(name: &str, source: DependencySource, line: u32) -> NuGetDependency {
562        NuGetDependency {
563            name: name.into(),
564            name_range: Range::new(Position::new(line, 0), Position::new(line, 0)),
565            version_requirement: Some("1.0.0".into()),
566            version_range: Some(Range::new(Position::new(line, 0), Position::new(line, 10))),
567            source,
568        }
569    }
570
571    #[tokio::test]
572    async fn test_complete_versions_position_based_lookup_finds_correct_dependency() {
573        let mut server = mockito::Server::new_async().await;
574        let base = server.url();
575        let _index_mock = server
576            .mock("GET", "/index.json")
577            .with_status(200)
578            .with_body(nuget_service_index_body(&base))
579            .create_async()
580            .await;
581        let _flat_mock = server
582            .mock("GET", "/flatcontainer/targetpkg/index.json")
583            .with_status(200)
584            .with_body(r#"{"versions": ["1.0.0", "1.2.0"]}"#)
585            .create_async()
586            .await;
587
588        let registry = NuGetRegistry::with_service_index_url(
589            Arc::new(deps_core::HttpCache::new()),
590            format!("{base}/index.json"),
591        );
592        let eco = NuGetEcosystem::with_registry(registry);
593
594        // "otherpkg" has no mock registered — if position-based lookup picked it instead of
595        // the dependency actually under the cursor, this would fail closed to empty instead
596        // of returning `targetpkg`'s versions.
597        let other = dep_with_source("otherpkg", DependencySource::Registry, 0);
598        let target = dep_with_source("targetpkg", DependencySource::Registry, 1);
599        let target_position = target.version_range.unwrap().start;
600        let parse_result = NuGetParseResult {
601            dependencies: vec![other, target],
602            uri: deps_core::test_util::test_uri("/test/App.csproj"),
603            resolved_chains: Vec::new(),
604        };
605
606        let results = eco
607            .complete_versions(
608                &parse_result,
609                target_position,
610                "1",
611                deps_core::FreshnessSettings::default(),
612            )
613            .await;
614        assert!(
615            !results.is_empty(),
616            "position-based lookup must resolve the dependency at the cursor position"
617        );
618    }
619
620    #[tokio::test]
621    async fn test_complete_versions_no_dependency_at_position_offers_nothing() {
622        let cache = Arc::new(deps_core::HttpCache::new());
623        let eco = NuGetEcosystem::new(cache);
624
625        let dep = dep_with_source("somepkg", DependencySource::Registry, 0);
626        let parse_result = NuGetParseResult {
627            dependencies: vec![dep],
628            uri: deps_core::test_util::test_uri("/test/App.csproj"),
629            resolved_chains: Vec::new(),
630        };
631
632        let results = eco
633            .complete_versions(
634                &parse_result,
635                Position::new(99, 0),
636                "1",
637                deps_core::FreshnessSettings::default(),
638            )
639            .await;
640        assert!(results.is_empty());
641    }
642
643    /// `can_resolve_source`'s gate (matching hover/diagnostics/code-actions' identical check,
644    /// #248 leak class) must keep an unresolvable `CustomRegistry` source from ever reaching
645    /// api.nuget.org. The `.expect(0)` mock fails the test if that endpoint is hit at all.
646    #[tokio::test]
647    async fn test_complete_versions_gate_blocks_unresolvable_source() {
648        let mut server = mockito::Server::new_async().await;
649        let base = server.url();
650        let _index_mock = server
651            .mock("GET", "/index.json")
652            .expect(0)
653            .create_async()
654            .await;
655
656        let registry = NuGetRegistry::with_service_index_url(
657            Arc::new(deps_core::HttpCache::new()),
658            format!("{base}/index.json"),
659        );
660        let eco = NuGetEcosystem::with_registry(registry);
661
662        let dep = dep_with_source(
663            "privatepkg",
664            DependencySource::CustomRegistry {
665                url: "https://feed.mycorp.example/v3/index.json".to_string(),
666            },
667            0,
668        );
669        let position = dep.version_range.unwrap().start;
670        let parse_result = NuGetParseResult {
671            dependencies: vec![dep],
672            uri: deps_core::test_util::test_uri("/test/App.csproj"),
673            resolved_chains: Vec::new(),
674        };
675
676        let results = eco
677            .complete_versions(
678                &parse_result,
679                position,
680                "1",
681                deps_core::FreshnessSettings::default(),
682            )
683            .await;
684        assert!(
685            results.is_empty(),
686            "an unresolvable CustomRegistry source must offer no completions"
687        );
688        _index_mock.assert_async().await;
689    }
690
691    /// An `AlternateRegistry` source whose index has no registered client offers no
692    /// completions rather than falling back to the public registry.
693    #[tokio::test]
694    async fn test_complete_versions_unregistered_alternate_offers_nothing() {
695        let mut server = mockito::Server::new_async().await;
696        let base = server.url();
697        let _index_mock = server
698            .mock("GET", "/index.json")
699            .expect(0)
700            .create_async()
701            .await;
702
703        let registry = NuGetRegistry::with_service_index_url(
704            Arc::new(deps_core::HttpCache::new()),
705            format!("{base}/index.json"),
706        );
707        let eco = NuGetEcosystem::with_registry(registry);
708
709        let dep = dep_with_source(
710            "internal.auth",
711            DependencySource::AlternateRegistry {
712                index: "nuget-chain:never-registered".to_string(),
713                mirrors_crates_io: false,
714            },
715            0,
716        );
717        let position = dep.version_range.unwrap().start;
718        let parse_result = NuGetParseResult {
719            dependencies: vec![dep],
720            uri: deps_core::test_util::test_uri("/test/App.csproj"),
721            resolved_chains: Vec::new(),
722        };
723
724        let results = eco
725            .complete_versions(
726                &parse_result,
727                position,
728                "1.",
729                deps_core::FreshnessSettings::default(),
730            )
731            .await;
732        assert!(
733            results.is_empty(),
734            "unregistered alternate index must offer no completions"
735        );
736        _index_mock.assert_async().await;
737    }
738
739    /// A registered `AlternateRegistry` chain routes `complete_versions` through its own
740    /// client, never the public root registry.
741    #[tokio::test]
742    async fn test_complete_versions_routes_to_registered_alternate_client() {
743        let mut alt_server = mockito::Server::new_async().await;
744        let alt_base = alt_server.url();
745        let _alt_index_mock = alt_server
746            .mock("GET", "/index.json")
747            .with_status(200)
748            .with_body(nuget_service_index_body(&alt_base))
749            .create_async()
750            .await;
751        let _alt_flat_mock = alt_server
752            .mock("GET", "/flatcontainer/internal.auth/index.json")
753            .with_status(200)
754            .with_body(r#"{"versions": ["1.2.3", "1.3.0"]}"#)
755            .create_async()
756            .await;
757
758        let mut public_server = mockito::Server::new_async().await;
759        let public_base = public_server.url();
760        let _public_index_mock = public_server
761            .mock("GET", "/index.json")
762            .expect(0)
763            .create_async()
764            .await;
765
766        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
767            deps_core::net_policy::WorkspaceRegistryAccess::All,
768        ));
769        let root = Arc::new(NuGetRegistry::with_service_index_url(
770            Arc::new(deps_core::HttpCache::new()),
771            format!("{public_base}/index.json"),
772        ));
773
774        let feed_url =
775            crate::config::NuGetFeedUrl::new(&format!("{alt_base}/index.json"), &policy).unwrap();
776        let chain = crate::config::NuGetSourceChain {
777            key: "nuget-chain:test-alt".to_string(),
778            hops: vec![crate::config::ResolvedHop {
779                url: feed_url,
780                slot: None,
781                auth: None,
782            }],
783            implicit_public_fallback: false,
784        };
785        NuGetRegistry::register_chain(&root, &chain, &policy);
786
787        let context = crate::config::NuGetParseContext {
788            policy: Arc::clone(&policy),
789            config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
790            user_profile_config: None,
791            user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
792        };
793        let eco = NuGetEcosystem::with_context(root, context);
794
795        let dep = dep_with_source(
796            "internal.auth",
797            DependencySource::AlternateRegistry {
798                index: "nuget-chain:test-alt".to_string(),
799                mirrors_crates_io: false,
800            },
801            0,
802        );
803        let position = dep.version_range.unwrap().start;
804        let parse_result = NuGetParseResult {
805            dependencies: vec![dep],
806            uri: deps_core::test_util::test_uri("/test/App.csproj"),
807            resolved_chains: Vec::new(),
808        };
809
810        let results = eco
811            .complete_versions(
812                &parse_result,
813                position,
814                "1.",
815                deps_core::FreshnessSettings::default(),
816            )
817            .await;
818        assert!(
819            !results.is_empty(),
820            "a registered alternate index must route completions through its own client"
821        );
822        _public_index_mock.assert_async().await;
823    }
824
825    /// Two dependencies sharing one `PackageName` but resolving to different sources must
826    /// route independently by cursor position, not collapse into an ambiguous "offer nothing
827    /// for either" result.
828    #[tokio::test]
829    async fn test_complete_versions_same_name_different_sources_routes_by_position() {
830        let mut server = mockito::Server::new_async().await;
831        let base = server.url();
832        let _index_mock = server
833            .mock("GET", "/index.json")
834            .expect(0)
835            .create_async()
836            .await;
837
838        let registry = NuGetRegistry::with_service_index_url(
839            Arc::new(deps_core::HttpCache::new()),
840            format!("{base}/index.json"),
841        );
842        let eco = NuGetEcosystem::with_registry(registry);
843
844        let registry_dep = dep_with_source("shared.pkg", DependencySource::Registry, 0);
845        let alternate_dep = dep_with_source(
846            "shared.pkg",
847            DependencySource::AlternateRegistry {
848                index: "nuget-chain:never-registered".to_string(),
849                mirrors_crates_io: false,
850            },
851            1,
852        );
853        let alternate_position = alternate_dep.version_range.unwrap().start;
854        let parse_result = NuGetParseResult {
855            dependencies: vec![registry_dep, alternate_dep],
856            uri: deps_core::test_util::test_uri("/test/App.csproj"),
857            resolved_chains: Vec::new(),
858        };
859
860        // The alternate occurrence resolves deterministically without network: its index was
861        // never registered, so the fetch fails closed with `PackageNotFound` before any HTTP
862        // call — proving its own source, not the co-occurring `Registry`-sourced entry, drove
863        // the routing. The `.expect(0)` mock proves no fallback to the public registry either.
864        let results = eco
865            .complete_versions(
866                &parse_result,
867                alternate_position,
868                "1",
869                deps_core::FreshnessSettings::default(),
870            )
871            .await;
872        assert!(
873            results.is_empty(),
874            "unregistered alternate index must offer no completions, not fall back to the \
875             co-occurring Registry-sourced entry"
876        );
877        _index_mock.assert_async().await;
878    }
879
880    /// End-to-end regression for issue #163: a `.csproj`/`Directory.Packages.props`
881    /// bare-floor `Version` pinned behind the latest registry release must render `❌
882    /// {latest}`, not `✅` — see `NuGetFormatter::is_requirement_up_to_date`.
883    async fn inlay_hint_labels(
884        eco: &NuGetEcosystem,
885        content: &str,
886        uri: &tower_lsp_server::ls_types::Uri,
887        latest: &str,
888    ) -> Vec<String> {
889        use deps_core::lsp_helpers::VersionData;
890        use deps_core::{EcosystemConfig, LoadingState, PackageVersions};
891        use tower_lsp_server::ls_types::InlayHintLabel;
892
893        let parse_result = eco.parse_manifest(content, uri).await.unwrap();
894        let mut cached = std::collections::HashMap::new();
895        cached.insert(
896            "newtonsoft.json".into(),
897            PackageVersions::latest_only(latest),
898        );
899        let resolved = std::collections::HashMap::new();
900
901        let hints = eco
902            .generate_inlay_hints(
903                parse_result.as_ref(),
904                VersionData::new(&cached, &resolved),
905                LoadingState::Idle,
906                &EcosystemConfig::default(),
907            )
908            .await;
909
910        hints
911            .into_iter()
912            .map(|h| match h.label {
913                InlayHintLabel::String(s) => s,
914                InlayHintLabel::LabelParts(_) => unreachable!("NuGet never emits label parts"),
915            })
916            .collect()
917    }
918
919    #[tokio::test]
920    async fn test_inlay_hint_flags_outdated_csproj_package_reference() {
921        let cache = Arc::new(deps_core::HttpCache::new());
922        let eco = NuGetEcosystem::new(cache);
923        let uri = deps_core::test_util::test_uri("/test/App.csproj");
924        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
925
926        let labels = inlay_hint_labels(&eco, content, &uri, "13.0.4").await;
927        assert_eq!(labels, vec!["❌ 13.0.4"]);
928    }
929
930    #[tokio::test]
931    async fn test_inlay_hint_marks_up_to_date_csproj_package_reference() {
932        let cache = Arc::new(deps_core::HttpCache::new());
933        let eco = NuGetEcosystem::new(cache);
934        let uri = deps_core::test_util::test_uri("/test/App.csproj");
935        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
936
937        let labels = inlay_hint_labels(&eco, content, &uri, "13.0.3").await;
938        assert_eq!(labels, vec!["✅"]);
939    }
940
941    #[tokio::test]
942    async fn test_inlay_hint_flags_outdated_directory_packages_props() {
943        let cache = Arc::new(deps_core::HttpCache::new());
944        let eco = NuGetEcosystem::new(cache);
945        let uri = deps_core::test_util::test_uri("/test/Directory.Packages.props");
946        let content = r#"<Project><ItemGroup><PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
947
948        let labels = inlay_hint_labels(&eco, content, &uri, "13.0.4").await;
949        assert_eq!(labels, vec!["❌ 13.0.4"]);
950    }
951
952    #[tokio::test]
953    async fn test_inlay_hint_packages_config_exact_pin_unaffected() {
954        let cache = Arc::new(deps_core::HttpCache::new());
955        let eco = NuGetEcosystem::new(cache);
956        let uri = deps_core::test_util::test_uri("/test/packages.config");
957        let content = r#"<packages><package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" /></packages>"#;
958
959        assert_eq!(
960            inlay_hint_labels(&eco, content, &uri, "13.0.4").await,
961            vec!["❌ 13.0.4"]
962        );
963        assert_eq!(
964            inlay_hint_labels(&eco, content, &uri, "13.0.3").await,
965            vec!["✅"]
966        );
967    }
968
969    /// Diagnostics counterpart of the inlay-hint regressions above: `generate_diagnostics`
970    /// (default impl, delegates to `lsp_helpers::generate_diagnostics_from_cache`) shares
971    /// the same `EcosystemFormatter::is_requirement_up_to_date` call site, so it was
972    /// affected by the same bug and must be verified separately (no inlay-hint test
973    /// exercises this path).
974    async fn diagnostic_messages(
975        eco: &NuGetEcosystem,
976        content: &str,
977        uri: &tower_lsp_server::ls_types::Uri,
978        latest: &str,
979    ) -> Vec<String> {
980        use deps_core::PackageVersions;
981        use deps_core::lsp_helpers::VersionData;
982
983        let parse_result = eco.parse_manifest(content, uri).await.unwrap();
984        let mut cached = std::collections::HashMap::new();
985        cached.insert(
986            "newtonsoft.json".into(),
987            PackageVersions::latest_only(latest),
988        );
989        let resolved = std::collections::HashMap::new();
990
991        eco.generate_diagnostics(
992            parse_result.as_ref(),
993            VersionData::new(&cached, &resolved),
994            uri,
995            deps_core::FreshnessSettings::default(),
996            deps_core::DiagnosticSeverities::default(),
997        )
998        .await
999        .into_iter()
1000        .map(|d| d.message)
1001        .collect()
1002    }
1003
1004    #[tokio::test]
1005    async fn test_diagnostics_flag_outdated_csproj_package_reference() {
1006        let cache = Arc::new(deps_core::HttpCache::new());
1007        let eco = NuGetEcosystem::new(cache);
1008        let uri = deps_core::test_util::test_uri("/test/App.csproj");
1009        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1010
1011        let messages = diagnostic_messages(&eco, content, &uri, "13.0.4").await;
1012        assert_eq!(messages, vec!["Newer version available: 13.0.4"]);
1013    }
1014
1015    #[tokio::test]
1016    async fn test_diagnostics_silent_when_up_to_date() {
1017        let cache = Arc::new(deps_core::HttpCache::new());
1018        let eco = NuGetEcosystem::new(cache);
1019        let uri = deps_core::test_util::test_uri("/test/App.csproj");
1020        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1021
1022        assert!(
1023            diagnostic_messages(&eco, content, &uri, "13.0.3")
1024                .await
1025                .is_empty()
1026        );
1027    }
1028
1029    // --- annotate_unlisted_versions (D1, #451) ---
1030
1031    #[test]
1032    fn test_annotate_unlisted_versions_tags_matching_bullet() {
1033        let markdown = "**Recent versions**:\n- `2.0.0` *(latest)*\n- `1.0.0`\n";
1034        let unlisted: HashSet<String> = HashSet::from(["1.0.0".to_string()]);
1035        let out = annotate_unlisted_versions(markdown, &unlisted);
1036        assert!(out.contains("- `1.0.0` *(unlisted)*\n"));
1037        assert!(out.contains("- `2.0.0` *(latest)*\n"));
1038    }
1039
1040    #[test]
1041    fn test_annotate_unlisted_versions_preserves_existing_tags_and_age_suffix() {
1042        let markdown = "- `1.2.1` *(yanked)* — 5 months ago\n";
1043        let unlisted: HashSet<String> = HashSet::from(["1.2.1".to_string()]);
1044        let out = annotate_unlisted_versions(markdown, &unlisted);
1045        assert_eq!(out, "- `1.2.1` *(unlisted)* *(yanked)* — 5 months ago\n");
1046    }
1047
1048    #[test]
1049    fn test_annotate_unlisted_versions_untagged_line_when_no_match() {
1050        let markdown = "- `1.0.0`\n";
1051        let unlisted: HashSet<String> = HashSet::from(["2.0.0".to_string()]);
1052        assert_eq!(annotate_unlisted_versions(markdown, &unlisted), markdown);
1053    }
1054
1055    #[test]
1056    fn test_annotate_unlisted_versions_leaves_non_bullet_lines_untouched() {
1057        let markdown = "**Latest**: `1.0.0`\n\n---\n⌨️ **Press `Cmd+.` to update version**";
1058        let unlisted: HashSet<String> = HashSet::from(["1.0.0".to_string()]);
1059        assert_eq!(annotate_unlisted_versions(markdown, &unlisted), markdown);
1060    }
1061
1062    #[test]
1063    fn test_annotate_unlisted_versions_no_trailing_newline_preserved() {
1064        let markdown = "- `1.0.0`";
1065        let unlisted: HashSet<String> = HashSet::from(["1.0.0".to_string()]);
1066        assert_eq!(
1067            annotate_unlisted_versions(markdown, &unlisted),
1068            "- `1.0.0` *(unlisted)*"
1069        );
1070    }
1071
1072    // --- generate_hover: hover-only unlisted enrichment (D1, #451) ---
1073
1074    fn nuget_service_index_body(base: &str) -> String {
1075        format!(
1076            r#"{{"version": "3.0.0", "resources": [
1077                {{"@id": "{base}/flatcontainer", "@type": "PackageBaseAddress/3.0.0"}},
1078                {{"@id": "{base}/query", "@type": "SearchQueryService/3.5.0"}},
1079                {{"@id": "{base}/registrations", "@type": "RegistrationsBaseUrl/3.6.0"}}
1080            ]}}"#
1081        )
1082    }
1083
1084    #[tokio::test]
1085    async fn test_generate_hover_marks_unlisted_recent_version() {
1086        let mut server = mockito::Server::new_async().await;
1087        let base = server.url();
1088
1089        let _service_index_mock = server
1090            .mock("GET", "/index.json")
1091            .with_status(200)
1092            .with_body(nuget_service_index_body(&base))
1093            .create_async()
1094            .await;
1095        let _flat_mock = server
1096            .mock("GET", "/flatcontainer/newtonsoft.json/index.json")
1097            .with_status(200)
1098            .with_body(r#"{"versions": ["12.0.1", "13.0.3"]}"#)
1099            .create_async()
1100            .await;
1101        let _reg_mock = server
1102            .mock("GET", "/registrations/newtonsoft.json/index.json")
1103            .with_status(200)
1104            .with_body(
1105                r#"{"count": 1, "items": [{"@id": "x", "count": 2, "items": [
1106                    {"catalogEntry": {"version": "12.0.1", "listed": true}},
1107                    {"catalogEntry": {"version": "13.0.3", "listed": false}}
1108                ]}]}"#,
1109            )
1110            .create_async()
1111            .await;
1112
1113        let registry = NuGetRegistry::with_service_index_url(
1114            Arc::new(deps_core::HttpCache::new()),
1115            format!("{base}/index.json"),
1116        );
1117        let eco = NuGetEcosystem::with_registry(registry);
1118
1119        let uri = deps_core::test_util::test_uri("/test/App.csproj");
1120        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1121        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1122
1123        let cached = std::collections::HashMap::new();
1124        let resolved = std::collections::HashMap::new();
1125        // Freshness disabled: proves the unlisted marker doesn't depend on the freshness
1126        // toggle at all (unlike `published_at`, which is gated by it) — see
1127        // `unlisted_versions_for_hover`'s doc comment.
1128        let freshness = deps_core::FreshnessSettings {
1129            enabled: false,
1130            cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1131        };
1132
1133        let hover = eco
1134            .generate_hover(
1135                parse_result.as_ref(),
1136                Position::new(0, 49), // inside "Newtonsoft.Json"
1137                deps_core::VersionData::new(&cached, &resolved),
1138                freshness,
1139            )
1140            .await
1141            .expect("hover for a resolvable in-range dependency must not be None");
1142
1143        let HoverContents::Markup(content) = hover.contents else {
1144            panic!("expected markup hover contents");
1145        };
1146        assert!(
1147            content.value.contains("- `13.0.3` *(unlisted)*"),
1148            "unlisted version must be tagged, got: {}",
1149            content.value
1150        );
1151        assert!(
1152            !content.value.contains("`12.0.1` *(unlisted)*"),
1153            "listed version must not be tagged, got: {}",
1154            content.value
1155        );
1156    }
1157
1158    #[tokio::test]
1159    async fn test_generate_hover_degrades_gracefully_when_registration_fetch_fails() {
1160        let mut server = mockito::Server::new_async().await;
1161        let base = server.url();
1162
1163        let _service_index_mock = server
1164            .mock("GET", "/index.json")
1165            .with_status(200)
1166            .with_body(nuget_service_index_body(&base))
1167            .create_async()
1168            .await;
1169        let _flat_mock = server
1170            .mock("GET", "/flatcontainer/newtonsoft.json/index.json")
1171            .with_status(200)
1172            .with_body(r#"{"versions": ["13.0.3"]}"#)
1173            .create_async()
1174            .await;
1175        let _reg_mock = server
1176            .mock("GET", "/registrations/newtonsoft.json/index.json")
1177            .with_status(500)
1178            .create_async()
1179            .await;
1180
1181        let registry = NuGetRegistry::with_service_index_url(
1182            Arc::new(deps_core::HttpCache::new()),
1183            format!("{base}/index.json"),
1184        );
1185        let eco = NuGetEcosystem::with_registry(registry);
1186
1187        let uri = deps_core::test_util::test_uri("/test/App.csproj");
1188        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1189        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1190
1191        let cached = std::collections::HashMap::new();
1192        let resolved = std::collections::HashMap::new();
1193
1194        let hover = eco
1195            .generate_hover(
1196                parse_result.as_ref(),
1197                Position::new(0, 49),
1198                deps_core::VersionData::new(&cached, &resolved),
1199                deps_core::FreshnessSettings {
1200                    enabled: false,
1201                    cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1202                },
1203            )
1204            .await
1205            .expect("a registration-hive failure must still degrade to the base hover");
1206
1207        let HoverContents::Markup(content) = hover.contents else {
1208            panic!("expected markup hover contents");
1209        };
1210        assert!(content.value.contains("`13.0.3`"));
1211        assert!(!content.value.contains("*(unlisted)*"));
1212    }
1213
1214    /// S4 regression (#451 follow-up): with no dependency at `position`, the base render
1215    /// resolves to `None` — the unlisted-versions fetch must be skipped entirely rather than
1216    /// issued (and awaited) for a hover response that will end up empty anyway. The `.expect(0)`
1217    /// mocks fail the test if either endpoint is hit.
1218    #[tokio::test]
1219    async fn test_generate_hover_skips_unlisted_fetch_when_no_dependency_at_position() {
1220        let mut server = mockito::Server::new_async().await;
1221        let base = server.url();
1222
1223        let _service_index_mock = server
1224            .mock("GET", "/index.json")
1225            .with_status(200)
1226            .with_body(nuget_service_index_body(&base))
1227            .expect(0)
1228            .create_async()
1229            .await;
1230
1231        let registry = NuGetRegistry::with_service_index_url(
1232            Arc::new(deps_core::HttpCache::new()),
1233            format!("{base}/index.json"),
1234        );
1235        let eco = NuGetEcosystem::with_registry(registry);
1236
1237        let uri = deps_core::test_util::test_uri("/test/App.csproj");
1238        let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>"#;
1239        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1240
1241        let cached = std::collections::HashMap::new();
1242        let resolved = std::collections::HashMap::new();
1243
1244        let hover = eco
1245            .generate_hover(
1246                parse_result.as_ref(),
1247                Position::new(0, 0), // outside any dependency's name/version range
1248                deps_core::VersionData::new(&cached, &resolved),
1249                deps_core::FreshnessSettings {
1250                    enabled: false,
1251                    cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1252                },
1253            )
1254            .await;
1255
1256        assert!(hover.is_none());
1257        // `.expect(0)` above already asserts this, but check() surfaces a clear message.
1258        _service_index_mock.assert_async().await;
1259    }
1260
1261    // --- private feed end-to-end (issue #523) ---
1262
1263    /// C1 end-to-end: a root `NuGet.Config` `<clear/>` + CorpFeed must resolve a private
1264    /// package's versions from CorpFeed alone — the root registry's own service index (the
1265    /// production api.nuget.org stand-in here) must receive **zero** requests, proving the
1266    /// resurrection bug (#248 class) is closed at the real `parse_manifest`/`Registry`
1267    /// call path, not just at `NuGetConfig`'s own unit-test level.
1268    #[tokio::test]
1269    async fn test_private_feed_clear_resolves_zero_requests_to_public_registry() {
1270        let mut server = mockito::Server::new_async().await;
1271        let base = server.url();
1272
1273        let _public_index_mock = server
1274            .mock("GET", "/public/index.json")
1275            .with_status(200)
1276            .expect(0)
1277            .create_async()
1278            .await;
1279        let _corp_index_mock = server
1280            .mock("GET", "/corp/index.json")
1281            .with_status(200)
1282            .with_body(nuget_service_index_body(&format!("{base}/corp")))
1283            .create_async()
1284            .await;
1285        let _corp_flat_mock = server
1286            .mock("GET", "/corp/flatcontainer/mycompany.internal/index.json")
1287            .with_status(200)
1288            .with_body(r#"{"versions": ["1.2.3"]}"#)
1289            .create_async()
1290            .await;
1291
1292        let dir = tempfile::tempdir().unwrap();
1293        std::fs::write(
1294            dir.path().join("NuGet.Config"),
1295            format!(
1296                r#"<configuration><packageSources>
1297                    <clear />
1298                    <add key="CorpFeed" value="{base}/corp/index.json" />
1299                </packageSources></configuration>"#
1300            ),
1301        )
1302        .unwrap();
1303        let manifest_path = dir.path().join("App.csproj");
1304        let content = r#"<Project><ItemGroup><PackageReference Include="MyCompany.Internal" Version="1.0.0" /></ItemGroup></Project>"#;
1305        std::fs::write(&manifest_path, content).unwrap();
1306        let uri = tower_lsp_server::ls_types::Uri::from_file_path(&manifest_path).unwrap();
1307
1308        let registry = NuGetRegistry::with_service_index_url(
1309            Arc::new(deps_core::HttpCache::new()),
1310            format!("{base}/public/index.json"),
1311        );
1312        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1313            deps_core::net_policy::WorkspaceRegistryAccess::All,
1314        ));
1315        let context = crate::config::NuGetParseContext {
1316            policy: Arc::clone(&policy),
1317            config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
1318            user_profile_config: None,
1319            user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1320        };
1321        let eco = NuGetEcosystem::with_context(Arc::new(registry), context);
1322
1323        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1324        let dep = parse_result
1325            .dependencies()
1326            .into_iter()
1327            .find(|d| d.name().as_str() == "MyCompany.Internal")
1328            .expect("dependency must be present");
1329        let source = dep.source();
1330        assert!(
1331            matches!(source, DependencySource::AlternateRegistry { .. }),
1332            "expected AlternateRegistry, got {source:?}"
1333        );
1334        let name = dep.name().clone();
1335
1336        let versions = eco
1337            .registry
1338            .as_ref()
1339            .get_versions_from(&name, &source, deps_core::FreshnessSettings::default())
1340            .await
1341            .unwrap();
1342        assert_eq!(versions.len(), 1);
1343
1344        _public_index_mock.assert_async().await;
1345        _corp_index_mock.assert_async().await;
1346        _corp_flat_mock.assert_async().await;
1347    }
1348
1349    /// C1 regression (impl-critic): `generate_hover`'s unlisted-versions decoration must
1350    /// never fire against the public root registry for a dependency that resolved to a
1351    /// private feed — before the fix, `unlisted_versions_for_hover` was called unconditionally
1352    /// on `self.registry` (always `Public`-tier), sending the private package's real name to
1353    /// the mocked-as-public-registry endpoint regardless of which feed it actually resolved
1354    /// to. The `.expect(0)` mock fails the test if that endpoint is ever hit.
1355    #[tokio::test]
1356    async fn test_generate_hover_skips_unlisted_fetch_for_private_feed_dependency() {
1357        let mut server = mockito::Server::new_async().await;
1358        let base = server.url();
1359
1360        let _public_index_mock = server
1361            .mock("GET", "/public/index.json")
1362            .with_status(200)
1363            .expect(0)
1364            .create_async()
1365            .await;
1366        let _corp_index_mock = server
1367            .mock("GET", "/corp/index.json")
1368            .with_status(200)
1369            .with_body(nuget_service_index_body(&format!("{base}/corp")))
1370            .create_async()
1371            .await;
1372        let _corp_flat_mock = server
1373            .mock("GET", "/corp/flatcontainer/mycompany.internal/index.json")
1374            .with_status(200)
1375            .with_body(r#"{"versions": ["1.2.3"]}"#)
1376            .create_async()
1377            .await;
1378
1379        let dir = tempfile::tempdir().unwrap();
1380        std::fs::write(
1381            dir.path().join("NuGet.Config"),
1382            format!(
1383                r#"<configuration><packageSources>
1384                    <clear />
1385                    <add key="CorpFeed" value="{base}/corp/index.json" />
1386                </packageSources></configuration>"#
1387            ),
1388        )
1389        .unwrap();
1390        let manifest_path = dir.path().join("App.csproj");
1391        let content = r#"<Project><ItemGroup><PackageReference Include="MyCompany.Internal" Version="1.0.0" /></ItemGroup></Project>"#;
1392        std::fs::write(&manifest_path, content).unwrap();
1393        let uri = tower_lsp_server::ls_types::Uri::from_file_path(&manifest_path).unwrap();
1394
1395        let registry = NuGetRegistry::with_service_index_url(
1396            Arc::new(deps_core::HttpCache::new()),
1397            format!("{base}/public/index.json"),
1398        );
1399        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1400            deps_core::net_policy::WorkspaceRegistryAccess::All,
1401        ));
1402        let context = crate::config::NuGetParseContext {
1403            policy: Arc::clone(&policy),
1404            config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
1405            user_profile_config: None,
1406            user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1407        };
1408        let eco = NuGetEcosystem::with_context(Arc::new(registry), context);
1409
1410        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1411        // Position inside "MyCompany.Internal" in the Include attribute.
1412        let position = Position::new(0, 49);
1413
1414        let cached = std::collections::HashMap::new();
1415        let resolved = std::collections::HashMap::new();
1416        let hover = eco
1417            .generate_hover(
1418                parse_result.as_ref(),
1419                position,
1420                deps_core::VersionData::new(&cached, &resolved),
1421                deps_core::FreshnessSettings {
1422                    enabled: false,
1423                    cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1424                },
1425            )
1426            .await;
1427
1428        assert!(
1429            hover.is_some(),
1430            "expected a hover render for a resolvable in-range dependency"
1431        );
1432        _public_index_mock.assert_async().await;
1433    }
1434
1435    /// SC-004/US-004 (issue #562, FR-012): a package resolved via a workspace-declared
1436    /// (`AlternateRegistry`) feed now gets the same hover-only `*(unlisted)*` marker a
1437    /// public-registry dependency gets — registration-hive enrichment is no longer skipped for
1438    /// alternate feeds.
1439    #[tokio::test]
1440    async fn test_generate_hover_marks_unlisted_for_alternate_registry_dependency() {
1441        let mut server = mockito::Server::new_async().await;
1442        let base = server.url();
1443
1444        let _corp_index_mock = server
1445            .mock("GET", "/corp/index.json")
1446            .with_status(200)
1447            .with_body(nuget_service_index_body(&format!("{base}/corp")))
1448            .create_async()
1449            .await;
1450        let _corp_flat_mock = server
1451            .mock("GET", "/corp/flatcontainer/mycompany.internal/index.json")
1452            .with_status(200)
1453            .with_body(r#"{"versions": ["1.2.3"]}"#)
1454            .create_async()
1455            .await;
1456        let _corp_reg_mock = server
1457            .mock("GET", "/corp/registrations/mycompany.internal/index.json")
1458            .with_status(200)
1459            .with_body(
1460                r#"{"count": 1, "items": [{"@id": "x", "count": 1, "items": [
1461                    {"catalogEntry": {"version": "1.2.3", "listed": false}}
1462                ]}]}"#,
1463            )
1464            .create_async()
1465            .await;
1466
1467        let dir = tempfile::tempdir().unwrap();
1468        std::fs::write(
1469            dir.path().join("NuGet.Config"),
1470            format!(
1471                r#"<configuration><packageSources>
1472                    <clear />
1473                    <add key="CorpFeed" value="{base}/corp/index.json" />
1474                </packageSources></configuration>"#
1475            ),
1476        )
1477        .unwrap();
1478        let manifest_path = dir.path().join("App.csproj");
1479        let content = r#"<Project><ItemGroup><PackageReference Include="MyCompany.Internal" Version="1.2.3" /></ItemGroup></Project>"#;
1480        std::fs::write(&manifest_path, content).unwrap();
1481        let uri = tower_lsp_server::ls_types::Uri::from_file_path(&manifest_path).unwrap();
1482
1483        let registry = NuGetRegistry::with_service_index_url(
1484            Arc::new(deps_core::HttpCache::new()),
1485            format!("{base}/public/index.json"),
1486        );
1487        let policy = Arc::new(deps_core::net_policy::RegistryAccessPolicy::new(
1488            deps_core::net_policy::WorkspaceRegistryAccess::All,
1489        ));
1490        let context = crate::config::NuGetParseContext {
1491            policy: Arc::clone(&policy),
1492            config_cache: Arc::new(crate::config::NuGetConfigCache::new()),
1493            user_profile_config: None,
1494            user_profile_sources: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1495        };
1496        let eco = NuGetEcosystem::with_context(Arc::new(registry), context);
1497
1498        let parse_result = eco.parse_manifest(content, &uri).await.unwrap();
1499        let position = Position::new(0, 49); // inside "MyCompany.Internal"
1500
1501        let cached = std::collections::HashMap::new();
1502        let resolved = std::collections::HashMap::new();
1503        let hover = eco
1504            .generate_hover(
1505                parse_result.as_ref(),
1506                position,
1507                deps_core::VersionData::new(&cached, &resolved),
1508                deps_core::FreshnessSettings {
1509                    enabled: false,
1510                    cooldown_secs: deps_core::DEFAULT_COOLDOWN_SECS,
1511                },
1512            )
1513            .await
1514            .expect("hover for a resolvable alternate-feed dependency must not be None");
1515
1516        let HoverContents::Markup(content) = hover.contents else {
1517            panic!("expected markup hover contents");
1518        };
1519        assert!(
1520            content.value.contains("- `1.2.3` *(unlisted)*"),
1521            "expected the alternate-feed dependency's unlisted marker, got: {}",
1522            content.value
1523        );
1524        _corp_index_mock.assert_async().await;
1525        _corp_flat_mock.assert_async().await;
1526        _corp_reg_mock.assert_async().await;
1527    }
1528}