1use 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
35pub struct CargoLockParser;
65
66impl CargoLockParser {
67 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 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 let source = parse_cargo_source(
119 table
120 .get("source")
121 .and_then(|v: &toml_edit::Item| v.as_str()),
122 );
123
124 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
145fn 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
179fn 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 if let Some(s) = item.as_str() {
199 return Some(s.to_string());
200 }
201
202 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 let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); 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}