1use crate::error::Result;
10use dashmap::DashMap;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::time::{Instant, SystemTime};
14use tower_lsp_server::ls_types::Uri;
15
16const MAX_WORKSPACE_DEPTH: usize = 5;
18
19pub fn locate_lockfile_for_manifest(
50 manifest_uri: &Uri,
51 lockfile_names: &[&str],
52) -> Option<PathBuf> {
53 let manifest_path = manifest_uri.to_file_path()?;
54 let manifest_dir = manifest_path.parent()?;
55
56 let mut lock_path = manifest_dir.to_path_buf();
58
59 for &name in lockfile_names {
61 lock_path.push(name);
62 if lock_path.exists() {
63 tracing::debug!("Found {} at: {}", name, lock_path.display());
64 return Some(lock_path);
65 }
66 lock_path.pop();
67 }
68
69 let Some(mut current_dir) = manifest_dir.parent() else {
71 tracing::debug!("No lock file found for: {:?}", manifest_uri);
72 return None;
73 };
74
75 for depth in 0..MAX_WORKSPACE_DEPTH {
76 lock_path.clear();
77 lock_path.push(current_dir);
78
79 for &name in lockfile_names {
80 lock_path.push(name);
81 if lock_path.exists() {
82 tracing::debug!(
83 "Found workspace {} at depth {}: {}",
84 name,
85 depth + 1,
86 lock_path.display()
87 );
88 return Some(lock_path);
89 }
90 lock_path.pop();
91 }
92
93 match current_dir.parent() {
94 Some(parent) => current_dir = parent,
95 None => break,
96 }
97 }
98
99 tracing::debug!("No lock file found for: {:?}", manifest_uri);
100 None
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ResolvedPackage {
109 pub name: String,
111 pub version: String,
113 pub source: ResolvedSource,
115 pub dependencies: Vec<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ResolvedSource {
124 Registry {
126 url: String,
128 checksum: String,
130 },
131 Git {
133 url: String,
135 rev: String,
137 },
138 Path {
140 path: String,
142 },
143}
144
145#[derive(Debug, Default, Clone)]
170pub struct ResolvedPackages {
171 packages: HashMap<String, Vec<ResolvedPackage>>,
172}
173
174fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> {
176 packages.iter().max_by(|a, b| {
177 match (
178 semver::Version::parse(&a.version),
179 semver::Version::parse(&b.version),
180 ) {
181 (Ok(va), Ok(vb)) => va.cmp(&vb),
182 (Ok(_), Err(_)) => std::cmp::Ordering::Greater,
183 (Err(_), Ok(_)) => std::cmp::Ordering::Less,
184 (Err(_), Err(_)) => a.version.cmp(&b.version),
185 }
186 })
187}
188
189impl ResolvedPackages {
190 pub fn new() -> Self {
192 Self {
193 packages: HashMap::new(),
194 }
195 }
196
197 pub fn insert(&mut self, package: ResolvedPackage) {
199 self.packages
200 .entry(package.name.clone())
201 .or_default()
202 .push(package);
203 }
204
205 pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
207 self.packages.get(name).and_then(|v| best_package(v))
208 }
209
210 pub fn get_version(&self, name: &str) -> Option<&str> {
212 self.get(name).map(|p| p.version.as_str())
213 }
214
215 pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
217 self.packages.get(name).map(|v| v.as_slice())
218 }
219
220 pub fn len(&self) -> usize {
222 self.packages.len()
223 }
224
225 pub fn is_empty(&self) -> bool {
227 self.packages.is_empty()
228 }
229
230 pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
232 self.packages.keys().filter_map(|name| {
233 self.packages
234 .get(name)
235 .and_then(|v| best_package(v).map(|p| (name, p)))
236 })
237 }
238
239 pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
241 self.packages
242 .into_iter()
243 .filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p)))
244 .collect()
245 }
246}
247
248pub trait LockFileProvider: Send + Sync {
278 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
293
294 fn parse_lockfile<'a>(
311 &'a self,
312 lockfile_path: &'a Path,
313 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>;
314
315 fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool {
329 if let Ok(metadata) = std::fs::metadata(lockfile_path)
330 && let Ok(mtime) = metadata.modified()
331 {
332 return mtime > last_modified;
333 }
334 true
335 }
336}
337
338struct CachedLockFile {
340 packages: ResolvedPackages,
341 modified_at: SystemTime,
342 #[allow(dead_code)]
343 parsed_at: Instant,
344}
345
346pub struct LockFileCache {
365 entries: DashMap<PathBuf, CachedLockFile>,
366}
367
368impl LockFileCache {
369 pub fn new() -> Self {
371 Self {
372 entries: DashMap::new(),
373 }
374 }
375
376 pub async fn get_or_parse(
395 &self,
396 provider: &dyn LockFileProvider,
397 lockfile_path: &Path,
398 ) -> Result<ResolvedPackages> {
399 if let Some(cached) = self.entries.get(lockfile_path)
401 && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
402 && let Ok(mtime) = metadata.modified()
403 && mtime <= cached.modified_at
404 {
405 tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
406 return Ok(cached.packages.clone());
407 }
408
409 tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
411 let packages = provider.parse_lockfile(lockfile_path).await?;
412
413 let metadata = tokio::fs::metadata(lockfile_path).await?;
414 let modified_at = metadata.modified()?;
415
416 self.entries.insert(
417 lockfile_path.to_path_buf(),
418 CachedLockFile {
419 packages: packages.clone(),
420 modified_at,
421 parsed_at: Instant::now(),
422 },
423 );
424
425 Ok(packages)
426 }
427
428 pub fn invalidate(&self, lockfile_path: &Path) {
433 self.entries.remove(lockfile_path);
434 }
435
436 pub fn len(&self) -> usize {
438 self.entries.len()
439 }
440
441 pub fn is_empty(&self) -> bool {
443 self.entries.is_empty()
444 }
445}
446
447impl Default for LockFileCache {
448 fn default() -> Self {
449 Self::new()
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn test_resolved_packages_new() {
459 let packages = ResolvedPackages::new();
460 assert!(packages.is_empty());
461 assert_eq!(packages.len(), 0);
462 }
463
464 #[test]
465 fn test_resolved_packages_insert_and_get() {
466 let mut packages = ResolvedPackages::new();
467
468 let pkg = ResolvedPackage {
469 name: "serde".into(),
470 version: "1.0.195".into(),
471 source: ResolvedSource::Registry {
472 url: "https://github.com/rust-lang/crates.io-index".into(),
473 checksum: "abc123".into(),
474 },
475 dependencies: vec!["serde_derive".into()],
476 };
477
478 packages.insert(pkg);
479
480 assert_eq!(packages.len(), 1);
481 assert!(!packages.is_empty());
482 assert_eq!(packages.get_version("serde"), Some("1.0.195"));
483
484 let retrieved = packages.get("serde");
485 assert!(retrieved.is_some());
486 assert_eq!(retrieved.unwrap().name, "serde");
487 assert_eq!(retrieved.unwrap().dependencies.len(), 1);
488 }
489
490 #[test]
491 fn test_resolved_packages_get_nonexistent() {
492 let packages = ResolvedPackages::new();
493 assert_eq!(packages.get("nonexistent"), None);
494 assert_eq!(packages.get_version("nonexistent"), None);
495 }
496
497 #[test]
498 fn test_resolved_packages_replace() {
499 let mut packages = ResolvedPackages::new();
500
501 packages.insert(ResolvedPackage {
502 name: "serde".into(),
503 version: "1.0.0".into(),
504 source: ResolvedSource::Registry {
505 url: "test".into(),
506 checksum: "old".into(),
507 },
508 dependencies: vec![],
509 });
510
511 packages.insert(ResolvedPackage {
512 name: "serde".into(),
513 version: "1.0.195".into(),
514 source: ResolvedSource::Registry {
515 url: "test".into(),
516 checksum: "new".into(),
517 },
518 dependencies: vec![],
519 });
520
521 assert_eq!(packages.len(), 1);
523 assert_eq!(packages.get_version("serde"), Some("1.0.195"));
524 assert_eq!(packages.get_all("serde").unwrap().len(), 2);
526 }
527
528 #[test]
529 fn test_resolved_packages_multiple_versions() {
530 let mut packages = ResolvedPackages::new();
531
532 packages.insert(ResolvedPackage {
533 name: "serde".into(),
534 version: "1.0.195".into(),
535 source: ResolvedSource::Registry {
536 url: "test".into(),
537 checksum: "a".into(),
538 },
539 dependencies: vec![],
540 });
541
542 packages.insert(ResolvedPackage {
543 name: "serde".into(),
544 version: "0.9.0".into(),
545 source: ResolvedSource::Registry {
546 url: "test".into(),
547 checksum: "b".into(),
548 },
549 dependencies: vec![],
550 });
551
552 packages.insert(ResolvedPackage {
553 name: "serde".into(),
554 version: "2.0.0-beta.1".into(),
555 source: ResolvedSource::Registry {
556 url: "test".into(),
557 checksum: "c".into(),
558 },
559 dependencies: vec![],
560 });
561
562 assert_eq!(packages.len(), 1);
563 assert_eq!(packages.get_version("serde"), Some("2.0.0-beta.1"));
564 assert_eq!(packages.get_all("serde").unwrap().len(), 3);
565 }
566
567 #[test]
568 fn test_resolved_packages_non_semver_fallback() {
569 let mut packages = ResolvedPackages::new();
570
571 packages.insert(ResolvedPackage {
572 name: "weird".into(),
573 version: "abc".into(),
574 source: ResolvedSource::Path { path: ".".into() },
575 dependencies: vec![],
576 });
577
578 packages.insert(ResolvedPackage {
579 name: "weird".into(),
580 version: "xyz".into(),
581 source: ResolvedSource::Path { path: ".".into() },
582 dependencies: vec![],
583 });
584
585 assert_eq!(packages.get_version("weird"), Some("xyz"));
587 }
588
589 #[test]
590 fn test_resolved_packages_semver_preferred_over_non_semver() {
591 let mut packages = ResolvedPackages::new();
592
593 packages.insert(ResolvedPackage {
594 name: "mixed".into(),
595 version: "not-a-version".into(),
596 source: ResolvedSource::Path { path: ".".into() },
597 dependencies: vec![],
598 });
599
600 packages.insert(ResolvedPackage {
601 name: "mixed".into(),
602 version: "1.0.0".into(),
603 source: ResolvedSource::Path { path: ".".into() },
604 dependencies: vec![],
605 });
606
607 assert_eq!(packages.get_version("mixed"), Some("1.0.0"));
609 }
610
611 #[test]
612 fn test_resolved_source_equality() {
613 let source1 = ResolvedSource::Registry {
614 url: "https://test.com".into(),
615 checksum: "abc".into(),
616 };
617 let source2 = ResolvedSource::Registry {
618 url: "https://test.com".into(),
619 checksum: "abc".into(),
620 };
621 let source3 = ResolvedSource::Git {
622 url: "https://github.com/test".into(),
623 rev: "abc123".into(),
624 };
625
626 assert_eq!(source1, source2);
627 assert_ne!(source1, source3);
628 }
629
630 #[test]
631 fn test_resolved_packages_iter() {
632 let mut packages = ResolvedPackages::new();
633
634 packages.insert(ResolvedPackage {
635 name: "serde".into(),
636 version: "1.0.0".into(),
637 source: ResolvedSource::Registry {
638 url: "test".into(),
639 checksum: "a".into(),
640 },
641 dependencies: vec![],
642 });
643
644 packages.insert(ResolvedPackage {
645 name: "tokio".into(),
646 version: "1.0.0".into(),
647 source: ResolvedSource::Registry {
648 url: "test".into(),
649 checksum: "b".into(),
650 },
651 dependencies: vec![],
652 });
653
654 let count = packages.iter().count();
655 assert_eq!(count, 2);
656
657 let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
658 assert!(names.contains(&"serde"));
659 assert!(names.contains(&"tokio"));
660 }
661
662 #[test]
663 fn test_resolved_packages_into_map() {
664 let mut packages = ResolvedPackages::new();
665
666 packages.insert(ResolvedPackage {
667 name: "serde".into(),
668 version: "1.0.0".into(),
669 source: ResolvedSource::Registry {
670 url: "test".into(),
671 checksum: "a".into(),
672 },
673 dependencies: vec![],
674 });
675
676 let map = packages.into_map();
677 assert_eq!(map.len(), 1);
678 assert!(map.contains_key("serde"));
679 }
680
681 #[test]
682 fn test_lockfile_cache_new() {
683 let cache = LockFileCache::new();
684 assert!(cache.is_empty());
685 assert_eq!(cache.len(), 0);
686 }
687
688 #[test]
689 fn test_lockfile_cache_invalidate() {
690 let cache = LockFileCache::new();
691 let test_path = PathBuf::from("/test/Cargo.lock");
692
693 cache.entries.insert(
694 test_path.clone(),
695 CachedLockFile {
696 packages: ResolvedPackages::new(),
697 modified_at: SystemTime::now(),
698 parsed_at: Instant::now(),
699 },
700 );
701
702 assert_eq!(cache.len(), 1);
703
704 cache.invalidate(&test_path);
705 assert_eq!(cache.len(), 0);
706 assert!(cache.is_empty());
707 }
708
709 #[test]
710 fn test_locate_lockfile_for_manifest_same_directory() {
711 let temp_dir = tempfile::tempdir().unwrap();
712 let manifest_path = temp_dir.path().join("Cargo.toml");
713 let lock_path = temp_dir.path().join("Cargo.lock");
714
715 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
716 std::fs::write(&lock_path, "version = 4").unwrap();
717
718 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
719 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
720
721 assert!(located.is_some());
722 assert_eq!(located.unwrap(), lock_path);
723 }
724
725 #[test]
726 fn test_locate_lockfile_for_manifest_workspace_root() {
727 let temp_dir = tempfile::tempdir().unwrap();
728 let workspace_lock = temp_dir.path().join("Cargo.lock");
729 let member_dir = temp_dir.path().join("crates").join("member");
730 std::fs::create_dir_all(&member_dir).unwrap();
731 let member_manifest = member_dir.join("Cargo.toml");
732
733 std::fs::write(&workspace_lock, "version = 4").unwrap();
734 std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
735
736 let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
737 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
738
739 assert!(located.is_some());
740 assert_eq!(located.unwrap(), workspace_lock);
741 }
742
743 #[test]
744 fn test_locate_lockfile_for_manifest_not_found() {
745 let temp_dir = tempfile::tempdir().unwrap();
746 let manifest_path = temp_dir.path().join("Cargo.toml");
747 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
748
749 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
750 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
751
752 assert!(located.is_none());
753 }
754
755 #[test]
756 fn test_locate_lockfile_for_manifest_multiple_names() {
757 let temp_dir = tempfile::tempdir().unwrap();
758 let manifest_path = temp_dir.path().join("pyproject.toml");
759 let uv_lock = temp_dir.path().join("uv.lock");
760
761 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
762 std::fs::write(&uv_lock, "version = 1").unwrap();
763
764 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
765 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
767
768 assert!(located.is_some());
769 assert_eq!(located.unwrap(), uv_lock);
770 }
771
772 #[test]
773 fn test_locate_lockfile_for_manifest_first_match_wins() {
774 let temp_dir = tempfile::tempdir().unwrap();
775 let manifest_path = temp_dir.path().join("pyproject.toml");
776 let poetry_lock = temp_dir.path().join("poetry.lock");
777 let uv_lock = temp_dir.path().join("uv.lock");
778
779 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
780 std::fs::write(&poetry_lock, "# poetry lock").unwrap();
781 std::fs::write(&uv_lock, "version = 1").unwrap();
782
783 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
784 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
786
787 assert!(located.is_some());
788 assert_eq!(located.unwrap(), poetry_lock);
789 }
790}