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::lsp_types::Url;
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::lsp_types::Url;
152///
153/// struct MyLockParser;
154///
155/// #[async_trait]
156/// impl LockFileProvider for MyLockParser {
157/// fn locate_lockfile(&self, manifest_uri: &Url) -> Option<PathBuf> {
158/// let manifest_path = manifest_uri.to_file_path().ok()?;
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: &Url) -> 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}