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