deps_core/
lsp_helpers.rs

1//! Shared LSP response builders.
2
3use std::collections::HashMap;
4use tower_lsp_server::ls_types::{
5    CodeAction, CodeActionKind, Diagnostic, DiagnosticSeverity, Hover, HoverContents, InlayHint,
6    InlayHintKind, InlayHintLabel, InlayHintTooltip, MarkupContent, MarkupKind, Position, Range,
7    TextEdit, Uri, WorkspaceEdit,
8};
9
10use crate::{Dependency, EcosystemConfig, ParseResult, Registry};
11
12/// Checks if a position overlaps with a range (inclusive start, exclusive end).
13pub fn ranges_overlap(range: Range, position: Position) -> bool {
14    !(range.end.line < position.line
15        || (range.end.line == position.line && range.end.character <= position.character)
16        || position.line < range.start.line
17        || (position.line == range.start.line && position.character < range.start.character))
18}
19
20/// Checks if two version strings have the same major and minor version.
21pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
22    if v1.is_empty() || v2.is_empty() {
23        return false;
24    }
25
26    let mut parts1 = v1.split('.');
27    let mut parts2 = v2.split('.');
28
29    if parts1.next() != parts2.next() {
30        return false;
31    }
32
33    match (parts1.next(), parts2.next()) {
34        (Some(m1), Some(m2)) => m1 == m2,
35        _ => true,
36    }
37}
38
39/// Ecosystem-specific formatting and comparison logic.
40pub trait EcosystemFormatter: Send + Sync {
41    /// Normalize package name for lookup (default: identity).
42    fn normalize_package_name(&self, name: &str) -> String {
43        name.to_string()
44    }
45
46    /// Format version string for code action text edit.
47    fn format_version_for_code_action(&self, version: &str) -> String;
48
49    /// Check if a version satisfies a requirement string.
50    fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool {
51        // Handle caret (^) - allows changes that don't modify left-most non-zero
52        // ^2.0 allows 2.x.x, ^0.2 allows 0.2.x, ^0.0.3 allows only 0.0.3
53        if let Some(req) = requirement.strip_prefix('^') {
54            let req_parts: Vec<&str> = req.split('.').collect();
55            let ver_parts: Vec<&str> = version.split('.').collect();
56
57            // Must have same major version
58            if req_parts.first() != ver_parts.first() {
59                return false;
60            }
61
62            // For ^X.Y where X > 0, any X.*.* is allowed
63            if req_parts.first().is_some_and(|m| *m != "0") {
64                return true;
65            }
66
67            // For ^0.Y, must have same minor
68            if req_parts.len() >= 2 && ver_parts.len() >= 2 {
69                return req_parts[1] == ver_parts[1];
70            }
71
72            return true;
73        }
74
75        // Handle tilde (~) - allows patch-level changes
76        // ~2.0 allows 2.0.x, ~2.0.1 allows 2.0.x where x >= 1
77        if let Some(req) = requirement.strip_prefix('~') {
78            return is_same_major_minor(req, version);
79        }
80
81        // Plain version or partial version
82        let req_parts: Vec<&str> = requirement.split('.').collect();
83        let is_partial_version = req_parts.len() <= 2;
84
85        version == requirement
86            || (is_partial_version && is_same_major_minor(requirement, version))
87            || (is_partial_version && version.starts_with(requirement))
88    }
89
90    /// Get package URL for hover markdown.
91    fn package_url(&self, name: &str) -> String;
92
93    /// Message for yanked/deprecated versions in diagnostics.
94    fn yanked_message(&self) -> &'static str {
95        "This version has been yanked"
96    }
97
98    /// Label for yanked versions in hover.
99    fn yanked_label(&self) -> &'static str {
100        "*(yanked)*"
101    }
102
103    /// Detect if cursor position is on a dependency for code actions.
104    fn is_position_on_dependency(&self, dep: &dyn Dependency, position: Position) -> bool {
105        dep.version_range()
106            .is_some_and(|r| ranges_overlap(r, position))
107    }
108}
109
110pub fn generate_inlay_hints(
111    parse_result: &dyn ParseResult,
112    cached_versions: &HashMap<String, String>,
113    resolved_versions: &HashMap<String, String>,
114    loading_state: crate::LoadingState,
115    config: &EcosystemConfig,
116    formatter: &dyn EcosystemFormatter,
117) -> Vec<InlayHint> {
118    let deps = parse_result.dependencies();
119    let mut hints = Vec::with_capacity(deps.len());
120
121    for dep in deps {
122        let Some(version_range) = dep.version_range() else {
123            continue;
124        };
125
126        let normalized_name = formatter.normalize_package_name(dep.name());
127        let latest_version = cached_versions
128            .get(&normalized_name)
129            .or_else(|| cached_versions.get(dep.name()));
130        let resolved_version = resolved_versions
131            .get(&normalized_name)
132            .or_else(|| resolved_versions.get(dep.name()));
133
134        // Show loading hint if loading and no cached version
135        if loading_state == crate::LoadingState::Loading
136            && config.show_loading_hints
137            && latest_version.is_none()
138        {
139            hints.push(InlayHint {
140                position: version_range.end,
141                label: InlayHintLabel::String(config.loading_text.clone()),
142                kind: Some(InlayHintKind::TYPE),
143                tooltip: Some(InlayHintTooltip::String(
144                    "Fetching latest version...".to_string(),
145                )),
146                padding_left: Some(true),
147                padding_right: None,
148                text_edits: None,
149                data: None,
150            });
151            continue;
152        }
153
154        let Some(latest) = latest_version else {
155            if let Some(resolved) = resolved_version
156                && config.show_up_to_date_hints
157            {
158                hints.push(InlayHint {
159                    position: version_range.end,
160                    label: InlayHintLabel::String(format!(
161                        "{} {}",
162                        config.up_to_date_text, resolved
163                    )),
164                    kind: Some(InlayHintKind::TYPE),
165                    padding_left: Some(true),
166                    padding_right: None,
167                    text_edits: None,
168                    tooltip: None,
169                    data: None,
170                });
171            }
172            continue;
173        };
174
175        // Two-tier check for up-to-date status:
176        // 1. If lock file has the dep, check if resolved == latest
177        // 2. If NOT in lock file, check if version requirement is satisfied by latest
178        let is_up_to_date = if let Some(resolved) = resolved_version {
179            resolved.as_str() == latest.as_str()
180        } else {
181            let version_req = dep.version_requirement().unwrap_or("");
182            formatter.version_satisfies_requirement(latest, version_req)
183        };
184
185        let label_text = if is_up_to_date {
186            if config.show_up_to_date_hints {
187                if let Some(resolved) = resolved_version {
188                    format!("{} {}", config.up_to_date_text, resolved)
189                } else {
190                    config.up_to_date_text.clone()
191                }
192            } else {
193                continue;
194            }
195        } else {
196            config.needs_update_text.replace("{}", latest)
197        };
198
199        hints.push(InlayHint {
200            position: version_range.end,
201            label: InlayHintLabel::String(label_text),
202            kind: Some(InlayHintKind::TYPE),
203            padding_left: Some(true),
204            padding_right: None,
205            text_edits: None,
206            tooltip: None,
207            data: None,
208        });
209    }
210
211    hints
212}
213
214pub async fn generate_hover<R: Registry + ?Sized>(
215    parse_result: &dyn ParseResult,
216    position: Position,
217    cached_versions: &HashMap<String, String>,
218    resolved_versions: &HashMap<String, String>,
219    registry: &R,
220    formatter: &dyn EcosystemFormatter,
221) -> Option<Hover> {
222    use std::fmt::Write;
223
224    let dep = parse_result.dependencies().into_iter().find(|d| {
225        let on_name = ranges_overlap(d.name_range(), position);
226        let on_version = d
227            .version_range()
228            .is_some_and(|r| ranges_overlap(r, position));
229        on_name || on_version
230    })?;
231
232    let versions = registry.get_versions(dep.name()).await.ok()?;
233
234    let url = formatter.package_url(dep.name());
235
236    // Pre-allocate with estimated capacity to reduce allocations
237    let mut markdown = String::with_capacity(512);
238    write!(&mut markdown, "# [{}]({})\n\n", dep.name(), url).unwrap();
239
240    let normalized_name = formatter.normalize_package_name(dep.name());
241
242    let resolved = resolved_versions
243        .get(&normalized_name)
244        .or_else(|| resolved_versions.get(dep.name()));
245    if let Some(resolved_ver) = resolved {
246        write!(&mut markdown, "**Current**: `{}`\n\n", resolved_ver).unwrap();
247    } else if let Some(version_req) = dep.version_requirement() {
248        write!(&mut markdown, "**Requirement**: `{}`\n\n", version_req).unwrap();
249    }
250
251    let latest = cached_versions
252        .get(&normalized_name)
253        .or_else(|| cached_versions.get(dep.name()));
254    if let Some(latest_ver) = latest {
255        write!(&mut markdown, "**Latest**: `{}`\n\n", latest_ver).unwrap();
256    }
257
258    markdown.push_str("**Recent versions**:\n");
259    for (i, version) in versions.iter().take(8).enumerate() {
260        if i == 0 {
261            writeln!(&mut markdown, "- {} *(latest)*", version.version_string()).unwrap();
262        } else if version.is_yanked() {
263            writeln!(
264                &mut markdown,
265                "- {} {}",
266                version.version_string(),
267                formatter.yanked_label()
268            )
269            .unwrap();
270        } else {
271            writeln!(&mut markdown, "- {}", version.version_string()).unwrap();
272        }
273    }
274
275    markdown.push_str("\n---\n⌨️ **Press `Cmd+.` to update version**");
276
277    Some(Hover {
278        contents: HoverContents::Markup(MarkupContent {
279            kind: MarkupKind::Markdown,
280            value: markdown,
281        }),
282        range: Some(dep.name_range()),
283    })
284}
285
286pub async fn generate_code_actions<R: Registry + ?Sized>(
287    parse_result: &dyn ParseResult,
288    position: Position,
289    uri: &Uri,
290    registry: &R,
291    formatter: &dyn EcosystemFormatter,
292) -> Vec<CodeAction> {
293    use crate::completion::prepare_version_display_items;
294
295    let deps = parse_result.dependencies();
296    let mut actions = Vec::with_capacity(deps.len().min(5));
297
298    let Some(dep) = deps
299        .into_iter()
300        .find(|d| formatter.is_position_on_dependency(*d, position))
301    else {
302        return actions;
303    };
304
305    let Some(version_range) = dep.version_range() else {
306        return actions;
307    };
308
309    let Ok(versions) = registry.get_versions(dep.name()).await else {
310        return actions;
311    };
312
313    let display_items = prepare_version_display_items(&versions, dep.name());
314
315    for item in display_items {
316        let new_text = formatter.format_version_for_code_action(&item.version);
317
318        let mut edits = HashMap::new();
319        edits.insert(
320            uri.clone(),
321            vec![TextEdit {
322                range: version_range,
323                new_text,
324            }],
325        );
326
327        actions.push(CodeAction {
328            title: item.label,
329            kind: Some(CodeActionKind::REFACTOR),
330            edit: Some(WorkspaceEdit {
331                changes: Some(edits),
332                ..Default::default()
333            }),
334            is_preferred: Some(item.is_latest),
335            ..Default::default()
336        });
337    }
338
339    actions
340}
341
342/// Generates diagnostics using cached versions (no network calls).
343///
344/// Uses pre-fetched version information from the lifecycle's parallel fetch.
345/// This avoids making additional network requests during diagnostic generation.
346///
347/// # Arguments
348///
349/// * `parse_result` - Parsed dependencies from manifest
350/// * `cached_versions` - Latest versions from registry (name -> latest version)
351/// * `resolved_versions` - Resolved versions from lock file (name -> installed version)
352/// * `formatter` - Ecosystem-specific formatting and comparison logic
353pub fn generate_diagnostics_from_cache(
354    parse_result: &dyn ParseResult,
355    cached_versions: &HashMap<String, String>,
356    _resolved_versions: &HashMap<String, String>,
357    formatter: &dyn EcosystemFormatter,
358) -> Vec<Diagnostic> {
359    let deps = parse_result.dependencies();
360    let mut diagnostics = Vec::with_capacity(deps.len());
361
362    for dep in deps {
363        let normalized_name = formatter.normalize_package_name(dep.name());
364        let latest_version = cached_versions
365            .get(&normalized_name)
366            .or_else(|| cached_versions.get(dep.name()));
367
368        let Some(latest) = latest_version else {
369            diagnostics.push(Diagnostic {
370                range: dep.name_range(),
371                severity: Some(DiagnosticSeverity::WARNING),
372                message: format!("Unknown package '{}' (or failed to fetch)", dep.name()),
373                source: Some("deps-lsp".into()),
374                ..Default::default()
375            });
376            continue;
377        };
378
379        let Some(version_range) = dep.version_range() else {
380            continue;
381        };
382
383        let version_req = dep.version_requirement().unwrap_or("");
384        let requirement_allows_latest =
385            formatter.version_satisfies_requirement(latest, version_req);
386
387        if !requirement_allows_latest {
388            diagnostics.push(Diagnostic {
389                range: version_range,
390                severity: Some(DiagnosticSeverity::HINT),
391                message: format!("Newer version available: {}", latest),
392                source: Some("deps-lsp".into()),
393                ..Default::default()
394            });
395        }
396    }
397
398    diagnostics
399}
400
401/// Generates diagnostics by fetching from registry (makes network calls).
402///
403/// **Warning**: This function makes network requests for each dependency.
404/// Prefer `generate_diagnostics_from_cache` when cached versions are available.
405#[allow(dead_code)]
406pub async fn generate_diagnostics<R: Registry + ?Sized>(
407    parse_result: &dyn ParseResult,
408    registry: &R,
409    formatter: &dyn EcosystemFormatter,
410) -> Vec<Diagnostic> {
411    let deps = parse_result.dependencies();
412    let mut diagnostics = Vec::with_capacity(deps.len());
413
414    for dep in deps {
415        let versions = match registry.get_versions(dep.name()).await {
416            Ok(v) => v,
417            Err(_) => {
418                diagnostics.push(Diagnostic {
419                    range: dep.name_range(),
420                    severity: Some(DiagnosticSeverity::WARNING),
421                    message: format!("Unknown package '{}'", dep.name()),
422                    source: Some("deps-lsp".into()),
423                    ..Default::default()
424                });
425                continue;
426            }
427        };
428
429        let Some(version_req) = dep.version_requirement() else {
430            continue;
431        };
432        let Some(version_range) = dep.version_range() else {
433            continue;
434        };
435
436        let matching = registry
437            .get_latest_matching(dep.name(), version_req)
438            .await
439            .ok()
440            .flatten();
441
442        if let Some(current) = matching {
443            if current.is_yanked() {
444                diagnostics.push(Diagnostic {
445                    range: version_range,
446                    severity: Some(DiagnosticSeverity::WARNING),
447                    message: formatter.yanked_message().into(),
448                    source: Some("deps-lsp".into()),
449                    ..Default::default()
450                });
451            }
452
453            let latest = crate::registry::find_latest_stable(&versions);
454            if let Some(latest) = latest
455                && latest.version_string() != current.version_string()
456            {
457                diagnostics.push(Diagnostic {
458                    range: version_range,
459                    severity: Some(DiagnosticSeverity::HINT),
460                    message: format!("Newer version available: {}", latest.version_string()),
461                    source: Some("deps-lsp".into()),
462                    ..Default::default()
463                });
464            }
465        }
466    }
467
468    diagnostics
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_ranges_overlap_inside() {
477        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
478        let position = Position::new(5, 15);
479        assert!(ranges_overlap(range, position));
480    }
481
482    #[test]
483    fn test_ranges_overlap_at_start() {
484        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
485        let position = Position::new(5, 10);
486        assert!(ranges_overlap(range, position));
487    }
488
489    #[test]
490    fn test_ranges_overlap_at_end() {
491        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
492        let position = Position::new(5, 20);
493        assert!(!ranges_overlap(range, position));
494    }
495
496    #[test]
497    fn test_ranges_overlap_before() {
498        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
499        let position = Position::new(5, 5);
500        assert!(!ranges_overlap(range, position));
501    }
502
503    #[test]
504    fn test_ranges_overlap_after() {
505        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
506        let position = Position::new(5, 25);
507        assert!(!ranges_overlap(range, position));
508    }
509
510    #[test]
511    fn test_ranges_overlap_different_line_before() {
512        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
513        let position = Position::new(4, 15);
514        assert!(!ranges_overlap(range, position));
515    }
516
517    #[test]
518    fn test_ranges_overlap_different_line_after() {
519        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
520        let position = Position::new(6, 15);
521        assert!(!ranges_overlap(range, position));
522    }
523
524    #[test]
525    fn test_ranges_overlap_multiline() {
526        let range = Range::new(Position::new(5, 10), Position::new(7, 5));
527        let position = Position::new(6, 0);
528        assert!(ranges_overlap(range, position));
529    }
530
531    #[test]
532    fn test_is_same_major_minor_full_match() {
533        assert!(is_same_major_minor("1.2.3", "1.2.9"));
534    }
535
536    #[test]
537    fn test_is_same_major_minor_exact_match() {
538        assert!(is_same_major_minor("1.2.3", "1.2.3"));
539    }
540
541    #[test]
542    fn test_is_same_major_minor_major_only_match() {
543        assert!(is_same_major_minor("1", "1.2.3"));
544        assert!(is_same_major_minor("1.2.3", "1"));
545    }
546
547    #[test]
548    fn test_is_same_major_minor_no_match_different_minor() {
549        assert!(!is_same_major_minor("1.2.3", "1.3.0"));
550    }
551
552    #[test]
553    fn test_is_same_major_minor_no_match_different_major() {
554        assert!(!is_same_major_minor("1.2.3", "2.2.3"));
555    }
556
557    #[test]
558    fn test_is_same_major_minor_empty_strings() {
559        assert!(!is_same_major_minor("", ""));
560        assert!(!is_same_major_minor("1.2.3", ""));
561        assert!(!is_same_major_minor("", "1.2.3"));
562    }
563
564    #[test]
565    fn test_is_same_major_minor_partial_versions() {
566        assert!(is_same_major_minor("1.2", "1.2.3"));
567        assert!(is_same_major_minor("1.2.3", "1.2"));
568    }
569
570    struct MockFormatter;
571
572    impl EcosystemFormatter for MockFormatter {
573        fn format_version_for_code_action(&self, version: &str) -> String {
574            format!("\"{}\"", version)
575        }
576
577        fn package_url(&self, name: &str) -> String {
578            format!("https://example.com/{}", name)
579        }
580    }
581
582    #[test]
583    fn test_ecosystem_formatter_defaults() {
584        let formatter = MockFormatter;
585        assert_eq!(formatter.normalize_package_name("test-pkg"), "test-pkg");
586        assert_eq!(formatter.yanked_message(), "This version has been yanked");
587        assert_eq!(formatter.yanked_label(), "*(yanked)*");
588    }
589
590    #[test]
591    fn test_ecosystem_formatter_version_satisfies() {
592        let formatter = MockFormatter;
593
594        assert!(formatter.version_satisfies_requirement("1.2.3", "1.2.3"));
595
596        assert!(formatter.version_satisfies_requirement("1.2.3", "^1.2"));
597        assert!(formatter.version_satisfies_requirement("1.2.3", "~1.2"));
598
599        assert!(formatter.version_satisfies_requirement("1.2.3", "1"));
600        assert!(formatter.version_satisfies_requirement("1.2.3", "1.2"));
601
602        assert!(!formatter.version_satisfies_requirement("1.2.3", "2.0.0"));
603        assert!(!formatter.version_satisfies_requirement("1.2.3", "1.3"));
604    }
605
606    #[test]
607    fn test_ecosystem_formatter_custom_normalize() {
608        struct PyPIFormatter;
609
610        impl EcosystemFormatter for PyPIFormatter {
611            fn normalize_package_name(&self, name: &str) -> String {
612                name.to_lowercase().replace('-', "_")
613            }
614
615            fn format_version_for_code_action(&self, version: &str) -> String {
616                format!(
617                    ">={},<{}",
618                    version,
619                    version.split('.').next().unwrap_or("0")
620                )
621            }
622
623            fn package_url(&self, name: &str) -> String {
624                format!("https://pypi.org/project/{}", name)
625            }
626        }
627
628        let formatter = PyPIFormatter;
629        assert_eq!(
630            formatter.normalize_package_name("Test-Package"),
631            "test_package"
632        );
633        assert_eq!(
634            formatter.format_version_for_code_action("1.2.3"),
635            ">=1.2.3,<1"
636        );
637        assert_eq!(
638            formatter.package_url("requests"),
639            "https://pypi.org/project/requests"
640        );
641    }
642
643    #[test]
644    fn test_inlay_hint_exact_version_shows_update_needed() {
645        use std::any::Any;
646        use std::collections::HashMap;
647        use tower_lsp_server::ls_types::{Position, Range, Uri};
648
649        let formatter = MockFormatter;
650        let config = EcosystemConfig {
651            show_up_to_date_hints: true,
652            up_to_date_text: "✅".to_string(),
653            needs_update_text: "❌ {}".to_string(),
654            loading_text: "⏳".to_string(),
655            show_loading_hints: true,
656        };
657
658        struct MockParseResult {
659            deps: Vec<MockDep>,
660            uri: Uri,
661        }
662
663        impl ParseResult for MockParseResult {
664            fn dependencies(&self) -> Vec<&dyn Dependency> {
665                self.deps.iter().map(|d| d as &dyn Dependency).collect()
666            }
667            fn workspace_root(&self) -> Option<&std::path::Path> {
668                None
669            }
670            fn uri(&self) -> &Uri {
671                &self.uri
672            }
673            fn as_any(&self) -> &dyn Any {
674                self
675            }
676        }
677
678        struct MockDep {
679            name: String,
680            version_req: String,
681            version_range: Range,
682            name_range: Range,
683        }
684
685        impl Dependency for MockDep {
686            fn name(&self) -> &str {
687                &self.name
688            }
689            fn name_range(&self) -> Range {
690                self.name_range
691            }
692            fn version_requirement(&self) -> Option<&str> {
693                Some(&self.version_req)
694            }
695            fn version_range(&self) -> Option<Range> {
696                Some(self.version_range)
697            }
698            fn source(&self) -> crate::parser::DependencySource {
699                crate::parser::DependencySource::Registry
700            }
701            fn as_any(&self) -> &dyn Any {
702                self
703            }
704        }
705
706        let parse_result = MockParseResult {
707            deps: vec![MockDep {
708                name: "serde".to_string(),
709                version_req: "=2.0.12".to_string(),
710                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
711                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
712            }],
713            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
714        };
715
716        let mut cached_versions = HashMap::new();
717        cached_versions.insert("serde".to_string(), "2.1.1".to_string());
718
719        let mut resolved_versions = HashMap::new();
720        resolved_versions.insert("serde".to_string(), "2.0.12".to_string());
721
722        let hints = generate_inlay_hints(
723            &parse_result,
724            &cached_versions,
725            &resolved_versions,
726            crate::LoadingState::Loaded,
727            &config,
728            &formatter,
729        );
730
731        assert_eq!(hints.len(), 1);
732        match &hints[0].label {
733            InlayHintLabel::String(text) => {
734                assert_eq!(text, "❌ 2.1.1");
735            }
736            _ => panic!("Expected string label"),
737        }
738    }
739
740    #[test]
741    fn test_inlay_hint_caret_version_up_to_date() {
742        use std::any::Any;
743        use std::collections::HashMap;
744        use tower_lsp_server::ls_types::{Position, Range, Uri};
745
746        let formatter = MockFormatter;
747        let config = EcosystemConfig {
748            show_up_to_date_hints: true,
749            up_to_date_text: "✅".to_string(),
750            needs_update_text: "❌ {}".to_string(),
751            loading_text: "⏳".to_string(),
752            show_loading_hints: true,
753        };
754
755        struct MockParseResult {
756            deps: Vec<MockDep>,
757            uri: Uri,
758        }
759
760        impl ParseResult for MockParseResult {
761            fn dependencies(&self) -> Vec<&dyn Dependency> {
762                self.deps.iter().map(|d| d as &dyn Dependency).collect()
763            }
764            fn workspace_root(&self) -> Option<&std::path::Path> {
765                None
766            }
767            fn uri(&self) -> &Uri {
768                &self.uri
769            }
770            fn as_any(&self) -> &dyn Any {
771                self
772            }
773        }
774
775        struct MockDep {
776            name: String,
777            version_req: String,
778            version_range: Range,
779            name_range: Range,
780        }
781
782        impl Dependency for MockDep {
783            fn name(&self) -> &str {
784                &self.name
785            }
786            fn name_range(&self) -> Range {
787                self.name_range
788            }
789            fn version_requirement(&self) -> Option<&str> {
790                Some(&self.version_req)
791            }
792            fn version_range(&self) -> Option<Range> {
793                Some(self.version_range)
794            }
795            fn source(&self) -> crate::parser::DependencySource {
796                crate::parser::DependencySource::Registry
797            }
798            fn as_any(&self) -> &dyn Any {
799                self
800            }
801        }
802
803        let parse_result = MockParseResult {
804            deps: vec![MockDep {
805                name: "serde".to_string(),
806                version_req: "^2.0".to_string(),
807                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
808                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
809            }],
810            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
811        };
812
813        let mut cached_versions = HashMap::new();
814        cached_versions.insert("serde".to_string(), "2.1.1".to_string());
815
816        let mut resolved_versions = HashMap::new();
817        resolved_versions.insert("serde".to_string(), "2.1.1".to_string());
818
819        let hints = generate_inlay_hints(
820            &parse_result,
821            &cached_versions,
822            &resolved_versions,
823            crate::LoadingState::Loaded,
824            &config,
825            &formatter,
826        );
827
828        assert_eq!(hints.len(), 1);
829        match &hints[0].label {
830            InlayHintLabel::String(text) => {
831                assert!(
832                    text.starts_with("✅"),
833                    "Expected up-to-date hint, got: {}",
834                    text
835                );
836            }
837            _ => panic!("Expected string label"),
838        }
839    }
840
841    #[test]
842    fn test_loading_hint_shows_when_no_cached_version() {
843        use std::any::Any;
844        use std::collections::HashMap;
845        use tower_lsp_server::ls_types::{Position, Range, Uri};
846
847        let formatter = MockFormatter;
848        let config = EcosystemConfig {
849            show_up_to_date_hints: true,
850            up_to_date_text: "✅".to_string(),
851            needs_update_text: "❌ {}".to_string(),
852            loading_text: "⏳".to_string(),
853            show_loading_hints: true,
854        };
855
856        struct MockParseResult {
857            deps: Vec<MockDep>,
858            uri: Uri,
859        }
860
861        impl ParseResult for MockParseResult {
862            fn dependencies(&self) -> Vec<&dyn Dependency> {
863                self.deps.iter().map(|d| d as &dyn Dependency).collect()
864            }
865            fn workspace_root(&self) -> Option<&std::path::Path> {
866                None
867            }
868            fn uri(&self) -> &Uri {
869                &self.uri
870            }
871            fn as_any(&self) -> &dyn Any {
872                self
873            }
874        }
875
876        struct MockDep {
877            name: String,
878            version_req: String,
879            version_range: Range,
880            name_range: Range,
881        }
882
883        impl Dependency for MockDep {
884            fn name(&self) -> &str {
885                &self.name
886            }
887            fn name_range(&self) -> Range {
888                self.name_range
889            }
890            fn version_requirement(&self) -> Option<&str> {
891                Some(&self.version_req)
892            }
893            fn version_range(&self) -> Option<Range> {
894                Some(self.version_range)
895            }
896            fn source(&self) -> crate::parser::DependencySource {
897                crate::parser::DependencySource::Registry
898            }
899            fn as_any(&self) -> &dyn Any {
900                self
901            }
902        }
903
904        let parse_result = MockParseResult {
905            deps: vec![MockDep {
906                name: "tokio".to_string(),
907                version_req: "1.0".to_string(),
908                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
909                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
910            }],
911            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
912        };
913
914        let cached_versions = HashMap::new();
915        let resolved_versions = HashMap::new();
916
917        let hints = generate_inlay_hints(
918            &parse_result,
919            &cached_versions,
920            &resolved_versions,
921            crate::LoadingState::Loading,
922            &config,
923            &formatter,
924        );
925
926        assert_eq!(hints.len(), 1);
927        match &hints[0].label {
928            InlayHintLabel::String(text) => {
929                assert_eq!(text, "⏳", "Expected loading hint");
930            }
931            _ => panic!("Expected string label"),
932        }
933
934        if let Some(InlayHintTooltip::String(tooltip)) = &hints[0].tooltip {
935            assert_eq!(tooltip, "Fetching latest version...");
936        } else {
937            panic!("Expected tooltip");
938        }
939    }
940
941    #[test]
942    fn test_loading_hint_disabled_when_config_false() {
943        use std::any::Any;
944        use std::collections::HashMap;
945        use tower_lsp_server::ls_types::{Position, Range, Uri};
946
947        let formatter = MockFormatter;
948        let config = EcosystemConfig {
949            show_up_to_date_hints: true,
950            up_to_date_text: "✅".to_string(),
951            needs_update_text: "❌ {}".to_string(),
952            loading_text: "⏳".to_string(),
953            show_loading_hints: false,
954        };
955
956        struct MockParseResult {
957            deps: Vec<MockDep>,
958            uri: Uri,
959        }
960
961        impl ParseResult for MockParseResult {
962            fn dependencies(&self) -> Vec<&dyn Dependency> {
963                self.deps.iter().map(|d| d as &dyn Dependency).collect()
964            }
965            fn workspace_root(&self) -> Option<&std::path::Path> {
966                None
967            }
968            fn uri(&self) -> &Uri {
969                &self.uri
970            }
971            fn as_any(&self) -> &dyn Any {
972                self
973            }
974        }
975
976        struct MockDep {
977            name: String,
978            version_req: String,
979            version_range: Range,
980            name_range: Range,
981        }
982
983        impl Dependency for MockDep {
984            fn name(&self) -> &str {
985                &self.name
986            }
987            fn name_range(&self) -> Range {
988                self.name_range
989            }
990            fn version_requirement(&self) -> Option<&str> {
991                Some(&self.version_req)
992            }
993            fn version_range(&self) -> Option<Range> {
994                Some(self.version_range)
995            }
996            fn source(&self) -> crate::parser::DependencySource {
997                crate::parser::DependencySource::Registry
998            }
999            fn as_any(&self) -> &dyn Any {
1000                self
1001            }
1002        }
1003
1004        let parse_result = MockParseResult {
1005            deps: vec![MockDep {
1006                name: "tokio".to_string(),
1007                version_req: "1.0".to_string(),
1008                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1009                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1010            }],
1011            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1012        };
1013
1014        let cached_versions = HashMap::new();
1015        let resolved_versions = HashMap::new();
1016
1017        let hints = generate_inlay_hints(
1018            &parse_result,
1019            &cached_versions,
1020            &resolved_versions,
1021            crate::LoadingState::Loading,
1022            &config,
1023            &formatter,
1024        );
1025
1026        assert_eq!(
1027            hints.len(),
1028            0,
1029            "Expected no hints when loading hints disabled"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_caret_version_0x_edge_cases() {
1035        let formatter = MockFormatter;
1036
1037        // ^0.2 should only allow 0.2.x
1038        assert!(formatter.version_satisfies_requirement("0.2.0", "^0.2"));
1039        assert!(formatter.version_satisfies_requirement("0.2.5", "^0.2"));
1040        assert!(formatter.version_satisfies_requirement("0.2.99", "^0.2"));
1041
1042        // ^0.2 should NOT allow 0.3.x or 0.1.x
1043        assert!(!formatter.version_satisfies_requirement("0.3.0", "^0.2"));
1044        assert!(!formatter.version_satisfies_requirement("0.1.0", "^0.2"));
1045        assert!(!formatter.version_satisfies_requirement("1.0.0", "^0.2"));
1046
1047        // ^0.0.3 should only allow 0.0.3 (left-most non-zero is patch)
1048        assert!(formatter.version_satisfies_requirement("0.0.3", "^0.0.3"));
1049        assert!(formatter.version_satisfies_requirement("0.0.3", "^0.0"));
1050
1051        // ^0 should only allow 0.x.y (major is 0)
1052        assert!(formatter.version_satisfies_requirement("0.0.0", "^0"));
1053        assert!(formatter.version_satisfies_requirement("0.5.0", "^0"));
1054        assert!(!formatter.version_satisfies_requirement("1.0.0", "^0"));
1055    }
1056
1057    #[test]
1058    fn test_caret_version_non_zero_major() {
1059        let formatter = MockFormatter;
1060
1061        // ^1.2 allows any 1.x.x
1062        assert!(formatter.version_satisfies_requirement("1.0.0", "^1.2"));
1063        assert!(formatter.version_satisfies_requirement("1.2.0", "^1.2"));
1064        assert!(formatter.version_satisfies_requirement("1.9.9", "^1.2"));
1065
1066        // ^1.2 should NOT allow 2.x.x
1067        assert!(!formatter.version_satisfies_requirement("2.0.0", "^1.2"));
1068        assert!(!formatter.version_satisfies_requirement("0.9.0", "^1.2"));
1069    }
1070
1071    #[test]
1072    fn test_loading_hint_not_shown_when_cached_version_exists() {
1073        use std::any::Any;
1074        use std::collections::HashMap;
1075        use tower_lsp_server::ls_types::{Position, Range, Uri};
1076
1077        let formatter = MockFormatter;
1078        let config = EcosystemConfig {
1079            show_up_to_date_hints: true,
1080            up_to_date_text: "✅".to_string(),
1081            needs_update_text: "❌ {}".to_string(),
1082            loading_text: "⏳".to_string(),
1083            show_loading_hints: true,
1084        };
1085
1086        struct MockParseResult {
1087            deps: Vec<MockDep>,
1088            uri: Uri,
1089        }
1090
1091        impl ParseResult for MockParseResult {
1092            fn dependencies(&self) -> Vec<&dyn Dependency> {
1093                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1094            }
1095            fn workspace_root(&self) -> Option<&std::path::Path> {
1096                None
1097            }
1098            fn uri(&self) -> &Uri {
1099                &self.uri
1100            }
1101            fn as_any(&self) -> &dyn Any {
1102                self
1103            }
1104        }
1105
1106        struct MockDep {
1107            name: String,
1108            version_req: String,
1109            version_range: Range,
1110            name_range: Range,
1111        }
1112
1113        impl Dependency for MockDep {
1114            fn name(&self) -> &str {
1115                &self.name
1116            }
1117            fn name_range(&self) -> Range {
1118                self.name_range
1119            }
1120            fn version_requirement(&self) -> Option<&str> {
1121                Some(&self.version_req)
1122            }
1123            fn version_range(&self) -> Option<Range> {
1124                Some(self.version_range)
1125            }
1126            fn source(&self) -> crate::parser::DependencySource {
1127                crate::parser::DependencySource::Registry
1128            }
1129            fn as_any(&self) -> &dyn Any {
1130                self
1131            }
1132        }
1133
1134        let parse_result = MockParseResult {
1135            deps: vec![MockDep {
1136                name: "serde".to_string(),
1137                version_req: "1.0".to_string(),
1138                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1139                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1140            }],
1141            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1142        };
1143
1144        let mut cached_versions = HashMap::new();
1145        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
1146
1147        // Lock file has the latest version
1148        let mut resolved_versions = HashMap::new();
1149        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
1150
1151        let hints = generate_inlay_hints(
1152            &parse_result,
1153            &cached_versions,
1154            &resolved_versions,
1155            crate::LoadingState::Loading,
1156            &config,
1157            &formatter,
1158        );
1159
1160        assert_eq!(hints.len(), 1);
1161        match &hints[0].label {
1162            InlayHintLabel::String(text) => {
1163                assert_eq!(
1164                    text, "✅ 1.0.214",
1165                    "Expected up-to-date hint, not loading hint, got: {}",
1166                    text
1167                );
1168            }
1169            _ => panic!("Expected string label"),
1170        }
1171    }
1172
1173    #[test]
1174    fn test_generate_diagnostics_from_cache_unknown_package() {
1175        use std::any::Any;
1176        use std::collections::HashMap;
1177        use tower_lsp_server::ls_types::{Position, Range, Uri};
1178
1179        let formatter = MockFormatter;
1180
1181        struct MockParseResult {
1182            deps: Vec<MockDep>,
1183            uri: Uri,
1184        }
1185
1186        impl ParseResult for MockParseResult {
1187            fn dependencies(&self) -> Vec<&dyn Dependency> {
1188                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1189            }
1190            fn workspace_root(&self) -> Option<&std::path::Path> {
1191                None
1192            }
1193            fn uri(&self) -> &Uri {
1194                &self.uri
1195            }
1196            fn as_any(&self) -> &dyn Any {
1197                self
1198            }
1199        }
1200
1201        struct MockDep {
1202            name: String,
1203            version_req: String,
1204            version_range: Range,
1205            name_range: Range,
1206        }
1207
1208        impl Dependency for MockDep {
1209            fn name(&self) -> &str {
1210                &self.name
1211            }
1212            fn name_range(&self) -> Range {
1213                self.name_range
1214            }
1215            fn version_requirement(&self) -> Option<&str> {
1216                Some(&self.version_req)
1217            }
1218            fn version_range(&self) -> Option<Range> {
1219                Some(self.version_range)
1220            }
1221            fn source(&self) -> crate::parser::DependencySource {
1222                crate::parser::DependencySource::Registry
1223            }
1224            fn as_any(&self) -> &dyn Any {
1225                self
1226            }
1227        }
1228
1229        let parse_result = MockParseResult {
1230            deps: vec![MockDep {
1231                name: "unknown-pkg".to_string(),
1232                version_req: "1.0.0".to_string(),
1233                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1234                name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1235            }],
1236            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1237        };
1238
1239        let cached_versions = HashMap::new();
1240        let resolved_versions = HashMap::new();
1241
1242        let diagnostics = generate_diagnostics_from_cache(
1243            &parse_result,
1244            &cached_versions,
1245            &resolved_versions,
1246            &formatter,
1247        );
1248
1249        assert_eq!(diagnostics.len(), 1);
1250        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
1251        assert!(diagnostics[0].message.contains("Unknown package"));
1252        assert!(diagnostics[0].message.contains("unknown-pkg"));
1253    }
1254
1255    #[test]
1256    fn test_generate_diagnostics_from_cache_outdated_version() {
1257        use std::any::Any;
1258        use std::collections::HashMap;
1259        use tower_lsp_server::ls_types::{Position, Range, Uri};
1260
1261        let formatter = MockFormatter;
1262
1263        struct MockParseResult {
1264            deps: Vec<MockDep>,
1265            uri: Uri,
1266        }
1267
1268        impl ParseResult for MockParseResult {
1269            fn dependencies(&self) -> Vec<&dyn Dependency> {
1270                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1271            }
1272            fn workspace_root(&self) -> Option<&std::path::Path> {
1273                None
1274            }
1275            fn uri(&self) -> &Uri {
1276                &self.uri
1277            }
1278            fn as_any(&self) -> &dyn Any {
1279                self
1280            }
1281        }
1282
1283        struct MockDep {
1284            name: String,
1285            version_req: String,
1286            version_range: Range,
1287            name_range: Range,
1288        }
1289
1290        impl Dependency for MockDep {
1291            fn name(&self) -> &str {
1292                &self.name
1293            }
1294            fn name_range(&self) -> Range {
1295                self.name_range
1296            }
1297            fn version_requirement(&self) -> Option<&str> {
1298                Some(&self.version_req)
1299            }
1300            fn version_range(&self) -> Option<Range> {
1301                Some(self.version_range)
1302            }
1303            fn source(&self) -> crate::parser::DependencySource {
1304                crate::parser::DependencySource::Registry
1305            }
1306            fn as_any(&self) -> &dyn Any {
1307                self
1308            }
1309        }
1310
1311        let parse_result = MockParseResult {
1312            deps: vec![MockDep {
1313                name: "serde".to_string(),
1314                version_req: "1.0".to_string(),
1315                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1316                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1317            }],
1318            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1319        };
1320
1321        let mut cached_versions = HashMap::new();
1322        cached_versions.insert("serde".to_string(), "2.0.0".to_string());
1323
1324        let resolved_versions = HashMap::new();
1325
1326        let diagnostics = generate_diagnostics_from_cache(
1327            &parse_result,
1328            &cached_versions,
1329            &resolved_versions,
1330            &formatter,
1331        );
1332
1333        assert_eq!(diagnostics.len(), 1);
1334        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
1335        assert!(diagnostics[0].message.contains("Newer version available"));
1336        assert!(diagnostics[0].message.contains("2.0.0"));
1337    }
1338
1339    #[test]
1340    fn test_generate_diagnostics_from_cache_up_to_date() {
1341        use std::any::Any;
1342        use std::collections::HashMap;
1343        use tower_lsp_server::ls_types::{Position, Range, Uri};
1344
1345        let formatter = MockFormatter;
1346
1347        struct MockParseResult {
1348            deps: Vec<MockDep>,
1349            uri: Uri,
1350        }
1351
1352        impl ParseResult for MockParseResult {
1353            fn dependencies(&self) -> Vec<&dyn Dependency> {
1354                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1355            }
1356            fn workspace_root(&self) -> Option<&std::path::Path> {
1357                None
1358            }
1359            fn uri(&self) -> &Uri {
1360                &self.uri
1361            }
1362            fn as_any(&self) -> &dyn Any {
1363                self
1364            }
1365        }
1366
1367        struct MockDep {
1368            name: String,
1369            version_req: String,
1370            version_range: Range,
1371            name_range: Range,
1372        }
1373
1374        impl Dependency for MockDep {
1375            fn name(&self) -> &str {
1376                &self.name
1377            }
1378            fn name_range(&self) -> Range {
1379                self.name_range
1380            }
1381            fn version_requirement(&self) -> Option<&str> {
1382                Some(&self.version_req)
1383            }
1384            fn version_range(&self) -> Option<Range> {
1385                Some(self.version_range)
1386            }
1387            fn source(&self) -> crate::parser::DependencySource {
1388                crate::parser::DependencySource::Registry
1389            }
1390            fn as_any(&self) -> &dyn Any {
1391                self
1392            }
1393        }
1394
1395        let parse_result = MockParseResult {
1396            deps: vec![MockDep {
1397                name: "serde".to_string(),
1398                version_req: "^1.0".to_string(),
1399                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1400                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1401            }],
1402            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1403        };
1404
1405        let mut cached_versions = HashMap::new();
1406        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
1407
1408        let resolved_versions = HashMap::new();
1409
1410        let diagnostics = generate_diagnostics_from_cache(
1411            &parse_result,
1412            &cached_versions,
1413            &resolved_versions,
1414            &formatter,
1415        );
1416
1417        assert!(
1418            diagnostics.is_empty(),
1419            "Expected no diagnostics for up-to-date dependency"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_generate_diagnostics_from_cache_multiple_deps() {
1425        use std::any::Any;
1426        use std::collections::HashMap;
1427        use tower_lsp_server::ls_types::{Position, Range, Uri};
1428
1429        let formatter = MockFormatter;
1430
1431        struct MockParseResult {
1432            deps: Vec<MockDep>,
1433            uri: Uri,
1434        }
1435
1436        impl ParseResult for MockParseResult {
1437            fn dependencies(&self) -> Vec<&dyn Dependency> {
1438                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1439            }
1440            fn workspace_root(&self) -> Option<&std::path::Path> {
1441                None
1442            }
1443            fn uri(&self) -> &Uri {
1444                &self.uri
1445            }
1446            fn as_any(&self) -> &dyn Any {
1447                self
1448            }
1449        }
1450
1451        struct MockDep {
1452            name: String,
1453            version_req: String,
1454            version_range: Range,
1455            name_range: Range,
1456        }
1457
1458        impl Dependency for MockDep {
1459            fn name(&self) -> &str {
1460                &self.name
1461            }
1462            fn name_range(&self) -> Range {
1463                self.name_range
1464            }
1465            fn version_requirement(&self) -> Option<&str> {
1466                Some(&self.version_req)
1467            }
1468            fn version_range(&self) -> Option<Range> {
1469                Some(self.version_range)
1470            }
1471            fn source(&self) -> crate::parser::DependencySource {
1472                crate::parser::DependencySource::Registry
1473            }
1474            fn as_any(&self) -> &dyn Any {
1475                self
1476            }
1477        }
1478
1479        let parse_result = MockParseResult {
1480            deps: vec![
1481                MockDep {
1482                    name: "serde".to_string(),
1483                    version_req: "^1.0".to_string(),
1484                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1485                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1486                },
1487                MockDep {
1488                    name: "tokio".to_string(),
1489                    version_req: "1.0".to_string(),
1490                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1491                    name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
1492                },
1493                MockDep {
1494                    name: "unknown".to_string(),
1495                    version_req: "1.0".to_string(),
1496                    version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
1497                    name_range: Range::new(Position::new(2, 0), Position::new(2, 7)),
1498                },
1499            ],
1500            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1501        };
1502
1503        let mut cached_versions = HashMap::new();
1504        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
1505        cached_versions.insert("tokio".to_string(), "2.0.0".to_string());
1506
1507        let resolved_versions = HashMap::new();
1508
1509        let diagnostics = generate_diagnostics_from_cache(
1510            &parse_result,
1511            &cached_versions,
1512            &resolved_versions,
1513            &formatter,
1514        );
1515
1516        assert_eq!(diagnostics.len(), 2);
1517
1518        let has_outdated = diagnostics
1519            .iter()
1520            .any(|d| d.message.contains("Newer version"));
1521        let has_unknown = diagnostics
1522            .iter()
1523            .any(|d| d.message.contains("Unknown package"));
1524
1525        assert!(has_outdated, "Expected outdated version diagnostic");
1526        assert!(has_unknown, "Expected unknown package diagnostic");
1527    }
1528
1529    #[test]
1530    fn test_inlay_hint_not_in_lockfile_but_satisfies_requirement() {
1531        use std::any::Any;
1532        use std::collections::HashMap;
1533        use tower_lsp_server::ls_types::{Position, Range, Uri};
1534
1535        let formatter = MockFormatter;
1536        let config = EcosystemConfig {
1537            show_up_to_date_hints: true,
1538            up_to_date_text: "✅".to_string(),
1539            needs_update_text: "❌ {}".to_string(),
1540            loading_text: "⏳".to_string(),
1541            show_loading_hints: true,
1542        };
1543
1544        struct MockParseResult {
1545            deps: Vec<MockDep>,
1546            uri: Uri,
1547        }
1548
1549        impl ParseResult for MockParseResult {
1550            fn dependencies(&self) -> Vec<&dyn Dependency> {
1551                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1552            }
1553            fn workspace_root(&self) -> Option<&std::path::Path> {
1554                None
1555            }
1556            fn uri(&self) -> &Uri {
1557                &self.uri
1558            }
1559            fn as_any(&self) -> &dyn Any {
1560                self
1561            }
1562        }
1563
1564        struct MockDep {
1565            name: String,
1566            version_req: String,
1567            version_range: Range,
1568            name_range: Range,
1569        }
1570
1571        impl Dependency for MockDep {
1572            fn name(&self) -> &str {
1573                &self.name
1574            }
1575            fn name_range(&self) -> Range {
1576                self.name_range
1577            }
1578            fn version_requirement(&self) -> Option<&str> {
1579                Some(&self.version_req)
1580            }
1581            fn version_range(&self) -> Option<Range> {
1582                Some(self.version_range)
1583            }
1584            fn source(&self) -> crate::parser::DependencySource {
1585                crate::parser::DependencySource::Registry
1586            }
1587            fn as_any(&self) -> &dyn Any {
1588                self
1589            }
1590        }
1591
1592        let parse_result = MockParseResult {
1593            deps: vec![MockDep {
1594                name: "criterion".to_string(),
1595                version_req: "0.5".to_string(),
1596                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1597                name_range: Range::new(Position::new(0, 0), Position::new(0, 9)),
1598            }],
1599            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1600        };
1601
1602        let mut cached_versions = HashMap::new();
1603        cached_versions.insert("criterion".to_string(), "0.5.1".to_string());
1604
1605        // Not in lock file (empty resolved_versions)
1606        let resolved_versions = HashMap::new();
1607
1608        let hints = generate_inlay_hints(
1609            &parse_result,
1610            &cached_versions,
1611            &resolved_versions,
1612            crate::LoadingState::Loaded,
1613            &config,
1614            &formatter,
1615        );
1616
1617        assert_eq!(hints.len(), 1);
1618        match &hints[0].label {
1619            InlayHintLabel::String(text) => {
1620                assert!(
1621                    text.starts_with("✅"),
1622                    "Expected up-to-date hint for satisfied requirement, got: {}",
1623                    text
1624                );
1625            }
1626            _ => panic!("Expected string label"),
1627        }
1628    }
1629
1630    #[test]
1631    fn test_inlay_hint_not_in_lockfile_and_outdated() {
1632        use std::any::Any;
1633        use std::collections::HashMap;
1634        use tower_lsp_server::ls_types::{Position, Range, Uri};
1635
1636        let formatter = MockFormatter;
1637        let config = EcosystemConfig {
1638            show_up_to_date_hints: true,
1639            up_to_date_text: "✅".to_string(),
1640            needs_update_text: "❌ {}".to_string(),
1641            loading_text: "⏳".to_string(),
1642            show_loading_hints: true,
1643        };
1644
1645        struct MockParseResult {
1646            deps: Vec<MockDep>,
1647            uri: Uri,
1648        }
1649
1650        impl ParseResult for MockParseResult {
1651            fn dependencies(&self) -> Vec<&dyn Dependency> {
1652                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1653            }
1654            fn workspace_root(&self) -> Option<&std::path::Path> {
1655                None
1656            }
1657            fn uri(&self) -> &Uri {
1658                &self.uri
1659            }
1660            fn as_any(&self) -> &dyn Any {
1661                self
1662            }
1663        }
1664
1665        struct MockDep {
1666            name: String,
1667            version_req: String,
1668            version_range: Range,
1669            name_range: Range,
1670        }
1671
1672        impl Dependency for MockDep {
1673            fn name(&self) -> &str {
1674                &self.name
1675            }
1676            fn name_range(&self) -> Range {
1677                self.name_range
1678            }
1679            fn version_requirement(&self) -> Option<&str> {
1680                Some(&self.version_req)
1681            }
1682            fn version_range(&self) -> Option<Range> {
1683                Some(self.version_range)
1684            }
1685            fn source(&self) -> crate::parser::DependencySource {
1686                crate::parser::DependencySource::Registry
1687            }
1688            fn as_any(&self) -> &dyn Any {
1689                self
1690            }
1691        }
1692
1693        let parse_result = MockParseResult {
1694            deps: vec![MockDep {
1695                name: "criterion".to_string(),
1696                version_req: "0.4".to_string(),
1697                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1698                name_range: Range::new(Position::new(0, 0), Position::new(0, 9)),
1699            }],
1700            uri: Uri::from_file_path("/test/Cargo.toml").unwrap(),
1701        };
1702
1703        let mut cached_versions = HashMap::new();
1704        cached_versions.insert("criterion".to_string(), "0.5.1".to_string());
1705
1706        // Not in lock file (empty resolved_versions)
1707        let resolved_versions = HashMap::new();
1708
1709        let hints = generate_inlay_hints(
1710            &parse_result,
1711            &cached_versions,
1712            &resolved_versions,
1713            crate::LoadingState::Loaded,
1714            &config,
1715            &formatter,
1716        );
1717
1718        assert_eq!(hints.len(), 1);
1719        match &hints[0].label {
1720            InlayHintLabel::String(text) => {
1721                assert!(
1722                    text.starts_with("❌"),
1723                    "Expected needs-update hint for unsatisfied requirement, got: {}",
1724                    text
1725                );
1726                assert!(text.contains("0.5.1"), "Expected latest version in hint");
1727            }
1728            _ => panic!("Expected string label"),
1729        }
1730    }
1731}