use crate::error::Result;
use dashmap::DashMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime};
use tower_lsp_server::ls_types::Uri;
const MAX_WORKSPACE_DEPTH: usize = 5;
pub fn locate_lockfile_for_manifest(
manifest_uri: &Uri,
lockfile_names: &[&str],
) -> Option<PathBuf> {
let manifest_path = manifest_uri.to_file_path()?;
let manifest_dir = manifest_path.parent()?;
let mut lock_path = manifest_dir.to_path_buf();
for &name in lockfile_names {
lock_path.push(name);
if lock_path.exists() {
tracing::debug!("Found {} at: {}", name, lock_path.display());
return Some(lock_path);
}
lock_path.pop();
}
let Some(mut current_dir) = manifest_dir.parent() else {
tracing::debug!("No lock file found for: {:?}", manifest_uri);
return None;
};
for depth in 0..MAX_WORKSPACE_DEPTH {
lock_path.clear();
lock_path.push(current_dir);
for &name in lockfile_names {
lock_path.push(name);
if lock_path.exists() {
tracing::debug!(
"Found workspace {} at depth {}: {}",
name,
depth + 1,
lock_path.display()
);
return Some(lock_path);
}
lock_path.pop();
}
match current_dir.parent() {
Some(parent) => current_dir = parent,
None => break,
}
}
tracing::debug!("No lock file found for: {:?}", manifest_uri);
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPackage {
pub name: String,
pub version: String,
pub source: ResolvedSource,
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedSource {
Registry {
url: String,
checksum: String,
},
Git {
url: String,
rev: String,
},
Path {
path: String,
},
}
#[derive(Debug, Default, Clone)]
pub struct ResolvedPackages {
packages: HashMap<String, Vec<ResolvedPackage>>,
}
fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> {
packages.iter().max_by(|a, b| {
match (
semver::Version::parse(&a.version),
semver::Version::parse(&b.version),
) {
(Ok(va), Ok(vb)) => va.cmp(&vb),
(Ok(_), Err(_)) => std::cmp::Ordering::Greater,
(Err(_), Ok(_)) => std::cmp::Ordering::Less,
(Err(_), Err(_)) => a.version.cmp(&b.version),
}
})
}
impl ResolvedPackages {
pub fn new() -> Self {
Self {
packages: HashMap::new(),
}
}
pub fn insert(&mut self, package: ResolvedPackage) {
self.packages
.entry(package.name.clone())
.or_default()
.push(package);
}
pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
self.packages.get(name).and_then(|v| best_package(v))
}
pub fn get_version(&self, name: &str) -> Option<&str> {
self.get(name).map(|p| p.version.as_str())
}
pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
self.packages.get(name).map(|v| v.as_slice())
}
pub fn len(&self) -> usize {
self.packages.len()
}
pub fn is_empty(&self) -> bool {
self.packages.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
self.packages.keys().filter_map(|name| {
self.packages
.get(name)
.and_then(|v| best_package(v).map(|p| (name, p)))
})
}
pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
self.packages
.into_iter()
.filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p)))
.collect()
}
}
pub trait LockFileProvider: Send + Sync {
fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf>;
fn parse_lockfile<'a>(
&'a self,
lockfile_path: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>;
fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool {
if let Ok(metadata) = std::fs::metadata(lockfile_path)
&& let Ok(mtime) = metadata.modified()
{
return mtime > last_modified;
}
true
}
}
struct CachedLockFile {
packages: ResolvedPackages,
modified_at: SystemTime,
#[allow(dead_code)]
parsed_at: Instant,
}
pub struct LockFileCache {
entries: DashMap<PathBuf, CachedLockFile>,
}
impl LockFileCache {
pub fn new() -> Self {
Self {
entries: DashMap::new(),
}
}
pub async fn get_or_parse(
&self,
provider: &dyn LockFileProvider,
lockfile_path: &Path,
) -> Result<ResolvedPackages> {
if let Some(cached) = self.entries.get(lockfile_path)
&& let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
&& let Ok(mtime) = metadata.modified()
&& mtime <= cached.modified_at
{
tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
return Ok(cached.packages.clone());
}
tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
let packages = provider.parse_lockfile(lockfile_path).await?;
let metadata = tokio::fs::metadata(lockfile_path).await?;
let modified_at = metadata.modified()?;
self.entries.insert(
lockfile_path.to_path_buf(),
CachedLockFile {
packages: packages.clone(),
modified_at,
parsed_at: Instant::now(),
},
);
Ok(packages)
}
pub fn invalidate(&self, lockfile_path: &Path) {
self.entries.remove(lockfile_path);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for LockFileCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolved_packages_new() {
let packages = ResolvedPackages::new();
assert!(packages.is_empty());
assert_eq!(packages.len(), 0);
}
#[test]
fn test_resolved_packages_insert_and_get() {
let mut packages = ResolvedPackages::new();
let pkg = ResolvedPackage {
name: "serde".into(),
version: "1.0.195".into(),
source: ResolvedSource::Registry {
url: "https://github.com/rust-lang/crates.io-index".into(),
checksum: "abc123".into(),
},
dependencies: vec!["serde_derive".into()],
};
packages.insert(pkg);
assert_eq!(packages.len(), 1);
assert!(!packages.is_empty());
assert_eq!(packages.get_version("serde"), Some("1.0.195"));
let retrieved = packages.get("serde");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().name, "serde");
assert_eq!(retrieved.unwrap().dependencies.len(), 1);
}
#[test]
fn test_resolved_packages_get_nonexistent() {
let packages = ResolvedPackages::new();
assert_eq!(packages.get("nonexistent"), None);
assert_eq!(packages.get_version("nonexistent"), None);
}
#[test]
fn test_resolved_packages_replace() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "old".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.195".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "new".into(),
},
dependencies: vec![],
});
assert_eq!(packages.len(), 1);
assert_eq!(packages.get_version("serde"), Some("1.0.195"));
assert_eq!(packages.get_all("serde").unwrap().len(), 2);
}
#[test]
fn test_resolved_packages_multiple_versions() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.195".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "a".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "0.9.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "b".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "2.0.0-beta.1".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "c".into(),
},
dependencies: vec![],
});
assert_eq!(packages.len(), 1);
assert_eq!(packages.get_version("serde"), Some("2.0.0-beta.1"));
assert_eq!(packages.get_all("serde").unwrap().len(), 3);
}
#[test]
fn test_resolved_packages_non_semver_fallback() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "weird".into(),
version: "abc".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "weird".into(),
version: "xyz".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
assert_eq!(packages.get_version("weird"), Some("xyz"));
}
#[test]
fn test_resolved_packages_semver_preferred_over_non_semver() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "mixed".into(),
version: "not-a-version".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "mixed".into(),
version: "1.0.0".into(),
source: ResolvedSource::Path { path: ".".into() },
dependencies: vec![],
});
assert_eq!(packages.get_version("mixed"), Some("1.0.0"));
}
#[test]
fn test_resolved_source_equality() {
let source1 = ResolvedSource::Registry {
url: "https://test.com".into(),
checksum: "abc".into(),
};
let source2 = ResolvedSource::Registry {
url: "https://test.com".into(),
checksum: "abc".into(),
};
let source3 = ResolvedSource::Git {
url: "https://github.com/test".into(),
rev: "abc123".into(),
};
assert_eq!(source1, source2);
assert_ne!(source1, source3);
}
#[test]
fn test_resolved_packages_iter() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "a".into(),
},
dependencies: vec![],
});
packages.insert(ResolvedPackage {
name: "tokio".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "b".into(),
},
dependencies: vec![],
});
let count = packages.iter().count();
assert_eq!(count, 2);
let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
assert!(names.contains(&"serde"));
assert!(names.contains(&"tokio"));
}
#[test]
fn test_resolved_packages_into_map() {
let mut packages = ResolvedPackages::new();
packages.insert(ResolvedPackage {
name: "serde".into(),
version: "1.0.0".into(),
source: ResolvedSource::Registry {
url: "test".into(),
checksum: "a".into(),
},
dependencies: vec![],
});
let map = packages.into_map();
assert_eq!(map.len(), 1);
assert!(map.contains_key("serde"));
}
#[test]
fn test_lockfile_cache_new() {
let cache = LockFileCache::new();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[test]
fn test_lockfile_cache_invalidate() {
let cache = LockFileCache::new();
let test_path = PathBuf::from("/test/Cargo.lock");
cache.entries.insert(
test_path.clone(),
CachedLockFile {
packages: ResolvedPackages::new(),
modified_at: SystemTime::now(),
parsed_at: Instant::now(),
},
);
assert_eq!(cache.len(), 1);
cache.invalidate(&test_path);
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[test]
fn test_locate_lockfile_for_manifest_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 located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), lock_path);
}
#[test]
fn test_locate_lockfile_for_manifest_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 located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), workspace_lock);
}
#[test]
fn test_locate_lockfile_for_manifest_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 located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
assert!(located.is_none());
}
#[test]
fn test_locate_lockfile_for_manifest_multiple_names() {
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("pyproject.toml");
let uv_lock = temp_dir.path().join("uv.lock");
std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
std::fs::write(&uv_lock, "version = 1").unwrap();
let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), uv_lock);
}
#[test]
fn test_locate_lockfile_for_manifest_first_match_wins() {
let temp_dir = tempfile::tempdir().unwrap();
let manifest_path = temp_dir.path().join("pyproject.toml");
let poetry_lock = temp_dir.path().join("poetry.lock");
let uv_lock = temp_dir.path().join("uv.lock");
std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
std::fs::write(&poetry_lock, "# poetry lock").unwrap();
std::fs::write(&uv_lock, "version = 1").unwrap();
let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
assert!(located.is_some());
assert_eq!(located.unwrap(), poetry_lock);
}
}