Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.1] - 2026-02-16

### Added
- **deps-bundler benchmarks** — Criterion benchmarks for Gemfile/Gemfile.lock parsing with various file sizes (5-100 deps)

Expand All @@ -17,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed
- **deps-bundler test coverage increased to 90%+** — Added comprehensive tests for error handling, parser edge cases, registry response parsing
- **Lock file duplicate versions** — ResolvedPackages now stores all versions per package name and returns the highest semver version, fixing incorrect outdated status when both direct and transitive dependency versions coexist

## [0.6.0] - 2026-02-03

Expand Down Expand Up @@ -298,7 +301,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- TLS enforced via rustls
- cargo-deny configured for vulnerability scanning

[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.6.0...HEAD
[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.6.1...HEAD
[0.6.1]: https://github.com/bug-ops/deps-lsp/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/bug-ops/deps-lsp/compare/v0.5.5...v0.6.0
[0.5.5]: https://github.com/bug-ops/deps-lsp/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/bug-ops/deps-lsp/compare/v0.5.3...v0.5.3
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exclude = ["crates/deps-zed"]
resolver = "3"

[workspace.package]
version = "0.6.0"
version = "0.6.1"
edition = "2024"
rust-version = "1.89"
authors = ["Andrei G"]
Expand All @@ -15,13 +15,13 @@ repository = "https://github.com/bug-ops/deps-lsp"
async-trait = "0.1"
criterion = "0.8"
dashmap = "6.1"
deps-core = { version = "0.6.0", path = "crates/deps-core" }
deps-cargo = { version = "0.6.0", path = "crates/deps-cargo" }
deps-npm = { version = "0.6.0", path = "crates/deps-npm" }
deps-pypi = { version = "0.6.0", path = "crates/deps-pypi" }
deps-go = { version = "0.6.0", path = "crates/deps-go" }
deps-bundler = { version = "0.6.0", path = "crates/deps-bundler" }
deps-lsp = { version = "0.6.0", path = "crates/deps-lsp" }
deps-core = { version = "0.6.1", path = "crates/deps-core" }
deps-cargo = { version = "0.6.1", path = "crates/deps-cargo" }
deps-npm = { version = "0.6.1", path = "crates/deps-npm" }
deps-pypi = { version = "0.6.1", path = "crates/deps-pypi" }
deps-go = { version = "0.6.1", path = "crates/deps-go" }
deps-bundler = { version = "0.6.1", path = "crates/deps-bundler" }
deps-lsp = { version = "0.6.1", path = "crates/deps-lsp" }
futures = "0.3"
insta = "1"
mockito = "1"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ deps-lsp is optimized for responsiveness:
cargo install deps-lsp
```

Latest published crate version: `0.6.0`.
Latest published crate version: `0.6.1`.

> [!TIP]
> Use `cargo binstall deps-lsp` for faster installation without compilation.
Expand Down
150 changes: 129 additions & 21 deletions crates/deps-core/src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ pub enum ResolvedSource {

/// Collection of resolved packages from a lock file.
///
/// Provides efficient lookup of resolved versions by package name.
/// Supports multiple versions per package name, returning the highest
/// semver version through public API methods.
///
/// # Examples
///
Expand All @@ -168,8 +169,22 @@ pub enum ResolvedSource {
/// ```
#[derive(Debug, Default, Clone)]
pub struct ResolvedPackages {
/// Map from package name to resolved package info
packages: HashMap<String, ResolvedPackage>,
packages: HashMap<String, Vec<ResolvedPackage>>,
}

/// Returns the package with the highest semver version from a slice.
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 {
Expand All @@ -180,30 +195,30 @@ impl ResolvedPackages {
}
}

/// Inserts a resolved package.
///
/// If a package with the same name already exists, it is replaced.
/// Inserts a resolved package, storing all versions per name.
pub fn insert(&mut self, package: ResolvedPackage) {
self.packages.insert(package.name.clone(), package);
self.packages
.entry(package.name.clone())
.or_default()
.push(package);
}

/// Gets a resolved package by name.
///
/// Returns `None` if the package is not in the lock file.
/// Gets the resolved package with the highest semver version.
pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
self.packages.get(name)
self.packages.get(name).and_then(|v| best_package(v))
}

/// Gets the resolved version string for a package.
///
/// Returns `None` if the package is not in the lock file.
///
/// This is a convenience method equivalent to `get(name).map(|p| p.version.as_str())`.
/// Gets the highest resolved version string for a package.
pub fn get_version(&self, name: &str) -> Option<&str> {
self.packages.get(name).map(|p| p.version.as_str())
self.get(name).map(|p| p.version.as_str())
}

/// Returns all stored versions for a package.
pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
self.packages.get(name).map(|v| v.as_slice())
}

/// Returns the number of resolved packages.
/// Returns the number of unique package names.
pub fn len(&self) -> usize {
self.packages.len()
}
Expand All @@ -213,14 +228,21 @@ impl ResolvedPackages {
self.packages.is_empty()
}

/// Returns an iterator over package names and their resolved info.
/// Returns an iterator yielding the best version per unique package name.
pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
self.packages.iter()
self.packages.keys().filter_map(|name| {
self.packages
.get(name)
.and_then(|v| best_package(v).map(|p| (name, p)))
})
}

/// Converts into a HashMap for easier integration.
/// Converts into a HashMap with the best version per package name.
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()
}
}

Expand Down Expand Up @@ -495,8 +517,94 @@ mod tests {
dependencies: vec![],
});

// Both versions stored, but len counts unique names
assert_eq!(packages.len(), 1);
assert_eq!(packages.get_version("serde"), Some("1.0.195"));
// Both versions accessible via get_all
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![],
});

// Falls back to string comparison: "xyz" > "abc"
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![],
});

// Parseable semver is preferred over non-parseable
assert_eq!(packages.get_version("mixed"), Some("1.0.0"));
}

#[test]
Expand Down