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/// Provides efficient lookup of resolved versions by package name.
149///
150/// # Examples
151///
152/// ```
153/// use deps_core::lockfile::{ResolvedPackages, ResolvedPackage, ResolvedSource};
154///
155/// let mut packages = ResolvedPackages::new();
156/// packages.insert(ResolvedPackage {
157///     name: "serde".into(),
158///     version: "1.0.195".into(),
159///     source: ResolvedSource::Registry {
160///         url: "https://github.com/rust-lang/crates.io-index".into(),
161///         checksum: "abc123".into(),
162///     },
163///     dependencies: vec!["serde_derive".into()],
164/// });
165///
166/// assert_eq!(packages.get_version("serde"), Some("1.0.195"));
167/// assert_eq!(packages.len(), 1);
168/// ```
169#[derive(Debug, Default, Clone)]
170pub struct ResolvedPackages {
171    /// Map from package name to resolved package info
172    packages: HashMap<String, ResolvedPackage>,
173}
174
175impl ResolvedPackages {
176    /// Creates a new empty collection.
177    pub fn new() -> Self {
178        Self {
179            packages: HashMap::new(),
180        }
181    }
182
183    /// Inserts a resolved package.
184    ///
185    /// If a package with the same name already exists, it is replaced.
186    pub fn insert(&mut self, package: ResolvedPackage) {
187        self.packages.insert(package.name.clone(), package);
188    }
189
190    /// Gets a resolved package by name.
191    ///
192    /// Returns `None` if the package is not in the lock file.
193    pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
194        self.packages.get(name)
195    }
196
197    /// Gets the resolved version string for a package.
198    ///
199    /// Returns `None` if the package is not in the lock file.
200    ///
201    /// This is a convenience method equivalent to `get(name).map(|p| p.version.as_str())`.
202    pub fn get_version(&self, name: &str) -> Option<&str> {
203        self.packages.get(name).map(|p| p.version.as_str())
204    }
205
206    /// Returns the number of resolved packages.
207    pub fn len(&self) -> usize {
208        self.packages.len()
209    }
210
211    /// Returns true if there are no resolved packages.
212    pub fn is_empty(&self) -> bool {
213        self.packages.is_empty()
214    }
215
216    /// Returns an iterator over package names and their resolved info.
217    pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
218        self.packages.iter()
219    }
220
221    /// Converts into a HashMap for easier integration.
222    pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
223        self.packages
224    }
225}
226
227/// Lock file provider trait for ecosystem-specific implementations.
228///
229/// Implementations parse lock files for a specific package ecosystem
230/// (Cargo.lock, package-lock.json, etc.) and extract resolved versions.
231///
232/// # Examples
233///
234/// ```no_run
235/// use deps_core::lockfile::{LockFileProvider, ResolvedPackages};
236/// use async_trait::async_trait;
237/// use std::path::{Path, PathBuf};
238/// use tower_lsp_server::ls_types::Uri;
239///
240/// struct MyLockParser;
241///
242/// #[async_trait]
243/// impl LockFileProvider for MyLockParser {
244///     fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
245///         let manifest_path = manifest_uri.to_file_path()?;
246///         let lock_path = manifest_path.with_file_name("my.lock");
247///         lock_path.exists().then_some(lock_path)
248///     }
249///
250///     async fn parse_lockfile(&self, lockfile_path: &Path) -> deps_core::error::Result<ResolvedPackages> {
251///         // Parse lock file format and extract packages
252///         Ok(ResolvedPackages::new())
253///     }
254/// }
255/// ```
256#[async_trait]
257pub trait LockFileProvider: Send + Sync {
258    /// Locates the lock file for a given manifest URI.
259    ///
260    /// Returns `None` if:
261    /// - Lock file doesn't exist
262    /// - Manifest path cannot be determined from URI
263    /// - Workspace root search fails
264    ///
265    /// # Arguments
266    ///
267    /// * `manifest_uri` - URI of the manifest file (Cargo.toml, package.json, etc.)
268    ///
269    /// # Returns
270    ///
271    /// Path to lock file if found
272    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
273
274    /// Parses a lock file and extracts resolved packages.
275    ///
276    /// # Arguments
277    ///
278    /// * `lockfile_path` - Path to the lock file
279    ///
280    /// # Returns
281    ///
282    /// ResolvedPackages on success, error if parse fails
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if:
287    /// - File cannot be read
288    /// - File format is invalid
289    /// - Required fields are missing
290    async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages>;
291
292    /// Checks if lock file has been modified since last parse.
293    ///
294    /// Used for cache invalidation. Default implementation compares
295    /// file modification time.
296    ///
297    /// # Arguments
298    ///
299    /// * `lockfile_path` - Path to the lock file
300    /// * `last_modified` - Last known modification time
301    ///
302    /// # Returns
303    ///
304    /// `true` if file has been modified or cannot be stat'd, `false` otherwise
305    fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool {
306        if let Ok(metadata) = std::fs::metadata(lockfile_path)
307            && let Ok(mtime) = metadata.modified()
308        {
309            return mtime > last_modified;
310        }
311        true
312    }
313}
314
315/// Cached lock file entry with staleness detection.
316struct CachedLockFile {
317    packages: ResolvedPackages,
318    modified_at: SystemTime,
319    #[allow(dead_code)]
320    parsed_at: Instant,
321}
322
323/// Cache for parsed lock files with automatic staleness detection.
324///
325/// Caches parsed lock file contents and checks file modification time
326/// to avoid re-parsing unchanged files. Thread-safe for concurrent access.
327///
328/// # Examples
329///
330/// ```no_run
331/// use deps_core::lockfile::LockFileCache;
332/// use std::path::Path;
333///
334/// # async fn example() -> deps_core::error::Result<()> {
335/// let cache = LockFileCache::new();
336/// // First call parses the file
337/// // Second call returns cached result if file hasn't changed
338/// # Ok(())
339/// # }
340/// ```
341pub struct LockFileCache {
342    entries: DashMap<PathBuf, CachedLockFile>,
343}
344
345impl LockFileCache {
346    /// Creates a new empty lock file cache.
347    pub fn new() -> Self {
348        Self {
349            entries: DashMap::new(),
350        }
351    }
352
353    /// Gets parsed packages from cache or parses the lock file.
354    ///
355    /// Checks file modification time to detect changes. If the file
356    /// has been modified since last parse, re-parses it. Otherwise,
357    /// returns the cached result.
358    ///
359    /// # Arguments
360    ///
361    /// * `provider` - Lock file provider implementation
362    /// * `lockfile_path` - Path to the lock file
363    ///
364    /// # Returns
365    ///
366    /// Resolved packages on success
367    ///
368    /// # Errors
369    ///
370    /// Returns error if file cannot be read or parsed
371    pub async fn get_or_parse(
372        &self,
373        provider: &dyn LockFileProvider,
374        lockfile_path: &Path,
375    ) -> Result<ResolvedPackages> {
376        // Check cache first
377        if let Some(cached) = self.entries.get(lockfile_path)
378            && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
379            && let Ok(mtime) = metadata.modified()
380            && mtime <= cached.modified_at
381        {
382            tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
383            return Ok(cached.packages.clone());
384        }
385
386        // Cache miss - parse and store
387        tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
388        let packages = provider.parse_lockfile(lockfile_path).await?;
389
390        let metadata = tokio::fs::metadata(lockfile_path).await?;
391        let modified_at = metadata.modified()?;
392
393        self.entries.insert(
394            lockfile_path.to_path_buf(),
395            CachedLockFile {
396                packages: packages.clone(),
397                modified_at,
398                parsed_at: Instant::now(),
399            },
400        );
401
402        Ok(packages)
403    }
404
405    /// Invalidates cached entry for a lock file.
406    ///
407    /// Forces next access to re-parse the file. Use when you know
408    /// the file has changed but modification time might not reflect it.
409    pub fn invalidate(&self, lockfile_path: &Path) {
410        self.entries.remove(lockfile_path);
411    }
412
413    /// Returns the number of cached lock files.
414    pub fn len(&self) -> usize {
415        self.entries.len()
416    }
417
418    /// Returns true if the cache is empty.
419    pub fn is_empty(&self) -> bool {
420        self.entries.is_empty()
421    }
422}
423
424impl Default for LockFileCache {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_resolved_packages_new() {
436        let packages = ResolvedPackages::new();
437        assert!(packages.is_empty());
438        assert_eq!(packages.len(), 0);
439    }
440
441    #[test]
442    fn test_resolved_packages_insert_and_get() {
443        let mut packages = ResolvedPackages::new();
444
445        let pkg = ResolvedPackage {
446            name: "serde".into(),
447            version: "1.0.195".into(),
448            source: ResolvedSource::Registry {
449                url: "https://github.com/rust-lang/crates.io-index".into(),
450                checksum: "abc123".into(),
451            },
452            dependencies: vec!["serde_derive".into()],
453        };
454
455        packages.insert(pkg);
456
457        assert_eq!(packages.len(), 1);
458        assert!(!packages.is_empty());
459        assert_eq!(packages.get_version("serde"), Some("1.0.195"));
460
461        let retrieved = packages.get("serde");
462        assert!(retrieved.is_some());
463        assert_eq!(retrieved.unwrap().name, "serde");
464        assert_eq!(retrieved.unwrap().dependencies.len(), 1);
465    }
466
467    #[test]
468    fn test_resolved_packages_get_nonexistent() {
469        let packages = ResolvedPackages::new();
470        assert_eq!(packages.get("nonexistent"), None);
471        assert_eq!(packages.get_version("nonexistent"), None);
472    }
473
474    #[test]
475    fn test_resolved_packages_replace() {
476        let mut packages = ResolvedPackages::new();
477
478        packages.insert(ResolvedPackage {
479            name: "serde".into(),
480            version: "1.0.0".into(),
481            source: ResolvedSource::Registry {
482                url: "test".into(),
483                checksum: "old".into(),
484            },
485            dependencies: vec![],
486        });
487
488        packages.insert(ResolvedPackage {
489            name: "serde".into(),
490            version: "1.0.195".into(),
491            source: ResolvedSource::Registry {
492                url: "test".into(),
493                checksum: "new".into(),
494            },
495            dependencies: vec![],
496        });
497
498        assert_eq!(packages.len(), 1);
499        assert_eq!(packages.get_version("serde"), Some("1.0.195"));
500    }
501
502    #[test]
503    fn test_resolved_source_equality() {
504        let source1 = ResolvedSource::Registry {
505            url: "https://test.com".into(),
506            checksum: "abc".into(),
507        };
508        let source2 = ResolvedSource::Registry {
509            url: "https://test.com".into(),
510            checksum: "abc".into(),
511        };
512        let source3 = ResolvedSource::Git {
513            url: "https://github.com/test".into(),
514            rev: "abc123".into(),
515        };
516
517        assert_eq!(source1, source2);
518        assert_ne!(source1, source3);
519    }
520
521    #[test]
522    fn test_resolved_packages_iter() {
523        let mut packages = ResolvedPackages::new();
524
525        packages.insert(ResolvedPackage {
526            name: "serde".into(),
527            version: "1.0.0".into(),
528            source: ResolvedSource::Registry {
529                url: "test".into(),
530                checksum: "a".into(),
531            },
532            dependencies: vec![],
533        });
534
535        packages.insert(ResolvedPackage {
536            name: "tokio".into(),
537            version: "1.0.0".into(),
538            source: ResolvedSource::Registry {
539                url: "test".into(),
540                checksum: "b".into(),
541            },
542            dependencies: vec![],
543        });
544
545        let count = packages.iter().count();
546        assert_eq!(count, 2);
547
548        let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
549        assert!(names.contains(&"serde"));
550        assert!(names.contains(&"tokio"));
551    }
552
553    #[test]
554    fn test_resolved_packages_into_map() {
555        let mut packages = ResolvedPackages::new();
556
557        packages.insert(ResolvedPackage {
558            name: "serde".into(),
559            version: "1.0.0".into(),
560            source: ResolvedSource::Registry {
561                url: "test".into(),
562                checksum: "a".into(),
563            },
564            dependencies: vec![],
565        });
566
567        let map = packages.into_map();
568        assert_eq!(map.len(), 1);
569        assert!(map.contains_key("serde"));
570    }
571
572    #[test]
573    fn test_lockfile_cache_new() {
574        let cache = LockFileCache::new();
575        assert!(cache.is_empty());
576        assert_eq!(cache.len(), 0);
577    }
578
579    #[test]
580    fn test_lockfile_cache_invalidate() {
581        let cache = LockFileCache::new();
582        let test_path = PathBuf::from("/test/Cargo.lock");
583
584        cache.entries.insert(
585            test_path.clone(),
586            CachedLockFile {
587                packages: ResolvedPackages::new(),
588                modified_at: SystemTime::now(),
589                parsed_at: Instant::now(),
590            },
591        );
592
593        assert_eq!(cache.len(), 1);
594
595        cache.invalidate(&test_path);
596        assert_eq!(cache.len(), 0);
597        assert!(cache.is_empty());
598    }
599
600    #[test]
601    fn test_locate_lockfile_for_manifest_same_directory() {
602        let temp_dir = tempfile::tempdir().unwrap();
603        let manifest_path = temp_dir.path().join("Cargo.toml");
604        let lock_path = temp_dir.path().join("Cargo.lock");
605
606        std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
607        std::fs::write(&lock_path, "version = 4").unwrap();
608
609        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
610        let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
611
612        assert!(located.is_some());
613        assert_eq!(located.unwrap(), lock_path);
614    }
615
616    #[test]
617    fn test_locate_lockfile_for_manifest_workspace_root() {
618        let temp_dir = tempfile::tempdir().unwrap();
619        let workspace_lock = temp_dir.path().join("Cargo.lock");
620        let member_dir = temp_dir.path().join("crates").join("member");
621        std::fs::create_dir_all(&member_dir).unwrap();
622        let member_manifest = member_dir.join("Cargo.toml");
623
624        std::fs::write(&workspace_lock, "version = 4").unwrap();
625        std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
626
627        let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
628        let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
629
630        assert!(located.is_some());
631        assert_eq!(located.unwrap(), workspace_lock);
632    }
633
634    #[test]
635    fn test_locate_lockfile_for_manifest_not_found() {
636        let temp_dir = tempfile::tempdir().unwrap();
637        let manifest_path = temp_dir.path().join("Cargo.toml");
638        std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
639
640        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
641        let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
642
643        assert!(located.is_none());
644    }
645
646    #[test]
647    fn test_locate_lockfile_for_manifest_multiple_names() {
648        let temp_dir = tempfile::tempdir().unwrap();
649        let manifest_path = temp_dir.path().join("pyproject.toml");
650        let uv_lock = temp_dir.path().join("uv.lock");
651
652        std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
653        std::fs::write(&uv_lock, "version = 1").unwrap();
654
655        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
656        // poetry.lock doesn't exist, but uv.lock does - should find uv.lock
657        let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
658
659        assert!(located.is_some());
660        assert_eq!(located.unwrap(), uv_lock);
661    }
662
663    #[test]
664    fn test_locate_lockfile_for_manifest_first_match_wins() {
665        let temp_dir = tempfile::tempdir().unwrap();
666        let manifest_path = temp_dir.path().join("pyproject.toml");
667        let poetry_lock = temp_dir.path().join("poetry.lock");
668        let uv_lock = temp_dir.path().join("uv.lock");
669
670        std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
671        std::fs::write(&poetry_lock, "# poetry lock").unwrap();
672        std::fs::write(&uv_lock, "version = 1").unwrap();
673
674        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
675        // Both exist, poetry.lock should be found first (listed first)
676        let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
677
678        assert!(located.is_some());
679        assert_eq!(located.unwrap(), poetry_lock);
680    }
681}