Skip to main content

deps_core/
lockfile.rs

1//! Lock file parsing abstractions.
2//!
3//! Provides generic types and traits for parsing lock files across different
4//! package ecosystems (Cargo.lock, package-lock.json, poetry.lock, etc.).
5//!
6//! Lock files contain resolved dependency versions, allowing instant display
7//! without network requests to registries.
8
9use 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
16/// Maximum depth to search for workspace root lock file.
17const MAX_WORKSPACE_DEPTH: usize = 5;
18
19/// Generic lock file locator.
20///
21/// Searches for lock files in the following order:
22/// 1. Same directory as the manifest
23/// 2. Parent directories (up to MAX_WORKSPACE_DEPTH levels) for workspace root
24///
25/// This function is ecosystem-agnostic and works with any lock file name.
26///
27/// # Arguments
28///
29/// * `manifest_uri` - URI of the manifest file
30/// * `lockfile_names` - List of possible lock file names to search for
31///
32/// # Returns
33///
34/// Path to the first found lock file, or None if not found.
35///
36/// # Examples
37///
38/// ```no_run
39/// use deps_core::lockfile::locate_lockfile_for_manifest;
40/// use tower_lsp_server::ls_types::Uri;
41///
42/// let manifest_uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap();
43/// let lockfile_names = &["Cargo.lock"];
44///
45/// if let Some(path) = locate_lockfile_for_manifest(&manifest_uri, lockfile_names) {
46///     println!("Found lock file at: {}", path.display());
47/// }
48/// ```
49pub 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    // Reuse single PathBuf to avoid allocations in loops
57    let mut lock_path = manifest_dir.to_path_buf();
58
59    // Try same directory as manifest
60    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    // Search up the directory tree for workspace root
70    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/// Resolved package information from a lock file.
104///
105/// Contains the exact version and source information for a dependency
106/// as resolved by the package manager.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ResolvedPackage {
109    /// Package name
110    pub name: String,
111    /// Resolved version (exact version from lock file)
112    pub version: String,
113    /// Source information (registry URL, git commit, path)
114    pub source: ResolvedSource,
115    /// Dependencies of this package (for dependency tree analysis)
116    pub dependencies: Vec<String>,
117}
118
119/// Source of a resolved dependency.
120///
121/// Indicates where the package was downloaded from or how it was resolved.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ResolvedSource {
124    /// From a registry with optional checksum
125    Registry {
126        /// Registry URL
127        url: String,
128        /// Checksum/integrity hash
129        checksum: String,
130    },
131    /// From git with commit hash
132    Git {
133        /// Git repository URL
134        url: String,
135        /// Commit SHA or tag
136        rev: String,
137    },
138    /// From local file system
139    Path {
140        /// Relative or absolute path
141        path: String,
142    },
143}
144
145/// Collection of resolved packages from a lock file.
146///
147/// Supports multiple versions per package name, returning the highest
148/// semver version through public API methods.
149///
150/// # Examples
151///
152/// ```
153/// use deps_core::lockfile::{ResolvedPackages, ResolvedPackage, ResolvedSource};
154///
155/// let mut packages = ResolvedPackages::new();
156/// packages.insert(ResolvedPackage {
157///     name: "serde".into(),
158///     version: "1.0.195".into(),
159///     source: ResolvedSource::Registry {
160///         url: "https://github.com/rust-lang/crates.io-index".into(),
161///         checksum: "abc123".into(),
162///     },
163///     dependencies: vec!["serde_derive".into()],
164/// });
165///
166/// assert_eq!(packages.get_version("serde"), Some("1.0.195"));
167/// assert_eq!(packages.len(), 1);
168/// ```
169#[derive(Debug, Default, Clone)]
170pub struct ResolvedPackages {
171    packages: HashMap<String, Vec<ResolvedPackage>>,
172}
173
174/// Returns the package with the highest semver version from a slice.
175fn 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    /// Creates a new empty collection.
191    pub fn new() -> Self {
192        Self {
193            packages: HashMap::new(),
194        }
195    }
196
197    /// Inserts a resolved package, storing all versions per name.
198    pub fn insert(&mut self, package: ResolvedPackage) {
199        self.packages
200            .entry(package.name.clone())
201            .or_default()
202            .push(package);
203    }
204
205    /// Gets the resolved package with the highest semver version.
206    pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
207        self.packages.get(name).and_then(|v| best_package(v))
208    }
209
210    /// Gets the highest resolved version string for a package.
211    pub fn get_version(&self, name: &str) -> Option<&str> {
212        self.get(name).map(|p| p.version.as_str())
213    }
214
215    /// Returns all stored versions for a package.
216    pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
217        self.packages.get(name).map(|v| v.as_slice())
218    }
219
220    /// Returns the number of unique package names.
221    pub fn len(&self) -> usize {
222        self.packages.len()
223    }
224
225    /// Returns true if there are no resolved packages.
226    pub fn is_empty(&self) -> bool {
227        self.packages.is_empty()
228    }
229
230    /// Returns an iterator yielding the best version per unique package name.
231    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    /// Converts into a HashMap with the best version per package name.
240    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
248/// Lock file provider trait for ecosystem-specific implementations.
249///
250/// Implementations parse lock files for a specific package ecosystem
251/// (Cargo.lock, package-lock.json, etc.) and extract resolved versions.
252///
253/// # Examples
254///
255/// ```no_run
256/// use deps_core::lockfile::{LockFileProvider, ResolvedPackages};
257/// use std::path::{Path, PathBuf};
258/// use tower_lsp_server::ls_types::Uri;
259///
260/// struct MyLockParser;
261///
262/// impl LockFileProvider for MyLockParser {
263///     fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
264///         let manifest_path = manifest_uri.to_file_path()?;
265///         let lock_path = manifest_path.with_file_name("my.lock");
266///         lock_path.exists().then_some(lock_path)
267///     }
268///
269///     fn parse_lockfile<'a>(&'a self, lockfile_path: &'a Path) -> std::pin::Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<ResolvedPackages>> + Send + 'a>> {
270///         Box::pin(async move {
271///             // Parse lock file format and extract packages
272///             Ok(ResolvedPackages::new())
273///         })
274///     }
275/// }
276/// ```
277pub trait LockFileProvider: Send + Sync {
278    /// Locates the lock file for a given manifest URI.
279    ///
280    /// Returns `None` if:
281    /// - Lock file doesn't exist
282    /// - Manifest path cannot be determined from URI
283    /// - Workspace root search fails
284    ///
285    /// # Arguments
286    ///
287    /// * `manifest_uri` - URI of the manifest file (Cargo.toml, package.json, etc.)
288    ///
289    /// # Returns
290    ///
291    /// Path to lock file if found
292    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
293
294    /// Parses a lock file and extracts resolved packages.
295    ///
296    /// # Arguments
297    ///
298    /// * `lockfile_path` - Path to the lock file
299    ///
300    /// # Returns
301    ///
302    /// ResolvedPackages on success, error if parse fails
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if:
307    /// - File cannot be read
308    /// - File format is invalid
309    /// - Required fields are missing
310    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    /// Checks if lock file has been modified since last parse.
316    ///
317    /// Used for cache invalidation. Default implementation compares
318    /// file modification time.
319    ///
320    /// # Arguments
321    ///
322    /// * `lockfile_path` - Path to the lock file
323    /// * `last_modified` - Last known modification time
324    ///
325    /// # Returns
326    ///
327    /// `true` if file has been modified or cannot be stat'd, `false` otherwise
328    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
338/// Cached lock file entry with staleness detection.
339struct CachedLockFile {
340    packages: ResolvedPackages,
341    modified_at: SystemTime,
342    #[allow(dead_code)]
343    parsed_at: Instant,
344}
345
346/// Cache for parsed lock files with automatic staleness detection.
347///
348/// Caches parsed lock file contents and checks file modification time
349/// to avoid re-parsing unchanged files. Thread-safe for concurrent access.
350///
351/// # Examples
352///
353/// ```no_run
354/// use deps_core::lockfile::LockFileCache;
355/// use std::path::Path;
356///
357/// # async fn example() -> deps_core::error::Result<()> {
358/// let cache = LockFileCache::new();
359/// // First call parses the file
360/// // Second call returns cached result if file hasn't changed
361/// # Ok(())
362/// # }
363/// ```
364pub struct LockFileCache {
365    entries: DashMap<PathBuf, CachedLockFile>,
366}
367
368impl LockFileCache {
369    /// Creates a new empty lock file cache.
370    pub fn new() -> Self {
371        Self {
372            entries: DashMap::new(),
373        }
374    }
375
376    /// Gets parsed packages from cache or parses the lock file.
377    ///
378    /// Checks file modification time to detect changes. If the file
379    /// has been modified since last parse, re-parses it. Otherwise,
380    /// returns the cached result.
381    ///
382    /// # Arguments
383    ///
384    /// * `provider` - Lock file provider implementation
385    /// * `lockfile_path` - Path to the lock file
386    ///
387    /// # Returns
388    ///
389    /// Resolved packages on success
390    ///
391    /// # Errors
392    ///
393    /// Returns error if file cannot be read or parsed
394    pub async fn get_or_parse(
395        &self,
396        provider: &dyn LockFileProvider,
397        lockfile_path: &Path,
398    ) -> Result<ResolvedPackages> {
399        // Check cache first
400        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        // Cache miss - parse and store
410        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    /// Invalidates cached entry for a lock file.
429    ///
430    /// Forces next access to re-parse the file. Use when you know
431    /// the file has changed but modification time might not reflect it.
432    pub fn invalidate(&self, lockfile_path: &Path) {
433        self.entries.remove(lockfile_path);
434    }
435
436    /// Returns the number of cached lock files.
437    pub fn len(&self) -> usize {
438        self.entries.len()
439    }
440
441    /// Returns true if the cache is empty.
442    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        // Both versions stored, but len counts unique names
522        assert_eq!(packages.len(), 1);
523        assert_eq!(packages.get_version("serde"), Some("1.0.195"));
524        // Both versions accessible via get_all
525        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        // Falls back to string comparison: "xyz" > "abc"
586        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        // Parseable semver is preferred over non-parseable
608        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        // poetry.lock doesn't exist, but uv.lock does - should find uv.lock
766        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        // Both exist, poetry.lock should be found first (listed first)
785        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}