Skip to main content

deps_cargo/
lockfile.rs

1//! Cargo.lock file parsing.
2//!
3//! Parses Cargo.lock files (version 3 and 4) to extract resolved dependency
4//! versions. Supports workspace lock files and proper path resolution.
5//!
6//! # Cargo.lock Format
7//!
8//! Cargo.lock uses TOML format with an array of packages:
9//!
10//! ```toml
11//! # This file is automatically @generated by Cargo.
12//! # It is not intended for manual editing.
13//! version = 4
14//!
15//! [[package]]
16//! name = "serde"
17//! version = "1.0.195"
18//! source = "registry+https://github.com/rust-lang/crates.io-index"
19//! checksum = "..."
20//! dependencies = [
21//!     "serde_derive",
22//! ]
23//! ```
24
25use deps_core::error::{DepsError, Result};
26use deps_core::lockfile::{
27    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
28    locate_lockfile_for_manifest,
29};
30use std::path::{Path, PathBuf};
31use tower_lsp_server::ls_types::Uri;
32
33/// Cargo.lock file parser.
34///
35/// Implements lock file parsing for Rust's Cargo build system.
36/// Supports both project-level and workspace-level lock files.
37///
38/// # Lock File Location
39///
40/// The parser searches for Cargo.lock in the following order:
41/// 1. Same directory as Cargo.toml
42/// 2. Parent directories (up to 5 levels) for workspace root
43///
44/// # Examples
45///
46/// ```no_run
47/// use deps_cargo::lockfile::CargoLockParser;
48/// use deps_core::lockfile::LockFileProvider;
49/// use tower_lsp_server::ls_types::Uri;
50///
51/// # async fn example() -> deps_core::error::Result<()> {
52/// let parser = CargoLockParser;
53/// let manifest_uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap();
54///
55/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) {
56///     let resolved = parser.parse_lockfile(&lockfile_path).await?;
57///     println!("Found {} resolved packages", resolved.len());
58/// }
59/// # Ok(())
60/// # }
61/// ```
62pub struct CargoLockParser;
63
64impl CargoLockParser {
65    /// Lock file names for Cargo ecosystem.
66    const LOCKFILE_NAMES: &'static [&'static str] = &["Cargo.lock"];
67}
68
69impl LockFileProvider for CargoLockParser {
70    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
71        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
72    }
73
74    fn parse_lockfile<'a>(
75        &'a self,
76        lockfile_path: &'a Path,
77    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
78    {
79        Box::pin(async move {
80            tracing::debug!("Parsing Cargo.lock: {}", lockfile_path.display());
81
82            let content = tokio::fs::read_to_string(lockfile_path)
83                .await
84                .map_err(|e| DepsError::ParseError {
85                    file_type: format!("Cargo.lock at {}", lockfile_path.display()),
86                    source: Box::new(e),
87                })?;
88
89            let doc = toml_span::parse(&content).map_err(|e| DepsError::ParseError {
90                file_type: "Cargo.lock".into(),
91                source: Box::new(std::io::Error::other(e.to_string())),
92            })?;
93
94            let mut packages = ResolvedPackages::new();
95
96            let Some(root_table) = doc.as_table() else {
97                tracing::warn!("Cargo.lock root is not a table");
98                return Ok(packages);
99            };
100
101            let Some(package_array_val) = root_table.get("package") else {
102                tracing::warn!("Cargo.lock missing [[package]] array of tables");
103                return Ok(packages);
104            };
105
106            let Some(package_array) = package_array_val.as_array() else {
107                tracing::warn!("Cargo.lock [[package]] is not an array");
108                return Ok(packages);
109            };
110
111            for entry in package_array {
112                let Some(table) = entry.as_table() else {
113                    continue;
114                };
115
116                // Extract required fields
117                let Some(name) = table.get("name").and_then(|v| v.as_str()) else {
118                    tracing::warn!("Package missing name field");
119                    continue;
120                };
121
122                let Some(version) = table.get("version").and_then(|v| v.as_str()) else {
123                    tracing::warn!("Package '{}' missing version field", name);
124                    continue;
125                };
126
127                // Parse source (optional for path dependencies)
128                let source = parse_cargo_source(table.get("source").and_then(|v| v.as_str()));
129
130                // Parse dependencies array (optional)
131                let dependencies = parse_cargo_dependencies_from_table(table);
132
133                packages.insert(ResolvedPackage {
134                    name: name.to_string(),
135                    version: version.to_string(),
136                    source,
137                    dependencies,
138                });
139            }
140
141            tracing::info!(
142                "Parsed Cargo.lock: {} packages from {}",
143                packages.len(),
144                lockfile_path.display()
145            );
146
147            Ok(packages)
148        })
149    }
150}
151
152/// Parses Cargo source field into ResolvedSource.
153///
154/// # Source Formats
155///
156/// - `"registry+https://github.com/rust-lang/crates.io-index"` → Registry
157/// - `"git+https://github.com/user/repo#commit"` → Git
158/// - None (path dependencies don't have source field) → Path
159fn parse_cargo_source(source_str: Option<&str>) -> ResolvedSource {
160    let Some(source) = source_str else {
161        return ResolvedSource::Path {
162            path: String::new(),
163        };
164    };
165
166    if let Some(registry_url) = source.strip_prefix("registry+") {
167        ResolvedSource::Registry {
168            url: registry_url.to_string(),
169            checksum: String::new(),
170        }
171    } else if let Some(git_part) = source.strip_prefix("git+") {
172        let (url, rev) = if let Some((u, r)) = git_part.split_once('#') {
173            (u.to_string(), r.to_string())
174        } else {
175            (git_part.to_string(), String::new())
176        };
177
178        ResolvedSource::Git { url, rev }
179    } else {
180        ResolvedSource::Path {
181            path: source.to_string(),
182        }
183    }
184}
185
186/// Parses dependencies array from package table.
187///
188/// Dependencies are typically simple strings in Cargo.lock v4:
189/// ```toml
190/// dependencies = ["serde_derive", "syn"]
191/// ```
192fn parse_cargo_dependencies_from_table(table: &toml_span::value::Table<'_>) -> Vec<String> {
193    let Some(deps_value) = table.get("dependencies") else {
194        return vec![];
195    };
196
197    let Some(deps_array) = deps_value.as_array() else {
198        return vec![];
199    };
200
201    deps_array
202        .iter()
203        .filter_map(|item| {
204            // Simple string format (most common)
205            if let Some(s) = item.as_str() {
206                return Some(s.to_string());
207            }
208
209            // Table format (rare, extract "name" field)
210            if let Some(t) = item.as_table()
211                && let Some(name) = t.get("name").and_then(|v| v.as_str())
212            {
213                return Some(name.to_string());
214            }
215
216            None
217        })
218        .collect()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_parse_cargo_source_registry() {
227        let source = parse_cargo_source(Some(
228            "registry+https://github.com/rust-lang/crates.io-index",
229        ));
230
231        match source {
232            ResolvedSource::Registry { url, .. } => {
233                assert_eq!(url, "https://github.com/rust-lang/crates.io-index");
234            }
235            _ => panic!("Expected Registry source"),
236        }
237    }
238
239    #[test]
240    fn test_parse_cargo_source_git() {
241        let source = parse_cargo_source(Some("git+https://github.com/user/repo#abc123"));
242
243        match source {
244            ResolvedSource::Git { url, rev } => {
245                assert_eq!(url, "https://github.com/user/repo");
246                assert_eq!(rev, "abc123");
247            }
248            _ => panic!("Expected Git source"),
249        }
250    }
251
252    #[test]
253    fn test_parse_cargo_source_git_no_commit() {
254        let source = parse_cargo_source(Some("git+https://github.com/user/repo"));
255
256        match source {
257            ResolvedSource::Git { url, rev } => {
258                assert_eq!(url, "https://github.com/user/repo");
259                assert!(rev.is_empty());
260            }
261            _ => panic!("Expected Git source"),
262        }
263    }
264
265    #[test]
266    fn test_parse_cargo_source_path() {
267        let source = parse_cargo_source(None);
268
269        match source {
270            ResolvedSource::Path { path } => {
271                assert!(path.is_empty());
272            }
273            _ => panic!("Expected Path source"),
274        }
275    }
276
277    #[tokio::test]
278    async fn test_parse_simple_cargo_lock() {
279        let lockfile_content = r#"
280# This file is automatically @generated by Cargo.
281version = 4
282
283[[package]]
284name = "serde"
285version = "1.0.195"
286source = "registry+https://github.com/rust-lang/crates.io-index"
287checksum = "abc123"
288dependencies = [
289    "serde_derive",
290]
291
292[[package]]
293name = "serde_derive"
294version = "1.0.195"
295source = "registry+https://github.com/rust-lang/crates.io-index"
296checksum = "def456"
297"#;
298
299        let temp_dir = tempfile::tempdir().unwrap();
300        let lockfile_path = temp_dir.path().join("Cargo.lock");
301        std::fs::write(&lockfile_path, lockfile_content).unwrap();
302
303        let parser = CargoLockParser;
304        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
305
306        assert_eq!(resolved.len(), 2);
307        assert_eq!(resolved.get_version("serde"), Some("1.0.195"));
308        assert_eq!(resolved.get_version("serde_derive"), Some("1.0.195"));
309
310        let serde_pkg = resolved.get("serde").unwrap();
311        assert_eq!(serde_pkg.dependencies.len(), 1);
312        assert_eq!(serde_pkg.dependencies[0], "serde_derive");
313    }
314
315    #[tokio::test]
316    async fn test_parse_cargo_lock_with_git() {
317        let lockfile_content = r#"
318version = 4
319
320[[package]]
321name = "my-git-dep"
322version = "0.1.0"
323source = "git+https://github.com/user/repo#abc123"
324"#;
325
326        let temp_dir = tempfile::tempdir().unwrap();
327        let lockfile_path = temp_dir.path().join("Cargo.lock");
328        std::fs::write(&lockfile_path, lockfile_content).unwrap();
329
330        let parser = CargoLockParser;
331        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
332
333        assert_eq!(resolved.len(), 1);
334        let pkg = resolved.get("my-git-dep").unwrap();
335        assert_eq!(pkg.version, "0.1.0");
336
337        match &pkg.source {
338            ResolvedSource::Git { url, rev } => {
339                assert_eq!(url, "https://github.com/user/repo");
340                assert_eq!(rev, "abc123");
341            }
342            _ => panic!("Expected Git source"),
343        }
344    }
345
346    #[tokio::test]
347    async fn test_parse_empty_cargo_lock() {
348        let lockfile_content = r"
349version = 4
350";
351
352        let temp_dir = tempfile::tempdir().unwrap();
353        let lockfile_path = temp_dir.path().join("Cargo.lock");
354        std::fs::write(&lockfile_path, lockfile_content).unwrap();
355
356        let parser = CargoLockParser;
357        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
358
359        assert_eq!(resolved.len(), 0);
360        assert!(resolved.is_empty());
361    }
362
363    #[tokio::test]
364    async fn test_parse_malformed_cargo_lock() {
365        let lockfile_content = "not valid toml {{{";
366
367        let temp_dir = tempfile::tempdir().unwrap();
368        let lockfile_path = temp_dir.path().join("Cargo.lock");
369        std::fs::write(&lockfile_path, lockfile_content).unwrap();
370
371        let parser = CargoLockParser;
372        let result = parser.parse_lockfile(&lockfile_path).await;
373
374        assert!(result.is_err());
375    }
376
377    #[test]
378    fn test_locate_lockfile_same_directory() {
379        let temp_dir = tempfile::tempdir().unwrap();
380        let manifest_path = temp_dir.path().join("Cargo.toml");
381        let lock_path = temp_dir.path().join("Cargo.lock");
382
383        std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
384        std::fs::write(&lock_path, "version = 4").unwrap();
385
386        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
387        let parser = CargoLockParser;
388
389        let located = parser.locate_lockfile(&manifest_uri);
390        assert!(located.is_some());
391        assert_eq!(located.unwrap(), lock_path);
392    }
393
394    #[test]
395    fn test_locate_lockfile_workspace_root() {
396        let temp_dir = tempfile::tempdir().unwrap();
397        let workspace_lock = temp_dir.path().join("Cargo.lock");
398        let member_dir = temp_dir.path().join("crates").join("member");
399        std::fs::create_dir_all(&member_dir).unwrap();
400        let member_manifest = member_dir.join("Cargo.toml");
401
402        std::fs::write(&workspace_lock, "version = 4").unwrap();
403        std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
404
405        let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
406        let parser = CargoLockParser;
407
408        let located = parser.locate_lockfile(&manifest_uri);
409        assert!(located.is_some());
410        assert_eq!(located.unwrap(), workspace_lock);
411    }
412
413    #[test]
414    fn test_locate_lockfile_not_found() {
415        let temp_dir = tempfile::tempdir().unwrap();
416        let manifest_path = temp_dir.path().join("Cargo.toml");
417        std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
418
419        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
420        let parser = CargoLockParser;
421
422        let located = parser.locate_lockfile(&manifest_uri);
423        assert!(located.is_none());
424    }
425
426    #[test]
427    fn test_is_lockfile_stale_not_modified() {
428        let temp_dir = tempfile::tempdir().unwrap();
429        let lockfile_path = temp_dir.path().join("Cargo.lock");
430        std::fs::write(&lockfile_path, "version = 4").unwrap();
431
432        let mtime = std::fs::metadata(&lockfile_path)
433            .unwrap()
434            .modified()
435            .unwrap();
436        let parser = CargoLockParser;
437
438        assert!(
439            !parser.is_lockfile_stale(&lockfile_path, mtime),
440            "Lock file should not be stale when mtime matches"
441        );
442    }
443
444    #[test]
445    fn test_is_lockfile_stale_modified() {
446        let temp_dir = tempfile::tempdir().unwrap();
447        let lockfile_path = temp_dir.path().join("Cargo.lock");
448        std::fs::write(&lockfile_path, "version = 4").unwrap();
449
450        let old_time = std::time::UNIX_EPOCH;
451        let parser = CargoLockParser;
452
453        assert!(
454            parser.is_lockfile_stale(&lockfile_path, old_time),
455            "Lock file should be stale when last_modified is old"
456        );
457    }
458
459    #[test]
460    fn test_is_lockfile_stale_deleted() {
461        let parser = CargoLockParser;
462        let non_existent = std::path::Path::new("/nonexistent/Cargo.lock");
463
464        assert!(
465            parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
466            "Non-existent lock file should be considered stale"
467        );
468    }
469
470    #[test]
471    fn test_is_lockfile_stale_future_time() {
472        let temp_dir = tempfile::tempdir().unwrap();
473        let lockfile_path = temp_dir.path().join("Cargo.lock");
474        std::fs::write(&lockfile_path, "version = 4").unwrap();
475
476        // Use a time far in the future
477        let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); // +1 day
478        let parser = CargoLockParser;
479
480        assert!(
481            !parser.is_lockfile_stale(&lockfile_path, future_time),
482            "Lock file should not be stale when last_modified is in the future"
483        );
484    }
485}