1use deps_core::error::{DepsError, Result};
26use deps_core::lockfile::{
27 LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
28 locate_lockfile_for_manifest, read_lockfile_content,
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 = read_lockfile_content(lockfile_path, "Cargo.lock").await?;
83
84 if let Err(depth) =
85 deps_core::check_toml_nesting_depth(&content, deps_core::MAX_TOML_NESTING_DEPTH)
86 {
87 return Err(DepsError::ParseError {
88 file_type: "Cargo.lock".into(),
89 source: Box::new(std::io::Error::other(format!(
90 "array/table nesting depth {depth} exceeds maximum of {}",
91 deps_core::MAX_TOML_NESTING_DEPTH
92 ))),
93 });
94 }
95
96 let doc = toml_span::parse(&content).map_err(|e| DepsError::ParseError {
97 file_type: "Cargo.lock".into(),
98 source: Box::new(std::io::Error::other(e.to_string())),
99 })?;
100
101 let mut packages = ResolvedPackages::new();
102
103 let Some(root_table) = doc.as_table() else {
104 tracing::warn!("Cargo.lock root is not a table");
105 return Ok(packages);
106 };
107
108 let Some(package_array_val) = root_table.get("package") else {
109 tracing::warn!("Cargo.lock missing [[package]] array of tables");
110 return Ok(packages);
111 };
112
113 let Some(package_array) = package_array_val.as_array() else {
114 tracing::warn!("Cargo.lock [[package]] is not an array");
115 return Ok(packages);
116 };
117
118 for entry in package_array {
119 let Some(table) = entry.as_table() else {
120 continue;
121 };
122
123 let Some(name) = table.get("name").and_then(|v| v.as_str()) else {
125 tracing::warn!("Package missing name field");
126 continue;
127 };
128
129 let Some(version) = table.get("version").and_then(|v| v.as_str()) else {
130 tracing::warn!("Package '{}' missing version field", name);
131 continue;
132 };
133
134 let source = parse_cargo_source(table.get("source").and_then(|v| v.as_str()));
136
137 let dependencies = parse_cargo_dependencies_from_table(table);
139
140 packages.insert(ResolvedPackage {
141 name: name.to_string(),
142 version: version.to_string(),
143 source,
144 dependencies,
145 });
146 }
147
148 tracing::info!(
149 "Parsed Cargo.lock: {} packages from {}",
150 packages.len(),
151 lockfile_path.display()
152 );
153
154 Ok(packages)
155 })
156 }
157}
158
159fn parse_cargo_source(source_str: Option<&str>) -> ResolvedSource {
167 let Some(source) = source_str else {
168 return ResolvedSource::Path {
169 path: String::new(),
170 };
171 };
172
173 if let Some(registry_url) = source.strip_prefix("registry+") {
174 ResolvedSource::Registry {
175 url: registry_url.to_string(),
176 checksum: String::new(),
177 }
178 } else if let Some(git_part) = source.strip_prefix("git+") {
179 let (url, rev) = if let Some((u, r)) = git_part.split_once('#') {
180 (u.to_string(), r.to_string())
181 } else {
182 (git_part.to_string(), String::new())
183 };
184
185 ResolvedSource::Git { url, rev }
186 } else {
187 ResolvedSource::Path {
188 path: source.to_string(),
189 }
190 }
191}
192
193fn parse_cargo_dependencies_from_table(table: &toml_span::value::Table<'_>) -> Vec<String> {
200 let Some(deps_value) = table.get("dependencies") else {
201 return vec![];
202 };
203
204 let Some(deps_array) = deps_value.as_array() else {
205 return vec![];
206 };
207
208 deps_array
209 .iter()
210 .filter_map(|item| {
211 if let Some(s) = item.as_str() {
213 return Some(s.to_string());
214 }
215
216 if let Some(t) = item.as_table()
218 && let Some(name) = t.get("name").and_then(|v| v.as_str())
219 {
220 return Some(name.to_string());
221 }
222
223 None
224 })
225 .collect()
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 use std::assert_matches;
233
234 #[test]
235 fn test_parse_cargo_source_registry() {
236 let source = parse_cargo_source(Some(
237 "registry+https://github.com/rust-lang/crates.io-index",
238 ));
239
240 match source {
241 ResolvedSource::Registry { url, .. } => {
242 assert_eq!(url, "https://github.com/rust-lang/crates.io-index");
243 }
244 _ => panic!("Expected Registry source"),
245 }
246 }
247
248 #[test]
249 fn test_parse_cargo_source_git() {
250 let source = parse_cargo_source(Some("git+https://github.com/user/repo#abc123"));
251
252 match source {
253 ResolvedSource::Git { url, rev } => {
254 assert_eq!(url, "https://github.com/user/repo");
255 assert_eq!(rev, "abc123");
256 }
257 _ => panic!("Expected Git source"),
258 }
259 }
260
261 #[test]
262 fn test_parse_cargo_source_git_no_commit() {
263 let source = parse_cargo_source(Some("git+https://github.com/user/repo"));
264
265 match source {
266 ResolvedSource::Git { url, rev } => {
267 assert_eq!(url, "https://github.com/user/repo");
268 assert!(rev.is_empty());
269 }
270 _ => panic!("Expected Git source"),
271 }
272 }
273
274 #[test]
275 fn test_parse_cargo_source_path() {
276 let source = parse_cargo_source(None);
277
278 match source {
279 ResolvedSource::Path { path } => {
280 assert!(path.is_empty());
281 }
282 _ => panic!("Expected Path source"),
283 }
284 }
285
286 #[tokio::test]
287 async fn test_parse_cargo_lock_rejects_excessive_nesting() {
288 let lockfile_content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
292
293 let temp_dir = tempfile::tempdir().unwrap();
294 let lockfile_path = temp_dir.path().join("Cargo.lock");
295 std::fs::write(&lockfile_path, lockfile_content).unwrap();
296
297 let parser = CargoLockParser;
298 let result = parser.parse_lockfile(&lockfile_path).await;
299 assert_matches!(
300 result,
301 Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.lock"
302 );
303 }
304
305 #[tokio::test]
306 async fn test_parse_cargo_lock_rejects_excessive_inline_table_nesting() {
307 let depth = deps_core::MAX_TOML_NESTING_DEPTH + 1;
315 let lockfile_content = format!(
316 "[[package]]\nname = \"evil\"\nversion = \"1.0.0\"\nx = {}1{}\n",
317 "{a=".repeat(depth),
318 "}".repeat(depth)
319 );
320
321 let temp_dir = tempfile::tempdir().unwrap();
322 let lockfile_path = temp_dir.path().join("Cargo.lock");
323 std::fs::write(&lockfile_path, lockfile_content).unwrap();
324
325 let parser = CargoLockParser;
326 let result = parser.parse_lockfile(&lockfile_path).await;
327 assert_matches!(
328 result,
329 Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.lock"
330 );
331 }
332
333 #[tokio::test]
334 async fn test_parse_cargo_lock_rejects_dotted_header_nesting() {
335 let depth = deps_core::MAX_TOML_NESTING_DEPTH + 1;
342 let dotted_lockfile_content = format!(
343 "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n\n[package{}]\ny = 1\n",
344 ".a".repeat(depth)
345 );
346
347 let temp_dir = tempfile::tempdir().unwrap();
348 let lockfile_path = temp_dir.path().join("Cargo.lock");
349 std::fs::write(&lockfile_path, dotted_lockfile_content).unwrap();
350
351 let parser = CargoLockParser;
352 let result = parser.parse_lockfile(&lockfile_path).await;
353 assert_matches!(
354 result,
355 Err(DepsError::ParseError { file_type, .. }) if file_type == "Cargo.lock"
356 );
357 }
358
359 #[tokio::test]
360 async fn test_parse_simple_cargo_lock() {
361 let lockfile_content = r#"
362# This file is automatically @generated by Cargo.
363version = 4
364
365[[package]]
366name = "serde"
367version = "1.0.195"
368source = "registry+https://github.com/rust-lang/crates.io-index"
369checksum = "abc123"
370dependencies = [
371 "serde_derive",
372]
373
374[[package]]
375name = "serde_derive"
376version = "1.0.195"
377source = "registry+https://github.com/rust-lang/crates.io-index"
378checksum = "def456"
379"#;
380
381 let temp_dir = tempfile::tempdir().unwrap();
382 let lockfile_path = temp_dir.path().join("Cargo.lock");
383 std::fs::write(&lockfile_path, lockfile_content).unwrap();
384
385 let parser = CargoLockParser;
386 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
387
388 assert_eq!(resolved.len(), 2);
389 assert_eq!(resolved.get_version("serde"), Some("1.0.195"));
390 assert_eq!(resolved.get_version("serde_derive"), Some("1.0.195"));
391
392 let serde_pkg = resolved.get("serde").unwrap();
393 assert_eq!(serde_pkg.dependencies.len(), 1);
394 assert_eq!(serde_pkg.dependencies[0], "serde_derive");
395 }
396
397 #[tokio::test]
398 async fn test_parse_cargo_lock_with_git() {
399 let lockfile_content = r#"
400version = 4
401
402[[package]]
403name = "my-git-dep"
404version = "0.1.0"
405source = "git+https://github.com/user/repo#abc123"
406"#;
407
408 let temp_dir = tempfile::tempdir().unwrap();
409 let lockfile_path = temp_dir.path().join("Cargo.lock");
410 std::fs::write(&lockfile_path, lockfile_content).unwrap();
411
412 let parser = CargoLockParser;
413 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
414
415 assert_eq!(resolved.len(), 1);
416 let pkg = resolved.get("my-git-dep").unwrap();
417 assert_eq!(pkg.version, "0.1.0");
418
419 match &pkg.source {
420 ResolvedSource::Git { url, rev } => {
421 assert_eq!(url, "https://github.com/user/repo");
422 assert_eq!(rev, "abc123");
423 }
424 _ => panic!("Expected Git source"),
425 }
426 }
427
428 #[tokio::test]
429 async fn test_parse_empty_cargo_lock() {
430 let lockfile_content = r"
431version = 4
432";
433
434 let temp_dir = tempfile::tempdir().unwrap();
435 let lockfile_path = temp_dir.path().join("Cargo.lock");
436 std::fs::write(&lockfile_path, lockfile_content).unwrap();
437
438 let parser = CargoLockParser;
439 let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
440
441 assert_eq!(resolved.len(), 0);
442 assert!(resolved.is_empty());
443 }
444
445 #[tokio::test]
446 async fn test_parse_malformed_cargo_lock() {
447 let lockfile_content = "not valid toml {{{";
448
449 let temp_dir = tempfile::tempdir().unwrap();
450 let lockfile_path = temp_dir.path().join("Cargo.lock");
451 std::fs::write(&lockfile_path, lockfile_content).unwrap();
452
453 let parser = CargoLockParser;
454 let result = parser.parse_lockfile(&lockfile_path).await;
455
456 assert!(result.is_err());
457 }
458
459 #[test]
460 fn test_locate_lockfile_same_directory() {
461 let temp_dir = tempfile::tempdir().unwrap();
462 let manifest_path = temp_dir.path().join("Cargo.toml");
463 let lock_path = temp_dir.path().join("Cargo.lock");
464
465 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
466 std::fs::write(&lock_path, "version = 4").unwrap();
467
468 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
469 let parser = CargoLockParser;
470
471 let located = parser.locate_lockfile(&manifest_uri);
472 assert!(located.is_some());
473 assert_eq!(located.unwrap(), lock_path);
474 }
475
476 #[test]
477 fn test_locate_lockfile_workspace_root() {
478 let temp_dir = tempfile::tempdir().unwrap();
479 let workspace_lock = temp_dir.path().join("Cargo.lock");
480 let member_dir = temp_dir.path().join("crates").join("member");
481 std::fs::create_dir_all(&member_dir).unwrap();
482 let member_manifest = member_dir.join("Cargo.toml");
483
484 std::fs::write(&workspace_lock, "version = 4").unwrap();
485 std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
486
487 let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
488 let parser = CargoLockParser;
489
490 let located = parser.locate_lockfile(&manifest_uri);
491 assert!(located.is_some());
492 assert_eq!(located.unwrap(), workspace_lock);
493 }
494
495 #[test]
496 fn test_locate_lockfile_not_found() {
497 let temp_dir = tempfile::tempdir().unwrap();
498 let manifest_path = temp_dir.path().join("Cargo.toml");
499 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
500
501 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
502 let parser = CargoLockParser;
503
504 let located = parser.locate_lockfile(&manifest_uri);
505 assert!(located.is_none());
506 }
507
508 #[test]
509 fn test_is_lockfile_stale_not_modified() {
510 let temp_dir = tempfile::tempdir().unwrap();
511 let lockfile_path = temp_dir.path().join("Cargo.lock");
512 std::fs::write(&lockfile_path, "version = 4").unwrap();
513
514 let mtime = std::fs::metadata(&lockfile_path)
515 .unwrap()
516 .modified()
517 .unwrap();
518 let parser = CargoLockParser;
519
520 assert!(
521 !parser.is_lockfile_stale(&lockfile_path, mtime),
522 "Lock file should not be stale when mtime matches"
523 );
524 }
525
526 #[test]
527 fn test_is_lockfile_stale_modified() {
528 let temp_dir = tempfile::tempdir().unwrap();
529 let lockfile_path = temp_dir.path().join("Cargo.lock");
530 std::fs::write(&lockfile_path, "version = 4").unwrap();
531
532 let old_time = std::time::UNIX_EPOCH;
533 let parser = CargoLockParser;
534
535 assert!(
536 parser.is_lockfile_stale(&lockfile_path, old_time),
537 "Lock file should be stale when last_modified is old"
538 );
539 }
540
541 #[test]
542 fn test_is_lockfile_stale_deleted() {
543 let parser = CargoLockParser;
544 let non_existent = std::path::Path::new("/nonexistent/Cargo.lock");
545
546 assert!(
547 parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
548 "Non-existent lock file should be considered stale"
549 );
550 }
551
552 #[test]
553 fn test_is_lockfile_stale_future_time() {
554 let temp_dir = tempfile::tempdir().unwrap();
555 let lockfile_path = temp_dir.path().join("Cargo.lock");
556 std::fs::write(&lockfile_path, "version = 4").unwrap();
557
558 let future_time = std::time::SystemTime::now() + std::time::Duration::from_hours(24);
560 let parser = CargoLockParser;
561
562 assert!(
563 !parser.is_lockfile_stale(&lockfile_path, future_time),
564 "Lock file should not be stale when last_modified is in the future"
565 );
566 }
567}