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/// Resolved package information from a lock file.
18///
19/// Contains the exact version and source information for a dependency
20/// as resolved by the package manager.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ResolvedPackage {
23    /// Package name
24    pub name: String,
25    /// Resolved version (exact version from lock file)
26    pub version: String,
27    /// Source information (registry URL, git commit, path)
28    pub source: ResolvedSource,
29    /// Dependencies of this package (for dependency tree analysis)
30    pub dependencies: Vec<String>,
31}
32
33/// Source of a resolved dependency.
34///
35/// Indicates where the package was downloaded from or how it was resolved.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ResolvedSource {
38    /// From a registry with optional checksum
39    Registry {
40        /// Registry URL
41        url: String,
42        /// Checksum/integrity hash
43        checksum: String,
44    },
45    /// From git with commit hash
46    Git {
47        /// Git repository URL
48        url: String,
49        /// Commit SHA or tag
50        rev: String,
51    },
52    /// From local file system
53    Path {
54        /// Relative or absolute path
55        path: String,
56    },
57}
58
59/// Collection of resolved packages from a lock file.
60///
61/// Provides efficient lookup of resolved versions by package name.
62///
63/// # Examples
64///
65/// ```
66/// use deps_core::lockfile::{ResolvedPackages, ResolvedPackage, ResolvedSource};
67///
68/// let mut packages = ResolvedPackages::new();
69/// packages.insert(ResolvedPackage {
70///     name: "serde".into(),
71///     version: "1.0.195".into(),
72///     source: ResolvedSource::Registry {
73///         url: "https://github.com/rust-lang/crates.io-index".into(),
74///         checksum: "abc123".into(),
75///     },
76///     dependencies: vec!["serde_derive".into()],
77/// });
78///
79/// assert_eq!(packages.get_version("serde"), Some("1.0.195"));
80/// assert_eq!(packages.len(), 1);
81/// ```
82#[derive(Debug, Default, Clone)]
83pub struct ResolvedPackages {
84    /// Map from package name to resolved package info
85    packages: HashMap<String, ResolvedPackage>,
86}
87
88impl ResolvedPackages {
89    /// Creates a new empty collection.
90    pub fn new() -> Self {
91        Self {
92            packages: HashMap::new(),
93        }
94    }
95
96    /// Inserts a resolved package.
97    ///
98    /// If a package with the same name already exists, it is replaced.
99    pub fn insert(&mut self, package: ResolvedPackage) {
100        self.packages.insert(package.name.clone(), package);
101    }
102
103    /// Gets a resolved package by name.
104    ///
105    /// Returns `None` if the package is not in the lock file.
106    pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
107        self.packages.get(name)
108    }
109
110    /// Gets the resolved version string for a package.
111    ///
112    /// Returns `None` if the package is not in the lock file.
113    ///
114    /// This is a convenience method equivalent to `get(name).map(|p| p.version.as_str())`.
115    pub fn get_version(&self, name: &str) -> Option<&str> {
116        self.packages.get(name).map(|p| p.version.as_str())
117    }
118
119    /// Returns the number of resolved packages.
120    pub fn len(&self) -> usize {
121        self.packages.len()
122    }
123
124    /// Returns true if there are no resolved packages.
125    pub fn is_empty(&self) -> bool {
126        self.packages.is_empty()
127    }
128
129    /// Returns an iterator over package names and their resolved info.
130    pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
131        self.packages.iter()
132    }
133
134    /// Converts into a HashMap for easier integration.
135    pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
136        self.packages
137    }
138}
139
140/// Lock file provider trait for ecosystem-specific implementations.
141///
142/// Implementations parse lock files for a specific package ecosystem
143/// (Cargo.lock, package-lock.json, etc.) and extract resolved versions.
144///
145/// # Examples
146///
147/// ```no_run
148/// use deps_core::lockfile::{LockFileProvider, ResolvedPackages};
149/// use async_trait::async_trait;
150/// use std::path::{Path, PathBuf};
151/// use tower_lsp_server::ls_types::Uri;
152///
153/// struct MyLockParser;
154///
155/// #[async_trait]
156/// impl LockFileProvider for MyLockParser {
157///     fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
158///         let manifest_path = manifest_uri.to_file_path()?;
159///         let lock_path = manifest_path.with_file_name("my.lock");
160///         lock_path.exists().then_some(lock_path)
161///     }
162///
163///     async fn parse_lockfile(&self, lockfile_path: &Path) -> deps_core::error::Result<ResolvedPackages> {
164///         // Parse lock file format and extract packages
165///         Ok(ResolvedPackages::new())
166///     }
167/// }
168/// ```
169#[async_trait]
170pub trait LockFileProvider: Send + Sync {
171    /// Locates the lock file for a given manifest URI.
172    ///
173    /// Returns `None` if:
174    /// - Lock file doesn't exist
175    /// - Manifest path cannot be determined from URI
176    /// - Workspace root search fails
177    ///
178    /// # Arguments
179    ///
180    /// * `manifest_uri` - URI of the manifest file (Cargo.toml, package.json, etc.)
181    ///
182    /// # Returns
183    ///
184    /// Path to lock file if found
185    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
186
187    /// Parses a lock file and extracts resolved packages.
188    ///
189    /// # Arguments
190    ///
191    /// * `lockfile_path` - Path to the lock file
192    ///
193    /// # Returns
194    ///
195    /// ResolvedPackages on success, error if parse fails
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if:
200    /// - File cannot be read
201    /// - File format is invalid
202    /// - Required fields are missing
203    async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages>;
204
205    /// Checks if lock file has been modified since last parse.
206    ///
207    /// Used for cache invalidation. Default implementation compares
208    /// file modification time.
209    ///
210    /// # Arguments
211    ///
212    /// * `lockfile_path` - Path to the lock file
213    /// * `last_modified` - Last known modification time
214    ///
215    /// # Returns
216    ///
217    /// `true` if file has been modified or cannot be stat'd, `false` otherwise
218    fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool {
219        if let Ok(metadata) = std::fs::metadata(lockfile_path)
220            && let Ok(mtime) = metadata.modified()
221        {
222            return mtime > last_modified;
223        }
224        true
225    }
226}
227
228/// Cached lock file entry with staleness detection.
229struct CachedLockFile {
230    packages: ResolvedPackages,
231    modified_at: SystemTime,
232    #[allow(dead_code)]
233    parsed_at: Instant,
234}
235
236/// Cache for parsed lock files with automatic staleness detection.
237///
238/// Caches parsed lock file contents and checks file modification time
239/// to avoid re-parsing unchanged files. Thread-safe for concurrent access.
240///
241/// # Examples
242///
243/// ```no_run
244/// use deps_core::lockfile::LockFileCache;
245/// use std::path::Path;
246///
247/// # async fn example() -> deps_core::error::Result<()> {
248/// let cache = LockFileCache::new();
249/// // First call parses the file
250/// // Second call returns cached result if file hasn't changed
251/// # Ok(())
252/// # }
253/// ```
254pub struct LockFileCache {
255    entries: DashMap<PathBuf, CachedLockFile>,
256}
257
258impl LockFileCache {
259    /// Creates a new empty lock file cache.
260    pub fn new() -> Self {
261        Self {
262            entries: DashMap::new(),
263        }
264    }
265
266    /// Gets parsed packages from cache or parses the lock file.
267    ///
268    /// Checks file modification time to detect changes. If the file
269    /// has been modified since last parse, re-parses it. Otherwise,
270    /// returns the cached result.
271    ///
272    /// # Arguments
273    ///
274    /// * `provider` - Lock file provider implementation
275    /// * `lockfile_path` - Path to the lock file
276    ///
277    /// # Returns
278    ///
279    /// Resolved packages on success
280    ///
281    /// # Errors
282    ///
283    /// Returns error if file cannot be read or parsed
284    pub async fn get_or_parse(
285        &self,
286        provider: &dyn LockFileProvider,
287        lockfile_path: &Path,
288    ) -> Result<ResolvedPackages> {
289        // Check cache first
290        if let Some(cached) = self.entries.get(lockfile_path)
291            && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
292            && let Ok(mtime) = metadata.modified()
293            && mtime <= cached.modified_at
294        {
295            tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
296            return Ok(cached.packages.clone());
297        }
298
299        // Cache miss - parse and store
300        tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
301        let packages = provider.parse_lockfile(lockfile_path).await?;
302
303        let metadata = tokio::fs::metadata(lockfile_path).await?;
304        let modified_at = metadata.modified()?;
305
306        self.entries.insert(
307            lockfile_path.to_path_buf(),
308            CachedLockFile {
309                packages: packages.clone(),
310                modified_at,
311                parsed_at: Instant::now(),
312            },
313        );
314
315        Ok(packages)
316    }
317
318    /// Invalidates cached entry for a lock file.
319    ///
320    /// Forces next access to re-parse the file. Use when you know
321    /// the file has changed but modification time might not reflect it.
322    pub fn invalidate(&self, lockfile_path: &Path) {
323        self.entries.remove(lockfile_path);
324    }
325
326    /// Returns the number of cached lock files.
327    pub fn len(&self) -> usize {
328        self.entries.len()
329    }
330
331    /// Returns true if the cache is empty.
332    pub fn is_empty(&self) -> bool {
333        self.entries.is_empty()
334    }
335}
336
337impl Default for LockFileCache {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_resolved_packages_new() {
349        let packages = ResolvedPackages::new();
350        assert!(packages.is_empty());
351        assert_eq!(packages.len(), 0);
352    }
353
354    #[test]
355    fn test_resolved_packages_insert_and_get() {
356        let mut packages = ResolvedPackages::new();
357
358        let pkg = ResolvedPackage {
359            name: "serde".into(),
360            version: "1.0.195".into(),
361            source: ResolvedSource::Registry {
362                url: "https://github.com/rust-lang/crates.io-index".into(),
363                checksum: "abc123".into(),
364            },
365            dependencies: vec!["serde_derive".into()],
366        };
367
368        packages.insert(pkg);
369
370        assert_eq!(packages.len(), 1);
371        assert!(!packages.is_empty());
372        assert_eq!(packages.get_version("serde"), Some("1.0.195"));
373
374        let retrieved = packages.get("serde");
375        assert!(retrieved.is_some());
376        assert_eq!(retrieved.unwrap().name, "serde");
377        assert_eq!(retrieved.unwrap().dependencies.len(), 1);
378    }
379
380    #[test]
381    fn test_resolved_packages_get_nonexistent() {
382        let packages = ResolvedPackages::new();
383        assert_eq!(packages.get("nonexistent"), None);
384        assert_eq!(packages.get_version("nonexistent"), None);
385    }
386
387    #[test]
388    fn test_resolved_packages_replace() {
389        let mut packages = ResolvedPackages::new();
390
391        packages.insert(ResolvedPackage {
392            name: "serde".into(),
393            version: "1.0.0".into(),
394            source: ResolvedSource::Registry {
395                url: "test".into(),
396                checksum: "old".into(),
397            },
398            dependencies: vec![],
399        });
400
401        packages.insert(ResolvedPackage {
402            name: "serde".into(),
403            version: "1.0.195".into(),
404            source: ResolvedSource::Registry {
405                url: "test".into(),
406                checksum: "new".into(),
407            },
408            dependencies: vec![],
409        });
410
411        assert_eq!(packages.len(), 1);
412        assert_eq!(packages.get_version("serde"), Some("1.0.195"));
413    }
414
415    #[test]
416    fn test_resolved_source_equality() {
417        let source1 = ResolvedSource::Registry {
418            url: "https://test.com".into(),
419            checksum: "abc".into(),
420        };
421        let source2 = ResolvedSource::Registry {
422            url: "https://test.com".into(),
423            checksum: "abc".into(),
424        };
425        let source3 = ResolvedSource::Git {
426            url: "https://github.com/test".into(),
427            rev: "abc123".into(),
428        };
429
430        assert_eq!(source1, source2);
431        assert_ne!(source1, source3);
432    }
433
434    #[test]
435    fn test_resolved_packages_iter() {
436        let mut packages = ResolvedPackages::new();
437
438        packages.insert(ResolvedPackage {
439            name: "serde".into(),
440            version: "1.0.0".into(),
441            source: ResolvedSource::Registry {
442                url: "test".into(),
443                checksum: "a".into(),
444            },
445            dependencies: vec![],
446        });
447
448        packages.insert(ResolvedPackage {
449            name: "tokio".into(),
450            version: "1.0.0".into(),
451            source: ResolvedSource::Registry {
452                url: "test".into(),
453                checksum: "b".into(),
454            },
455            dependencies: vec![],
456        });
457
458        let count = packages.iter().count();
459        assert_eq!(count, 2);
460
461        let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
462        assert!(names.contains(&"serde"));
463        assert!(names.contains(&"tokio"));
464    }
465
466    #[test]
467    fn test_resolved_packages_into_map() {
468        let mut packages = ResolvedPackages::new();
469
470        packages.insert(ResolvedPackage {
471            name: "serde".into(),
472            version: "1.0.0".into(),
473            source: ResolvedSource::Registry {
474                url: "test".into(),
475                checksum: "a".into(),
476            },
477            dependencies: vec![],
478        });
479
480        let map = packages.into_map();
481        assert_eq!(map.len(), 1);
482        assert!(map.contains_key("serde"));
483    }
484
485    #[test]
486    fn test_lockfile_cache_new() {
487        let cache = LockFileCache::new();
488        assert!(cache.is_empty());
489        assert_eq!(cache.len(), 0);
490    }
491
492    #[test]
493    fn test_lockfile_cache_invalidate() {
494        let cache = LockFileCache::new();
495        let test_path = PathBuf::from("/test/Cargo.lock");
496
497        cache.entries.insert(
498            test_path.clone(),
499            CachedLockFile {
500                packages: ResolvedPackages::new(),
501                modified_at: SystemTime::now(),
502                parsed_at: Instant::now(),
503            },
504        );
505
506        assert_eq!(cache.len(), 1);
507
508        cache.invalidate(&test_path);
509        assert_eq!(cache.len(), 0);
510        assert!(cache.is_empty());
511    }
512}