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)]
170pub struct ResolvedPackages {
171 packages: HashMap<String, ResolvedPackage>,
173}
174
175impl ResolvedPackages {
176 pub fn new() -> Self {
178 Self {
179 packages: HashMap::new(),
180 }
181 }
182
183 pub fn insert(&mut self, package: ResolvedPackage) {
187 self.packages.insert(package.name.clone(), package);
188 }
189
190 pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
194 self.packages.get(name)
195 }
196
197 pub fn get_version(&self, name: &str) -> Option<&str> {
203 self.packages.get(name).map(|p| p.version.as_str())
204 }
205
206 pub fn len(&self) -> usize {
208 self.packages.len()
209 }
210
211 pub fn is_empty(&self) -> bool {
213 self.packages.is_empty()
214 }
215
216 pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
218 self.packages.iter()
219 }
220
221 pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
223 self.packages
224 }
225}
226
227#[async_trait]
257pub trait LockFileProvider: Send + Sync {
258 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
273
274 async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages>;
291
292 fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool {
306 if let Ok(metadata) = std::fs::metadata(lockfile_path)
307 && let Ok(mtime) = metadata.modified()
308 {
309 return mtime > last_modified;
310 }
311 true
312 }
313}
314
315struct CachedLockFile {
317 packages: ResolvedPackages,
318 modified_at: SystemTime,
319 #[allow(dead_code)]
320 parsed_at: Instant,
321}
322
323pub struct LockFileCache {
342 entries: DashMap<PathBuf, CachedLockFile>,
343}
344
345impl LockFileCache {
346 pub fn new() -> Self {
348 Self {
349 entries: DashMap::new(),
350 }
351 }
352
353 pub async fn get_or_parse(
372 &self,
373 provider: &dyn LockFileProvider,
374 lockfile_path: &Path,
375 ) -> Result<ResolvedPackages> {
376 if let Some(cached) = self.entries.get(lockfile_path)
378 && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
379 && let Ok(mtime) = metadata.modified()
380 && mtime <= cached.modified_at
381 {
382 tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
383 return Ok(cached.packages.clone());
384 }
385
386 tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
388 let packages = provider.parse_lockfile(lockfile_path).await?;
389
390 let metadata = tokio::fs::metadata(lockfile_path).await?;
391 let modified_at = metadata.modified()?;
392
393 self.entries.insert(
394 lockfile_path.to_path_buf(),
395 CachedLockFile {
396 packages: packages.clone(),
397 modified_at,
398 parsed_at: Instant::now(),
399 },
400 );
401
402 Ok(packages)
403 }
404
405 pub fn invalidate(&self, lockfile_path: &Path) {
410 self.entries.remove(lockfile_path);
411 }
412
413 pub fn len(&self) -> usize {
415 self.entries.len()
416 }
417
418 pub fn is_empty(&self) -> bool {
420 self.entries.is_empty()
421 }
422}
423
424impl Default for LockFileCache {
425 fn default() -> Self {
426 Self::new()
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn test_resolved_packages_new() {
436 let packages = ResolvedPackages::new();
437 assert!(packages.is_empty());
438 assert_eq!(packages.len(), 0);
439 }
440
441 #[test]
442 fn test_resolved_packages_insert_and_get() {
443 let mut packages = ResolvedPackages::new();
444
445 let pkg = ResolvedPackage {
446 name: "serde".into(),
447 version: "1.0.195".into(),
448 source: ResolvedSource::Registry {
449 url: "https://github.com/rust-lang/crates.io-index".into(),
450 checksum: "abc123".into(),
451 },
452 dependencies: vec!["serde_derive".into()],
453 };
454
455 packages.insert(pkg);
456
457 assert_eq!(packages.len(), 1);
458 assert!(!packages.is_empty());
459 assert_eq!(packages.get_version("serde"), Some("1.0.195"));
460
461 let retrieved = packages.get("serde");
462 assert!(retrieved.is_some());
463 assert_eq!(retrieved.unwrap().name, "serde");
464 assert_eq!(retrieved.unwrap().dependencies.len(), 1);
465 }
466
467 #[test]
468 fn test_resolved_packages_get_nonexistent() {
469 let packages = ResolvedPackages::new();
470 assert_eq!(packages.get("nonexistent"), None);
471 assert_eq!(packages.get_version("nonexistent"), None);
472 }
473
474 #[test]
475 fn test_resolved_packages_replace() {
476 let mut packages = ResolvedPackages::new();
477
478 packages.insert(ResolvedPackage {
479 name: "serde".into(),
480 version: "1.0.0".into(),
481 source: ResolvedSource::Registry {
482 url: "test".into(),
483 checksum: "old".into(),
484 },
485 dependencies: vec![],
486 });
487
488 packages.insert(ResolvedPackage {
489 name: "serde".into(),
490 version: "1.0.195".into(),
491 source: ResolvedSource::Registry {
492 url: "test".into(),
493 checksum: "new".into(),
494 },
495 dependencies: vec![],
496 });
497
498 assert_eq!(packages.len(), 1);
499 assert_eq!(packages.get_version("serde"), Some("1.0.195"));
500 }
501
502 #[test]
503 fn test_resolved_source_equality() {
504 let source1 = ResolvedSource::Registry {
505 url: "https://test.com".into(),
506 checksum: "abc".into(),
507 };
508 let source2 = ResolvedSource::Registry {
509 url: "https://test.com".into(),
510 checksum: "abc".into(),
511 };
512 let source3 = ResolvedSource::Git {
513 url: "https://github.com/test".into(),
514 rev: "abc123".into(),
515 };
516
517 assert_eq!(source1, source2);
518 assert_ne!(source1, source3);
519 }
520
521 #[test]
522 fn test_resolved_packages_iter() {
523 let mut packages = ResolvedPackages::new();
524
525 packages.insert(ResolvedPackage {
526 name: "serde".into(),
527 version: "1.0.0".into(),
528 source: ResolvedSource::Registry {
529 url: "test".into(),
530 checksum: "a".into(),
531 },
532 dependencies: vec![],
533 });
534
535 packages.insert(ResolvedPackage {
536 name: "tokio".into(),
537 version: "1.0.0".into(),
538 source: ResolvedSource::Registry {
539 url: "test".into(),
540 checksum: "b".into(),
541 },
542 dependencies: vec![],
543 });
544
545 let count = packages.iter().count();
546 assert_eq!(count, 2);
547
548 let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
549 assert!(names.contains(&"serde"));
550 assert!(names.contains(&"tokio"));
551 }
552
553 #[test]
554 fn test_resolved_packages_into_map() {
555 let mut packages = ResolvedPackages::new();
556
557 packages.insert(ResolvedPackage {
558 name: "serde".into(),
559 version: "1.0.0".into(),
560 source: ResolvedSource::Registry {
561 url: "test".into(),
562 checksum: "a".into(),
563 },
564 dependencies: vec![],
565 });
566
567 let map = packages.into_map();
568 assert_eq!(map.len(), 1);
569 assert!(map.contains_key("serde"));
570 }
571
572 #[test]
573 fn test_lockfile_cache_new() {
574 let cache = LockFileCache::new();
575 assert!(cache.is_empty());
576 assert_eq!(cache.len(), 0);
577 }
578
579 #[test]
580 fn test_lockfile_cache_invalidate() {
581 let cache = LockFileCache::new();
582 let test_path = PathBuf::from("/test/Cargo.lock");
583
584 cache.entries.insert(
585 test_path.clone(),
586 CachedLockFile {
587 packages: ResolvedPackages::new(),
588 modified_at: SystemTime::now(),
589 parsed_at: Instant::now(),
590 },
591 );
592
593 assert_eq!(cache.len(), 1);
594
595 cache.invalidate(&test_path);
596 assert_eq!(cache.len(), 0);
597 assert!(cache.is_empty());
598 }
599
600 #[test]
601 fn test_locate_lockfile_for_manifest_same_directory() {
602 let temp_dir = tempfile::tempdir().unwrap();
603 let manifest_path = temp_dir.path().join("Cargo.toml");
604 let lock_path = temp_dir.path().join("Cargo.lock");
605
606 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
607 std::fs::write(&lock_path, "version = 4").unwrap();
608
609 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
610 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
611
612 assert!(located.is_some());
613 assert_eq!(located.unwrap(), lock_path);
614 }
615
616 #[test]
617 fn test_locate_lockfile_for_manifest_workspace_root() {
618 let temp_dir = tempfile::tempdir().unwrap();
619 let workspace_lock = temp_dir.path().join("Cargo.lock");
620 let member_dir = temp_dir.path().join("crates").join("member");
621 std::fs::create_dir_all(&member_dir).unwrap();
622 let member_manifest = member_dir.join("Cargo.toml");
623
624 std::fs::write(&workspace_lock, "version = 4").unwrap();
625 std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
626
627 let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
628 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
629
630 assert!(located.is_some());
631 assert_eq!(located.unwrap(), workspace_lock);
632 }
633
634 #[test]
635 fn test_locate_lockfile_for_manifest_not_found() {
636 let temp_dir = tempfile::tempdir().unwrap();
637 let manifest_path = temp_dir.path().join("Cargo.toml");
638 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
639
640 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
641 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
642
643 assert!(located.is_none());
644 }
645
646 #[test]
647 fn test_locate_lockfile_for_manifest_multiple_names() {
648 let temp_dir = tempfile::tempdir().unwrap();
649 let manifest_path = temp_dir.path().join("pyproject.toml");
650 let uv_lock = temp_dir.path().join("uv.lock");
651
652 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
653 std::fs::write(&uv_lock, "version = 1").unwrap();
654
655 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
656 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
658
659 assert!(located.is_some());
660 assert_eq!(located.unwrap(), uv_lock);
661 }
662
663 #[test]
664 fn test_locate_lockfile_for_manifest_first_match_wins() {
665 let temp_dir = tempfile::tempdir().unwrap();
666 let manifest_path = temp_dir.path().join("pyproject.toml");
667 let poetry_lock = temp_dir.path().join("poetry.lock");
668 let uv_lock = temp_dir.path().join("uv.lock");
669
670 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
671 std::fs::write(&poetry_lock, "# poetry lock").unwrap();
672 std::fs::write(&uv_lock, "version = 1").unwrap();
673
674 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
675 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
677
678 assert!(located.is_some());
679 assert_eq!(located.unwrap(), poetry_lock);
680 }
681}