1use 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
33pub struct CargoLockParser;
63
64impl CargoLockParser {
65 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 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 let source = parse_cargo_source(table.get("source").and_then(|v| v.as_str()));
129
130 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
152fn 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
186fn 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 if let Some(s) = item.as_str() {
206 return Some(s.to_string());
207 }
208
209 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 let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); 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}