use deps_core::error::{DepsError, Result};
use deps_core::lockfile::{
LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
locate_lockfile_for_manifest,
};
use std::path::{Path, PathBuf};
use tower_lsp_server::ls_types::Uri;
pub struct CargoLockParser;
impl CargoLockParser {
const LOCKFILE_NAMES: &'static [&'static str] = &["Cargo.lock"];
}
impl LockFileProvider for CargoLockParser {
fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
}
fn parse_lockfile<'a>(
&'a self,
lockfile_path: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
{
Box::pin(async move {
tracing::debug!("Parsing Cargo.lock: {}", lockfile_path.display());
let content = tokio::fs::read_to_string(lockfile_path)
.await
.map_err(|e| DepsError::ParseError {
file_type: format!("Cargo.lock at {}", lockfile_path.display()),
source: Box::new(e),
})?;
let doc = toml_span::parse(&content).map_err(|e| DepsError::ParseError {
file_type: "Cargo.lock".into(),
source: Box::new(std::io::Error::other(e.to_string())),
})?;
let mut packages = ResolvedPackages::new();
let Some(root_table) = doc.as_table() else {
tracing::warn!("Cargo.lock root is not a table");
return Ok(packages);
};
let Some(package_array_val) = root_table.get("package") else {
tracing::warn!("Cargo.lock missing [[package]] array of tables");
return Ok(packages);
};
let Some(package_array) = package_array_val.as_array() else {
tracing::warn!("Cargo.lock [[package]] is not an array");
return Ok(packages);
};
for entry in package_array {
let Some(table) = entry.as_table() else {
continue;
};
let Some(name) = table.get("name").and_then(|v| v.as_str()) else {
tracing::warn!("Package missing name field");
continue;
};
let Some(version) = table.get("version").and_then(|v| v.as_str()) else {
tracing::warn!("Package '{}' missing version field", name);
continue;
};
let source = parse_cargo_source(table.get("source").and_then(|v| v.as_str()));
let dependencies = parse_cargo_dependencies_from_table(table);
packages.insert(ResolvedPackage {
name: name.to_string(),
version: version.to_string(),
source,
dependencies,
});
}
tracing::info!(
"Parsed Cargo.lock: {} packages from {}",
packages.len(),
lockfile_path.display()
);
Ok(packages)
})
}
}
fn parse_cargo_source(source_str: Option<&str>) -> ResolvedSource {
let Some(source) = source_str else {
return ResolvedSource::Path {
path: String::new(),
};
};
if let Some(registry_url) = source.strip_prefix("registry+") {
ResolvedSource::Registry {
url: registry_url.to_string(),
checksum: String::new(),
}
} else if let Some(git_part) = source.strip_prefix("git+") {
let (url, rev) = if let Some((u, r)) = git_part.split_once('#') {
(u.to_string(), r.to_string())
} else {
(git_part.to_string(), String::new())
};
ResolvedSource::Git { url, rev }
} else {
ResolvedSource::Path {
path: source.to_string(),
}
}
}
fn parse_cargo_dependencies_from_table(table: &toml_span::value::Table<'_>) -> Vec<String> {
let Some(deps_value) = table.get("dependencies") else {
return vec![];
};
let Some(deps_array) = deps_value.as_array() else {
return vec![];
};
deps_array
.iter()
.filter_map(|item| {
if let Some(s) = item.as_str() {
return Some(s.to_string());
}
if let Some(t) = item.as_table()
&& let Some(name) = t.get("name").and_then(|v| v.as_str())
{
return Some(name.to_string());
}
None
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cargo_source_registry() {
let source = parse_cargo_source(Some(
"registry+https://github.com/rust-lang/crates.io-index",
));
match source {
ResolvedSource::Registry { url, .. } => {
assert_eq!(url, "https://github.com/rust-lang/crates.io-index");
}
_ => panic!("Expected Registry source"),
}
}
#[test]
fn test_parse_cargo_source_git() {
let source = parse_cargo_source(Some("git+https://github.com/user/repo#abc123"));
match source {
ResolvedSource::Git { url, rev } => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(rev, "abc123");
}
_ => panic!("Expected Git source"),
}
}
#[test]
fn test_parse_cargo_source_git_no_commit() {
let source = parse_cargo_source(Some("git+https://github.com/user/repo"));
match source {
ResolvedSource::Git { url, rev } => {
assert_eq!(url, "https://github.com/user/repo");
assert!(rev.is_empty());
}
_ => panic!("Expected Git source"),
}
}
#[test]
fn test_parse_cargo_source_path() {
let source = parse_cargo_source(None);
match source {
ResolvedSource::Path { path } => {
assert!(path.is_empty());
}
_ => panic!("Expected Path source"),
}
}
#[tokio::test]
async fn test_parse_simple_cargo_lock() {
let lockfile_content = r#"
# This file is automatically @generated by Cargo.
version = 4
[[package]]
name = "serde"
version = "1.0.195"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc123"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.195"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "def456"
"#;
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, lockfile_content).unwrap();
let parser = CargoLockParser;
let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
assert_eq!(resolved.len(), 2);
assert_eq!(resolved.get_version("serde"), Some("1.0.195"));
assert_eq!(resolved.get_version("serde_derive"), Some("1.0.195"));
let serde_pkg = resolved.get("serde").unwrap();
assert_eq!(serde_pkg.dependencies.len(), 1);
assert_eq!(serde_pkg.dependencies[0], "serde_derive");
}
#[tokio::test]
async fn test_parse_cargo_lock_with_git() {
let lockfile_content = r#"
version = 4
[[package]]
name = "my-git-dep"
version = "0.1.0"
source = "git+https://github.com/user/repo#abc123"
"#;
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, lockfile_content).unwrap();
let parser = CargoLockParser;
let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
assert_eq!(resolved.len(), 1);
let pkg = resolved.get("my-git-dep").unwrap();
assert_eq!(pkg.version, "0.1.0");
match &pkg.source {
ResolvedSource::Git { url, rev } => {
assert_eq!(url, "https://github.com/user/repo");
assert_eq!(rev, "abc123");
}
_ => panic!("Expected Git source"),
}
}
#[tokio::test]
async fn test_parse_empty_cargo_lock() {
let lockfile_content = r"
version = 4
";
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, lockfile_content).unwrap();
let parser = CargoLockParser;
let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
assert_eq!(resolved.len(), 0);
assert!(resolved.is_empty());
}
#[tokio::test]
async fn test_parse_malformed_cargo_lock() {
let lockfile_content = "not valid toml {{{";
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, lockfile_content).unwrap();
let parser = CargoLockParser;
let result = parser.parse_lockfile(&lockfile_path).await;
assert!(result.is_err());
}
#[test]
fn test_locate_lockfile_same_directory() {
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
let lock_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
std::fs::write(&lock_path, "version = 4").unwrap();
let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
let parser = CargoLockParser;
let located = parser.locate_lockfile(&manifest_uri);
assert!(located.is_some());
assert_eq!(located.unwrap(), lock_path);
}
#[test]
fn test_locate_lockfile_workspace_root() {
let temp_dir = tempfile::tempdir().unwrap();
let workspace_lock = temp_dir.path().join("Cargo.lock");
let member_dir = temp_dir.path().join("crates").join("member");
std::fs::create_dir_all(&member_dir).unwrap();
let member_manifest = member_dir.join("Cargo.toml");
std::fs::write(&workspace_lock, "version = 4").unwrap();
std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
let parser = CargoLockParser;
let located = parser.locate_lockfile(&manifest_uri);
assert!(located.is_some());
assert_eq!(located.unwrap(), workspace_lock);
}
#[test]
fn test_locate_lockfile_not_found() {
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("Cargo.toml");
std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
let parser = CargoLockParser;
let located = parser.locate_lockfile(&manifest_uri);
assert!(located.is_none());
}
#[test]
fn test_is_lockfile_stale_not_modified() {
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, "version = 4").unwrap();
let mtime = std::fs::metadata(&lockfile_path)
.unwrap()
.modified()
.unwrap();
let parser = CargoLockParser;
assert!(
!parser.is_lockfile_stale(&lockfile_path, mtime),
"Lock file should not be stale when mtime matches"
);
}
#[test]
fn test_is_lockfile_stale_modified() {
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, "version = 4").unwrap();
let old_time = std::time::UNIX_EPOCH;
let parser = CargoLockParser;
assert!(
parser.is_lockfile_stale(&lockfile_path, old_time),
"Lock file should be stale when last_modified is old"
);
}
#[test]
fn test_is_lockfile_stale_deleted() {
let parser = CargoLockParser;
let non_existent = std::path::Path::new("/nonexistent/Cargo.lock");
assert!(
parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
"Non-existent lock file should be considered stale"
);
}
#[test]
fn test_is_lockfile_stale_future_time() {
let temp_dir = tempfile::tempdir().unwrap();
let lockfile_path = temp_dir.path().join("Cargo.lock");
std::fs::write(&lockfile_path, "version = 4").unwrap();
let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); let parser = CargoLockParser;
assert!(
!parser.is_lockfile_stale(&lockfile_path, future_time),
"Lock file should not be stale when last_modified is in the future"
);
}
}