diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcd72ea..3cb3bc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 10d20917..43448ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,7 +409,7 @@ dependencies = [ [[package]] name = "deps-bundler" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -430,7 +430,7 @@ dependencies = [ [[package]] name = "deps-cargo" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -452,7 +452,7 @@ dependencies = [ [[package]] name = "deps-core" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "bytes", @@ -475,7 +475,7 @@ dependencies = [ [[package]] name = "deps-go" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -495,7 +495,7 @@ dependencies = [ [[package]] name = "deps-lsp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -522,7 +522,7 @@ dependencies = [ [[package]] name = "deps-npm" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", @@ -541,7 +541,7 @@ dependencies = [ [[package]] name = "deps-pypi" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "criterion", diff --git a/Cargo.toml b/Cargo.toml index dd02a83e..bf2674be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] @@ -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" diff --git a/README.md b/README.md index e488ed6d..35fc2000 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/deps-core/src/lockfile.rs b/crates/deps-core/src/lockfile.rs index b71700a0..733f8d23 100644 --- a/crates/deps-core/src/lockfile.rs +++ b/crates/deps-core/src/lockfile.rs @@ -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 /// @@ -168,8 +169,22 @@ pub enum ResolvedSource { /// ``` #[derive(Debug, Default, Clone)] pub struct ResolvedPackages { - /// Map from package name to resolved package info - packages: HashMap, + packages: HashMap>, +} + +/// 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 { @@ -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() } @@ -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 { - 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 { self.packages + .into_iter() + .filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p))) + .collect() } } @@ -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]