1use crate::error::{DepsError, Result};
10use crate::fs_probe;
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 const MAX_LOCKFILE_BYTES: u64 = 32 * 1024 * 1024;
31
32pub async fn read_lockfile_content(path: &Path, file_type: &str) -> Result<String> {
68 let to_parse_error = |e: std::io::Error| DepsError::ParseError {
69 file_type: format!("{file_type} at {}", path.display()),
70 source: Box::new(e),
71 };
72 let oversized_error = || {
73 to_parse_error(std::io::Error::new(
74 std::io::ErrorKind::InvalidData,
75 format!("exceeds {MAX_LOCKFILE_BYTES} byte size cap"),
76 ))
77 };
78
79 if let Ok(metadata) = fs_probe::metadata(path) {
87 if !metadata.is_file() {
88 return Err(to_parse_error(std::io::Error::new(
89 std::io::ErrorKind::InvalidInput,
90 "not a regular file",
91 )));
92 }
93 if metadata.len() > MAX_LOCKFILE_BYTES {
94 tracing::warn!(
95 path = %path.display(),
96 len = metadata.len(),
97 cap = MAX_LOCKFILE_BYTES,
98 "lock file exceeds size cap; not reading"
99 );
100 return Err(oversized_error());
101 }
102 }
103
104 let path_buf = path.to_path_buf();
105 let read_result = tokio::task::spawn_blocking(move || {
106 fs_probe::read_to_string_capped(&path_buf, MAX_LOCKFILE_BYTES)
107 })
108 .await
109 .map_err(|e| to_parse_error(std::io::Error::other(e)))?;
110
111 match read_result.map_err(to_parse_error)? {
112 Some(content) => Ok(content),
113 None => {
114 tracing::warn!(
115 path = %path.display(),
116 cap = MAX_LOCKFILE_BYTES,
117 "lock file exceeds size cap during read; not reading"
118 );
119 Err(oversized_error())
120 }
121 }
122}
123
124pub fn locate_lockfile_for_manifest(
160 manifest_uri: &Uri,
161 lockfile_names: &[&str],
162) -> Option<PathBuf> {
163 let manifest_path = manifest_uri.to_file_path()?;
164 let manifest_dir = manifest_path.parent()?;
165
166 let mut lock_path = manifest_dir.to_path_buf();
168
169 for &name in lockfile_names {
171 lock_path.push(name);
172 if fs_probe::is_file(&lock_path) {
173 tracing::debug!("Found {} at: {}", name, lock_path.display());
174 return Some(lock_path);
175 }
176 lock_path.pop();
177 }
178
179 let Some(mut current_dir) = manifest_dir.parent() else {
181 tracing::debug!("No lock file found for: {:?}", manifest_uri);
182 return None;
183 };
184
185 for depth in 0..MAX_WORKSPACE_DEPTH {
186 lock_path.clear();
187 lock_path.push(current_dir);
188
189 for &name in lockfile_names {
190 lock_path.push(name);
191 if fs_probe::is_file(&lock_path) {
192 tracing::debug!(
193 "Found workspace {} at depth {}: {}",
194 name,
195 depth + 1,
196 lock_path.display()
197 );
198 return Some(lock_path);
199 }
200 lock_path.pop();
201 }
202
203 match current_dir.parent() {
204 Some(parent) => current_dir = parent,
205 None => break,
206 }
207 }
208
209 tracing::debug!("No lock file found for: {:?}", manifest_uri);
210 None
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ResolvedPackage {
219 pub name: String,
221 pub version: String,
223 pub source: ResolvedSource,
225 pub dependencies: Vec<String>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ResolvedSource {
234 Registry {
236 url: String,
238 checksum: String,
240 },
241 Git {
243 url: String,
245 rev: String,
247 },
248 Path {
250 path: String,
252 },
253}
254
255#[derive(Debug, Default, Clone)]
280pub struct ResolvedPackages {
281 packages: HashMap<String, Vec<ResolvedPackage>>,
282}
283
284fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> {
286 packages.iter().max_by(|a, b| {
287 match (
288 semver::Version::parse(&a.version),
289 semver::Version::parse(&b.version),
290 ) {
291 (Ok(va), Ok(vb)) => va.cmp(&vb),
292 (Ok(_), Err(_)) => std::cmp::Ordering::Greater,
293 (Err(_), Ok(_)) => std::cmp::Ordering::Less,
294 (Err(_), Err(_)) => a.version.cmp(&b.version),
295 }
296 })
297}
298
299impl ResolvedPackages {
300 pub fn new() -> Self {
302 Self {
303 packages: HashMap::new(),
304 }
305 }
306
307 pub fn insert(&mut self, package: ResolvedPackage) {
309 self.packages
310 .entry(package.name.clone())
311 .or_default()
312 .push(package);
313 }
314
315 pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
317 self.packages.get(name).and_then(|v| best_package(v))
318 }
319
320 pub fn get_version(&self, name: &str) -> Option<&str> {
322 self.get(name).map(|p| p.version.as_str())
323 }
324
325 pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
327 self.packages.get(name).map(|v| v.as_slice())
328 }
329
330 pub fn len(&self) -> usize {
332 self.packages.len()
333 }
334
335 pub fn is_empty(&self) -> bool {
337 self.packages.is_empty()
338 }
339
340 pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
342 self.packages.keys().filter_map(|name| {
343 self.packages
344 .get(name)
345 .and_then(|v| best_package(v).map(|p| (name, p)))
346 })
347 }
348
349 pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
351 self.packages
352 .into_iter()
353 .filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p)))
354 .collect()
355 }
356}
357
358pub trait LockFileProvider: Send + Sync {
388 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
403
404 fn parse_lockfile<'a>(
421 &'a self,
422 lockfile_path: &'a Path,
423 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>;
424
425 fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool {
439 if let Ok(metadata) = std::fs::metadata(lockfile_path)
440 && let Ok(mtime) = metadata.modified()
441 {
442 return mtime > last_modified;
443 }
444 true
445 }
446}
447
448struct CachedLockFile {
450 packages: ResolvedPackages,
451 modified_at: SystemTime,
452 #[allow(dead_code)]
453 parsed_at: Instant,
454}
455
456pub struct LockFileCache {
475 entries: DashMap<PathBuf, CachedLockFile>,
476}
477
478impl LockFileCache {
479 pub fn new() -> Self {
481 Self {
482 entries: DashMap::new(),
483 }
484 }
485
486 pub async fn get_or_parse(
505 &self,
506 provider: &dyn LockFileProvider,
507 lockfile_path: &Path,
508 ) -> Result<ResolvedPackages> {
509 let cached = self
514 .entries
515 .get(lockfile_path)
516 .map(|entry| (entry.modified_at, entry.packages.clone()));
517
518 if let Some((cached_modified_at, cached_packages)) = cached
520 && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
521 && let Ok(mtime) = metadata.modified()
522 && mtime <= cached_modified_at
523 {
524 tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
525 return Ok(cached_packages);
526 }
527
528 tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
536 let metadata = tokio::fs::metadata(lockfile_path).await?;
537 let modified_at = metadata.modified()?;
538
539 let packages = provider.parse_lockfile(lockfile_path).await?;
540
541 self.entries.insert(
542 lockfile_path.to_path_buf(),
543 CachedLockFile {
544 packages: packages.clone(),
545 modified_at,
546 parsed_at: Instant::now(),
547 },
548 );
549
550 Ok(packages)
551 }
552
553 pub fn invalidate(&self, lockfile_path: &Path) {
558 self.entries.remove(lockfile_path);
559 }
560
561 pub fn len(&self) -> usize {
563 self.entries.len()
564 }
565
566 pub fn is_empty(&self) -> bool {
568 self.entries.is_empty()
569 }
570}
571
572impl Default for LockFileCache {
573 fn default() -> Self {
574 Self::new()
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[tokio::test]
583 async fn test_read_lockfile_content_success() {
584 let temp_dir = tempfile::tempdir().unwrap();
585 let lock_path = temp_dir.path().join("Cargo.lock");
586 std::fs::write(&lock_path, "version = 4").unwrap();
587
588 let content = read_lockfile_content(&lock_path, "Cargo.lock")
589 .await
590 .unwrap();
591
592 assert_eq!(content, "version = 4");
593 }
594
595 #[tokio::test]
602 async fn test_read_lockfile_content_rejects_oversized_file() {
603 let temp_dir = tempfile::tempdir().unwrap();
604 let lock_path = temp_dir.path().join("Cargo.lock");
605 let file = std::fs::File::create(&lock_path).unwrap();
606 file.set_len(MAX_LOCKFILE_BYTES + 1).unwrap();
607
608 let err = read_lockfile_content(&lock_path, "Cargo.lock")
609 .await
610 .unwrap_err();
611
612 match err {
613 DepsError::ParseError { file_type, .. } => {
614 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
615 }
616 other => panic!("Expected ParseError, got: {other:?}"),
617 }
618 }
619
620 #[tokio::test]
625 async fn test_read_lockfile_content_rejects_non_regular_file() {
626 let temp_dir = tempfile::tempdir().unwrap();
627 let lock_path = temp_dir.path().join("Cargo.lock");
628 std::fs::create_dir(&lock_path).unwrap();
629
630 let err = read_lockfile_content(&lock_path, "Cargo.lock")
631 .await
632 .unwrap_err();
633
634 match err {
635 DepsError::ParseError { file_type, .. } => {
636 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
637 }
638 other => panic!("Expected ParseError, got: {other:?}"),
639 }
640 }
641
642 #[tokio::test]
643 async fn test_read_lockfile_content_missing_file_wraps_error() {
644 let temp_dir = tempfile::tempdir().unwrap();
645 let lock_path = temp_dir.path().join("Cargo.lock");
646
647 let err = read_lockfile_content(&lock_path, "Cargo.lock")
648 .await
649 .unwrap_err();
650
651 match err {
652 DepsError::ParseError { file_type, .. } => {
653 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
654 }
655 other => panic!("Expected ParseError, got: {other:?}"),
656 }
657 }
658
659 #[test]
660 fn test_resolved_packages_new() {
661 let packages = ResolvedPackages::new();
662 assert!(packages.is_empty());
663 assert_eq!(packages.len(), 0);
664 }
665
666 #[test]
667 fn test_resolved_packages_insert_and_get() {
668 let mut packages = ResolvedPackages::new();
669
670 let pkg = ResolvedPackage {
671 name: "serde".into(),
672 version: "1.0.195".into(),
673 source: ResolvedSource::Registry {
674 url: "https://github.com/rust-lang/crates.io-index".into(),
675 checksum: "abc123".into(),
676 },
677 dependencies: vec!["serde_derive".into()],
678 };
679
680 packages.insert(pkg);
681
682 assert_eq!(packages.len(), 1);
683 assert!(!packages.is_empty());
684 assert_eq!(packages.get_version("serde"), Some("1.0.195"));
685
686 let retrieved = packages.get("serde");
687 assert!(retrieved.is_some());
688 assert_eq!(retrieved.unwrap().name, "serde");
689 assert_eq!(retrieved.unwrap().dependencies.len(), 1);
690 }
691
692 #[test]
693 fn test_resolved_packages_get_nonexistent() {
694 let packages = ResolvedPackages::new();
695 assert_eq!(packages.get("nonexistent"), None);
696 assert_eq!(packages.get_version("nonexistent"), None);
697 }
698
699 #[test]
700 fn test_resolved_packages_replace() {
701 let mut packages = ResolvedPackages::new();
702
703 packages.insert(ResolvedPackage {
704 name: "serde".into(),
705 version: "1.0.0".into(),
706 source: ResolvedSource::Registry {
707 url: "test".into(),
708 checksum: "old".into(),
709 },
710 dependencies: vec![],
711 });
712
713 packages.insert(ResolvedPackage {
714 name: "serde".into(),
715 version: "1.0.195".into(),
716 source: ResolvedSource::Registry {
717 url: "test".into(),
718 checksum: "new".into(),
719 },
720 dependencies: vec![],
721 });
722
723 assert_eq!(packages.len(), 1);
725 assert_eq!(packages.get_version("serde"), Some("1.0.195"));
726 assert_eq!(packages.get_all("serde").unwrap().len(), 2);
728 }
729
730 #[test]
731 fn test_resolved_packages_multiple_versions() {
732 let mut packages = ResolvedPackages::new();
733
734 packages.insert(ResolvedPackage {
735 name: "serde".into(),
736 version: "1.0.195".into(),
737 source: ResolvedSource::Registry {
738 url: "test".into(),
739 checksum: "a".into(),
740 },
741 dependencies: vec![],
742 });
743
744 packages.insert(ResolvedPackage {
745 name: "serde".into(),
746 version: "0.9.0".into(),
747 source: ResolvedSource::Registry {
748 url: "test".into(),
749 checksum: "b".into(),
750 },
751 dependencies: vec![],
752 });
753
754 packages.insert(ResolvedPackage {
755 name: "serde".into(),
756 version: "2.0.0-beta.1".into(),
757 source: ResolvedSource::Registry {
758 url: "test".into(),
759 checksum: "c".into(),
760 },
761 dependencies: vec![],
762 });
763
764 assert_eq!(packages.len(), 1);
765 assert_eq!(packages.get_version("serde"), Some("2.0.0-beta.1"));
766 assert_eq!(packages.get_all("serde").unwrap().len(), 3);
767 }
768
769 #[test]
770 fn test_resolved_packages_non_semver_fallback() {
771 let mut packages = ResolvedPackages::new();
772
773 packages.insert(ResolvedPackage {
774 name: "weird".into(),
775 version: "abc".into(),
776 source: ResolvedSource::Path { path: ".".into() },
777 dependencies: vec![],
778 });
779
780 packages.insert(ResolvedPackage {
781 name: "weird".into(),
782 version: "xyz".into(),
783 source: ResolvedSource::Path { path: ".".into() },
784 dependencies: vec![],
785 });
786
787 assert_eq!(packages.get_version("weird"), Some("xyz"));
789 }
790
791 #[test]
792 fn test_resolved_packages_semver_preferred_over_non_semver() {
793 let mut packages = ResolvedPackages::new();
794
795 packages.insert(ResolvedPackage {
796 name: "mixed".into(),
797 version: "not-a-version".into(),
798 source: ResolvedSource::Path { path: ".".into() },
799 dependencies: vec![],
800 });
801
802 packages.insert(ResolvedPackage {
803 name: "mixed".into(),
804 version: "1.0.0".into(),
805 source: ResolvedSource::Path { path: ".".into() },
806 dependencies: vec![],
807 });
808
809 assert_eq!(packages.get_version("mixed"), Some("1.0.0"));
811 }
812
813 #[test]
814 fn test_resolved_source_equality() {
815 let source1 = ResolvedSource::Registry {
816 url: "https://test.com".into(),
817 checksum: "abc".into(),
818 };
819 let source2 = ResolvedSource::Registry {
820 url: "https://test.com".into(),
821 checksum: "abc".into(),
822 };
823 let source3 = ResolvedSource::Git {
824 url: "https://github.com/test".into(),
825 rev: "abc123".into(),
826 };
827
828 assert_eq!(source1, source2);
829 assert_ne!(source1, source3);
830 }
831
832 #[test]
833 fn test_resolved_packages_iter() {
834 let mut packages = ResolvedPackages::new();
835
836 packages.insert(ResolvedPackage {
837 name: "serde".into(),
838 version: "1.0.0".into(),
839 source: ResolvedSource::Registry {
840 url: "test".into(),
841 checksum: "a".into(),
842 },
843 dependencies: vec![],
844 });
845
846 packages.insert(ResolvedPackage {
847 name: "tokio".into(),
848 version: "1.0.0".into(),
849 source: ResolvedSource::Registry {
850 url: "test".into(),
851 checksum: "b".into(),
852 },
853 dependencies: vec![],
854 });
855
856 let count = packages.iter().count();
857 assert_eq!(count, 2);
858
859 let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
860 assert!(names.contains(&"serde"));
861 assert!(names.contains(&"tokio"));
862 }
863
864 #[test]
865 fn test_resolved_packages_into_map() {
866 let mut packages = ResolvedPackages::new();
867
868 packages.insert(ResolvedPackage {
869 name: "serde".into(),
870 version: "1.0.0".into(),
871 source: ResolvedSource::Registry {
872 url: "test".into(),
873 checksum: "a".into(),
874 },
875 dependencies: vec![],
876 });
877
878 let map = packages.into_map();
879 assert_eq!(map.len(), 1);
880 assert!(map.contains_key("serde"));
881 }
882
883 #[test]
884 fn test_lockfile_cache_new() {
885 let cache = LockFileCache::new();
886 assert!(cache.is_empty());
887 assert_eq!(cache.len(), 0);
888 }
889
890 #[test]
891 fn test_lockfile_cache_invalidate() {
892 let cache = LockFileCache::new();
893 let test_path = PathBuf::from("/test/Cargo.lock");
894
895 cache.entries.insert(
896 test_path.clone(),
897 CachedLockFile {
898 packages: ResolvedPackages::new(),
899 modified_at: SystemTime::now(),
900 parsed_at: Instant::now(),
901 },
902 );
903
904 assert_eq!(cache.len(), 1);
905
906 cache.invalidate(&test_path);
907 assert_eq!(cache.len(), 0);
908 assert!(cache.is_empty());
909 }
910
911 #[test]
912 fn test_locate_lockfile_for_manifest_same_directory() {
913 let temp_dir = tempfile::tempdir().unwrap();
914 let manifest_path = temp_dir.path().join("Cargo.toml");
915 let lock_path = temp_dir.path().join("Cargo.lock");
916
917 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
918 std::fs::write(&lock_path, "version = 4").unwrap();
919
920 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
921 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
922
923 assert!(located.is_some());
924 assert_eq!(located.unwrap(), lock_path);
925 }
926
927 #[test]
928 fn test_locate_lockfile_for_manifest_workspace_root() {
929 let temp_dir = tempfile::tempdir().unwrap();
930 let workspace_lock = temp_dir.path().join("Cargo.lock");
931 let member_dir = temp_dir.path().join("crates").join("member");
932 std::fs::create_dir_all(&member_dir).unwrap();
933 let member_manifest = member_dir.join("Cargo.toml");
934
935 std::fs::write(&workspace_lock, "version = 4").unwrap();
936 std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
937
938 let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
939 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
940
941 assert!(located.is_some());
942 assert_eq!(located.unwrap(), workspace_lock);
943 }
944
945 #[test]
950 fn test_locate_lockfile_for_manifest_skips_non_regular_file() {
951 let temp_dir = tempfile::tempdir().unwrap();
952 let manifest_path = temp_dir.path().join("Cargo.toml");
953 let lock_path = temp_dir.path().join("Cargo.lock");
954
955 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
956 std::fs::create_dir(&lock_path).unwrap();
957
958 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
959 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
960
961 assert!(
962 located.is_none(),
963 "a directory at the lock file path must not be treated as a found lock file"
964 );
965 }
966
967 #[test]
968 fn test_locate_lockfile_for_manifest_not_found() {
969 let temp_dir = tempfile::tempdir().unwrap();
970 let manifest_path = temp_dir.path().join("Cargo.toml");
971 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
972
973 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
974 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
975
976 assert!(located.is_none());
977 }
978
979 #[test]
980 fn test_locate_lockfile_for_manifest_multiple_names() {
981 let temp_dir = tempfile::tempdir().unwrap();
982 let manifest_path = temp_dir.path().join("pyproject.toml");
983 let uv_lock = temp_dir.path().join("uv.lock");
984
985 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
986 std::fs::write(&uv_lock, "version = 1").unwrap();
987
988 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
989 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
991
992 assert!(located.is_some());
993 assert_eq!(located.unwrap(), uv_lock);
994 }
995
996 struct CountingLockFileProvider {
1000 parse_count: std::sync::atomic::AtomicUsize,
1001 }
1002
1003 impl CountingLockFileProvider {
1004 fn new() -> Self {
1005 Self {
1006 parse_count: std::sync::atomic::AtomicUsize::new(0),
1007 }
1008 }
1009
1010 fn parse_count(&self) -> usize {
1011 self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
1012 }
1013 }
1014
1015 impl LockFileProvider for CountingLockFileProvider {
1016 fn locate_lockfile(&self, _manifest_uri: &Uri) -> Option<PathBuf> {
1017 None
1018 }
1019
1020 fn parse_lockfile<'a>(
1021 &'a self,
1022 lockfile_path: &'a Path,
1023 ) -> std::pin::Pin<
1024 Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
1025 > {
1026 Box::pin(async move {
1027 self.parse_count
1028 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1029 let content = read_lockfile_content(lockfile_path, "test.lock").await?;
1030
1031 let mut packages = ResolvedPackages::new();
1032 packages.insert(ResolvedPackage {
1033 name: "test-package".into(),
1034 version: content.trim().to_string(),
1035 source: ResolvedSource::Path { path: ".".into() },
1036 dependencies: vec![],
1037 });
1038 Ok(packages)
1039 })
1040 }
1041 }
1042
1043 #[tokio::test]
1044 async fn test_get_or_parse_cache_hit_does_not_reparse() {
1045 let temp_dir = tempfile::tempdir().unwrap();
1046 let lock_path = temp_dir.path().join("test.lock");
1047 std::fs::write(&lock_path, "1.0.0").unwrap();
1048
1049 let provider = CountingLockFileProvider::new();
1050 let cache = LockFileCache::new();
1051
1052 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1053 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1054
1055 assert_eq!(provider.parse_count(), 1, "second call should hit cache");
1056 assert_eq!(first.get_version("test-package"), Some("1.0.0"));
1057 assert_eq!(second.get_version("test-package"), Some("1.0.0"));
1058 }
1059
1060 #[tokio::test]
1061 async fn test_get_or_parse_reparses_when_mtime_advances() {
1062 let temp_dir = tempfile::tempdir().unwrap();
1063 let lock_path = temp_dir.path().join("test.lock");
1064 std::fs::write(&lock_path, "1.0.0").unwrap();
1065
1066 let provider = CountingLockFileProvider::new();
1067 let cache = LockFileCache::new();
1068
1069 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1070 assert_eq!(first.get_version("test-package"), Some("1.0.0"));
1071
1072 std::fs::write(&lock_path, "2.0.0").unwrap();
1073 let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
1076 std::fs::OpenOptions::new()
1077 .write(true)
1078 .open(&lock_path)
1079 .unwrap()
1080 .set_modified(future_mtime)
1081 .unwrap();
1082
1083 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1084
1085 assert_eq!(
1086 provider.parse_count(),
1087 2,
1088 "stale mtime should trigger reparse"
1089 );
1090 assert_eq!(second.get_version("test-package"), Some("2.0.0"));
1091 }
1092
1093 struct RewritingDuringParseProvider {
1100 parse_count: std::sync::atomic::AtomicUsize,
1101 }
1102
1103 impl RewritingDuringParseProvider {
1104 fn new() -> Self {
1105 Self {
1106 parse_count: std::sync::atomic::AtomicUsize::new(0),
1107 }
1108 }
1109
1110 fn parse_count(&self) -> usize {
1111 self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
1112 }
1113 }
1114
1115 impl LockFileProvider for RewritingDuringParseProvider {
1116 fn locate_lockfile(&self, _manifest_uri: &Uri) -> Option<PathBuf> {
1117 None
1118 }
1119
1120 fn parse_lockfile<'a>(
1121 &'a self,
1122 lockfile_path: &'a Path,
1123 ) -> std::pin::Pin<
1124 Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
1125 > {
1126 Box::pin(async move {
1127 self.parse_count
1128 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1129 let content = read_lockfile_content(lockfile_path, "test.lock").await?;
1130
1131 std::fs::write(lockfile_path, "2.0.0").unwrap();
1133 let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
1134 std::fs::OpenOptions::new()
1135 .write(true)
1136 .open(lockfile_path)
1137 .unwrap()
1138 .set_modified(future_mtime)
1139 .unwrap();
1140
1141 let mut packages = ResolvedPackages::new();
1142 packages.insert(ResolvedPackage {
1143 name: "test-package".into(),
1144 version: content.trim().to_string(),
1145 source: ResolvedSource::Path { path: ".".into() },
1146 dependencies: vec![],
1147 });
1148 Ok(packages)
1149 })
1150 }
1151 }
1152
1153 #[tokio::test]
1167 async fn test_get_or_parse_detects_rewrite_during_parse() {
1168 let temp_dir = tempfile::tempdir().unwrap();
1169 let lock_path = temp_dir.path().join("test.lock");
1170 std::fs::write(&lock_path, "1.0.0").unwrap();
1171
1172 let provider = RewritingDuringParseProvider::new();
1173 let cache = LockFileCache::new();
1174
1175 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1176 assert_eq!(first.get_version("test-package"), Some("1.0.0"));
1177
1178 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1179
1180 assert_eq!(
1181 provider.parse_count(),
1182 2,
1183 "rewrite during first parse must be detected and trigger a reparse"
1184 );
1185 assert_eq!(second.get_version("test-package"), Some("2.0.0"));
1186 }
1187
1188 #[test]
1189 fn test_locate_lockfile_for_manifest_first_match_wins() {
1190 let temp_dir = tempfile::tempdir().unwrap();
1191 let manifest_path = temp_dir.path().join("pyproject.toml");
1192 let poetry_lock = temp_dir.path().join("poetry.lock");
1193 let uv_lock = temp_dir.path().join("uv.lock");
1194
1195 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
1196 std::fs::write(&poetry_lock, "# poetry lock").unwrap();
1197 std::fs::write(&uv_lock, "version = 1").unwrap();
1198
1199 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
1200 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
1202
1203 assert!(located.is_some());
1204 assert_eq!(located.unwrap(), poetry_lock);
1205 }
1206}