Skip to main content

deps_core/lsp_helpers/
inlay_hints.rs

1use tower_lsp_server::ls_types::{InlayHint, InlayHintKind, InlayHintLabel, InlayHintTooltip};
2
3use crate::{ConcreteVersion, EcosystemConfig, ParseResult};
4
5use super::{EcosystemFormatter, RequirementStatus, VersionData};
6
7pub fn generate_inlay_hints(
8    parse_result: &dyn ParseResult,
9    versions: VersionData<'_>,
10    loading_state: crate::LoadingState,
11    config: &EcosystemConfig,
12    formatter: &dyn EcosystemFormatter,
13) -> Vec<InlayHint> {
14    let deps = parse_result.dependencies();
15    let mut hints = Vec::with_capacity(deps.len());
16
17    for dep in deps {
18        let Some(version_range) = dep.version_range() else {
19            continue;
20        };
21
22        let normalized_name = formatter.normalize_package_name(dep.name());
23        let latest_version = versions
24            .cached
25            .get(normalized_name.as_str())
26            .or_else(|| versions.cached.get(dep.name()))
27            .map(|v| &v.latest);
28        let resolved_version: Option<ConcreteVersion> =
29            if formatter.manifest_requirement_is_resolved_version(dep) {
30                dep.version_requirement()
31                    .map(|r| ConcreteVersion::new(r.as_str()))
32            } else {
33                versions
34                    .resolved
35                    .get(normalized_name.as_str())
36                    .or_else(|| versions.resolved.get(dep.name()))
37                    .cloned()
38            };
39
40        // Show loading hint if loading and no cached version
41        if loading_state == crate::LoadingState::Loading
42            && config.show_loading_hints
43            && latest_version.is_none()
44        {
45            hints.push(InlayHint {
46                position: version_range.end,
47                label: InlayHintLabel::String(config.loading_text.clone()),
48                kind: Some(InlayHintKind::TYPE),
49                tooltip: Some(InlayHintTooltip::String(
50                    "Fetching latest version...".to_string(),
51                )),
52                padding_left: Some(true),
53                padding_right: None,
54                text_edits: None,
55                data: None,
56            });
57            continue;
58        }
59
60        let Some(latest) = latest_version else {
61            // Issue #483 I5/SEC-2: never discard a purely-local, lockfile-derived
62            // `resolved_version` just because the registry side is unknown while
63            // offline — show it alongside the marker rather than replacing it.
64            if config.offline {
65                let label = resolved_version
66                    .as_ref()
67                    .map_or_else(|| "📴".to_string(), |resolved| format!("📴 {resolved}"));
68                hints.push(InlayHint {
69                    position: version_range.end,
70                    label: InlayHintLabel::String(label),
71                    kind: Some(InlayHintKind::TYPE),
72                    tooltip: Some(InlayHintTooltip::String(
73                        "Offline: registry not checked".to_string(),
74                    )),
75                    padding_left: Some(true),
76                    padding_right: None,
77                    text_edits: None,
78                    data: None,
79                });
80                continue;
81            }
82
83            if let Some(resolved) = &resolved_version
84                && config.show_up_to_date_hints
85            {
86                hints.push(InlayHint {
87                    position: version_range.end,
88                    label: InlayHintLabel::String(format!(
89                        "{} {}",
90                        config.up_to_date_text, resolved
91                    )),
92                    kind: Some(InlayHintKind::TYPE),
93                    padding_left: Some(true),
94                    padding_right: None,
95                    text_edits: None,
96                    tooltip: None,
97                    data: None,
98                });
99            }
100            continue;
101        };
102
103        // Two-tier check for up-to-date status:
104        // 1. If lock file has the dep, check if resolved == latest
105        // 2. If NOT in lock file, check the version requirement against latest
106        let status = if let Some(resolved) = &resolved_version {
107            if resolved == latest {
108                RequirementStatus::UpToDate
109            } else {
110                RequirementStatus::Outdated
111            }
112        } else {
113            match dep.version_requirement() {
114                Some(version_req) => formatter.requirement_status(version_req, latest),
115                // No declared requirement at all (e.g. a dangling alias/reference the
116                // parser couldn't resolve to any string) — nothing was verified.
117                None => RequirementStatus::Unresolved,
118            }
119        };
120
121        let label_text = match status {
122            RequirementStatus::UpToDate => {
123                if config.show_up_to_date_hints {
124                    if let Some(resolved) = &resolved_version {
125                        format!("{} {}", config.up_to_date_text, resolved)
126                    } else {
127                        config.up_to_date_text.clone()
128                    }
129                } else {
130                    continue;
131                }
132            }
133            RequirementStatus::Outdated => config.needs_update_text.replace("{}", latest.as_str()),
134            // Resolution failed (e.g. dangling alias/unexpanded variable) — neither
135            // "up to date" nor "outdated" was actually verified, so show nothing.
136            RequirementStatus::Unresolved => continue,
137        };
138
139        // Issue #483 I5/SEC-2: `latest` here may be a warm-cache value fetched before an
140        // online -> offline flip — without this, the badge is indistinguishable from live
141        // data on this always-visible inline surface.
142        let label_text = if config.offline {
143            format!("{label_text} 📴")
144        } else {
145            label_text
146        };
147
148        hints.push(InlayHint {
149            position: version_range.end,
150            label: InlayHintLabel::String(label_text),
151            kind: Some(InlayHintKind::TYPE),
152            padding_left: Some(true),
153            padding_right: None,
154            text_edits: None,
155            tooltip: None,
156            data: None,
157        });
158    }
159
160    hints
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::lsp_helpers::test_support::*;
167    use crate::lsp_helpers::*;
168
169    #[test]
170    fn test_inlay_hint_exact_version_shows_update_needed() {
171        use std::collections::HashMap;
172        use tower_lsp_server::ls_types::{Position, Range};
173
174        let formatter = MockFormatter;
175        let config = EcosystemConfig {
176            show_up_to_date_hints: true,
177            up_to_date_text: "✅".to_string(),
178            needs_update_text: "❌ {}".to_string(),
179            loading_text: "⏳".to_string(),
180            show_loading_hints: true,
181            offline: false,
182        };
183
184        let parse_result = MockParseResult {
185            deps: vec![MockDep {
186                name: "serde".into(),
187                version_req: "=2.0.12".into(),
188                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
189                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
190            }],
191            uri: crate::test_util::test_uri("/test/Cargo.toml"),
192        };
193
194        let mut cached_versions = HashMap::new();
195        cached_versions.insert("serde".into(), PackageVersions::latest_only("2.1.1"));
196
197        let mut resolved_versions = HashMap::new();
198        resolved_versions.insert("serde".into(), "2.0.12".into());
199
200        let hints = generate_inlay_hints(
201            &parse_result,
202            VersionData::new(&cached_versions, &resolved_versions),
203            crate::LoadingState::Loaded,
204            &config,
205            &formatter,
206        );
207
208        assert_eq!(hints.len(), 1);
209        match &hints[0].label {
210            InlayHintLabel::String(text) => {
211                assert_eq!(text, "❌ 2.1.1");
212            }
213            _ => panic!("Expected string label"),
214        }
215    }
216
217    #[test]
218    fn test_inlay_hint_caret_version_up_to_date() {
219        use std::collections::HashMap;
220        use tower_lsp_server::ls_types::{Position, Range};
221
222        let formatter = MockFormatter;
223        let config = EcosystemConfig {
224            show_up_to_date_hints: true,
225            up_to_date_text: "✅".to_string(),
226            needs_update_text: "❌ {}".to_string(),
227            loading_text: "⏳".to_string(),
228            show_loading_hints: true,
229            offline: false,
230        };
231
232        let parse_result = MockParseResult {
233            deps: vec![MockDep {
234                name: "serde".into(),
235                version_req: "^2.0".into(),
236                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
237                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
238            }],
239            uri: crate::test_util::test_uri("/test/Cargo.toml"),
240        };
241
242        let mut cached_versions = HashMap::new();
243        cached_versions.insert("serde".into(), PackageVersions::latest_only("2.1.1"));
244
245        let mut resolved_versions = HashMap::new();
246        resolved_versions.insert("serde".into(), "2.1.1".into());
247
248        let hints = generate_inlay_hints(
249            &parse_result,
250            VersionData::new(&cached_versions, &resolved_versions),
251            crate::LoadingState::Loaded,
252            &config,
253            &formatter,
254        );
255
256        assert_eq!(hints.len(), 1);
257        match &hints[0].label {
258            InlayHintLabel::String(text) => {
259                assert!(
260                    text.starts_with("✅"),
261                    "Expected up-to-date hint, got: {}",
262                    text
263                );
264            }
265            _ => panic!("Expected string label"),
266        }
267    }
268
269    #[test]
270    fn test_inlay_hint_go_prefers_manifest_requirement_over_stale_resolved_version() {
271        use std::collections::HashMap;
272        use tower_lsp_server::ls_types::{Position, Range};
273
274        let formatter = MockGoFormatter;
275        let config = EcosystemConfig {
276            show_up_to_date_hints: true,
277            up_to_date_text: "✅".to_string(),
278            needs_update_text: "❌ {}".to_string(),
279            loading_text: "⏳".to_string(),
280            show_loading_hints: true,
281            offline: false,
282        };
283
284        let parse_result = MockParseResult {
285            deps: vec![MockDep {
286                name: "example.com/mod".into(),
287                version_req: "v0.8.1".into(),
288                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
289                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
290            }],
291            uri: crate::test_util::test_uri("/test/go.mod"),
292        };
293
294        let mut cached_versions = HashMap::new();
295        cached_versions.insert(
296            "example.com/mod".into(),
297            PackageVersions::latest_only("v0.9.1"),
298        );
299
300        // Stale go.sum entry left behind by a downgrade: go.sum is sorted ascending by
301        // semver, so it sorts last and would win naive last-occurrence-wins parsing
302        // even though go.mod's `require` line was downgraded back to v0.8.1 (#235).
303        let mut resolved_versions = HashMap::new();
304        resolved_versions.insert("example.com/mod".into(), "v0.9.1".into());
305
306        let hints = generate_inlay_hints(
307            &parse_result,
308            VersionData::new(&cached_versions, &resolved_versions),
309            crate::LoadingState::Loaded,
310            &config,
311            &formatter,
312        );
313
314        assert_eq!(hints.len(), 1);
315        match &hints[0].label {
316            InlayHintLabel::String(text) => {
317                // Bug: using the stale go.sum "v0.9.1" as resolved would equal latest
318                // ("v0.9.1") and wrongly report up-to-date. The fix takes go.mod's
319                // pinned "v0.8.1", which is genuinely outdated relative to latest.
320                assert!(
321                    text.starts_with("❌"),
322                    "expected outdated hint driven by go.mod pin, got: {text}"
323                );
324            }
325            _ => panic!("Expected string label"),
326        }
327    }
328
329    #[test]
330    fn test_inlay_hint_non_go_formatter_uses_resolved_lockfile_version() {
331        use std::collections::HashMap;
332        use tower_lsp_server::ls_types::{Position, Range};
333
334        let formatter = MockFormatter;
335        let config = EcosystemConfig {
336            show_up_to_date_hints: true,
337            up_to_date_text: "✅".to_string(),
338            needs_update_text: "❌ {}".to_string(),
339            loading_text: "⏳".to_string(),
340            show_loading_hints: true,
341            offline: false,
342        };
343
344        let parse_result = MockParseResult {
345            deps: vec![MockDep {
346                name: "serde".into(),
347                version_req: "1.0.0".into(),
348                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
349                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
350            }],
351            uri: crate::test_util::test_uri("/test/Cargo.toml"),
352        };
353
354        let mut cached_versions = HashMap::new();
355        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
356
357        let mut resolved_versions = HashMap::new();
358        resolved_versions.insert("serde".into(), "1.2.0".into());
359
360        let hints = generate_inlay_hints(
361            &parse_result,
362            VersionData::new(&cached_versions, &resolved_versions),
363            crate::LoadingState::Loaded,
364            &config,
365            &formatter,
366        );
367
368        assert_eq!(hints.len(), 1);
369        match &hints[0].label {
370            InlayHintLabel::String(text) => {
371                // Non-Go formatters must keep using the lockfile-resolved version
372                // ("1.2.0", matching latest) rather than the raw manifest requirement
373                // ("1.0.0", which would wrongly report outdated) — confirms the Go
374                // override does not leak into other ecosystems.
375                assert!(
376                    text.starts_with("✅"),
377                    "expected up-to-date hint from resolved lockfile version, got: {text}"
378                );
379            }
380            _ => panic!("Expected string label"),
381        }
382    }
383
384    #[test]
385    fn test_loading_hint_shows_when_no_cached_version() {
386        use std::collections::HashMap;
387        use tower_lsp_server::ls_types::{Position, Range};
388
389        let formatter = MockFormatter;
390        let config = EcosystemConfig {
391            show_up_to_date_hints: true,
392            up_to_date_text: "✅".to_string(),
393            needs_update_text: "❌ {}".to_string(),
394            loading_text: "⏳".to_string(),
395            show_loading_hints: true,
396            offline: false,
397        };
398
399        let parse_result = MockParseResult {
400            deps: vec![MockDep {
401                name: "tokio".into(),
402                version_req: "1.0".into(),
403                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
404                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
405            }],
406            uri: crate::test_util::test_uri("/test/Cargo.toml"),
407        };
408
409        let cached_versions = HashMap::new();
410        let resolved_versions = HashMap::new();
411
412        let hints = generate_inlay_hints(
413            &parse_result,
414            VersionData::new(&cached_versions, &resolved_versions),
415            crate::LoadingState::Loading,
416            &config,
417            &formatter,
418        );
419
420        assert_eq!(hints.len(), 1);
421        match &hints[0].label {
422            InlayHintLabel::String(text) => {
423                assert_eq!(text, "⏳", "Expected loading hint");
424            }
425            _ => panic!("Expected string label"),
426        }
427
428        if let Some(InlayHintTooltip::String(tooltip)) = &hints[0].tooltip {
429            assert_eq!(tooltip, "Fetching latest version...");
430        } else {
431            panic!("Expected tooltip");
432        }
433    }
434
435    #[test]
436    fn test_loading_hint_disabled_when_config_false() {
437        use std::collections::HashMap;
438        use tower_lsp_server::ls_types::{Position, Range};
439
440        let formatter = MockFormatter;
441        let config = EcosystemConfig {
442            show_up_to_date_hints: true,
443            up_to_date_text: "✅".to_string(),
444            needs_update_text: "❌ {}".to_string(),
445            loading_text: "⏳".to_string(),
446            show_loading_hints: false,
447            offline: false,
448        };
449
450        let parse_result = MockParseResult {
451            deps: vec![MockDep {
452                name: "tokio".into(),
453                version_req: "1.0".into(),
454                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
455                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
456            }],
457            uri: crate::test_util::test_uri("/test/Cargo.toml"),
458        };
459
460        let cached_versions = HashMap::new();
461        let resolved_versions = HashMap::new();
462
463        let hints = generate_inlay_hints(
464            &parse_result,
465            VersionData::new(&cached_versions, &resolved_versions),
466            crate::LoadingState::Loading,
467            &config,
468            &formatter,
469        );
470
471        assert_eq!(
472            hints.len(),
473            0,
474            "Expected no hints when loading hints disabled"
475        );
476    }
477
478    #[test]
479    fn test_caret_version_0x_edge_cases() {
480        let formatter = MockFormatter;
481
482        // ^0.2 should only allow 0.2.x
483        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.2.0"), "^0.2"));
484        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.2.5"), "^0.2"));
485        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.2.99"), "^0.2"));
486
487        // ^0.2 should NOT allow 0.3.x or 0.1.x
488        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("0.3.0"), "^0.2"));
489        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("0.1.0"), "^0.2"));
490        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "^0.2"));
491
492        // ^0.0.3 should only allow 0.0.3 (left-most non-zero is patch)
493        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.0.3"), "^0.0.3"));
494        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.0.3"), "^0.0"));
495
496        // ^0 should only allow 0.x.y (major is 0)
497        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.0.0"), "^0"));
498        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("0.5.0"), "^0"));
499        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "^0"));
500    }
501
502    #[test]
503    fn test_caret_version_non_zero_major() {
504        let formatter = MockFormatter;
505
506        // ^1.2 allows any 1.x.x
507        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "^1.2"));
508        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.0"), "^1.2"));
509        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.9.9"), "^1.2"));
510
511        // ^1.2 should NOT allow 2.x.x
512        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), "^1.2"));
513        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("0.9.0"), "^1.2"));
514    }
515
516    #[test]
517    fn test_loading_hint_not_shown_when_cached_version_exists() {
518        use std::collections::HashMap;
519        use tower_lsp_server::ls_types::{Position, Range};
520
521        let formatter = MockFormatter;
522        let config = EcosystemConfig {
523            show_up_to_date_hints: true,
524            up_to_date_text: "✅".to_string(),
525            needs_update_text: "❌ {}".to_string(),
526            loading_text: "⏳".to_string(),
527            show_loading_hints: true,
528            offline: false,
529        };
530
531        let parse_result = MockParseResult {
532            deps: vec![MockDep {
533                name: "serde".into(),
534                version_req: "1.0".into(),
535                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
536                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
537            }],
538            uri: crate::test_util::test_uri("/test/Cargo.toml"),
539        };
540
541        let mut cached_versions = HashMap::new();
542        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
543
544        // Lock file has the latest version
545        let mut resolved_versions = HashMap::new();
546        resolved_versions.insert("serde".into(), "1.0.214".into());
547
548        let hints = generate_inlay_hints(
549            &parse_result,
550            VersionData::new(&cached_versions, &resolved_versions),
551            crate::LoadingState::Loading,
552            &config,
553            &formatter,
554        );
555
556        assert_eq!(hints.len(), 1);
557        match &hints[0].label {
558            InlayHintLabel::String(text) => {
559                assert_eq!(
560                    text, "✅ 1.0.214",
561                    "Expected up-to-date hint, not loading hint, got: {}",
562                    text
563                );
564            }
565            _ => panic!("Expected string label"),
566        }
567    }
568
569    #[test]
570    fn test_inlay_hint_not_in_lockfile_but_satisfies_requirement() {
571        use std::collections::HashMap;
572        use tower_lsp_server::ls_types::{Position, Range};
573
574        let formatter = MockFormatter;
575        let config = EcosystemConfig {
576            show_up_to_date_hints: true,
577            up_to_date_text: "✅".to_string(),
578            needs_update_text: "❌ {}".to_string(),
579            loading_text: "⏳".to_string(),
580            show_loading_hints: true,
581            offline: false,
582        };
583
584        let parse_result = MockParseResult {
585            deps: vec![MockDep {
586                name: "criterion".into(),
587                version_req: "0.5".into(),
588                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
589                name_range: Range::new(Position::new(0, 0), Position::new(0, 9)),
590            }],
591            uri: crate::test_util::test_uri("/test/Cargo.toml"),
592        };
593
594        let mut cached_versions = HashMap::new();
595        cached_versions.insert("criterion".into(), PackageVersions::latest_only("0.5.1"));
596
597        // Not in lock file (empty resolved_versions)
598        let resolved_versions = HashMap::new();
599
600        let hints = generate_inlay_hints(
601            &parse_result,
602            VersionData::new(&cached_versions, &resolved_versions),
603            crate::LoadingState::Loaded,
604            &config,
605            &formatter,
606        );
607
608        assert_eq!(hints.len(), 1);
609        match &hints[0].label {
610            InlayHintLabel::String(text) => {
611                assert!(
612                    text.starts_with("✅"),
613                    "Expected up-to-date hint for satisfied requirement, got: {}",
614                    text
615                );
616            }
617            _ => panic!("Expected string label"),
618        }
619    }
620
621    #[test]
622    fn test_inlay_hint_not_in_lockfile_and_outdated() {
623        use std::collections::HashMap;
624        use tower_lsp_server::ls_types::{Position, Range};
625
626        let formatter = MockFormatter;
627        let config = EcosystemConfig {
628            show_up_to_date_hints: true,
629            up_to_date_text: "✅".to_string(),
630            needs_update_text: "❌ {}".to_string(),
631            loading_text: "⏳".to_string(),
632            show_loading_hints: true,
633            offline: false,
634        };
635
636        let parse_result = MockParseResult {
637            deps: vec![MockDep {
638                name: "criterion".into(),
639                version_req: "0.4".into(),
640                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
641                name_range: Range::new(Position::new(0, 0), Position::new(0, 9)),
642            }],
643            uri: crate::test_util::test_uri("/test/Cargo.toml"),
644        };
645
646        let mut cached_versions = HashMap::new();
647        cached_versions.insert("criterion".into(), PackageVersions::latest_only("0.5.1"));
648
649        // Not in lock file (empty resolved_versions)
650        let resolved_versions = HashMap::new();
651
652        let hints = generate_inlay_hints(
653            &parse_result,
654            VersionData::new(&cached_versions, &resolved_versions),
655            crate::LoadingState::Loaded,
656            &config,
657            &formatter,
658        );
659
660        assert_eq!(hints.len(), 1);
661        match &hints[0].label {
662            InlayHintLabel::String(text) => {
663                assert!(
664                    text.starts_with("❌"),
665                    "Expected needs-update hint for unsatisfied requirement, got: {}",
666                    text
667                );
668                assert!(text.contains("0.5.1"), "Expected latest version in hint");
669            }
670            _ => panic!("Expected string label"),
671        }
672    }
673
674    #[test]
675    fn test_inlay_hint_unresolved_requirement_emits_no_hint() {
676        use std::collections::HashMap;
677        use tower_lsp_server::ls_types::{Position, Range};
678
679        let config = EcosystemConfig {
680            show_up_to_date_hints: true,
681            up_to_date_text: "✅".to_string(),
682            needs_update_text: "❌ {}".to_string(),
683            loading_text: "⏳".to_string(),
684            show_loading_hints: true,
685            offline: false,
686        };
687
688        let parse_result = MockParseResult {
689            deps: vec![MockDep {
690                name: "spring-boot-starter".into(),
691                version_req: "$missing".into(),
692                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
693                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
694            }],
695            uri: crate::test_util::test_uri("/test/libs.versions.toml"),
696        };
697
698        let mut cached_versions = HashMap::new();
699        cached_versions.insert(
700            "spring-boot-starter".into(),
701            PackageVersions::latest_only("3.2.0"),
702        );
703
704        // Not in lock file, so status is derived from `requirement_status` on the
705        // formatter (which the caller sets to `Unresolved`) rather than a resolved-vs-latest
706        // comparison.
707        let resolved_versions = HashMap::new();
708
709        let hints = generate_inlay_hints(
710            &parse_result,
711            VersionData::new(&cached_versions, &resolved_versions),
712            crate::LoadingState::Loaded,
713            &config,
714            &MockUnresolvedFormatter,
715        );
716
717        assert!(
718            hints.is_empty(),
719            "Expected no inlay hint at all for an unresolved requirement (not even 'up to date'), got: {hints:?}"
720        );
721    }
722
723    /// Issue #483 I5/SEC-2 (cold, no resolved version): with no cached `latest` and no
724    /// lockfile-resolved version, offline mode shows the bare marker alone.
725    #[test]
726    fn test_inlay_hint_offline_cold_no_resolved_shows_bare_marker() {
727        use std::collections::HashMap;
728        use tower_lsp_server::ls_types::{Position, Range};
729
730        let formatter = MockFormatter;
731        let config = EcosystemConfig {
732            show_up_to_date_hints: true,
733            up_to_date_text: "✅".to_string(),
734            needs_update_text: "❌ {}".to_string(),
735            loading_text: "⏳".to_string(),
736            show_loading_hints: true,
737            offline: true,
738        };
739
740        let parse_result = MockParseResult {
741            deps: vec![MockDep {
742                name: "serde".into(),
743                version_req: "^2.0".into(),
744                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
745                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
746            }],
747            uri: crate::test_util::test_uri("/test/Cargo.toml"),
748        };
749
750        let cached_versions = HashMap::new();
751        let resolved_versions = HashMap::new();
752
753        let hints = generate_inlay_hints(
754            &parse_result,
755            VersionData::new(&cached_versions, &resolved_versions),
756            crate::LoadingState::Loaded,
757            &config,
758            &formatter,
759        );
760
761        assert_eq!(hints.len(), 1);
762        match &hints[0].label {
763            InlayHintLabel::String(text) => assert_eq!(text, "📴"),
764            _ => panic!("Expected string label"),
765        }
766    }
767
768    /// Issue #483 I5/SEC-2 (cold, with a lockfile-resolved version): offline mode must not
769    /// discard purely-local, lockfile-derived version information — show it alongside the
770    /// marker rather than replacing it with a bare 📴.
771    #[test]
772    fn test_inlay_hint_offline_cold_with_resolved_shows_marker_and_version() {
773        use std::collections::HashMap;
774        use tower_lsp_server::ls_types::{Position, Range};
775
776        let formatter = MockFormatter;
777        let config = EcosystemConfig {
778            show_up_to_date_hints: true,
779            up_to_date_text: "✅".to_string(),
780            needs_update_text: "❌ {}".to_string(),
781            loading_text: "⏳".to_string(),
782            show_loading_hints: true,
783            offline: true,
784        };
785
786        let parse_result = MockParseResult {
787            deps: vec![MockDep {
788                name: "serde".into(),
789                version_req: "^2.0".into(),
790                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
791                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
792            }],
793            uri: crate::test_util::test_uri("/test/Cargo.toml"),
794        };
795
796        let cached_versions = HashMap::new();
797        let mut resolved_versions = HashMap::new();
798        resolved_versions.insert("serde".into(), "2.0.12".into());
799
800        let hints = generate_inlay_hints(
801            &parse_result,
802            VersionData::new(&cached_versions, &resolved_versions),
803            crate::LoadingState::Loaded,
804            &config,
805            &formatter,
806        );
807
808        assert_eq!(hints.len(), 1);
809        match &hints[0].label {
810            InlayHintLabel::String(text) => assert_eq!(text, "📴 2.0.12"),
811            _ => panic!("Expected string label"),
812        }
813    }
814
815    /// Issue #483 I5/SEC-2 (warm cache): a normal up-to-date/outdated badge built from a
816    /// warm-cache `latest` value (possibly fetched before an online -> offline flip) must
817    /// carry the offline marker too, or it is indistinguishable from live data on this
818    /// always-visible inline surface.
819    #[test]
820    fn test_inlay_hint_offline_warm_cache_appends_marker_to_outdated_badge() {
821        use std::collections::HashMap;
822        use tower_lsp_server::ls_types::{Position, Range};
823
824        let formatter = MockFormatter;
825        let config = EcosystemConfig {
826            show_up_to_date_hints: true,
827            up_to_date_text: "✅".to_string(),
828            needs_update_text: "❌ {}".to_string(),
829            loading_text: "⏳".to_string(),
830            show_loading_hints: true,
831            offline: true,
832        };
833
834        let parse_result = MockParseResult {
835            deps: vec![MockDep {
836                name: "serde".into(),
837                version_req: "=2.0.12".into(),
838                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
839                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
840            }],
841            uri: crate::test_util::test_uri("/test/Cargo.toml"),
842        };
843
844        let mut cached_versions = HashMap::new();
845        cached_versions.insert("serde".into(), PackageVersions::latest_only("2.1.1"));
846
847        let mut resolved_versions = HashMap::new();
848        resolved_versions.insert("serde".into(), "2.0.12".into());
849
850        let hints = generate_inlay_hints(
851            &parse_result,
852            VersionData::new(&cached_versions, &resolved_versions),
853            crate::LoadingState::Loaded,
854            &config,
855            &formatter,
856        );
857
858        assert_eq!(hints.len(), 1);
859        match &hints[0].label {
860            InlayHintLabel::String(text) => assert_eq!(text, "❌ 2.1.1 📴"),
861            _ => panic!("Expected string label"),
862        }
863    }
864}