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