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::{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
17/// Maximum depth to search for workspace root lock file.
18const MAX_WORKSPACE_DEPTH: usize = 5;
19
20/// Maximum lock file size [`read_lockfile_content`] reads before parsing.
21///
22/// Deliberately a separate, larger constant than [`crate::mtime_cache::MAX_CACHED_FILE_BYTES`]
23/// — that cap is scoped to small config files (`.cargo/config.toml`, `.npmrc`, typically a
24/// few KB), while a lock file records every transitively resolved package across an entire
25/// dependency graph, and a large npm monorepo `package-lock.json` can legitimately run well
26/// past 8 MiB. 32 MiB matches [`crate::cache`]'s `MAX_RESPONSE_BYTES` order of magnitude —
27/// generous for any realistic lock file while still bounding the read against a
28/// maliciously large one discovered by an unauthenticated ancestor walk over a cloned
29/// repository (CWE-400).
30pub const MAX_LOCKFILE_BYTES: u64 = 32 * 1024 * 1024;
31
32/// Reads a lock file's contents, wrapping any I/O failure into a [`DepsError::ParseError`]
33/// tagged with the ecosystem's `file_type` label and the file's path.
34///
35/// Every `LockFileProvider::parse_lockfile` implementation reads its lock file the same
36/// way; this shares that boilerplate and keeps the error message format consistent
37/// across ecosystems.
38///
39/// Bounded by [`MAX_LOCKFILE_BYTES`] via [`fs_probe::read_to_string_capped`] — a lock file
40/// is discovered by an unauthenticated ancestor walk ([`locate_lockfile_for_manifest`]) over
41/// a possibly hostile cloned repository, so nothing here may assume it is reasonably sized
42/// or a regular file before reading it in full (CWE-400). The capped read itself runs on
43/// the blocking-thread pool via [`tokio::task::spawn_blocking`], not on the calling tokio
44/// worker thread: it is synchronous I/O with no `.await` of its own, and every
45/// `LockFileProvider::parse_lockfile` call site sits on the LSP request path, where a
46/// worker thread blocked on an 8+ MiB read — or indefinitely, on a FIFO — would violate the
47/// project's non-blocking-handler rule.
48///
49/// # Errors
50///
51/// Returns [`DepsError::ParseError`] if the file cannot be read (e.g. missing, not a
52/// regular file, unreadable, invalid UTF-8), exceeds [`MAX_LOCKFILE_BYTES`], or the
53/// blocking read task panicked.
54///
55/// # Examples
56///
57/// ```no_run
58/// use deps_core::lockfile::read_lockfile_content;
59/// use std::path::Path;
60///
61/// # async fn example() -> deps_core::error::Result<()> {
62/// let content = read_lockfile_content(Path::new("Cargo.lock"), "Cargo.lock").await?;
63/// println!("{} bytes read", content.len());
64/// # Ok(())
65/// # }
66/// ```
67pub 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    // Cheap `stat` pre-filter (mirrors `MtimeFileCache::get_or_parse`): rejects a
80    // non-regular file (FIFO, socket, directory — reading one of those can block
81    // indefinitely) and an obviously oversized file before it is ever opened. The capped
82    // read below still enforces the size bound on the read itself regardless of what this
83    // reports, closing the same TOCTOU gap (CWE-367) a stat-only check alone would leave
84    // open (e.g. a symlink swapped to a FIFO, or the file growing, between this stat and
85    // the read).
86    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
124/// Generic lock file locator.
125///
126/// Searches for lock files in the following order:
127/// 1. Same directory as the manifest
128/// 2. Parent directories (up to MAX_WORKSPACE_DEPTH levels) for workspace root
129///
130/// Each candidate is checked with [`fs_probe::is_file`], not a plain existence check — a
131/// FIFO or socket happening to sit at a lock file's conventional name must never be
132/// returned as "found", since [`read_lockfile_content`] opening one would block
133/// indefinitely.
134///
135/// This function is ecosystem-agnostic and works with any lock file name.
136///
137/// # Arguments
138///
139/// * `manifest_uri` - URI of the manifest file
140/// * `lockfile_names` - List of possible lock file names to search for
141///
142/// # Returns
143///
144/// Path to the first found lock file, or None if not found.
145///
146/// # Examples
147///
148/// ```no_run
149/// use deps_core::lockfile::locate_lockfile_for_manifest;
150/// use tower_lsp_server::ls_types::Uri;
151///
152/// let manifest_uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap();
153/// let lockfile_names = &["Cargo.lock"];
154///
155/// if let Some(path) = locate_lockfile_for_manifest(&manifest_uri, lockfile_names) {
156///     println!("Found lock file at: {}", path.display());
157/// }
158/// ```
159pub 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    // Reuse single PathBuf to avoid allocations in loops
167    let mut lock_path = manifest_dir.to_path_buf();
168
169    // Try same directory as manifest
170    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    // Search up the directory tree for workspace root
180    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/// Resolved package information from a lock file.
214///
215/// Contains the exact version and source information for a dependency
216/// as resolved by the package manager.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ResolvedPackage {
219    /// Package name
220    pub name: String,
221    /// Resolved version (exact version from lock file)
222    pub version: String,
223    /// Source information (registry URL, git commit, path)
224    pub source: ResolvedSource,
225    /// Dependencies of this package (for dependency tree analysis)
226    pub dependencies: Vec<String>,
227}
228
229/// Source of a resolved dependency.
230///
231/// Indicates where the package was downloaded from or how it was resolved.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ResolvedSource {
234    /// From a registry with optional checksum
235    Registry {
236        /// Registry URL
237        url: String,
238        /// Checksum/integrity hash
239        checksum: String,
240    },
241    /// From git with commit hash
242    Git {
243        /// Git repository URL
244        url: String,
245        /// Commit SHA or tag
246        rev: String,
247    },
248    /// From local file system
249    Path {
250        /// Relative or absolute path
251        path: String,
252    },
253}
254
255/// Collection of resolved packages from a lock file.
256///
257/// Supports multiple versions per package name, returning the highest
258/// semver version through public API methods.
259///
260/// # Examples
261///
262/// ```
263/// use deps_core::lockfile::{ResolvedPackages, ResolvedPackage, ResolvedSource};
264///
265/// let mut packages = ResolvedPackages::new();
266/// packages.insert(ResolvedPackage {
267///     name: "serde".into(),
268///     version: "1.0.195".into(),
269///     source: ResolvedSource::Registry {
270///         url: "https://github.com/rust-lang/crates.io-index".into(),
271///         checksum: "abc123".into(),
272///     },
273///     dependencies: vec!["serde_derive".into()],
274/// });
275///
276/// assert_eq!(packages.get_version("serde"), Some("1.0.195"));
277/// assert_eq!(packages.len(), 1);
278/// ```
279#[derive(Debug, Default, Clone)]
280pub struct ResolvedPackages {
281    packages: HashMap<String, Vec<ResolvedPackage>>,
282}
283
284/// Returns the package with the highest semver version from a slice.
285fn 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    /// Creates a new empty collection.
301    pub fn new() -> Self {
302        Self {
303            packages: HashMap::new(),
304        }
305    }
306
307    /// Inserts a resolved package, storing all versions per name.
308    pub fn insert(&mut self, package: ResolvedPackage) {
309        self.packages
310            .entry(package.name.clone())
311            .or_default()
312            .push(package);
313    }
314
315    /// Gets the resolved package with the highest semver version.
316    pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
317        self.packages.get(name).and_then(|v| best_package(v))
318    }
319
320    /// Gets the highest resolved version string for a package.
321    pub fn get_version(&self, name: &str) -> Option<&str> {
322        self.get(name).map(|p| p.version.as_str())
323    }
324
325    /// Returns all stored versions for a package.
326    pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
327        self.packages.get(name).map(|v| v.as_slice())
328    }
329
330    /// Returns the number of unique package names.
331    pub fn len(&self) -> usize {
332        self.packages.len()
333    }
334
335    /// Returns true if there are no resolved packages.
336    pub fn is_empty(&self) -> bool {
337        self.packages.is_empty()
338    }
339
340    /// Returns an iterator yielding the best version per unique package name.
341    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    /// Converts into a HashMap with the best version per package name.
350    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
358/// Lock file provider trait for ecosystem-specific implementations.
359///
360/// Implementations parse lock files for a specific package ecosystem
361/// (Cargo.lock, package-lock.json, etc.) and extract resolved versions.
362///
363/// # Examples
364///
365/// ```no_run
366/// use deps_core::lockfile::{LockFileProvider, ResolvedPackages};
367/// use std::path::{Path, PathBuf};
368/// use tower_lsp_server::ls_types::Uri;
369///
370/// struct MyLockParser;
371///
372/// impl LockFileProvider for MyLockParser {
373///     fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
374///         let manifest_path = manifest_uri.to_file_path()?;
375///         let lock_path = manifest_path.with_file_name("my.lock");
376///         lock_path.exists().then_some(lock_path)
377///     }
378///
379///     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>> {
380///         Box::pin(async move {
381///             // Parse lock file format and extract packages
382///             Ok(ResolvedPackages::new())
383///         })
384///     }
385/// }
386/// ```
387pub trait LockFileProvider: Send + Sync {
388    /// Locates the lock file for a given manifest URI.
389    ///
390    /// Returns `None` if:
391    /// - Lock file doesn't exist
392    /// - Manifest path cannot be determined from URI
393    /// - Workspace root search fails
394    ///
395    /// # Arguments
396    ///
397    /// * `manifest_uri` - URI of the manifest file (Cargo.toml, package.json, etc.)
398    ///
399    /// # Returns
400    ///
401    /// Path to lock file if found
402    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
403
404    /// Parses a lock file and extracts resolved packages.
405    ///
406    /// # Arguments
407    ///
408    /// * `lockfile_path` - Path to the lock file
409    ///
410    /// # Returns
411    ///
412    /// ResolvedPackages on success, error if parse fails
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if:
417    /// - File cannot be read
418    /// - File format is invalid
419    /// - Required fields are missing
420    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    /// Checks if lock file has been modified since last parse.
426    ///
427    /// Used for cache invalidation. Default implementation compares
428    /// file modification time.
429    ///
430    /// # Arguments
431    ///
432    /// * `lockfile_path` - Path to the lock file
433    /// * `last_modified` - Last known modification time
434    ///
435    /// # Returns
436    ///
437    /// `true` if file has been modified or cannot be stat'd, `false` otherwise
438    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
448/// Cached lock file entry with staleness detection.
449struct CachedLockFile {
450    packages: ResolvedPackages,
451    modified_at: SystemTime,
452    #[allow(dead_code)]
453    parsed_at: Instant,
454}
455
456/// Cache for parsed lock files with automatic staleness detection.
457///
458/// Caches parsed lock file contents and checks file modification time
459/// to avoid re-parsing unchanged files. Thread-safe for concurrent access.
460///
461/// # Examples
462///
463/// ```no_run
464/// use deps_core::lockfile::LockFileCache;
465/// use std::path::Path;
466///
467/// # async fn example() -> deps_core::error::Result<()> {
468/// let cache = LockFileCache::new();
469/// // First call parses the file
470/// // Second call returns cached result if file hasn't changed
471/// # Ok(())
472/// # }
473/// ```
474pub struct LockFileCache {
475    entries: DashMap<PathBuf, CachedLockFile>,
476}
477
478impl LockFileCache {
479    /// Creates a new empty lock file cache.
480    pub fn new() -> Self {
481        Self {
482            entries: DashMap::new(),
483        }
484    }
485
486    /// Gets parsed packages from cache or parses the lock file.
487    ///
488    /// Checks file modification time to detect changes. If the file
489    /// has been modified since last parse, re-parses it. Otherwise,
490    /// returns the cached result.
491    ///
492    /// # Arguments
493    ///
494    /// * `provider` - Lock file provider implementation
495    /// * `lockfile_path` - Path to the lock file
496    ///
497    /// # Returns
498    ///
499    /// Resolved packages on success
500    ///
501    /// # Errors
502    ///
503    /// Returns error if file cannot be read or parsed
504    pub async fn get_or_parse(
505        &self,
506        provider: &dyn LockFileProvider,
507        lockfile_path: &Path,
508    ) -> Result<ResolvedPackages> {
509        // Extract owned data from the cache entry before awaiting: the DashMap shard
510        // `Ref` returned by `entries.get` must not be held across `.await`, or a
511        // concurrent access to the same key blocks for the duration (#350, same hazard
512        // class as #333).
513        let cached = self
514            .entries
515            .get(lockfile_path)
516            .map(|entry| (entry.modified_at, entry.packages.clone()));
517
518        // Check cache first
519        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        // Cache miss - parse and store.
529        //
530        // Stat the file *before* parsing and use that pre-parse mtime as the cache
531        // key's freshness marker. If we stat'd after `parse_lockfile` instead, a
532        // concurrent rewrite landing mid-parse would let us store content read from
533        // the old version of the file under the new version's mtime, making the
534        // entry look fresh when it is actually stale (#359).
535        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    /// Invalidates cached entry for a lock file.
554    ///
555    /// Forces next access to re-parse the file. Use when you know
556    /// the file has changed but modification time might not reflect it.
557    pub fn invalidate(&self, lockfile_path: &Path) {
558        self.entries.remove(lockfile_path);
559    }
560
561    /// Returns the number of cached lock files.
562    pub fn len(&self) -> usize {
563        self.entries.len()
564    }
565
566    /// Returns true if the cache is empty.
567    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    /// An oversized lock file must be rejected by the capped read rather than read into
596    /// memory in full (CWE-400) — `read_lockfile_content` previously had no size gate at
597    /// all (#607). Uses a sparse file (`set_len`, all-zero bytes, valid UTF-8) so the test
598    /// does not need to write `MAX_LOCKFILE_BYTES` of real content to disk — `unwrap_err()`
599    /// panics outright if the cap is not actually applied, since a sparse file's all-NUL
600    /// content is otherwise perfectly valid `String` content for the success path to return.
601    #[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    /// A non-regular file (a directory here, portable across platforms unlike a FIFO) at
621    /// the lock file path must be rejected by the `is_file` stat pre-filter, not handed to
622    /// `File::open`/`read_to_string_capped` — the same class of hazard `fs_probe::is_file`'s
623    /// own doc warns about (a FIFO would block the read indefinitely).
624    #[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        // Both versions stored, but len counts unique names
724        assert_eq!(packages.len(), 1);
725        assert_eq!(packages.get_version("serde"), Some("1.0.195"));
726        // Both versions accessible via get_all
727        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        // Falls back to string comparison: "xyz" > "abc"
788        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        // Parseable semver is preferred over non-parseable
810        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    /// A directory named `Cargo.lock` (portable stand-in for a FIFO, which
946    /// `std::fs::exists`-style checks would also wrongly treat as "found") must not be
947    /// returned as a located lock file — `fs_probe::is_file` rejects anything but a
948    /// regular file, unlike the plain `Path::exists()` this locator used before the fix.
949    #[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        // poetry.lock doesn't exist, but uv.lock does - should find uv.lock
990        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    /// Stub [`LockFileProvider`] that counts `parse_lockfile` invocations and returns
997    /// a package whose version is the lock file's trimmed content, so tests can
998    /// observe both call count and which content was actually parsed.
999    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        // Explicitly bump mtime into the future rather than relying on filesystem
1074        // mtime resolution (coarse on some platforms) to observe the change.
1075        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    /// Stub [`LockFileProvider`] that simulates a concurrent writer racing the parse:
1094    /// each `parse_lockfile` call reads the file's *current* content first, then — as
1095    /// a side effect before returning — rewrites the file to `"2.0.0"` and bumps its
1096    /// mtime into the future, then returns packages parsed from the content it read
1097    /// *before* that rewrite. This reproduces the only await point between the
1098    /// `get_or_parse` stat and cache insert, entirely under test control.
1099    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                // Simulate a writer rewriting the lock file mid-parse.
1132                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    /// Regression test for #359: discriminates the stat-before-parse fix from the
1154    /// original stat-after-parse ordering.
1155    ///
1156    /// With the fix, `get_or_parse` stats the file *before* calling `parse_lockfile`,
1157    /// so the cached `modified_at` reflects the pre-rewrite mtime tied to the
1158    /// `"1.0.0"` content actually parsed. A second call then sees the file's mtime is
1159    /// newer than the cached one, so it re-parses and observes `"2.0.0"`.
1160    ///
1161    /// On the pre-fix ordering (stat after parse), the post-parse stat would pick up
1162    /// the mtime bump this same call just made, storing the *new* mtime alongside the
1163    /// *old* (`"1.0.0"`) content. The second call would then incorrectly hit cache and
1164    /// return stale `"1.0.0"` content without re-parsing — exactly the bug #359
1165    /// describes. This test would have failed on that code.
1166    #[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        // Both exist, poetry.lock should be found first (listed first)
1201        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}