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 async_trait::async_trait;
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tower_lsp_server::ls_types::{
11    CodeAction, CompletionItem, Diagnostic, Hover, InlayHint, Position, Uri,
12};
13
14use deps_core::{
15    Ecosystem, EcosystemConfig, ParseResult as ParseResultTrait, Registry, Result, Version,
16    lsp_helpers,
17};
18
19use crate::formatter::CargoFormatter;
20use crate::registry::CratesIoRegistry;
21
22/// Cargo ecosystem implementation.
23///
24/// Provides LSP functionality for Cargo.toml files, including:
25/// - Dependency parsing with position tracking
26/// - Version information from crates.io
27/// - Inlay hints for latest versions
28/// - Hover tooltips with package metadata
29/// - Code actions for version updates
30/// - Diagnostics for unknown/yanked packages
31pub struct CargoEcosystem {
32    registry: Arc<CratesIoRegistry>,
33    formatter: CargoFormatter,
34}
35
36impl CargoEcosystem {
37    /// Creates a new Cargo ecosystem with the given HTTP cache.
38    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
39        Self {
40            registry: Arc::new(CratesIoRegistry::new(cache)),
41            formatter: CargoFormatter,
42        }
43    }
44
45    /// Completes package names by searching the crates.io registry.
46    ///
47    /// Requires at least 2 characters for search. Returns up to 20 results.
48    async fn complete_package_names(&self, prefix: &str) -> Vec<CompletionItem> {
49        use deps_core::completion::build_package_completion;
50
51        // Security: reject too short or too long prefixes
52        if prefix.len() < 2 || prefix.len() > 100 {
53            return vec![];
54        }
55
56        // Search registry (limit to 20 results)
57        let results = match self.registry.search(prefix, 20).await {
58            Ok(r) => r,
59            Err(e) => {
60                tracing::warn!("Package search failed for '{}': {}", prefix, e);
61                return vec![];
62            }
63        };
64
65        // Use dummy range - completion will be inserted at cursor position
66        let insert_range = tower_lsp_server::ls_types::Range::default();
67
68        results
69            .into_iter()
70            .map(|metadata| {
71                let boxed: Box<dyn deps_core::Metadata> = Box::new(metadata);
72                build_package_completion(boxed.as_ref(), insert_range)
73            })
74            .collect()
75    }
76
77    async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
78        deps_core::completion::complete_versions_generic(
79            self.registry.as_ref(),
80            package_name,
81            prefix,
82            &['^', '~', '=', '<', '>'],
83        )
84        .await
85    }
86
87    /// Completes feature flags for a specific package.
88    ///
89    /// Fetches features from the latest stable version.
90    async fn complete_features(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
91        use deps_core::completion::build_feature_completion;
92
93        // Fetch all versions to find latest stable
94        let versions = match self.registry.get_versions(package_name).await {
95            Ok(v) => v,
96            Err(e) => {
97                tracing::warn!("Failed to fetch versions for '{}': {}", package_name, e);
98                return vec![];
99            }
100        };
101
102        let latest = match versions.iter().find(|v| v.is_stable()) {
103            Some(v) => v,
104            None => {
105                tracing::warn!("No stable version found for '{}'", package_name);
106                return vec![];
107            }
108        };
109
110        let insert_range = tower_lsp_server::ls_types::Range::default();
111
112        // Get features and filter by prefix
113        let features = latest.features();
114        features
115            .into_iter()
116            .filter(|f| f.starts_with(prefix))
117            .map(|feature| build_feature_completion(&feature, package_name, insert_range))
118            .collect()
119    }
120}
121
122#[async_trait]
123impl Ecosystem for CargoEcosystem {
124    fn id(&self) -> &'static str {
125        "cargo"
126    }
127
128    fn display_name(&self) -> &'static str {
129        "Cargo (Rust)"
130    }
131
132    fn manifest_filenames(&self) -> &[&'static str] {
133        &["Cargo.toml"]
134    }
135
136    fn lockfile_filenames(&self) -> &[&'static str] {
137        &["Cargo.lock"]
138    }
139
140    async fn parse_manifest(&self, content: &str, uri: &Uri) -> Result<Box<dyn ParseResultTrait>> {
141        let result = crate::parser::parse_cargo_toml(content, uri)?;
142        Ok(Box::new(result))
143    }
144
145    fn registry(&self) -> Arc<dyn Registry> {
146        self.registry.clone() as Arc<dyn Registry>
147    }
148
149    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
150        Some(Arc::new(crate::lockfile::CargoLockParser))
151    }
152
153    async fn generate_inlay_hints(
154        &self,
155        parse_result: &dyn ParseResultTrait,
156        cached_versions: &HashMap<String, String>,
157        resolved_versions: &HashMap<String, String>,
158        loading_state: deps_core::LoadingState,
159        config: &EcosystemConfig,
160    ) -> Vec<InlayHint> {
161        lsp_helpers::generate_inlay_hints(
162            parse_result,
163            cached_versions,
164            resolved_versions,
165            loading_state,
166            config,
167            &self.formatter,
168        )
169    }
170
171    async fn generate_hover(
172        &self,
173        parse_result: &dyn ParseResultTrait,
174        position: Position,
175        cached_versions: &HashMap<String, String>,
176        resolved_versions: &HashMap<String, String>,
177    ) -> Option<Hover> {
178        lsp_helpers::generate_hover(
179            parse_result,
180            position,
181            cached_versions,
182            resolved_versions,
183            self.registry.as_ref(),
184            &self.formatter,
185        )
186        .await
187    }
188
189    async fn generate_code_actions(
190        &self,
191        parse_result: &dyn ParseResultTrait,
192        position: Position,
193        _cached_versions: &HashMap<String, String>,
194        uri: &Uri,
195    ) -> Vec<CodeAction> {
196        lsp_helpers::generate_code_actions(
197            parse_result,
198            position,
199            uri,
200            self.registry.as_ref(),
201            &self.formatter,
202        )
203        .await
204    }
205
206    async fn generate_diagnostics(
207        &self,
208        parse_result: &dyn ParseResultTrait,
209        cached_versions: &HashMap<String, String>,
210        resolved_versions: &HashMap<String, String>,
211        _uri: &Uri,
212    ) -> Vec<Diagnostic> {
213        lsp_helpers::generate_diagnostics_from_cache(
214            parse_result,
215            cached_versions,
216            resolved_versions,
217            &self.formatter,
218        )
219    }
220
221    async fn generate_completions(
222        &self,
223        parse_result: &dyn ParseResultTrait,
224        position: Position,
225        content: &str,
226    ) -> Vec<CompletionItem> {
227        use deps_core::completion::{CompletionContext, detect_completion_context};
228
229        let context = detect_completion_context(parse_result, position, content);
230
231        match context {
232            CompletionContext::PackageName { prefix } => self.complete_package_names(&prefix).await,
233            CompletionContext::Version {
234                package_name,
235                prefix,
236            } => self.complete_versions(&package_name, &prefix).await,
237            CompletionContext::Feature {
238                package_name,
239                prefix,
240            } => self.complete_features(&package_name, &prefix).await,
241            CompletionContext::None => vec![],
242        }
243    }
244
245    fn as_any(&self) -> &dyn Any {
246        self
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::types::{DependencySection, DependencySource, ParsedDependency};
254    use std::collections::HashMap;
255    use tower_lsp_server::ls_types::{InlayHintLabel, Position, Range};
256
257    /// Mock dependency for testing
258    fn mock_dependency(
259        name: &str,
260        version: Option<&str>,
261        name_line: u32,
262        version_line: u32,
263    ) -> ParsedDependency {
264        ParsedDependency {
265            name: name.to_string(),
266            name_range: Range::new(
267                Position::new(name_line, 0),
268                Position::new(name_line, name.len() as u32),
269            ),
270            version_req: version.map(String::from),
271            version_range: version.map(|_| {
272                Range::new(
273                    Position::new(version_line, 0),
274                    Position::new(version_line, 10),
275                )
276            }),
277            features: vec![],
278            features_range: None,
279            source: DependencySource::Registry,
280            workspace_inherited: false,
281            section: DependencySection::Dependencies,
282        }
283    }
284
285    /// Mock parse result for testing
286    struct MockParseResult {
287        dependencies: Vec<ParsedDependency>,
288    }
289
290    impl deps_core::ParseResult for MockParseResult {
291        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
292            self.dependencies
293                .iter()
294                .map(|d| d as &dyn deps_core::Dependency)
295                .collect()
296        }
297
298        fn workspace_root(&self) -> Option<&std::path::Path> {
299            None
300        }
301
302        fn uri(&self) -> &Uri {
303            static URI: std::sync::LazyLock<Uri> =
304                std::sync::LazyLock::new(|| Uri::from_file_path("/test/Cargo.toml").unwrap());
305            &URI
306        }
307
308        fn as_any(&self) -> &dyn Any {
309            self
310        }
311    }
312
313    #[test]
314    fn test_ecosystem_id() {
315        let cache = Arc::new(deps_core::HttpCache::new());
316        let ecosystem = CargoEcosystem::new(cache);
317        assert_eq!(ecosystem.id(), "cargo");
318    }
319
320    #[test]
321    fn test_ecosystem_display_name() {
322        let cache = Arc::new(deps_core::HttpCache::new());
323        let ecosystem = CargoEcosystem::new(cache);
324        assert_eq!(ecosystem.display_name(), "Cargo (Rust)");
325    }
326
327    #[test]
328    fn test_ecosystem_manifest_filenames() {
329        let cache = Arc::new(deps_core::HttpCache::new());
330        let ecosystem = CargoEcosystem::new(cache);
331        assert_eq!(ecosystem.manifest_filenames(), &["Cargo.toml"]);
332    }
333
334    #[test]
335    fn test_ecosystem_lockfile_filenames() {
336        let cache = Arc::new(deps_core::HttpCache::new());
337        let ecosystem = CargoEcosystem::new(cache);
338        assert_eq!(ecosystem.lockfile_filenames(), &["Cargo.lock"]);
339    }
340
341    #[test]
342    fn test_generate_inlay_hints_up_to_date_exact_match() {
343        let cache = Arc::new(deps_core::HttpCache::new());
344        let ecosystem = CargoEcosystem::new(cache);
345
346        let parse_result = MockParseResult {
347            dependencies: vec![mock_dependency("serde", Some("1.0.214"), 5, 5)],
348        };
349
350        let mut cached_versions = HashMap::new();
351        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
352
353        let config = EcosystemConfig {
354            loading_text: "⏳".to_string(),
355            show_loading_hints: true,
356            show_up_to_date_hints: true,
357            up_to_date_text: "✅".to_string(),
358            needs_update_text: "❌ {}".to_string(),
359        };
360
361        // Lock file has the latest version
362        let mut resolved_versions = HashMap::new();
363        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
364        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
365            &parse_result,
366            &cached_versions,
367            &resolved_versions,
368            deps_core::LoadingState::Loaded,
369            &config,
370        ));
371
372        assert_eq!(hints.len(), 1);
373        match &hints[0].label {
374            InlayHintLabel::String(s) => assert_eq!(s, "✅ 1.0.214"),
375            _ => panic!("Expected String label"),
376        }
377    }
378
379    #[test]
380    fn test_generate_inlay_hints_up_to_date_caret_version() {
381        let cache = Arc::new(deps_core::HttpCache::new());
382        let ecosystem = CargoEcosystem::new(cache);
383
384        let parse_result = MockParseResult {
385            dependencies: vec![mock_dependency("serde", Some("^1.0"), 5, 5)],
386        };
387
388        let mut cached_versions = HashMap::new();
389        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
390
391        let config = EcosystemConfig {
392            loading_text: "⏳".to_string(),
393            show_loading_hints: true,
394            show_up_to_date_hints: true,
395            up_to_date_text: "✅".to_string(),
396            needs_update_text: "❌ {}".to_string(),
397        };
398
399        // Lock file has the latest version
400        let mut resolved_versions = HashMap::new();
401        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
402        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
403            &parse_result,
404            &cached_versions,
405            &resolved_versions,
406            deps_core::LoadingState::Loaded,
407            &config,
408        ));
409
410        assert_eq!(hints.len(), 1);
411        match &hints[0].label {
412            InlayHintLabel::String(s) => assert_eq!(s, "✅ 1.0.214"),
413            _ => panic!("Expected String label"),
414        }
415    }
416
417    #[test]
418    fn test_generate_inlay_hints_needs_update() {
419        let cache = Arc::new(deps_core::HttpCache::new());
420        let ecosystem = CargoEcosystem::new(cache);
421
422        let parse_result = MockParseResult {
423            dependencies: vec![mock_dependency("serde", Some("1.0.100"), 5, 5)],
424        };
425
426        let mut cached_versions = HashMap::new();
427        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
428
429        let config = EcosystemConfig {
430            loading_text: "⏳".to_string(),
431            show_loading_hints: true,
432            show_up_to_date_hints: true,
433            up_to_date_text: "✅".to_string(),
434            needs_update_text: "❌ {}".to_string(),
435        };
436
437        let resolved_versions = HashMap::new();
438        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
439            &parse_result,
440            &cached_versions,
441            &resolved_versions,
442            deps_core::LoadingState::Loaded,
443            &config,
444        ));
445
446        assert_eq!(hints.len(), 1);
447        match &hints[0].label {
448            InlayHintLabel::String(s) => assert_eq!(s, "❌ 1.0.214"),
449            _ => panic!("Expected String label"),
450        }
451    }
452
453    #[test]
454    fn test_generate_inlay_hints_hide_up_to_date() {
455        let cache = Arc::new(deps_core::HttpCache::new());
456        let ecosystem = CargoEcosystem::new(cache);
457
458        let parse_result = MockParseResult {
459            dependencies: vec![mock_dependency("serde", Some("1.0.214"), 5, 5)],
460        };
461
462        let mut cached_versions = HashMap::new();
463        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
464
465        let config = EcosystemConfig {
466            loading_text: "⏳".to_string(),
467            show_loading_hints: true,
468            show_up_to_date_hints: false,
469            up_to_date_text: "✅".to_string(),
470            needs_update_text: "❌ {}".to_string(),
471        };
472
473        // Lock file has the latest version - but show_up_to_date_hints is false
474        let mut resolved_versions = HashMap::new();
475        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
476        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
477            &parse_result,
478            &cached_versions,
479            &resolved_versions,
480            deps_core::LoadingState::Loaded,
481            &config,
482        ));
483
484        assert_eq!(hints.len(), 0);
485    }
486
487    #[test]
488    fn test_generate_inlay_hints_no_version_range() {
489        let cache = Arc::new(deps_core::HttpCache::new());
490        let ecosystem = CargoEcosystem::new(cache);
491
492        let mut dep = mock_dependency("serde", Some("1.0.214"), 5, 5);
493        dep.version_range = None;
494
495        let parse_result = MockParseResult {
496            dependencies: vec![dep],
497        };
498
499        let mut cached_versions = HashMap::new();
500        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
501
502        let config = EcosystemConfig {
503            loading_text: "⏳".to_string(),
504            show_loading_hints: true,
505            show_up_to_date_hints: true,
506            up_to_date_text: "✅".to_string(),
507            needs_update_text: "❌ {}".to_string(),
508        };
509
510        let resolved_versions = HashMap::new();
511        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
512            &parse_result,
513            &cached_versions,
514            &resolved_versions,
515            deps_core::LoadingState::Loaded,
516            &config,
517        ));
518
519        assert_eq!(hints.len(), 0);
520    }
521
522    #[test]
523    fn test_generate_inlay_hints_caret_edge_case() {
524        let cache = Arc::new(deps_core::HttpCache::new());
525        let ecosystem = CargoEcosystem::new(cache);
526
527        // Edge case: version_req is just "^" without version number
528        let dep = mock_dependency("serde", Some("^"), 5, 5);
529
530        let parse_result = MockParseResult {
531            dependencies: vec![dep],
532        };
533
534        let mut cached_versions = HashMap::new();
535        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
536
537        let config = EcosystemConfig {
538            loading_text: "⏳".to_string(),
539            show_loading_hints: true,
540            show_up_to_date_hints: true,
541            up_to_date_text: "✅".to_string(),
542            needs_update_text: "❌ {}".to_string(),
543        };
544
545        // Should not panic, should return update hint
546        let resolved_versions = HashMap::new();
547        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
548            &parse_result,
549            &cached_versions,
550            &resolved_versions,
551            deps_core::LoadingState::Loaded,
552            &config,
553        ));
554
555        assert_eq!(hints.len(), 1);
556    }
557
558    #[test]
559    fn test_as_any() {
560        let cache = Arc::new(deps_core::HttpCache::new());
561        let ecosystem = CargoEcosystem::new(cache);
562
563        // Verify we can downcast
564        let any = ecosystem.as_any();
565        assert!(any.is::<CargoEcosystem>());
566    }
567
568    #[tokio::test]
569    async fn test_complete_package_names_minimum_prefix() {
570        let cache = Arc::new(deps_core::HttpCache::new());
571        let ecosystem = CargoEcosystem::new(cache);
572
573        // Less than 2 characters should return empty
574        let results = ecosystem.complete_package_names("s").await;
575        assert!(results.is_empty());
576
577        // Empty prefix should return empty
578        let results = ecosystem.complete_package_names("").await;
579        assert!(results.is_empty());
580    }
581
582    #[tokio::test]
583    #[ignore] // Requires network access
584    async fn test_complete_package_names_real_search() {
585        let cache = Arc::new(deps_core::HttpCache::new());
586        let ecosystem = CargoEcosystem::new(cache);
587
588        let results = ecosystem.complete_package_names("serd").await;
589        assert!(!results.is_empty());
590        assert!(results.iter().any(|r| r.label == "serde"));
591    }
592
593    #[tokio::test]
594    #[ignore] // Requires network access
595    async fn test_complete_versions_real() {
596        let cache = Arc::new(deps_core::HttpCache::new());
597        let ecosystem = CargoEcosystem::new(cache);
598
599        let results = ecosystem.complete_versions("serde", "1.0").await;
600        assert!(!results.is_empty());
601        assert!(results.iter().all(|r| r.label.starts_with("1.0")));
602    }
603
604    #[tokio::test]
605    #[ignore] // Requires network access
606    async fn test_complete_versions_with_operator() {
607        let cache = Arc::new(deps_core::HttpCache::new());
608        let ecosystem = CargoEcosystem::new(cache);
609
610        let results = ecosystem.complete_versions("serde", "^1.0").await;
611        assert!(!results.is_empty());
612        assert!(results.iter().all(|r| r.label.starts_with("1.0")));
613    }
614
615    #[tokio::test]
616    #[ignore] // Requires network access
617    async fn test_complete_features_real() {
618        let cache = Arc::new(deps_core::HttpCache::new());
619        let ecosystem = CargoEcosystem::new(cache);
620
621        let results = ecosystem.complete_features("serde", "").await;
622        assert!(!results.is_empty());
623        assert!(results.iter().any(|r| r.label == "derive"));
624    }
625
626    #[tokio::test]
627    #[ignore] // Requires network access
628    async fn test_complete_features_with_prefix() {
629        let cache = Arc::new(deps_core::HttpCache::new());
630        let ecosystem = CargoEcosystem::new(cache);
631
632        let results = ecosystem.complete_features("serde", "der").await;
633        assert!(!results.is_empty());
634        assert!(results.iter().all(|r| r.label.starts_with("der")));
635    }
636
637    #[tokio::test]
638    async fn test_complete_versions_unknown_package() {
639        let cache = Arc::new(deps_core::HttpCache::new());
640        let ecosystem = CargoEcosystem::new(cache);
641
642        // Unknown package should return empty (graceful degradation)
643        let results = ecosystem
644            .complete_versions("this-package-does-not-exist-12345", "1.0")
645            .await;
646        assert!(results.is_empty());
647    }
648
649    #[tokio::test]
650    async fn test_complete_features_unknown_package() {
651        let cache = Arc::new(deps_core::HttpCache::new());
652        let ecosystem = CargoEcosystem::new(cache);
653
654        // Unknown package should return empty (graceful degradation)
655        let results = ecosystem
656            .complete_features("this-package-does-not-exist-12345", "")
657            .await;
658        assert!(results.is_empty());
659    }
660
661    #[tokio::test]
662    async fn test_complete_package_names_special_characters() {
663        let cache = Arc::new(deps_core::HttpCache::new());
664        let ecosystem = CargoEcosystem::new(cache);
665
666        // Package names with hyphens and underscores should work
667        let results = ecosystem.complete_package_names("tokio-ut").await;
668        // Should not panic or error
669        assert!(results.is_empty() || !results.is_empty());
670    }
671
672    #[tokio::test]
673    async fn test_complete_package_names_max_length() {
674        let cache = Arc::new(deps_core::HttpCache::new());
675        let ecosystem = CargoEcosystem::new(cache);
676
677        // Prefix longer than 100 chars should return empty (security)
678        let long_prefix = "a".repeat(101);
679        let results = ecosystem.complete_package_names(&long_prefix).await;
680        assert!(results.is_empty());
681
682        // Exactly 100 chars should work
683        let max_prefix = "a".repeat(100);
684        let results = ecosystem.complete_package_names(&max_prefix).await;
685        // Should not panic, but may return empty (no matches)
686        assert!(results.is_empty() || !results.is_empty());
687    }
688
689    #[tokio::test]
690    #[ignore] // Requires network access
691    async fn test_complete_versions_limit_20() {
692        let cache = Arc::new(deps_core::HttpCache::new());
693        let ecosystem = CargoEcosystem::new(cache);
694
695        // Test that we respect the 20 result limit
696        let results = ecosystem.complete_versions("serde", "1").await;
697        assert!(results.len() <= 20);
698    }
699
700    #[tokio::test]
701    #[ignore] // Requires network access
702    async fn test_complete_features_empty_list() {
703        let cache = Arc::new(deps_core::HttpCache::new());
704        let ecosystem = CargoEcosystem::new(cache);
705
706        // Some packages have no features - should handle gracefully
707        // (Using a package that likely has no features, or empty prefix on a small package)
708        let results = ecosystem.complete_features("anyhow", "nonexistent").await;
709        assert!(results.is_empty());
710    }
711
712    #[tokio::test]
713    #[ignore] // Requires network access
714    async fn test_complete_package_names_special_chars_real() {
715        let cache = Arc::new(deps_core::HttpCache::new());
716        let ecosystem = CargoEcosystem::new(cache);
717
718        // Real packages with special characters
719        let results = ecosystem.complete_package_names("tokio-ut").await;
720        assert!(!results.is_empty());
721        assert!(results.iter().any(|r| r.label.contains('-')));
722    }
723
724    #[test]
725    fn test_generate_inlay_hints_loading_state() {
726        let cache = Arc::new(deps_core::HttpCache::new());
727        let ecosystem = CargoEcosystem::new(cache);
728
729        let parse_result = MockParseResult {
730            dependencies: vec![mock_dependency("tokio", Some("1.0"), 5, 5)],
731        };
732
733        // Empty caches - simulating loading state
734        let cached_versions = HashMap::new();
735        let resolved_versions = HashMap::new();
736
737        let config = EcosystemConfig {
738            loading_text: "⏳".to_string(),
739            show_loading_hints: true,
740            show_up_to_date_hints: true,
741            up_to_date_text: "✅".to_string(),
742            needs_update_text: "❌ {}".to_string(),
743        };
744
745        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
746            &parse_result,
747            &cached_versions,
748            &resolved_versions,
749            deps_core::LoadingState::Loading,
750            &config,
751        ));
752
753        assert_eq!(hints.len(), 1);
754        match &hints[0].label {
755            InlayHintLabel::String(s) => assert_eq!(s, "⏳", "Expected loading indicator"),
756            _ => panic!("Expected String label"),
757        }
758
759        if let Some(tower_lsp_server::ls_types::InlayHintTooltip::String(tooltip)) =
760            &hints[0].tooltip
761        {
762            assert_eq!(tooltip, "Fetching latest version...");
763        } else {
764            panic!("Expected tooltip for loading state");
765        }
766    }
767}