Skip to main content

deps_cargo/
ecosystem.rs

1//! Cargo ecosystem implementation for deps-lsp.
2//!
3//! This module implements the `Ecosystem` trait for Cargo/Rust projects,
4//! providing LSP functionality for `Cargo.toml` files.
5
6use std::any::Any;
7use std::sync::Arc;
8use tower_lsp_server::ls_types::{CompletionItem, Position, Range, Uri};
9
10use deps_core::parser::DependencySource;
11use deps_core::{
12    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, Version, completion::Completions,
13    lsp_helpers::EcosystemFormatter,
14};
15
16use crate::formatter::CargoFormatter;
17use crate::parser::CargoParseContext;
18use crate::registry::CargoRegistry;
19
20/// Cargo ecosystem implementation.
21///
22/// Provides LSP functionality for Cargo.toml files, including:
23/// - Dependency parsing with position tracking
24/// - Version information from crates.io
25/// - Inlay hints for latest versions
26/// - Hover tooltips with package metadata
27/// - Code actions for version updates
28/// - Diagnostics for unknown/yanked packages
29pub struct CargoEcosystem {
30    registry: Arc<CargoRegistry>,
31    formatter: CargoFormatter,
32    /// The reachability policy (spec #443) and `.cargo/config.toml` memoization cache (spec
33    /// NFR-005) every `parse_manifest` call threads through to
34    /// [`crate::parser::parse_cargo_toml_with_context`]. Defaulted by [`Self::new`]; set
35    /// explicitly by [`Self::with_context`] so `crate::lib::register_ecosystems` can share
36    /// one process-wide policy handle with `ServerState`.
37    context: CargoParseContext,
38}
39
40/// The source(s) a `CompletionContext::Version`/`Feature`'s bare `package_name` joins back
41/// to within a manifest's already-parsed dependencies (spec FR-012).
42enum CompletionSource {
43    /// No dependency in the manifest has this exact name yet — most commonly because the
44    /// user is still typing a brand-new dependency line, with `registry`/`registry-index`
45    /// not yet present for the parser to classify. Callers fall back to the pre-existing
46    /// crates.io-only behavior, unchanged.
47    NotInManifest,
48    /// Every occurrence of this name in the manifest agrees on one resolved source.
49    Resolved(DependencySource),
50    /// Two or more occurrences of this name resolve to different sources (the same
51    /// ambiguity FR-011 covers for the background fetch) — callers must offer no
52    /// completions at all rather than picking one arbitrarily.
53    Ambiguous,
54}
55
56/// Joins `package_name` back to `parse_result.dependencies()` by name (spec FR-012).
57fn resolve_completion_source(
58    parse_result: &dyn ParseResultTrait,
59    package_name: &deps_core::PackageName,
60) -> CompletionSource {
61    let mut sources = parse_result
62        .dependencies()
63        .into_iter()
64        .filter(|d| d.name() == package_name)
65        .map(deps_core::Dependency::source);
66
67    let Some(first) = sources.next() else {
68        return CompletionSource::NotInManifest;
69    };
70    if sources.all(|s| s == first) {
71        CompletionSource::Resolved(first)
72    } else {
73        tracing::warn!(
74            package = %package_name,
75            "ambiguous dependency source for version/feature completion; offering none"
76        );
77        CompletionSource::Ambiguous
78    }
79}
80
81impl CargoEcosystem {
82    /// Creates a new Cargo ecosystem with the given HTTP cache, using a fresh, default
83    /// [`CargoParseContext`] — an all-`PublicOnly`-policy, empty-cache context private to
84    /// this ecosystem instance. Production use goes through [`Self::with_context`] instead,
85    /// so the policy handle is shared with `ServerState` and live-updatable via
86    /// `workspace/didChangeConfiguration`.
87    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
88        Self::with_context(cache, CargoParseContext::default())
89    }
90
91    /// Creates a new Cargo ecosystem sharing `ctx`'s reachability policy and config-file
92    /// cache — the production constructor (plan-1b §1.6), used by
93    /// `crate::lib::register_ecosystems` so `initialize`/`workspace/didChangeConfiguration`
94    /// can update the same `Arc<RegistryAccessPolicy>` this ecosystem's every parse reads.
95    pub fn with_context(cache: Arc<deps_core::HttpCache>, ctx: CargoParseContext) -> Self {
96        Self {
97            registry: Arc::new(CargoRegistry::new(cache)),
98            formatter: CargoFormatter,
99            context: ctx,
100        }
101    }
102
103    async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
104        // Package-name search is crates.io-only unconditionally (spec Out of Scope: the
105        // sparse index protocol has no search endpoint), so this never needs source
106        // awareness — `self.registry`'s source-blind `Registry::search` already means
107        // crates.io by construction (`CargoRegistry::search`).
108        deps_core::completion::complete_package_names_generic(
109            self.registry.as_ref(),
110            prefix,
111            20,
112            range,
113        )
114        .await
115    }
116
117    /// Completes version requirements for the dependency at `position`, resolved by cursor
118    /// position rather than by name (issue #593) — delegates to
119    /// [`deps_core::completion::complete_versions_at_position`], which mirrors
120    /// `deps_gitlab_ci::ecosystem::GitLabCiEcosystem::generate_completions`'s reference
121    /// pattern. Position-based lookup also fixes a residual gap in the old name-based
122    /// [`resolve_completion_source`] routing: two dependencies sharing one `PackageName` but
123    /// resolving to different sources (e.g. two `[[registries]]`-scoped Cargo entries) used to
124    /// collapse into `CompletionSource::Ambiguous` and offer no completions for either
125    /// occurrence, even though the cursor position unambiguously identifies which one the user
126    /// is editing.
127    ///
128    /// The shared helper's `can_resolve_source` gate keeps `Registry::get_versions_from`'s
129    /// permissive catch-all (anything it doesn't explicitly recognize — Git, Path, an
130    /// unresolved `CustomRegistry`, ...) from leaking a private/non-registry dependency's name
131    /// to crates.io on every keystroke (#248). One source it does *not* reject —
132    /// `AlternateRegistry { mirrors_crates_io: true, .. }` left unregistered — deliberately
133    /// degrades to crates.io inside `CargoRegistry::get_versions_for_source`
134    /// (`registry.rs`'s `mirrors_crates_io` arm): safe, since Cargo verifies per-version
135    /// checksum equality against crates.io for a `[source.crates-io] replace-with` mirror, and
136    /// matches hover's identical degrade-to-public behavior for the same flag.
137    async fn complete_versions(
138        &self,
139        parse_result: &dyn ParseResultTrait,
140        position: Position,
141        prefix: &str,
142        freshness: deps_core::FreshnessSettings,
143    ) -> Vec<CompletionItem> {
144        deps_core::completion::complete_versions_at_position(
145            self.registry.as_ref(),
146            &self.formatter,
147            parse_result,
148            position,
149            prefix,
150            &['^', '~', '=', '<', '>'],
151            freshness,
152        )
153        .await
154    }
155
156    /// Completes feature flags for a specific package.
157    ///
158    /// Fetches features from the latest stable version, routed by the source `package_name`
159    /// resolves to in `parse_result` by name (spec FR-012) — unlike [`Self::complete_versions`]
160    /// (issue #593, position-based), this still joins by name via
161    /// [`resolve_completion_source`]/[`CompletionSource`], so it keeps the same residual
162    /// same-name-different-source `Ambiguous` gap #593 fixed for versions (not itself in
163    /// #593's scope: `features_range`-based position routing for this method is a follow-up,
164    /// not done here).
165    async fn complete_features(
166        &self,
167        parse_result: &dyn ParseResultTrait,
168        package_name: &deps_core::PackageName,
169        prefix: &str,
170    ) -> Vec<CompletionItem> {
171        use deps_core::completion::build_feature_completion;
172
173        let versions_result: Result<Vec<Box<dyn Version>>> =
174            match resolve_completion_source(parse_result, package_name) {
175                CompletionSource::Ambiguous => return vec![],
176                CompletionSource::NotInManifest
177                | CompletionSource::Resolved(DependencySource::Registry) => {
178                    Registry::get_versions(self.registry.as_ref(), package_name).await
179                }
180                CompletionSource::Resolved(DependencySource::AlternateRegistry {
181                    index, ..
182                }) => match self.registry.alternate_client(&index) {
183                    Some(client) => Registry::get_versions(client.as_ref(), package_name).await,
184                    None => return vec![],
185                },
186                CompletionSource::Resolved(_) => return vec![],
187            };
188
189        let versions = match versions_result {
190            Ok(v) => v,
191            Err(e) => {
192                tracing::warn!("Failed to fetch versions for '{}': {}", package_name, e);
193                return vec![];
194            }
195        };
196
197        let latest = match versions.iter().find(|v| v.is_stable()) {
198            Some(v) => v,
199            None => {
200                tracing::warn!("No stable version found for '{}'", package_name);
201                return vec![];
202            }
203        };
204
205        // Get features and filter by prefix
206        let features = latest.features();
207        features
208            .into_iter()
209            .filter(|f| f.starts_with(prefix))
210            .map(|feature| build_feature_completion(&feature, package_name, None))
211            .collect()
212    }
213}
214
215impl deps_core::ecosystem::private::Sealed for CargoEcosystem {}
216
217impl Ecosystem for CargoEcosystem {
218    fn id(&self) -> &'static str {
219        "cargo"
220    }
221
222    fn display_name(&self) -> &'static str {
223        "Cargo (Rust)"
224    }
225
226    fn manifest_filenames(&self) -> &[&'static str] {
227        &["Cargo.toml"]
228    }
229
230    fn lockfile_filenames(&self) -> &[&'static str] {
231        &["Cargo.lock"]
232    }
233
234    fn parse_manifest<'a>(
235        &'a self,
236        content: &'a str,
237        uri: &'a Uri,
238    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
239        Box::pin(async move {
240            let result = crate::parser::parse_cargo_toml_with_context(content, uri, &self.context)?;
241            // Registers every alternate index this parse resolved (spec FR-002) into the
242            // shared router, including its credential (if any) — the only point in the
243            // whole pipeline where a `.cargo/config.toml`/`$CARGO_HOME` resolution and the
244            // long-lived `CargoRegistry` this ecosystem shares across every document ever
245            // meet. See `crate::parser::ParseResult::resolved_registries`'s docs.
246            for (index, auth) in result.resolved_registries.clone() {
247                self.registry.register_alternate(index, auth);
248            }
249            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
250        })
251    }
252
253    fn registry(&self) -> Arc<dyn Registry> {
254        self.registry.clone() as Arc<dyn Registry>
255    }
256
257    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
258        Some(Arc::new(crate::lockfile::CargoLockParser))
259    }
260
261    fn formatter(&self) -> &dyn EcosystemFormatter {
262        &self.formatter
263    }
264
265    fn generate_completions<'a>(
266        &'a self,
267        parse_result: &'a dyn ParseResultTrait,
268        position: Position,
269        content: &'a str,
270        freshness: deps_core::FreshnessSettings,
271    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
272        Box::pin(async move {
273            use deps_core::completion::{CompletionContext, detect_completion_context};
274
275            let context = detect_completion_context(parse_result, position, content);
276
277            match context {
278                CompletionContext::PackageName { prefix, range } => {
279                    self.complete_package_names(&prefix, range).await
280                }
281                CompletionContext::Version { prefix, .. } => {
282                    self.complete_versions(parse_result, position, &prefix, freshness)
283                        .await
284                }
285                CompletionContext::Feature {
286                    package_name,
287                    prefix,
288                } => {
289                    self.complete_features(parse_result, &package_name, &prefix)
290                        .await
291                }
292                CompletionContext::None => vec![],
293            }
294            .into()
295        })
296    }
297
298    fn as_any(&self) -> &dyn Any {
299        self
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::types::{DependencySection, DependencySource, ParsedDependency};
307    use deps_core::{EcosystemConfig, PackageVersions, VersionData};
308    use std::collections::HashMap;
309    use tower_lsp_server::ls_types::{InlayHintLabel, Position, Range};
310
311    fn pkg(s: &str) -> deps_core::PackageName {
312        deps_core::PackageName::new(s)
313    }
314
315    /// Mock dependency for testing
316    fn mock_dependency(
317        name: &str,
318        version: Option<&str>,
319        name_line: u32,
320        version_line: u32,
321    ) -> ParsedDependency {
322        ParsedDependency {
323            name: name.into(),
324            name_range: Range::new(
325                Position::new(name_line, 0),
326                Position::new(name_line, name.len() as u32),
327            ),
328            version_req: version.map(Into::into),
329            version_range: version.map(|_| {
330                Range::new(
331                    Position::new(version_line, 0),
332                    Position::new(version_line, 10),
333                )
334            }),
335            features: vec![],
336            features_range: None,
337            source: DependencySource::Registry,
338            section: DependencySection::Dependencies,
339        }
340    }
341
342    /// Mock parse result for testing
343    struct MockParseResult {
344        dependencies: Vec<ParsedDependency>,
345    }
346
347    impl deps_core::ParseResult for MockParseResult {
348        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
349            self.dependencies
350                .iter()
351                .map(|d| d as &dyn deps_core::Dependency)
352                .collect()
353        }
354
355        fn workspace_root(&self) -> Option<&std::path::Path> {
356            None
357        }
358
359        fn uri(&self) -> &Uri {
360            static URI: std::sync::LazyLock<Uri> =
361                std::sync::LazyLock::new(|| deps_core::test_util::test_uri("/test/Cargo.toml"));
362            &URI
363        }
364
365        fn as_any(&self) -> &dyn Any {
366            self
367        }
368    }
369
370    /// A `MockParseResult` with no dependencies — `resolve_completion_source` reports
371    /// `NotInManifest` for any name against it, so `complete_features` falls back to its
372    /// pre-existing crates.io-only behavior. Used by tests below that only exercise
373    /// `complete_features` (`complete_versions` is now position-based; see `mock_dependency`).
374    fn empty_parse_result() -> MockParseResult {
375        MockParseResult {
376            dependencies: vec![],
377        }
378    }
379
380    #[test]
381    fn test_ecosystem_id() {
382        let cache = Arc::new(deps_core::HttpCache::new());
383        let ecosystem = CargoEcosystem::new(cache);
384        assert_eq!(ecosystem.id(), "cargo");
385    }
386
387    #[test]
388    fn test_ecosystem_display_name() {
389        let cache = Arc::new(deps_core::HttpCache::new());
390        let ecosystem = CargoEcosystem::new(cache);
391        assert_eq!(ecosystem.display_name(), "Cargo (Rust)");
392    }
393
394    #[test]
395    fn test_ecosystem_manifest_filenames() {
396        let cache = Arc::new(deps_core::HttpCache::new());
397        let ecosystem = CargoEcosystem::new(cache);
398        assert_eq!(ecosystem.manifest_filenames(), &["Cargo.toml"]);
399    }
400
401    #[test]
402    fn test_ecosystem_lockfile_filenames() {
403        let cache = Arc::new(deps_core::HttpCache::new());
404        let ecosystem = CargoEcosystem::new(cache);
405        assert_eq!(ecosystem.lockfile_filenames(), &["Cargo.lock"]);
406    }
407
408    #[test]
409    fn test_generate_inlay_hints_up_to_date_exact_match() {
410        let cache = Arc::new(deps_core::HttpCache::new());
411        let ecosystem = CargoEcosystem::new(cache);
412
413        let parse_result = MockParseResult {
414            dependencies: vec![mock_dependency("serde", Some("1.0.214"), 5, 5)],
415        };
416
417        let mut cached_versions = HashMap::new();
418        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
419
420        let config = EcosystemConfig {
421            loading_text: "⏳".to_string(),
422            show_loading_hints: true,
423            offline: false,
424            show_up_to_date_hints: true,
425            up_to_date_text: "✅".to_string(),
426            needs_update_text: "❌ {}".to_string(),
427        };
428
429        // Lock file has the latest version
430        let mut resolved_versions = HashMap::new();
431        resolved_versions.insert("serde".into(), "1.0.214".into());
432        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
433            &parse_result,
434            VersionData::new(&cached_versions, &resolved_versions),
435            deps_core::LoadingState::Loaded,
436            &config,
437        ));
438
439        assert_eq!(hints.len(), 1);
440        match &hints[0].label {
441            InlayHintLabel::String(s) => assert_eq!(s, "✅ 1.0.214"),
442            _ => panic!("Expected String label"),
443        }
444    }
445
446    #[test]
447    fn test_generate_inlay_hints_up_to_date_caret_version() {
448        let cache = Arc::new(deps_core::HttpCache::new());
449        let ecosystem = CargoEcosystem::new(cache);
450
451        let parse_result = MockParseResult {
452            dependencies: vec![mock_dependency("serde", Some("^1.0"), 5, 5)],
453        };
454
455        let mut cached_versions = HashMap::new();
456        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
457
458        let config = EcosystemConfig {
459            loading_text: "⏳".to_string(),
460            show_loading_hints: true,
461            offline: false,
462            show_up_to_date_hints: true,
463            up_to_date_text: "✅".to_string(),
464            needs_update_text: "❌ {}".to_string(),
465        };
466
467        // Lock file has the latest version
468        let mut resolved_versions = HashMap::new();
469        resolved_versions.insert("serde".into(), "1.0.214".into());
470        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
471            &parse_result,
472            VersionData::new(&cached_versions, &resolved_versions),
473            deps_core::LoadingState::Loaded,
474            &config,
475        ));
476
477        assert_eq!(hints.len(), 1);
478        match &hints[0].label {
479            InlayHintLabel::String(s) => assert_eq!(s, "✅ 1.0.214"),
480            _ => panic!("Expected String label"),
481        }
482    }
483
484    #[test]
485    fn test_generate_inlay_hints_needs_update() {
486        let cache = Arc::new(deps_core::HttpCache::new());
487        let ecosystem = CargoEcosystem::new(cache);
488
489        let parse_result = MockParseResult {
490            dependencies: vec![mock_dependency("serde", Some("1.0.100"), 5, 5)],
491        };
492
493        let mut cached_versions = HashMap::new();
494        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
495
496        let config = EcosystemConfig {
497            loading_text: "⏳".to_string(),
498            show_loading_hints: true,
499            offline: false,
500            show_up_to_date_hints: true,
501            up_to_date_text: "✅".to_string(),
502            needs_update_text: "❌ {}".to_string(),
503        };
504
505        let resolved_versions = HashMap::new();
506        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
507            &parse_result,
508            VersionData::new(&cached_versions, &resolved_versions),
509            deps_core::LoadingState::Loaded,
510            &config,
511        ));
512
513        assert_eq!(hints.len(), 1);
514        match &hints[0].label {
515            InlayHintLabel::String(s) => assert_eq!(s, "❌ 1.0.214"),
516            _ => panic!("Expected String label"),
517        }
518    }
519
520    #[test]
521    fn test_generate_inlay_hints_hide_up_to_date() {
522        let cache = Arc::new(deps_core::HttpCache::new());
523        let ecosystem = CargoEcosystem::new(cache);
524
525        let parse_result = MockParseResult {
526            dependencies: vec![mock_dependency("serde", Some("1.0.214"), 5, 5)],
527        };
528
529        let mut cached_versions = HashMap::new();
530        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
531
532        let config = EcosystemConfig {
533            loading_text: "⏳".to_string(),
534            show_loading_hints: true,
535            offline: false,
536            show_up_to_date_hints: false,
537            up_to_date_text: "✅".to_string(),
538            needs_update_text: "❌ {}".to_string(),
539        };
540
541        // Lock file has the latest version - but show_up_to_date_hints is false
542        let mut resolved_versions = HashMap::new();
543        resolved_versions.insert("serde".into(), "1.0.214".into());
544        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
545            &parse_result,
546            VersionData::new(&cached_versions, &resolved_versions),
547            deps_core::LoadingState::Loaded,
548            &config,
549        ));
550
551        assert_eq!(hints.len(), 0);
552    }
553
554    #[test]
555    fn test_generate_inlay_hints_no_version_range() {
556        let cache = Arc::new(deps_core::HttpCache::new());
557        let ecosystem = CargoEcosystem::new(cache);
558
559        let mut dep = mock_dependency("serde", Some("1.0.214"), 5, 5);
560        dep.version_range = None;
561
562        let parse_result = MockParseResult {
563            dependencies: vec![dep],
564        };
565
566        let mut cached_versions = HashMap::new();
567        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
568
569        let config = EcosystemConfig {
570            loading_text: "⏳".to_string(),
571            show_loading_hints: true,
572            offline: false,
573            show_up_to_date_hints: true,
574            up_to_date_text: "✅".to_string(),
575            needs_update_text: "❌ {}".to_string(),
576        };
577
578        let resolved_versions = HashMap::new();
579        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
580            &parse_result,
581            VersionData::new(&cached_versions, &resolved_versions),
582            deps_core::LoadingState::Loaded,
583            &config,
584        ));
585
586        assert_eq!(hints.len(), 0);
587    }
588
589    #[test]
590    fn test_generate_inlay_hints_caret_edge_case() {
591        let cache = Arc::new(deps_core::HttpCache::new());
592        let ecosystem = CargoEcosystem::new(cache);
593
594        // Edge case: version_req is just "^" without version number
595        let dep = mock_dependency("serde", Some("^"), 5, 5);
596
597        let parse_result = MockParseResult {
598            dependencies: vec![dep],
599        };
600
601        let mut cached_versions = HashMap::new();
602        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
603
604        let config = EcosystemConfig {
605            loading_text: "⏳".to_string(),
606            show_loading_hints: true,
607            offline: false,
608            show_up_to_date_hints: true,
609            up_to_date_text: "✅".to_string(),
610            needs_update_text: "❌ {}".to_string(),
611        };
612
613        // Should not panic, should return update hint
614        let resolved_versions = HashMap::new();
615        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
616            &parse_result,
617            VersionData::new(&cached_versions, &resolved_versions),
618            deps_core::LoadingState::Loaded,
619            &config,
620        ));
621
622        assert_eq!(hints.len(), 1);
623    }
624
625    #[test]
626    fn test_as_any() {
627        let cache = Arc::new(deps_core::HttpCache::new());
628        let ecosystem = CargoEcosystem::new(cache);
629
630        // Verify we can downcast
631        let any = ecosystem.as_any();
632        assert!(any.is::<CargoEcosystem>());
633    }
634
635    #[tokio::test]
636    async fn test_package_name_completion_context_has_real_range() {
637        // Regression test for #232: the textEdit range for a package-name completion
638        // must be the real name token span, not the (0,0)-(0,0) placeholder.
639        let cache = Arc::new(deps_core::HttpCache::new());
640        let ecosystem = CargoEcosystem::new(cache);
641        let content = "[dependencies]\nserd = \"1.0\"\n";
642        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
643
644        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
645        let position = Position::new(1, 3); // cursor after "ser" in "serd"
646
647        let context = deps_core::completion::detect_completion_context(
648            parse_result.as_ref(),
649            position,
650            content,
651        );
652
653        match context {
654            deps_core::completion::CompletionContext::PackageName { prefix, range } => {
655                assert_eq!(prefix, "ser");
656                assert_ne!(range, Range::default());
657                assert_eq!(range, Range::new(Position::new(1, 0), Position::new(1, 4)));
658            }
659            other => panic!("Expected PackageName context, got {other:?}"),
660        }
661    }
662
663    #[tokio::test]
664    async fn test_complete_package_names_minimum_prefix() {
665        let cache = Arc::new(deps_core::HttpCache::new());
666        let ecosystem = CargoEcosystem::new(cache);
667
668        // Less than 2 characters should return empty
669        let results = ecosystem
670            .complete_package_names("s", Range::default())
671            .await;
672        assert!(results.is_empty());
673
674        // Empty prefix should return empty
675        let results = ecosystem.complete_package_names("", Range::default()).await;
676        assert!(results.is_empty());
677    }
678
679    #[tokio::test]
680    #[ignore] // Requires network access
681    async fn test_complete_package_names_real_search() {
682        let cache = Arc::new(deps_core::HttpCache::new());
683        let ecosystem = CargoEcosystem::new(cache);
684
685        let results = ecosystem
686            .complete_package_names("serd", Range::default())
687            .await;
688        assert!(!results.is_empty());
689        assert!(results.iter().any(|r| r.label == "serde"));
690    }
691
692    #[tokio::test]
693    #[ignore] // Requires network access
694    async fn test_complete_versions_real() {
695        let cache = Arc::new(deps_core::HttpCache::new());
696        let ecosystem = CargoEcosystem::new(cache);
697        let dep = mock_dependency("serde", Some("1.0"), 0, 0);
698        let position = dep.version_range.unwrap().start;
699        let parse_result = MockParseResult {
700            dependencies: vec![dep],
701        };
702
703        let results = ecosystem
704            .complete_versions(
705                &parse_result,
706                position,
707                "1.0",
708                deps_core::FreshnessSettings::default(),
709            )
710            .await;
711        assert!(!results.is_empty());
712        assert!(results.iter().all(|r| r.label.starts_with("1.0")));
713    }
714
715    #[tokio::test]
716    #[ignore] // Requires network access
717    async fn test_complete_versions_with_operator() {
718        let cache = Arc::new(deps_core::HttpCache::new());
719        let ecosystem = CargoEcosystem::new(cache);
720        let dep = mock_dependency("serde", Some("^1.0"), 0, 0);
721        let position = dep.version_range.unwrap().start;
722        let parse_result = MockParseResult {
723            dependencies: vec![dep],
724        };
725
726        let results = ecosystem
727            .complete_versions(
728                &parse_result,
729                position,
730                "^1.0",
731                deps_core::FreshnessSettings::default(),
732            )
733            .await;
734        assert!(!results.is_empty());
735        assert!(results.iter().all(|r| r.label.starts_with("1.0")));
736    }
737
738    #[tokio::test]
739    #[ignore] // Requires network access
740    async fn test_complete_features_real() {
741        let cache = Arc::new(deps_core::HttpCache::new());
742        let ecosystem = CargoEcosystem::new(cache);
743
744        let results = ecosystem
745            .complete_features(&empty_parse_result(), &pkg("serde"), "")
746            .await;
747        assert!(!results.is_empty());
748        assert!(results.iter().any(|r| r.label == "derive"));
749    }
750
751    #[tokio::test]
752    #[ignore] // Requires network access
753    async fn test_complete_features_with_prefix() {
754        let cache = Arc::new(deps_core::HttpCache::new());
755        let ecosystem = CargoEcosystem::new(cache);
756
757        let results = ecosystem
758            .complete_features(&empty_parse_result(), &pkg("serde"), "der")
759            .await;
760        assert!(!results.is_empty());
761        assert!(results.iter().all(|r| r.label.starts_with("der")));
762    }
763
764    /// Issue #593: two dependencies sharing one `PackageName` but resolving to different
765    /// sources no longer collapse into the old name-based `CompletionSource::Ambiguous`
766    /// "offer nothing for either" result (review finding #6) — cursor position now
767    /// identifies exactly one dependency, so each occurrence routes independently.
768    #[tokio::test]
769    async fn test_complete_versions_same_name_different_sources_routes_by_position() {
770        let cache = Arc::new(deps_core::HttpCache::new());
771        let ecosystem = CargoEcosystem::new(cache);
772
773        let mut registry_dep = mock_dependency("shared-name", Some("1.0"), 0, 0);
774        registry_dep.source = DependencySource::Registry;
775        let mut alternate_dep = mock_dependency("shared-name", Some("1.0"), 1, 1);
776        alternate_dep.source = DependencySource::AlternateRegistry {
777            index: "https://index.mycorp.dev/never-registered".into(),
778            mirrors_crates_io: false,
779        };
780        let alternate_position = alternate_dep.version_range.unwrap().start;
781        let parse_result = MockParseResult {
782            dependencies: vec![registry_dep, alternate_dep],
783        };
784
785        // The alternate occurrence resolves deterministically without network: its index
786        // was never registered, so `CargoRegistry::alternate_client` returns `None` and the
787        // fetch fails closed with `PackageNotFound` before any HTTP call — proving its own
788        // source, not the co-occurring `Registry`-sourced entry, drove the routing.
789        let results = ecosystem
790            .complete_versions(
791                &parse_result,
792                alternate_position,
793                "1",
794                deps_core::FreshnessSettings::default(),
795            )
796            .await;
797        assert!(
798            results.is_empty(),
799            "unregistered alternate index must offer no completions"
800        );
801    }
802
803    /// Issue #593 critic finding M5: the test above only proves the *empty* case, which a
804    /// totally broken position lookup would also satisfy. This proves position-based routing
805    /// actually selects the right source's data — a *registered* alternate index's own client
806    /// is hit and its versions come back — mirroring `deps-go`'s/`deps-nuget`'s equivalent
807    /// `..._routes_to_registered_alternate_client` tests.
808    #[tokio::test]
809    async fn test_complete_versions_routes_to_registered_alternate_client() {
810        let mut server = mockito::Server::new_async().await;
811        let _mock = server
812            .mock("GET", "/se/rd/serde")
813            .with_status(200)
814            .with_body(
815                "{\"name\":\"serde\",\"vers\":\"1.0.0\",\"yanked\":false,\"features\":{},\"deps\":[]}\n\
816                 {\"name\":\"serde\",\"vers\":\"1.5.0\",\"yanked\":false,\"features\":{},\"deps\":[]}\n",
817            )
818            .create_async()
819            .await;
820
821        let cache = Arc::new(deps_core::HttpCache::new());
822        let ecosystem = CargoEcosystem::new(cache);
823
824        let policy = deps_core::net_policy::RegistryAccessPolicy::default();
825        let registry_index = crate::config::RegistryIndex::new(
826            &server.url(),
827            crate::config::IndexTrust::Trusted,
828            &policy,
829        )
830        .unwrap();
831        let index_key = registry_index.as_str().to_string();
832        ecosystem.registry.register_alternate(registry_index, None);
833
834        let mut dep = mock_dependency("serde", Some("1.0"), 0, 0);
835        dep.source = DependencySource::AlternateRegistry {
836            index: index_key,
837            mirrors_crates_io: false,
838        };
839        let position = dep.version_range.unwrap().start;
840        let parse_result = MockParseResult {
841            dependencies: vec![dep],
842        };
843
844        let results = ecosystem
845            .complete_versions(
846                &parse_result,
847                position,
848                "1.",
849                deps_core::FreshnessSettings::default(),
850            )
851            .await;
852        assert!(
853            !results.is_empty(),
854            "a registered alternate index must route completions through its own client"
855        );
856    }
857
858    /// Same ambiguity, exercised through `complete_features` — mirrors
859    /// `test_complete_versions_ambiguous_source_offers_nothing`'s routing policy.
860    #[tokio::test]
861    async fn test_complete_features_ambiguous_source_offers_nothing() {
862        let cache = Arc::new(deps_core::HttpCache::new());
863        let ecosystem = CargoEcosystem::new(cache);
864
865        let mut registry_dep = mock_dependency("shared-name", Some("1.0"), 0, 0);
866        registry_dep.source = DependencySource::Registry;
867        let mut alternate_dep = mock_dependency("shared-name", Some("1.0"), 1, 1);
868        alternate_dep.source = DependencySource::AlternateRegistry {
869            index: "https://index.mycorp.dev".into(),
870            mirrors_crates_io: false,
871        };
872        let parse_result = MockParseResult {
873            dependencies: vec![registry_dep, alternate_dep],
874        };
875
876        let results = ecosystem
877            .complete_features(&parse_result, &pkg("shared-name"), "")
878            .await;
879        assert!(
880            results.is_empty(),
881            "an ambiguous source must offer no feature completions"
882        );
883    }
884
885    #[tokio::test]
886    async fn test_complete_versions_unknown_package() {
887        let cache = Arc::new(deps_core::HttpCache::new());
888        let ecosystem = CargoEcosystem::new(cache);
889        let dep = mock_dependency("this-package-does-not-exist-12345", Some("1.0"), 0, 0);
890        let position = dep.version_range.unwrap().start;
891        let parse_result = MockParseResult {
892            dependencies: vec![dep],
893        };
894
895        // Unknown package should return empty (graceful degradation)
896        let results = ecosystem
897            .complete_versions(
898                &parse_result,
899                position,
900                "1.0",
901                deps_core::FreshnessSettings::default(),
902            )
903            .await;
904        assert!(results.is_empty());
905    }
906
907    #[tokio::test]
908    async fn test_complete_features_unknown_package() {
909        let cache = Arc::new(deps_core::HttpCache::new());
910        let ecosystem = CargoEcosystem::new(cache);
911
912        // Unknown package should return empty (graceful degradation)
913        let results = ecosystem
914            .complete_features(
915                &empty_parse_result(),
916                &pkg("this-package-does-not-exist-12345"),
917                "",
918            )
919            .await;
920        assert!(results.is_empty());
921    }
922
923    #[tokio::test]
924    async fn test_complete_package_names_special_characters() {
925        let cache = Arc::new(deps_core::HttpCache::new());
926        let ecosystem = CargoEcosystem::new(cache);
927
928        // Package names with hyphens and underscores should work
929        let results = ecosystem
930            .complete_package_names("tokio-ut", Range::default())
931            .await;
932        // Should not panic or error
933        assert!(results.is_empty() || !results.is_empty());
934    }
935
936    #[tokio::test]
937    async fn test_complete_package_names_max_length() {
938        let cache = Arc::new(deps_core::HttpCache::new());
939        let ecosystem = CargoEcosystem::new(cache);
940
941        // Prefix longer than 200 chars should return empty (security)
942        let long_prefix = "a".repeat(201);
943        let results = ecosystem
944            .complete_package_names(&long_prefix, Range::default())
945            .await;
946        assert!(results.is_empty());
947
948        // Exactly 100 chars should work
949        let max_prefix = "a".repeat(100);
950        let results = ecosystem
951            .complete_package_names(&max_prefix, Range::default())
952            .await;
953        // Should not panic, but may return empty (no matches)
954        assert!(results.is_empty() || !results.is_empty());
955    }
956
957    #[tokio::test]
958    #[ignore] // Requires network access
959    async fn test_complete_versions_limit_20() {
960        let cache = Arc::new(deps_core::HttpCache::new());
961        let ecosystem = CargoEcosystem::new(cache);
962
963        // Test that we respect the 20 result limit
964        let dep = mock_dependency("serde", Some("1.0"), 0, 0);
965        let position = dep.version_range.unwrap().start;
966        let parse_result = MockParseResult {
967            dependencies: vec![dep],
968        };
969        let results = ecosystem
970            .complete_versions(
971                &parse_result,
972                position,
973                "1",
974                deps_core::FreshnessSettings::default(),
975            )
976            .await;
977        assert!(results.len() <= 20);
978    }
979
980    #[tokio::test]
981    #[ignore] // Requires network access
982    async fn test_complete_features_empty_list() {
983        let cache = Arc::new(deps_core::HttpCache::new());
984        let ecosystem = CargoEcosystem::new(cache);
985
986        // Some packages have no features - should handle gracefully
987        // (Using a package that likely has no features, or empty prefix on a small package)
988        let results = ecosystem
989            .complete_features(&empty_parse_result(), &pkg("anyhow"), "nonexistent")
990            .await;
991        assert!(results.is_empty());
992    }
993
994    #[tokio::test]
995    #[ignore] // Requires network access
996    async fn test_complete_package_names_special_chars_real() {
997        let cache = Arc::new(deps_core::HttpCache::new());
998        let ecosystem = CargoEcosystem::new(cache);
999
1000        // Real packages with special characters
1001        let results = ecosystem
1002            .complete_package_names("tokio-ut", Range::default())
1003            .await;
1004        assert!(!results.is_empty());
1005        assert!(results.iter().any(|r| r.label.contains('-')));
1006    }
1007
1008    #[test]
1009    fn test_generate_inlay_hints_loading_state() {
1010        let cache = Arc::new(deps_core::HttpCache::new());
1011        let ecosystem = CargoEcosystem::new(cache);
1012
1013        let parse_result = MockParseResult {
1014            dependencies: vec![mock_dependency("tokio", Some("1.0"), 5, 5)],
1015        };
1016
1017        // Empty caches - simulating loading state
1018        let cached_versions = HashMap::new();
1019        let resolved_versions = HashMap::new();
1020
1021        let config = EcosystemConfig {
1022            loading_text: "⏳".to_string(),
1023            show_loading_hints: true,
1024            offline: false,
1025            show_up_to_date_hints: true,
1026            up_to_date_text: "✅".to_string(),
1027            needs_update_text: "❌ {}".to_string(),
1028        };
1029
1030        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
1031            &parse_result,
1032            VersionData::new(&cached_versions, &resolved_versions),
1033            deps_core::LoadingState::Loading,
1034            &config,
1035        ));
1036
1037        assert_eq!(hints.len(), 1);
1038        match &hints[0].label {
1039            InlayHintLabel::String(s) => assert_eq!(s, "⏳", "Expected loading indicator"),
1040            _ => panic!("Expected String label"),
1041        }
1042
1043        if let Some(tower_lsp_server::ls_types::InlayHintTooltip::String(tooltip)) =
1044            &hints[0].tooltip
1045        {
1046            assert_eq!(tooltip, "Fetching latest version...");
1047        } else {
1048            panic!("Expected tooltip for loading state");
1049        }
1050    }
1051}