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