Skip to content

Commit 0248f0d

Browse files
authored
fix(core): resolve highest version when multiple versions exist in lock file (#56)
* fix(core): resolve highest version when multiple versions exist in lock file ResolvedPackages now stores all versions per package name and returns the highest semver version through public API. Fixes incorrect outdated status when both direct and transitive dependency versions coexist in the lock file. * style: apply rustfmt to lockfile module * release: prepare v0.6.1
1 parent 1c84ba5 commit 0248f0d

File tree

5 files changed

+150
-38
lines changed

5 files changed

+150
-38
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.1] - 2026-02-16
11+
1012
### Added
1113
- **deps-bundler benchmarks** — Criterion benchmarks for Gemfile/Gemfile.lock parsing with various file sizes (5-100 deps)
1214

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

1820
### Fixed
1921
- **deps-bundler test coverage increased to 90%+** — Added comprehensive tests for error handling, parser edge cases, registry response parsing
22+
- **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
2023

2124
## [0.6.0] - 2026-02-03
2225

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

301-
[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.6.0...HEAD
304+
[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.6.1...HEAD
305+
[0.6.1]: https://github.com/bug-ops/deps-lsp/compare/v0.6.0...v0.6.1
302306
[0.6.0]: https://github.com/bug-ops/deps-lsp/compare/v0.5.5...v0.6.0
303307
[0.5.5]: https://github.com/bug-ops/deps-lsp/compare/v0.5.4...v0.5.5
304308
[0.5.4]: https://github.com/bug-ops/deps-lsp/compare/v0.5.3...v0.5.3

Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ exclude = ["crates/deps-zed"]
44
resolver = "3"
55

66
[workspace.package]
7-
version = "0.6.0"
7+
version = "0.6.1"
88
edition = "2024"
99
rust-version = "1.89"
1010
authors = ["Andrei G"]
@@ -15,13 +15,13 @@ repository = "https://github.com/bug-ops/deps-lsp"
1515
async-trait = "0.1"
1616
criterion = "0.8"
1717
dashmap = "6.1"
18-
deps-core = { version = "0.6.0", path = "crates/deps-core" }
19-
deps-cargo = { version = "0.6.0", path = "crates/deps-cargo" }
20-
deps-npm = { version = "0.6.0", path = "crates/deps-npm" }
21-
deps-pypi = { version = "0.6.0", path = "crates/deps-pypi" }
22-
deps-go = { version = "0.6.0", path = "crates/deps-go" }
23-
deps-bundler = { version = "0.6.0", path = "crates/deps-bundler" }
24-
deps-lsp = { version = "0.6.0", path = "crates/deps-lsp" }
18+
deps-core = { version = "0.6.1", path = "crates/deps-core" }
19+
deps-cargo = { version = "0.6.1", path = "crates/deps-cargo" }
20+
deps-npm = { version = "0.6.1", path = "crates/deps-npm" }
21+
deps-pypi = { version = "0.6.1", path = "crates/deps-pypi" }
22+
deps-go = { version = "0.6.1", path = "crates/deps-go" }
23+
deps-bundler = { version = "0.6.1", path = "crates/deps-bundler" }
24+
deps-lsp = { version = "0.6.1", path = "crates/deps-lsp" }
2525
futures = "0.3"
2626
insta = "1"
2727
mockito = "1"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ deps-lsp is optimized for responsiveness:
5858
cargo install deps-lsp
5959
```
6060

61-
Latest published crate version: `0.6.0`.
61+
Latest published crate version: `0.6.1`.
6262

6363
> [!TIP]
6464
> Use `cargo binstall deps-lsp` for faster installation without compilation.

crates/deps-core/src/lockfile.rs

Lines changed: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ pub enum ResolvedSource {
145145

146146
/// Collection of resolved packages from a lock file.
147147
///
148-
/// Provides efficient lookup of resolved versions by package name.
148+
/// Supports multiple versions per package name, returning the highest
149+
/// semver version through public API methods.
149150
///
150151
/// # Examples
151152
///
@@ -168,8 +169,22 @@ pub enum ResolvedSource {
168169
/// ```
169170
#[derive(Debug, Default, Clone)]
170171
pub struct ResolvedPackages {
171-
/// Map from package name to resolved package info
172-
packages: HashMap<String, ResolvedPackage>,
172+
packages: HashMap<String, Vec<ResolvedPackage>>,
173+
}
174+
175+
/// Returns the package with the highest semver version from a slice.
176+
fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> {
177+
packages.iter().max_by(|a, b| {
178+
match (
179+
semver::Version::parse(&a.version),
180+
semver::Version::parse(&b.version),
181+
) {
182+
(Ok(va), Ok(vb)) => va.cmp(&vb),
183+
(Ok(_), Err(_)) => std::cmp::Ordering::Greater,
184+
(Err(_), Ok(_)) => std::cmp::Ordering::Less,
185+
(Err(_), Err(_)) => a.version.cmp(&b.version),
186+
}
187+
})
173188
}
174189

175190
impl ResolvedPackages {
@@ -180,30 +195,30 @@ impl ResolvedPackages {
180195
}
181196
}
182197

183-
/// Inserts a resolved package.
184-
///
185-
/// If a package with the same name already exists, it is replaced.
198+
/// Inserts a resolved package, storing all versions per name.
186199
pub fn insert(&mut self, package: ResolvedPackage) {
187-
self.packages.insert(package.name.clone(), package);
200+
self.packages
201+
.entry(package.name.clone())
202+
.or_default()
203+
.push(package);
188204
}
189205

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

197-
/// Gets the resolved version string for a package.
198-
///
199-
/// Returns `None` if the package is not in the lock file.
200-
///
201-
/// This is a convenience method equivalent to `get(name).map(|p| p.version.as_str())`.
211+
/// Gets the highest resolved version string for a package.
202212
pub fn get_version(&self, name: &str) -> Option<&str> {
203-
self.packages.get(name).map(|p| p.version.as_str())
213+
self.get(name).map(|p| p.version.as_str())
214+
}
215+
216+
/// Returns all stored versions for a package.
217+
pub fn get_all(&self, name: &str) -> Option<&[ResolvedPackage]> {
218+
self.packages.get(name).map(|v| v.as_slice())
204219
}
205220

206-
/// Returns the number of resolved packages.
221+
/// Returns the number of unique package names.
207222
pub fn len(&self) -> usize {
208223
self.packages.len()
209224
}
@@ -213,14 +228,21 @@ impl ResolvedPackages {
213228
self.packages.is_empty()
214229
}
215230

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

221-
/// Converts into a HashMap for easier integration.
240+
/// Converts into a HashMap with the best version per package name.
222241
pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
223242
self.packages
243+
.into_iter()
244+
.filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p)))
245+
.collect()
224246
}
225247
}
226248

@@ -495,8 +517,94 @@ mod tests {
495517
dependencies: vec![],
496518
});
497519

520+
// Both versions stored, but len counts unique names
498521
assert_eq!(packages.len(), 1);
499522
assert_eq!(packages.get_version("serde"), Some("1.0.195"));
523+
// Both versions accessible via get_all
524+
assert_eq!(packages.get_all("serde").unwrap().len(), 2);
525+
}
526+
527+
#[test]
528+
fn test_resolved_packages_multiple_versions() {
529+
let mut packages = ResolvedPackages::new();
530+
531+
packages.insert(ResolvedPackage {
532+
name: "serde".into(),
533+
version: "1.0.195".into(),
534+
source: ResolvedSource::Registry {
535+
url: "test".into(),
536+
checksum: "a".into(),
537+
},
538+
dependencies: vec![],
539+
});
540+
541+
packages.insert(ResolvedPackage {
542+
name: "serde".into(),
543+
version: "0.9.0".into(),
544+
source: ResolvedSource::Registry {
545+
url: "test".into(),
546+
checksum: "b".into(),
547+
},
548+
dependencies: vec![],
549+
});
550+
551+
packages.insert(ResolvedPackage {
552+
name: "serde".into(),
553+
version: "2.0.0-beta.1".into(),
554+
source: ResolvedSource::Registry {
555+
url: "test".into(),
556+
checksum: "c".into(),
557+
},
558+
dependencies: vec![],
559+
});
560+
561+
assert_eq!(packages.len(), 1);
562+
assert_eq!(packages.get_version("serde"), Some("2.0.0-beta.1"));
563+
assert_eq!(packages.get_all("serde").unwrap().len(), 3);
564+
}
565+
566+
#[test]
567+
fn test_resolved_packages_non_semver_fallback() {
568+
let mut packages = ResolvedPackages::new();
569+
570+
packages.insert(ResolvedPackage {
571+
name: "weird".into(),
572+
version: "abc".into(),
573+
source: ResolvedSource::Path { path: ".".into() },
574+
dependencies: vec![],
575+
});
576+
577+
packages.insert(ResolvedPackage {
578+
name: "weird".into(),
579+
version: "xyz".into(),
580+
source: ResolvedSource::Path { path: ".".into() },
581+
dependencies: vec![],
582+
});
583+
584+
// Falls back to string comparison: "xyz" > "abc"
585+
assert_eq!(packages.get_version("weird"), Some("xyz"));
586+
}
587+
588+
#[test]
589+
fn test_resolved_packages_semver_preferred_over_non_semver() {
590+
let mut packages = ResolvedPackages::new();
591+
592+
packages.insert(ResolvedPackage {
593+
name: "mixed".into(),
594+
version: "not-a-version".into(),
595+
source: ResolvedSource::Path { path: ".".into() },
596+
dependencies: vec![],
597+
});
598+
599+
packages.insert(ResolvedPackage {
600+
name: "mixed".into(),
601+
version: "1.0.0".into(),
602+
source: ResolvedSource::Path { path: ".".into() },
603+
dependencies: vec![],
604+
});
605+
606+
// Parseable semver is preferred over non-parseable
607+
assert_eq!(packages.get_version("mixed"), Some("1.0.0"));
500608
}
501609

502610
#[test]

0 commit comments

Comments
 (0)