From de6d4210699ead12a353701bc67704709f1302cf Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:22:23 +1000 Subject: [PATCH 01/10] feat(cli): [WIP] Support semantic version ranges. - Impl is in place but more thorough tests needed to validate --- Cargo.lock | 14 +++ Cargo.toml | 1 + src/lock.rs | 43 +++++++++- src/operations/install.rs | 12 ++- src/registry/artifactory.rs | 103 +++++++++++++--------- src/registry/cache.rs | 24 ++++-- src/registry/mod.rs | 117 ------------------------- src/resolver.rs | 166 +++++++++++++++++++++--------------- tests/registry.rs | 49 +++++++++++ tests/resolver/mod.rs | 3 + 10 files changed, 290 insertions(+), 242 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5666a654..36c7de7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,7 @@ checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -163,6 +164,8 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", @@ -1740,6 +1743,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" diff --git a/Cargo.toml b/Cargo.toml index aa642c9e..6d436734 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ assert_fs = "1.0" axum = { version = "0.8", default-features = false, features = [ "tokio", "http1", + "query", ] } hex = "0.4.3" pretty_assertions = "1.4" diff --git a/src/lock.rs b/src/lock.rs index b9ac10f9..9ef669c2 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -15,7 +15,7 @@ use std::{collections::BTreeMap, path::Path}; use miette::{Context, IntoDiagnostic, ensure}; -use semver::Version; +use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::fs; @@ -172,6 +172,11 @@ impl LockedPackage { } } + /// Returns true if the locked version satisfies the given requirement + pub fn satisfies_requirement(&self, req: &VersionReq) -> bool { + req.matches(&self.version) + } + /// Validates if another LockedPackage matches this one pub fn validate(&self, package: &Package) -> miette::Result<()> { let digest: Digest = DigestAlgorithm::SHA256.digest(&package.tgz); @@ -248,6 +253,11 @@ impl PackageLockfile { pub fn packages(&self) -> impl Iterator { self.packages.values() } + + /// Finds the locked package for `name` if its version satisfies `req` + pub fn find_satisfying(&self, name: &PackageName, req: &VersionReq) -> Option<&LockedPackage> { + self.packages.get(name).filter(|p| req.matches(&p.version)) + } } #[async_trait::async_trait] @@ -368,6 +378,18 @@ impl WorkspaceLockfile { self.packages.get(&(name.clone(), version.clone())) } + /// Finds the highest locked version of `name` that satisfies `req` + pub fn find_satisfying( + &self, + name: &PackageName, + req: &VersionReq, + ) -> Option<&LockedPackage> { + self.packages + .values() + .filter(|p| p.name == *name && req.matches(&p.version)) + .max_by_key(|p| &p.version) + } + /// Returns all packages in the lockfile pub fn packages(&self) -> impl Iterator { self.packages.values() @@ -474,7 +496,7 @@ pub enum Lockfile { } impl Lockfile { - /// Locates a package by name and version + /// Locates a package by name and exact version pub fn get(&self, name: &PackageName, version: &Version) -> Option { match self { Self::Package(lock) => lock @@ -566,6 +588,23 @@ impl Lockfile { )), } } + + /// Finds the highest locked version of `name` that satisfies `req`, returning its concrete + /// version and file requirement + pub fn find_satisfying( + &self, + name: &PackageName, + req: &VersionReq, + ) -> Option<(Version, FileRequirement)> { + match self { + Self::Package(lock) => lock + .find_satisfying(name, req) + .map(|p| (p.version.clone(), FileRequirement::from(p))), + Self::Workspace(lock) => lock + .find_satisfying(name, req) + .map(|p| (p.version.clone(), FileRequirement::from(p))), + } + } } #[async_trait::async_trait] diff --git a/src/operations/install.rs b/src/operations/install.rs index bec8e7a2..f72d4ac2 100644 --- a/src/operations/install.rs +++ b/src/operations/install.rs @@ -337,6 +337,16 @@ mod utils { let artifactory = Artifactory::new(registry.clone(), &ctx.credentials) .wrap_err_with(|| format!("failed to initialize registry {}", registry))?; + let resolved_version = artifactory + .resolve_version(repository.to_string(), package_name.clone(), version) + .await + .wrap_err_with(|| { + format!( + "could not resolve {}@{} from registry {}", + package_name, version, registry + ) + })?; + let dependency = Dependency { package: package_name.clone(), manifest: DependencyManifest::Remote(RemoteDependencyManifest { @@ -346,7 +356,7 @@ mod utils { }), }; - let downloaded_package = artifactory.download(dependency).await?; + let downloaded_package = artifactory.download(dependency, &resolved_version).await?; // 2. Cache the downloaded package for future installs let cache_key = CacheEntry::from(&downloaded_package); diff --git a/src/registry/artifactory.rs b/src/registry/artifactory.rs index 634c78f8..047f2d12 100644 --- a/src/registry/artifactory.rs +++ b/src/registry/artifactory.rs @@ -21,7 +21,7 @@ use crate::{ }; use miette::{Context, IntoDiagnostic, ensure, miette}; use reqwest::{Body, Method, Response}; -use semver::Version; +use semver::{Version, VersionReq}; use serde::Deserialize; use url::Url; @@ -82,18 +82,17 @@ impl Artifactory { .map(|_| ()) } - /// Retrieves the latest version of a package by querying artifactory. Returns an error if no artifact could be found - pub async fn get_latest_version( + /// Lists all available versions of a package from artifactory, sorted descending + pub async fn list_versions( &self, repository: String, name: PackageName, - ) -> miette::Result { - tracing::debug!("Artifactory::get_latest_version() called"); + ) -> miette::Result> { + tracing::debug!("Artifactory::list_versions() called"); tracing::debug!(" package name: {}", name); tracing::debug!(" repository: {}", repository); tracing::debug!(" registry: {}", self.registry); - // First retrieve all packages matching the given name let search_query_url: Url = { let mut url = self.registry.clone(); url.set_path("artifactory/api/search/artifact"); @@ -103,19 +102,16 @@ impl Artifactory { tracing::debug!("search query URL: {}", search_query_url); - tracing::debug!("sending artifact search request to artifactory"); let response = self .new_request(Method::GET, search_query_url) .send() .await?; let response: reqwest::Response = response.0; - tracing::debug!("received response from artifactory"); let headers = response.headers(); let content_type = headers .get(&reqwest::header::CONTENT_TYPE) .ok_or_else(|| miette!("missing content-type header"))?; - tracing::debug!("response content-type: {:?}", content_type); ensure!( content_type @@ -125,13 +121,10 @@ impl Artifactory { "server response has incorrect mime type: {content_type:?}" ); - tracing::debug!("parsing response body as text"); let response_str = response.text().await.into_diagnostic().wrap_err(miette!( "unexpected error: unable to retrieve response payload" ))?; - tracing::debug!("response body length: {} bytes", response_str.len()); - tracing::debug!("deserializing response to ArtifactSearchResponse"); let parsed_response = serde_json::from_str::(&response_str) .into_diagnostic() .wrap_err(miette!( @@ -139,14 +132,11 @@ impl Artifactory { ))?; tracing::debug!( - "found {} artifacts matching the name: {:?}", - parsed_response.results.len(), - parsed_response + "found {} artifacts matching the name", + parsed_response.results.len() ); - // Then from all package names retrieved from artifactory, extract the highest version number - tracing::debug!("extracting version numbers from artifact URIs"); - let highest_version = parsed_response + let mut versions: Vec = parsed_response .results .iter() .filter_map(|artifact_search_result| { @@ -158,44 +148,76 @@ impl Artifactory { .next_back() .map(|name_tgz| name_tgz.trim_end_matches(".tgz")); - if let Some(artifact_name) = full_artifact_name { - tracing::debug!(" artifact name: {}", artifact_name); - } - let artifact_version = full_artifact_name .and_then(|name| name.split('-').next_back()) - .and_then(|version_str| { - tracing::debug!(" parsing version string: {}", version_str); - Version::parse(version_str).ok() - }); + .and_then(|version_str| Version::parse(version_str).ok()); - // we double check that the artifact name matches exactly + // Double-check that the artifact name matches exactly let expected_artifact_name = artifact_version.clone().map(|av| format!("{name}-{av}")); if full_artifact_name.is_some_and(|actual| { expected_artifact_name.is_some_and(|expected| expected == actual) }) { - if let Some(ref version) = artifact_version { - tracing::debug!(" valid version found: {}", version); - } artifact_version } else { tracing::debug!(" artifact name doesn't match expected format, skipping"); None } }) - .max(); + .collect(); - tracing::debug!("highest version for artifact: {:?}", highest_version); + versions.sort_unstable_by(|a, b| b.cmp(a)); // descending + tracing::debug!("found {} valid versions", versions.len()); + Ok(versions) + } - highest_version.ok_or_else(|| { - tracing::error!("no version could be found for package {} in repository {}", name, repository); - miette!("no version could be found on artifactory for this artifact name. Does it exist in this registry and repository?") - }) + /// Resolves the highest available version of a package satisfying a requirement + pub async fn resolve_version( + &self, + repository: String, + name: PackageName, + req: &VersionReq, + ) -> miette::Result { + let versions = self.list_versions(repository, name.clone()).await?; + versions + .into_iter() + .find(|v| req.matches(v)) + .ok_or_else(|| { + miette!( + "no version of {} satisfies requirement {} in this registry", + name, + req + ) + }) } - /// Downloads a package from artifactory - pub async fn download(&self, dependency: Dependency) -> miette::Result { + /// Retrieves the latest version of a package by querying artifactory + pub async fn get_latest_version( + &self, + repository: String, + name: PackageName, + ) -> miette::Result { + tracing::debug!("Artifactory::get_latest_version() called"); + self.list_versions(repository.clone(), name.clone()) + .await? + .into_iter() + .next() // list_versions is already sorted descending + .ok_or_else(|| { + tracing::error!( + "no version could be found for package {} in repository {}", + name, + repository + ); + miette!("no version could be found on artifactory for this artifact name. Does it exist in this registry and repository?") + }) + } + + /// Downloads a specific version of a package from artifactory + pub async fn download( + &self, + dependency: Dependency, + version: &Version, + ) -> miette::Result { tracing::debug!("Artifactory::download() called"); tracing::debug!(" package name: {}", dependency.package); @@ -212,12 +234,9 @@ impl Artifactory { tracing::debug!(" registry: {}", manifest.registry); tracing::debug!(" repository: {}", manifest.repository); - tracing::debug!(" version requirement: {}", manifest.version); + tracing::debug!(" resolved version: {}", version); let artifact_url = { - let version = super::dependency_version_string(&dependency)?; - tracing::debug!(" resolved version: {}", version); - let path = manifest.registry.path().to_owned(); let mut url = manifest.registry.clone(); diff --git a/src/registry/cache.rs b/src/registry/cache.rs index fbdc18e4..25d16de3 100644 --- a/src/registry/cache.rs +++ b/src/registry/cache.rs @@ -37,7 +37,11 @@ impl LocalRegistry { } /// "Downloads" a package from the local filesystem - pub async fn download(&self, dependency: Dependency) -> miette::Result { + pub async fn download( + &self, + dependency: Dependency, + version: &semver::Version, + ) -> miette::Result { let DependencyManifest::Remote(ref manifest) = dependency.manifest else { return Err(miette!( "unable to serialize version of local dependency ({})", @@ -45,8 +49,6 @@ impl LocalRegistry { )); }; - let version = super::dependency_version_string(&dependency)?; - let path = self.base_dir.join(PathBuf::from(format!( "{}/{}/{}-{}.tgz", manifest.repository, dependency.package, dependency.package, version @@ -155,13 +157,17 @@ mod tests { // Download package from local registry and assert the tgz bytes and the metadata match what we // had published. + let version = semver::Version::new(0, 1, 0); let fetched = registry - .download(Dependency::new( - registry_uri, - "test-repo".into(), - "test-api".parse().unwrap(), - "=0.1.0".parse().unwrap(), - )) + .download( + Dependency::new( + registry_uri, + "test-repo".into(), + "test-api".parse().unwrap(), + "=0.1.0".parse().unwrap(), + ), + &version, + ) .await .unwrap(); diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 0db92bb2..6d275ae0 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -24,13 +24,9 @@ mod cache; pub use artifactory::Artifactory; use miette::{Context, IntoDiagnostic, ensure, miette}; -use semver::VersionReq; use serde::{Deserialize, Serialize}; -use thiserror::Error; use url::Url; -use crate::manifest::{Dependency, DependencyManifest}; - /// A representation of a registry URI #[derive(Debug, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct RegistryUri(Url); @@ -93,116 +89,3 @@ fn sanity_check_url(url: &Url) -> miette::Result<()> { Err(miette!("the URI must contain a host component: {url}")) } } - -#[derive(Error, Debug)] -#[error("{0} is not a supported version requirement")] -struct UnsupportedVersionRequirement(VersionReq); - -#[derive(Error, Debug)] -#[error( - "{0} is not supported yet. Pin the exact version you want to use with '='. For example: '=1.0.4' instead of '^1.0.0'" -)] -struct VersionNotPinned(VersionReq); - -fn dependency_version_string(dependency: &Dependency) -> miette::Result { - let DependencyManifest::Remote(ref manifest) = dependency.manifest else { - return Err(miette!( - "unable to serialize version of local dependency ({})", - dependency.package - )); - }; - - let version = manifest - .version - .comparators - .first() - .ok_or_else(|| UnsupportedVersionRequirement(manifest.version.clone())) - .into_diagnostic()?; - - ensure!( - version.op == semver::Op::Exact, - VersionNotPinned(manifest.version.clone()) - ); - - let minor_version = version - .minor - .ok_or_else(|| miette!("version missing minor number"))?; - - let patch_version = version - .patch - .ok_or_else(|| miette!("version missing patch number"))?; - - Ok(format!( - "{}.{}.{}{}", - version.major, - minor_version, - patch_version, - if version.pre.is_empty() { - "".to_owned() - } else { - format!("-{}", version.pre) - } - )) -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use semver::VersionReq; - - use crate::{ - manifest::Dependency, - package::PackageName, - registry::{VersionNotPinned, dependency_version_string}, - }; - - use super::RegistryUri; - - fn get_dependency(version: &str) -> Dependency { - let registry = RegistryUri::from_str("https://my-registry.com").unwrap(); - let repository = String::from("my-repo"); - let package = PackageName::from_str("package").unwrap(); - let version = VersionReq::from_str(version).unwrap(); - Dependency::new(registry, repository, package, version) - } - - #[test] - fn valid_version() { - let dependency = get_dependency("=0.0.1"); - assert!(dependency_version_string(&dependency).is_ok_and(|version| version == "0.0.1")); - - let dependency = get_dependency("=0.0.1-23"); - assert!(dependency_version_string(&dependency).is_ok_and(|version| version == "0.0.1-23")); - - let dependency = get_dependency("=0.0.1-ab"); - assert!(dependency_version_string(&dependency).is_ok_and(|version| version == "0.0.1-ab")); - } - - #[test] - fn unsupported_version_operator() { - let dependency = get_dependency("^0.0.1"); - assert!( - dependency_version_string(&dependency).is_err_and(|err| err.is::()) - ); - - let dependency = get_dependency("~0.0.1"); - assert!( - dependency_version_string(&dependency).is_err_and(|err| err.is::()) - ); - - let dependency = get_dependency("<=0.0.1"); - assert!( - dependency_version_string(&dependency).is_err_and(|err| err.is::()) - ); - } - - #[test] - fn incomplete_version() { - let dependency = get_dependency("=1.0"); - assert!(dependency_version_string(&dependency).is_err()); - - let dependency = get_dependency("=1"); - assert!(dependency_version_string(&dependency).is_err()); - } -} diff --git a/src/resolver.rs b/src/resolver.rs index f470bd79..6fa3f68e 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -51,8 +51,10 @@ pub struct DependencyNode { pub source: DependencySource, /// Packages that this package depends on pub dependencies: Vec, - /// Version requirement + /// Version requirement from the manifest pub version: VersionReq, + /// The concrete version that was resolved and downloaded (None for local deps) + pub resolved_version: Option, } /// Maps a package name to metadata describing the package @@ -314,6 +316,7 @@ impl<'a> GraphBuilder<'a> { }, dependencies: sub_dependencies.clone(), version: VersionReq::STAR, + resolved_version: None, }, ); @@ -361,68 +364,79 @@ impl<'a> GraphBuilder<'a> { let package_name = &dependency.package; let registry = &remote_manifest.registry; let repository = &remote_manifest.repository; - let version = &remote_manifest.version; - - let package = { - let reformatted = version - .to_string() - .chars() - .skip_while(|c| *c == '=') - .collect::(); - - let version = reformatted - .parse() - .map_err(|e| miette::miette!("{e}")) + let version_req = &remote_manifest.version; + + // Reuse or create artifactory client + let artifactory = if let Some(client) = self.registry_clients.get(registry) { + client.clone() + } else { + let client = Artifactory::new(registry.clone(), self.credentials) + .wrap_err_with(|| format!("failed to initialize registry {}", registry))?; + self.registry_clients + .insert(registry.clone(), client.clone()); + client + }; + + // Phase 1: resolve the requirement to a concrete version. + // Try the lockfile first (avoids a registry round-trip when the lock is fresh). + let (resolved_version, cached_package) = if let Some(lockfile) = &self.lockfile + && let Some((locked_version, file_req)) = + lockfile.find_satisfying(package_name, version_req) + && file_req.url().as_str().starts_with(registry.as_str()) + { + let cache = Cache::open().await?; + let cached = cache.get(file_req).await.ok().flatten(); + tracing::debug!( + "lockfile pins {}@{} (satisfies {})", + package_name, + locked_version, + version_req + ); + (locked_version, cached) + } else { + // No usable lockfile entry — resolve from registry. + let version = artifactory + .resolve_version(repository.clone(), package_name.clone(), version_req) + .await .wrap_err_with(|| { - format!("expected only exact version requirements: {package_name}@{version}") + format!( + "could not resolve {}@{} from registry {}", + package_name, version_req, registry + ) })?; + tracing::debug!("resolved {} {} -> {}", package_name, version_req, version); + (version, None) + }; - let mut cached_package = None; - - // Try to resolve from lockfile + cache first - if let Some(lockfile) = &self.lockfile - && let Some(file_req) = lockfile.get(package_name, &version) - { - // Verify registry matches (lockfile vs manifest) - if file_req.url().as_str().starts_with(registry.as_str()) { - let cache = Cache::open().await?; - if let Ok(Some(pkg)) = cache.get(file_req).await { - tracing::debug!("resolved {}@{} from local cache", package_name, version); - cached_package = Some(pkg); - } - } + // Phase 2: obtain the package bytes (cache hit or download). + let package = match (cached_package, self.network_mode) { + (Some(pkg), _) => { + tracing::debug!( + "resolved {}@{} from local cache", + package_name, + resolved_version + ); + pkg } - - match (cached_package, self.network_mode) { - (Some(pkg), _) => pkg, - (None, NetworkMode::Online) => { - tracing::debug!("downloading {}@{} from registry", package_name, version); - - // Reuse or create artifactory client - let artifactory = if let Some(client) = self.registry_clients.get(registry) { - client.clone() - } else { - let client = Artifactory::new(registry.clone(), self.credentials) - .wrap_err_with(|| { - format!("failed to initialize registry {}", registry) - })?; - self.registry_clients - .insert(registry.clone(), client.clone()); - client - }; - - artifactory.download(dependency.clone()).await? - } - (None, NetworkMode::Offline) => { - bail!(DependencyError::Offline { - name: package_name.clone(), - version: remote_manifest.version.clone(), - }); - } + (None, NetworkMode::Online) => { + tracing::debug!( + "downloading {}@{} from registry", + package_name, + resolved_version + ); + artifactory + .download(dependency.clone(), &resolved_version) + .await? + } + (None, NetworkMode::Offline) => { + bail!(DependencyError::Offline { + name: package_name.clone(), + version: remote_manifest.version.clone(), + }); } }; - // Read the package manifest to discover dependencies and package type + // Read the package manifest to discover dependencies and package type. let manifest = package.manifest; let package_type = manifest.package.as_ref().map(|p| p.kind); @@ -430,7 +444,7 @@ impl<'a> GraphBuilder<'a> { let sub_dependencies: Vec = manifest.get_dependency_package_names(); - // Add node with discovered metadata + // Add node with discovered metadata. self.nodes.insert( package_name.clone(), DependencyNode { @@ -441,11 +455,12 @@ impl<'a> GraphBuilder<'a> { repository: repository.clone(), }, dependencies: sub_dependencies.clone(), - version: version.clone(), + version: version_req.clone(), + resolved_version: Some(resolved_version), }, ); - // Recursively process transitive dependencies + // Recursively process transitive dependencies. for sub_dep in manifest.dependencies.unwrap_or_default() { self.add_dependency(&sub_dep, package_type) .await @@ -469,24 +484,30 @@ impl<'a> GraphBuilder<'a> { Ok(()) } - /// Validates that version requirements are compatible when the same package is requested multiple times + /// Validates that a new version requirement is compatible with the already-resolved version fn validate_version_compatibility( &self, dependency: &Dependency, existing: &DependencyNode, ) -> miette::Result<()> { - // Only check version compatibility for remote dependencies - if let (DependencyManifest::Remote(new_remote), DependencySource::Remote { .. }) = - (&dependency.manifest, &existing.source) - { - // For now, buffrs only supports pinned versions (see TODO #205) - // We check if the version requirements are equal since they should be exact pins - // In the future with dynamic version resolution, this would need to check for compatibility - if new_remote.version != existing.version { + if let ( + DependencyManifest::Remote(new_remote), + DependencySource::Remote { .. }, + Some(resolved_version), + ) = ( + &dependency.manifest, + &existing.source, + &existing.resolved_version, + ) { + // The package was already resolved to `resolved_version`. Check that the new + // requirement is also satisfied by that version (merge-and-resolve: accept when + // compatible, fail only when truly incompatible). + if !new_remote.version.matches(resolved_version) { bail!(DependencyError::VersionConflict { package: dependency.package.clone(), required_version: new_remote.version.clone(), existing_version: existing.version.clone(), + resolved_version: resolved_version.clone(), }); } } @@ -543,15 +564,18 @@ pub enum DependencyError { /// Version conflict between multiple dependants #[error( - "version conflict for {package}: requires {required_version} but already resolved to {existing_version}" + "version conflict for {package}: requirement {required_version} is not satisfied by \ + resolved version {resolved_version} (chosen to satisfy {existing_version})" )] VersionConflict { /// The package with conflicting versions package: PackageName, - /// The version requirement that conflicts + /// The new requirement that cannot be satisfied required_version: VersionReq, - /// The version already resolved in the graph + /// The requirement that led to the current resolved version existing_version: VersionReq, + /// The concrete version that was already resolved and downloaded + resolved_version: semver::Version, }, /// Failed to download a dependency from the registry diff --git a/tests/registry.rs b/tests/registry.rs index 6a7197ac..f0602235 100644 --- a/tests/registry.rs +++ b/tests/registry.rs @@ -11,6 +11,7 @@ use axum::{ }; use bytes::Bytes; use miette::{Context as _, IntoDiagnostic, miette}; +use serde::Deserialize; use tokio::net::TcpListener; type State = Arc>>; @@ -33,6 +34,8 @@ async fn test_registry( required_token, }; let app = Router::new() + // Artifactory-compatible artifact search endpoint (must be registered before the wildcard) + .route("/artifactory/api/search/artifact", get(search_artifacts)) .route("/{*path}", get(get_package).put(put_package)) .with_state(state); axum::serve(listener, app) @@ -41,6 +44,52 @@ async fn test_registry( .wrap_err(miette!("failed to read the token from the user")) } +/// Query parameters for the Artifactory artifact search endpoint +#[derive(Deserialize)] +struct SearchQuery { + name: String, + repos: String, +} + +/// Implements the Artifactory artifact search endpoint used by `list_versions`. +/// +/// Scans stored packages whose path matches `{any}/{repos}/{name}/{name}-{version}.tgz` +/// and returns them as an `ArtifactSearchResult` JSON payload. +async fn search_artifacts( + extract::State(state): extract::State, + extract::Query(query): extract::Query, +) -> impl IntoResponse { + let state = state.packages.read().unwrap(); + + // Stored keys look like: "{prefix}/{repos}/{name}/{name}-{version}.tgz" + let results: Vec = state + .keys() + .filter(|path| { + let segments: Vec<&str> = path.split('/').collect(); + // Need at least 4 segments: prefix / repos / name / filename + if segments.len() < 4 { + return false; + } + let repo_seg = segments[segments.len() - 3]; + let name_seg = segments[segments.len() - 2]; + repo_seg == query.repos && name_seg == query.name + }) + .map(|path| { + // The URI only needs to end with the correct filename for version parsing. + serde_json::json!({ "uri": format!("http://test-registry/{}", path) }) + }) + .collect(); + + let body = serde_json::json!({ "results": results }).to_string(); + ( + [( + header::CONTENT_TYPE, + "application/vnd.org.jfrog.artifactory.search.ArtifactSearchResult+json", + )], + body, + ) +} + // basic handler that responds with a static string async fn get_package( extract::State(state): extract::State, diff --git a/tests/resolver/mod.rs b/tests/resolver/mod.rs index 96819f89..88c4315e 100644 --- a/tests/resolver/mod.rs +++ b/tests/resolver/mod.rs @@ -824,6 +824,7 @@ fn build_test_graph(nodes: Vec<(PackageName, Vec)>) -> DependencyGr }, dependencies, version: VersionReq::STAR, + resolved_version: None, }, ); } @@ -1102,6 +1103,7 @@ fn test_topo_sort_detects_cycle() { }, dependencies: vec!["b".parse().expect("valid package name")], version: VersionReq::STAR, + resolved_version: None, }, ); @@ -1115,6 +1117,7 @@ fn test_topo_sort_detects_cycle() { }, dependencies: vec!["a".parse().expect("valid package name")], version: VersionReq::STAR, + resolved_version: None, }, ); From ce2b42f23cdf18cdd95973ae73e5b7f778898e15 Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:43:31 +1000 Subject: [PATCH 02/10] feat(cli): [WIP] Additional unit tests for semantic version ranges. --- tests/cmd/install/workspace/mod.rs | 5 + .../range_caret_resolution/in/Proto.toml | 4 + .../range_caret_resolution/in/pkg1/Proto.toml | 8 + .../in/pkg1/proto/pkg1.proto | 7 + .../workspace/range_caret_resolution/mod.rs | 148 ++++++++++++ .../range_compatible_diamond/in/Proto.toml | 4 + .../in/pkg1/Proto.toml | 8 + .../in/pkg1/proto/pkg1.proto | 7 + .../in/pkg2/Proto.toml | 8 + .../in/pkg2/proto/pkg2.proto | 7 + .../workspace/range_compatible_diamond/mod.rs | 220 ++++++++++++++++++ .../range_incompatible_diamond/in/Proto.toml | 4 + .../in/pkg1/Proto.toml | 8 + .../in/pkg1/proto/pkg1.proto | 7 + .../in/pkg2/Proto.toml | 8 + .../in/pkg2/proto/pkg2.proto | 7 + .../range_incompatible_diamond/mod.rs | 202 ++++++++++++++++ .../range_multi_level_tree/in/Proto.toml | 4 + .../range_multi_level_tree/in/pkg1/Proto.toml | 8 + .../in/pkg1/proto/pkg1.proto | 7 + .../workspace/range_multi_level_tree/mod.rs | 168 +++++++++++++ .../range_tilde_resolution/in/Proto.toml | 4 + .../range_tilde_resolution/in/pkg1/Proto.toml | 8 + .../in/pkg1/proto/pkg1.proto | 7 + .../workspace/range_tilde_resolution/mod.rs | 148 ++++++++++++ 25 files changed, 1016 insertions(+) create mode 100644 tests/cmd/install/workspace/range_caret_resolution/in/Proto.toml create mode 100644 tests/cmd/install/workspace/range_caret_resolution/in/pkg1/Proto.toml create mode 100644 tests/cmd/install/workspace/range_caret_resolution/in/pkg1/proto/pkg1.proto create mode 100644 tests/cmd/install/workspace/range_caret_resolution/mod.rs create mode 100644 tests/cmd/install/workspace/range_compatible_diamond/in/Proto.toml create mode 100644 tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/Proto.toml create mode 100644 tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/proto/pkg1.proto create mode 100644 tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/Proto.toml create mode 100644 tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/proto/pkg2.proto create mode 100644 tests/cmd/install/workspace/range_compatible_diamond/mod.rs create mode 100644 tests/cmd/install/workspace/range_incompatible_diamond/in/Proto.toml create mode 100644 tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/Proto.toml create mode 100644 tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/proto/pkg1.proto create mode 100644 tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/Proto.toml create mode 100644 tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/proto/pkg2.proto create mode 100644 tests/cmd/install/workspace/range_incompatible_diamond/mod.rs create mode 100644 tests/cmd/install/workspace/range_multi_level_tree/in/Proto.toml create mode 100644 tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/Proto.toml create mode 100644 tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/proto/pkg1.proto create mode 100644 tests/cmd/install/workspace/range_multi_level_tree/mod.rs create mode 100644 tests/cmd/install/workspace/range_tilde_resolution/in/Proto.toml create mode 100644 tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/Proto.toml create mode 100644 tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/proto/pkg1.proto create mode 100644 tests/cmd/install/workspace/range_tilde_resolution/mod.rs diff --git a/tests/cmd/install/workspace/mod.rs b/tests/cmd/install/workspace/mod.rs index a208389f..79c7bf53 100644 --- a/tests/cmd/install/workspace/mod.rs +++ b/tests/cmd/install/workspace/mod.rs @@ -3,3 +3,8 @@ mod lockfile; mod lockfile_diamond_dependencies; mod lockfile_multiple_versions; mod lockfile_transitive; +mod range_caret_resolution; +mod range_compatible_diamond; +mod range_incompatible_diamond; +mod range_multi_level_tree; +mod range_tilde_resolution; diff --git a/tests/cmd/install/workspace/range_caret_resolution/in/Proto.toml b/tests/cmd/install/workspace/range_caret_resolution/in/Proto.toml new file mode 100644 index 00000000..7873c093 --- /dev/null +++ b/tests/cmd/install/workspace/range_caret_resolution/in/Proto.toml @@ -0,0 +1,4 @@ +edition = "0.12" + +[workspace] +members = ["pkg1"] diff --git a/tests/cmd/install/workspace/range_caret_resolution/in/pkg1/Proto.toml b/tests/cmd/install/workspace/range_caret_resolution/in/pkg1/Proto.toml new file mode 100644 index 00000000..055e8789 --- /dev/null +++ b/tests/cmd/install/workspace/range_caret_resolution/in/pkg1/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg1" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_caret_resolution/in/pkg1/proto/pkg1.proto b/tests/cmd/install/workspace/range_caret_resolution/in/pkg1/proto/pkg1.proto new file mode 100644 index 00000000..f8deb6b3 --- /dev/null +++ b/tests/cmd/install/workspace/range_caret_resolution/in/pkg1/proto/pkg1.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg1; + +message Pkg1Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_caret_resolution/mod.rs b/tests/cmd/install/workspace/range_caret_resolution/mod.rs new file mode 100644 index 00000000..54079b3a --- /dev/null +++ b/tests/cmd/install/workspace/range_caret_resolution/mod.rs @@ -0,0 +1,148 @@ +use crate::{VirtualFileSystem, with_test_registry}; + +/// Verifies that a caret version requirement (^1.0.0) resolves to the highest +/// compatible version available in the registry, not the minimum. +/// +/// Publishes leaf-lib at v1.0.0, v1.1.0, and v1.2.0, then installs with +/// `^1.0.0`. Expects the lockfile to pin v1.2.0. +#[test] +fn fixture() { + with_test_registry(|url| { + let vfs = VirtualFileSystem::copy(crate::parent_directory!().join("in")); + let buffrs_home = vfs.root().join("$HOME"); + let cwd = vfs.root(); + + // Publish leaf-lib at v1.0.0 + { + let lib_dir = cwd.join("leaf-lib-v1"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v1.1.0 + { + let lib_dir = cwd.join("leaf-lib-v1-1"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.1.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n string extra = 2;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v1.2.0 (should be the resolved version) + { + let lib_dir = cwd.join("leaf-lib-v1-2"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.2.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n string extra = 2;\n int32 count = 3;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + std::fs::remove_dir_all(cwd.join("leaf-lib-v1")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-1")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-2")).unwrap(); + + // Add leaf-lib with caret requirement to pkg1 + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + // Install at workspace root + crate::cli!() + .arg("install") + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&cwd) + .assert() + .success(); + + let lockfile_path = cwd.join("Proto.lock"); + assert!(lockfile_path.exists(), "Proto.lock should exist"); + + let lockfile = std::fs::read_to_string(&lockfile_path).unwrap(); + + // ^1.0.0 must resolve to the highest 1.x.y: 1.2.0 + assert!( + lockfile.contains("version = \"1.2.0\""), + "^1.0.0 should resolve to 1.2.0 (highest compatible), got:\n{}", + lockfile + ); + assert!( + !lockfile.contains("version = \"1.0.0\""), + "^1.0.0 must not pin to the minimum 1.0.0" + ); + assert!( + !lockfile.contains("version = \"1.1.0\""), + "^1.0.0 must not pin to the intermediate 1.1.0" + ); + }) +} diff --git a/tests/cmd/install/workspace/range_compatible_diamond/in/Proto.toml b/tests/cmd/install/workspace/range_compatible_diamond/in/Proto.toml new file mode 100644 index 00000000..4e623662 --- /dev/null +++ b/tests/cmd/install/workspace/range_compatible_diamond/in/Proto.toml @@ -0,0 +1,4 @@ +edition = "0.12" + +[workspace] +members = ["pkg1", "pkg2"] diff --git a/tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/Proto.toml b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/Proto.toml new file mode 100644 index 00000000..055e8789 --- /dev/null +++ b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg1" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/proto/pkg1.proto b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/proto/pkg1.proto new file mode 100644 index 00000000..f8deb6b3 --- /dev/null +++ b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg1/proto/pkg1.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg1; + +message Pkg1Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/Proto.toml b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/Proto.toml new file mode 100644 index 00000000..bfbf81a0 --- /dev/null +++ b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg2" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/proto/pkg2.proto b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/proto/pkg2.proto new file mode 100644 index 00000000..3a7da6e7 --- /dev/null +++ b/tests/cmd/install/workspace/range_compatible_diamond/in/pkg2/proto/pkg2.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg2; + +message Pkg2Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_compatible_diamond/mod.rs b/tests/cmd/install/workspace/range_compatible_diamond/mod.rs new file mode 100644 index 00000000..d0eec65a --- /dev/null +++ b/tests/cmd/install/workspace/range_compatible_diamond/mod.rs @@ -0,0 +1,220 @@ +use crate::{VirtualFileSystem, with_test_registry}; + +/// Verifies that compatible but different range requirements on the same +/// transitive leaf are resolved to a single version within one package's +/// dependency graph. +/// +/// Diamond shape (all within pkg1): +/// pkg1 --(^1.0)--> lib-a --(^1.0.0)--> leaf-lib +/// pkg1 --(^1.0)--> lib-b --(>=1.2.0)--> leaf-lib +/// +/// leaf-lib is available at v1.0.0 and v1.5.0. The resolver encounters +/// leaf-lib first via lib-a (resolves to v1.5.0 for ^1.0.0), then +/// re-encounters it via lib-b and calls validate_version_compatibility, +/// which should accept v1.5.0 because it satisfies >=1.2.0. Install must +/// succeed and the lockfile must pin leaf-lib at v1.5.0 exactly once. +#[test] +fn fixture() { + with_test_registry(|url| { + let vfs = VirtualFileSystem::copy(crate::parent_directory!().join("in")); + let buffrs_home = vfs.root().join("$HOME"); + let cwd = vfs.root(); + + // Publish leaf-lib at v1.0.0 + { + let lib_dir = cwd.join("leaf-lib-v1-0"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v1.5.0 (satisfies both ^1.0.0 and >=1.2.0) + { + let lib_dir = cwd.join("leaf-lib-v1-5"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.5.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n int32 count = 2;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish lib-a at v1.0.0 (depends on leaf-lib@^1.0.0) + { + let lib_dir = cwd.join("lib-a"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "lib-a"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/liba.proto"), + "syntax = \"proto3\";\n\npackage liba;\n\nmessage LibAMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish lib-b at v1.0.0 (depends on leaf-lib@>=1.2.0, compatible with 1.5.0) + { + let lib_dir = cwd.join("lib-b"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "lib-b"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/libb.proto"), + "syntax = \"proto3\";\n\npackage libb;\n\nmessage LibBMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@>=1.2.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-0")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-5")).unwrap(); + std::fs::remove_dir_all(cwd.join("lib-a")).unwrap(); + std::fs::remove_dir_all(cwd.join("lib-b")).unwrap(); + + // pkg1 depends on BOTH lib-a and lib-b — creating a diamond on leaf-lib + // within a single package's dependency graph. + crate::cli!() + .args(["add", "--registry", url, "test-repo/lib-a@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + crate::cli!() + .args(["add", "--registry", url, "test-repo/lib-b@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + // Install must succeed — 1.5.0 satisfies both ^1.0.0 and >=1.2.0 + crate::cli!() + .arg("install") + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&cwd) + .assert() + .success(); + + let lockfile_path = cwd.join("Proto.lock"); + assert!(lockfile_path.exists(), "Proto.lock should exist"); + + let lockfile = std::fs::read_to_string(&lockfile_path).unwrap(); + + // leaf-lib must appear exactly once in the lockfile + let leaf_count = lockfile.matches("name = \"leaf-lib\"").count(); + assert_eq!( + leaf_count, 1, + "leaf-lib should appear exactly once in the lockfile (got {})", + leaf_count + ); + + // Find the leaf-lib section and verify its pinned version + let leaf_section = lockfile + .split("[[packages]]") + .find(|s| s.contains("name = \"leaf-lib\"")) + .expect("leaf-lib section should exist in lockfile"); + + assert!( + leaf_section.contains("version = \"1.5.0\""), + "leaf-lib should be pinned at 1.5.0 (satisfies both ^1.0.0 and >=1.2.0), got:\n{}", + leaf_section + ); + assert!( + !leaf_section.contains("version = \"1.0.0\""), + "leaf-lib must not be pinned at 1.0.0 (does not satisfy >=1.2.0), got:\n{}", + leaf_section + ); + }) +} diff --git a/tests/cmd/install/workspace/range_incompatible_diamond/in/Proto.toml b/tests/cmd/install/workspace/range_incompatible_diamond/in/Proto.toml new file mode 100644 index 00000000..4e623662 --- /dev/null +++ b/tests/cmd/install/workspace/range_incompatible_diamond/in/Proto.toml @@ -0,0 +1,4 @@ +edition = "0.12" + +[workspace] +members = ["pkg1", "pkg2"] diff --git a/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/Proto.toml b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/Proto.toml new file mode 100644 index 00000000..055e8789 --- /dev/null +++ b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg1" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/proto/pkg1.proto b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/proto/pkg1.proto new file mode 100644 index 00000000..f8deb6b3 --- /dev/null +++ b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg1/proto/pkg1.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg1; + +message Pkg1Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/Proto.toml b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/Proto.toml new file mode 100644 index 00000000..bfbf81a0 --- /dev/null +++ b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg2" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/proto/pkg2.proto b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/proto/pkg2.proto new file mode 100644 index 00000000..3a7da6e7 --- /dev/null +++ b/tests/cmd/install/workspace/range_incompatible_diamond/in/pkg2/proto/pkg2.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg2; + +message Pkg2Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_incompatible_diamond/mod.rs b/tests/cmd/install/workspace/range_incompatible_diamond/mod.rs new file mode 100644 index 00000000..21c9c08f --- /dev/null +++ b/tests/cmd/install/workspace/range_incompatible_diamond/mod.rs @@ -0,0 +1,202 @@ +use crate::{VirtualFileSystem, with_test_registry}; + +/// Verifies that incompatible range requirements on the same transitive leaf +/// within a single package's dependency graph cause `buffrs install` to fail +/// with a clear version-conflict error. +/// +/// Diamond shape (all within pkg1): +/// pkg1 --(^1.0)--> lib-a --(^1.0.0)--> leaf-lib (resolves to v1.0.0) +/// pkg1 --(^1.0)--> lib-b --(^2.0.0)--> leaf-lib (requires v2.x — conflict) +/// +/// No single version of leaf-lib satisfies both ^1.0.0 and ^2.0.0. +/// The resolver encounters leaf-lib a second time via lib-b, calls +/// validate_version_compatibility, and must reject with a version-conflict error. +#[test] +fn fixture() { + with_test_registry(|url| { + let vfs = VirtualFileSystem::copy(crate::parent_directory!().join("in")); + let buffrs_home = vfs.root().join("$HOME"); + let cwd = vfs.root(); + + // Publish leaf-lib at v1.0.0 + { + let lib_dir = cwd.join("leaf-lib-v1"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v2.0.0 + { + let lib_dir = cwd.join("leaf-lib-v2"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"2.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafV2 {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish lib-a at v1.0.0 (depends on leaf-lib@^1.0.0 — resolves to v1.0.0) + { + let lib_dir = cwd.join("lib-a"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "lib-a"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/liba.proto"), + "syntax = \"proto3\";\n\npackage liba;\n\nmessage LibAMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish lib-b at v1.0.0 (depends on leaf-lib@^2.0.0 — incompatible with ^1.0.0) + { + let lib_dir = cwd.join("lib-b"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "lib-b"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/libb.proto"), + "syntax = \"proto3\";\n\npackage libb;\n\nmessage LibBMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@^2.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + std::fs::remove_dir_all(cwd.join("leaf-lib-v1")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v2")).unwrap(); + std::fs::remove_dir_all(cwd.join("lib-a")).unwrap(); + std::fs::remove_dir_all(cwd.join("lib-b")).unwrap(); + + // pkg1 depends on BOTH lib-a and lib-b — the diamond conflict is within + // a single package's dependency graph, where validate_version_compatibility + // must reject leaf-lib@^2.0.0 after resolving leaf-lib to 1.0.0 via lib-a. + crate::cli!() + .args(["add", "--registry", url, "test-repo/lib-a@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + crate::cli!() + .args(["add", "--registry", url, "test-repo/lib-b@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + // Install must fail: leaf-lib cannot satisfy both ^1.0.0 and ^2.0.0 + let output = crate::cli!() + .arg("install") + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&cwd) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "install should fail when two branches of the same package require incompatible \ + versions of leaf-lib (^1.0.0 vs ^2.0.0)" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("version conflict for") || stderr.contains("leaf-lib"), + "error output should mention the version conflict, got:\n{}", + stderr + ); + }) +} diff --git a/tests/cmd/install/workspace/range_multi_level_tree/in/Proto.toml b/tests/cmd/install/workspace/range_multi_level_tree/in/Proto.toml new file mode 100644 index 00000000..7873c093 --- /dev/null +++ b/tests/cmd/install/workspace/range_multi_level_tree/in/Proto.toml @@ -0,0 +1,4 @@ +edition = "0.12" + +[workspace] +members = ["pkg1"] diff --git a/tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/Proto.toml b/tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/Proto.toml new file mode 100644 index 00000000..055e8789 --- /dev/null +++ b/tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg1" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/proto/pkg1.proto b/tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/proto/pkg1.proto new file mode 100644 index 00000000..f8deb6b3 --- /dev/null +++ b/tests/cmd/install/workspace/range_multi_level_tree/in/pkg1/proto/pkg1.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg1; + +message Pkg1Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_multi_level_tree/mod.rs b/tests/cmd/install/workspace/range_multi_level_tree/mod.rs new file mode 100644 index 00000000..2f9193d2 --- /dev/null +++ b/tests/cmd/install/workspace/range_multi_level_tree/mod.rs @@ -0,0 +1,168 @@ +use crate::{VirtualFileSystem, with_test_registry}; + +/// Verifies that range resolution propagates correctly through a three-level +/// dependency tree: pkg1 --(^2.0)--> mid-lib --(^1.0)--> leaf-lib. +/// +/// Publishes leaf-lib at v1.0.0 and v1.5.0, then mid-lib at v2.0.0 (which +/// declares `leaf-lib@^1.0.0`), then installs pkg1 with `mid-lib@^2.0.0`. +/// Expects mid-lib pinned at 2.0.0 and leaf-lib pinned at 1.5.0. +#[test] +fn fixture() { + with_test_registry(|url| { + let vfs = VirtualFileSystem::copy(crate::parent_directory!().join("in")); + let buffrs_home = vfs.root().join("$HOME"); + let cwd = vfs.root(); + + // Publish leaf-lib at v1.0.0 + { + let lib_dir = cwd.join("leaf-lib-v1-0"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v1.5.0 (should be resolved by mid-lib's ^1.0.0) + { + let lib_dir = cwd.join("leaf-lib-v1-5"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.5.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n int32 count = 2;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish mid-lib at v2.0.0 with a transitive dependency on leaf-lib@^1.0.0 + { + let lib_dir = cwd.join("mid-lib"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "mid-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"2.0.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/mid.proto"), + "syntax = \"proto3\";\n\npackage mid;\n\nmessage MidMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + // mid-lib depends on leaf-lib@^1.0.0 + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@^1.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-0")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-5")).unwrap(); + std::fs::remove_dir_all(cwd.join("mid-lib")).unwrap(); + + // Add mid-lib with caret requirement to pkg1 + crate::cli!() + .args(["add", "--registry", url, "test-repo/mid-lib@^2.0.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + // Install at workspace root + crate::cli!() + .arg("install") + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&cwd) + .assert() + .success(); + + let lockfile_path = cwd.join("Proto.lock"); + assert!(lockfile_path.exists(), "Proto.lock should exist"); + + let lockfile = std::fs::read_to_string(&lockfile_path).unwrap(); + + // mid-lib@^2.0.0 should resolve to the only available: 2.0.0 + assert!( + lockfile.contains("name = \"mid-lib\""), + "lockfile should contain mid-lib" + ); + assert!( + lockfile.contains("version = \"2.0.0\""), + "mid-lib@^2.0.0 should resolve to 2.0.0, got:\n{}", + lockfile + ); + + // mid-lib's transitive dep leaf-lib@^1.0.0 should resolve to 1.5.0 + assert!( + lockfile.contains("name = \"leaf-lib\""), + "lockfile should contain transitive dependency leaf-lib" + ); + assert!( + lockfile.contains("version = \"1.5.0\""), + "leaf-lib@^1.0.0 should resolve to 1.5.0 (highest compatible), got:\n{}", + lockfile + ); + assert!( + !lockfile.contains("version = \"1.0.0\""), + "leaf-lib must not pin to the minimum 1.0.0 when 1.5.0 is available" + ); + }) +} diff --git a/tests/cmd/install/workspace/range_tilde_resolution/in/Proto.toml b/tests/cmd/install/workspace/range_tilde_resolution/in/Proto.toml new file mode 100644 index 00000000..7873c093 --- /dev/null +++ b/tests/cmd/install/workspace/range_tilde_resolution/in/Proto.toml @@ -0,0 +1,4 @@ +edition = "0.12" + +[workspace] +members = ["pkg1"] diff --git a/tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/Proto.toml b/tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/Proto.toml new file mode 100644 index 00000000..055e8789 --- /dev/null +++ b/tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/Proto.toml @@ -0,0 +1,8 @@ +edition = "0.12" + +[package] +type = "lib" +name = "workspace-pkg1" +version = "1.0.0" + +[dependencies] diff --git a/tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/proto/pkg1.proto b/tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/proto/pkg1.proto new file mode 100644 index 00000000..f8deb6b3 --- /dev/null +++ b/tests/cmd/install/workspace/range_tilde_resolution/in/pkg1/proto/pkg1.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package workspace.pkg1; + +message Pkg1Message { + string value = 1; +} diff --git a/tests/cmd/install/workspace/range_tilde_resolution/mod.rs b/tests/cmd/install/workspace/range_tilde_resolution/mod.rs new file mode 100644 index 00000000..6c3dc663 --- /dev/null +++ b/tests/cmd/install/workspace/range_tilde_resolution/mod.rs @@ -0,0 +1,148 @@ +use crate::{VirtualFileSystem, with_test_registry}; + +/// Verifies that a tilde version requirement (~1.2.0) resolves to the highest +/// patch version within the same minor, and does not cross into the next minor. +/// +/// Publishes leaf-lib at v1.2.0, v1.2.5, and v1.3.0, then installs with +/// `~1.2.0`. Expects the lockfile to pin v1.2.5 (not v1.3.0). +#[test] +fn fixture() { + with_test_registry(|url| { + let vfs = VirtualFileSystem::copy(crate::parent_directory!().join("in")); + let buffrs_home = vfs.root().join("$HOME"); + let cwd = vfs.root(); + + // Publish leaf-lib at v1.2.0 + { + let lib_dir = cwd.join("leaf-lib-v1-2-0"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.2.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v1.2.5 (highest in the 1.2.x range — should be selected) + { + let lib_dir = cwd.join("leaf-lib-v1-2-5"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.2.5\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n string extra = 2;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + // Publish leaf-lib at v1.3.0 (out of ~1.2.x range — must NOT be selected) + { + let lib_dir = cwd.join("leaf-lib-v1-3-0"); + std::fs::create_dir(&lib_dir).unwrap(); + + crate::cli!() + .args(["init", "--lib", "leaf-lib"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + + let manifest_path = lib_dir.join("Proto.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + let updated = manifest.replace("version = \"0.1.0\"", "version = \"1.3.0\""); + std::fs::write(&manifest_path, updated).unwrap(); + + std::fs::write( + lib_dir.join("proto/leaf.proto"), + "syntax = \"proto3\";\n\npackage leaf;\n\nmessage LeafMessage {\n string value = 1;\n string extra = 2;\n int32 count = 3;\n}\n", + ) + .unwrap(); + + crate::cli!() + .args(["publish", "--registry", url, "--repository", "test-repo"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&lib_dir) + .assert() + .success(); + } + + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-2-0")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-2-5")).unwrap(); + std::fs::remove_dir_all(cwd.join("leaf-lib-v1-3-0")).unwrap(); + + // Add leaf-lib with tilde requirement to pkg1 + crate::cli!() + .args(["add", "--registry", url, "test-repo/leaf-lib@~1.2.0"]) + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(cwd.join("pkg1")) + .assert() + .success(); + + // Install at workspace root + crate::cli!() + .arg("install") + .env("BUFFRS_HOME", &buffrs_home) + .current_dir(&cwd) + .assert() + .success(); + + let lockfile_path = cwd.join("Proto.lock"); + assert!(lockfile_path.exists(), "Proto.lock should exist"); + + let lockfile = std::fs::read_to_string(&lockfile_path).unwrap(); + + // ~1.2.0 must resolve to the highest 1.2.x: 1.2.5 + assert!( + lockfile.contains("version = \"1.2.5\""), + "~1.2.0 should resolve to 1.2.5 (highest patch in 1.2.x), got:\n{}", + lockfile + ); + assert!( + !lockfile.contains("version = \"1.3.0\""), + "~1.2.0 must not cross into the next minor (1.3.0)" + ); + assert!( + !lockfile.contains("version = \"1.2.0\""), + "~1.2.0 must not pin to the minimum patch 1.2.0" + ); + }) +} From 99ed6e7e50f159bdc5db4d53a244e701e68c102c Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:53:12 +1000 Subject: [PATCH 03/10] feat(cli): Update documentation --- docs/src/commands/buffrs-add.md | 13 ++- docs/src/guide/consuming-packages.md | 4 +- docs/src/reference/resolver.md | 70 ++++++++++++++ docs/src/reference/semver.md | 87 ++++++++++++++++++ docs/src/reference/specifying-dependencies.md | 91 ++++++++++++------- 5 files changed, 225 insertions(+), 40 deletions(-) diff --git a/docs/src/commands/buffrs-add.md b/docs/src/commands/buffrs-add.md index c4a117cf..ff6911ba 100644 --- a/docs/src/commands/buffrs-add.md +++ b/docs/src/commands/buffrs-add.md @@ -26,13 +26,12 @@ it will default to the latest version of this artifact in the registry. The repository name should adhere to lower-kebab case (e.g. `my-buffrs-repo`). The package name has its own set of constraints as detailed in [Package Name -Specification](../reference/pkgid-spec.md). When specified, the version must -adhere to the [Semantic Version convention](https://semver.org/) (e.g. `1.2.3`) --- see [SemVer compatibility](../reference/semver.md) for more information. - -Currently there is no support for resolving version operators but the specific -version has to be provided. This means `^1.0.0`, `<2.3.0`, `~2.0.0`, etc. can't -be installed, but `=1.2.3` has to be provided. +Specification](../reference/pkgid-spec.md). When specified, the version must be a valid +[SemVer requirement](../reference/semver.md). Both exact pins (`=1.2.3`) and +range operators (`^1.0.0`, `~2.1.0`, `>=1.5.0`, etc.) are supported. During +`buffrs install`, the resolver queries the registry and selects the highest +available version that satisfies the requirement, then records the concrete +version in the lockfile for reproducibility. #### Lockfile interaction diff --git a/docs/src/guide/consuming-packages.md b/docs/src/guide/consuming-packages.md index 300099e5..75634494 100644 --- a/docs/src/guide/consuming-packages.md +++ b/docs/src/guide/consuming-packages.md @@ -35,7 +35,7 @@ type = "lib" version = "1.0.0" [dependencies] -google = { version = "=1.0.0", registry = "", repository = " } +google = { version = "^1.0.0", registry = "", repository = "" } ``` Running `buffrs install` yields you with the following filesystem: @@ -65,7 +65,7 @@ major difference is the lack of the `[package]` section in your manifest. ``` [dependencies] -logging = { version = "=1.0.0", registry = "", repository = " } +logging = { version = "^1.0.0", registry = "", repository = "" } ``` Running a `buffrs install` yields you the very same as above, except for the diff --git a/docs/src/reference/resolver.md b/docs/src/reference/resolver.md index 8cc7fce9..29d5d903 100644 --- a/docs/src/reference/resolver.md +++ b/docs/src/reference/resolver.md @@ -1 +1,71 @@ # Dependency Resolution + +When you run `buffrs install`, the resolver builds a complete dependency graph +for your project — including all transitive dependencies — and determines the +concrete version to install for each package. + +## Resolution algorithm + +For each dependency (direct or transitive), the resolver follows this priority +order: + +1. **Lockfile hit** — if `Proto.lock` already records a version of the package + that satisfies the requirement, that version is used immediately without + contacting the registry. This makes repeated installs fast and reproducible. + +2. **Registry resolution** — if no matching locked version exists, the resolver + queries the registry for all available versions of the package, then selects + the **highest** version that satisfies the requirement. + +3. **Download and cache** — the resolved version is downloaded, stored in the + local cache, and its digest is recorded in the lockfile for future installs. + +Transitive dependencies are discovered by reading the `Proto.toml` bundled +inside each downloaded package archive, then resolved recursively using the +same steps above. + +## Version conflict detection + +If the same package is required by more than one path in the dependency tree, +the resolver checks that the already-resolved version satisfies all +requirements. If it does not, the install fails with a version conflict error: + +``` +version conflict for leaf-lib: requirement ^2.0.0 is not satisfied by +resolved version 1.5.0 (chosen to satisfy ^1.0.0) +``` + +To fix a conflict, update the requiring packages so their version requirements +overlap, or introduce a package that bridges the incompatible requirements. + +Note that this conflict detection operates **within a single package's +dependency graph**. In a workspace, different members may independently resolve +different versions of the same package — the workspace lockfile records them +separately using a `(name, version)` composite key. + +## Workspace resolution + +In a workspace, each member package's dependency graph is resolved +independently. The workspace lockfile (`Proto.lock` at the workspace root) +accumulates all resolved packages across all members. Because the workspace +lockfile allows multiple versions of the same package, two members that require +incompatible versions of a shared library can co-exist. + +If a subsequent install finds a workspace lockfile, it reuses those locked +versions (subject to satisfying each member's requirements) to avoid redundant +registry queries. + +## Topological ordering + +After the full graph is built, packages are sorted topologically so that each +dependency is installed before its dependants. This guarantees that vendored +proto sources are available in the correct order during compilation. + +## Determinism and the lockfile + +The resolver always picks the **highest** satisfying version when multiple +candidates exist. This is deterministic given the same set of available +registry versions. Once a version is recorded in `Proto.lock`, it is used +as-is on all subsequent installs, regardless of newer versions that may have +been published since. Run `buffrs install` after deleting or modifying +`Proto.lock` to re-resolve against the current registry state. diff --git a/docs/src/reference/semver.md b/docs/src/reference/semver.md index b8bff592..0a27ddf0 100644 --- a/docs/src/reference/semver.md +++ b/docs/src/reference/semver.md @@ -1 +1,88 @@ # SemVer Compatibility + +buffrs uses [Semantic Versioning](https://semver.org/) for all packages. +Version requirements in `Proto.toml` follow the same syntax as +[Cargo](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html). + +## Version requirement syntax + +A version requirement is placed in the `version` field of a dependency: + +```toml +[dependencies.my-lib] +version = "^1.2.0" +registry = "https://my-registry.example.com" +repository = "my-repo" +``` + +The following operators are supported: + +### Caret (`^`) — default for ranges + +Allows minor and patch updates within the same major version. +This is the recommended operator for most dependencies. + +| Requirement | Resolves versions | +|-------------|-------------------| +| `^1.2.3` | `>=1.2.3, <2.0.0` | +| `^1.2` | `>=1.2.0, <2.0.0` | +| `^1` | `>=1.0.0, <2.0.0` | +| `^0.2.3` | `>=0.2.3, <0.3.0` | +| `^0.0.3` | `>=0.0.3, <0.0.4` | + +Note that `0.x` versions are treated as unstable: `^0.2` only allows `0.2.x`, +not `0.3.x`, since breaking changes are expected in pre-1.0 packages. + +### Tilde (`~`) — patch-level updates only + +Allows patch updates within the same minor version. + +| Requirement | Resolves versions | +|-------------|-------------------| +| `~1.2.3` | `>=1.2.3, <1.3.0` | +| `~1.2` | `>=1.2.0, <1.3.0` | +| `~1` | `>=1.0.0, <2.0.0` | + +### Exact (`=`) — pin to a specific version + +Resolves to exactly the stated version, with no flexibility. + +```toml +version = "=1.2.3" +``` + +Use exact pins when you need bit-for-bit reproducibility in the manifest +itself, or when you are distributing a library whose consumers should be +in full control of the version. + +### Comparison operators + +For more control, the standard comparison operators are available: + +| Requirement | Meaning | +|-----------------|----------------------------------| +| `>=1.2.0` | Any version at or above 1.2.0 | +| `>1.2.0` | Any version strictly above 1.2.0 | +| `<2.0.0` | Any version strictly below 2.0.0 | +| `<=2.0.0` | Any version at or below 2.0.0 | +| `>=1.0.0, <2.0.0` | Intersection (multiple constraints) | + +## How the resolver picks a version + +When a requirement matches more than one available version, buffrs always +selects the **highest** satisfying version. The resolved concrete version is +written to `Proto.lock` to ensure reproducible installs — re-running +`buffrs install` will use the locked version rather than querying the registry +again. + +See [Dependency Resolution](./resolver.md) for a full description of the +resolution algorithm. + +## Choosing between pinning and ranges + +| Situation | Recommended style | +|-----------|-------------------| +| Public library — let consumers decide | `^1.0.0` | +| Internal service — stable dependency set | `^1.0.0` or `~1.2.0` | +| Security patch must be applied exactly | `=1.2.5` | +| Compatibility ceiling known | `>=1.0.0, <3.0.0` | diff --git a/docs/src/reference/specifying-dependencies.md b/docs/src/reference/specifying-dependencies.md index 7f6bbb72..935015e4 100644 --- a/docs/src/reference/specifying-dependencies.md +++ b/docs/src/reference/specifying-dependencies.md @@ -1,52 +1,81 @@ # Specifying Dependencies -Dependencies are declared in the `[dependencies]` section of the `Proto.toml` -manifest. Each entry maps a dependency package name to a dependency -specification object. +Dependencies are declared in the `[dependencies]` section of `Proto.toml`. +Each entry names the package and provides a version requirement, registry URL, +and repository name. -## Remote Dependencies - -Remote dependencies are downloaded from an Artifactory registry during -[`buffrs install`](../commands/buffrs-install.md). +## Inline table syntax ```toml -[dependencies] -my-package = { registry = "https://your.registry/artifactory", repository = "my-repo", version = "1.2.3" } +[dependencies.my-lib] +version = "^1.0.0" +registry = "https://my-registry.example.com" +repository = "my-repo" ``` -The `version` field must be an exact semantic version (e.g. `"1.2.3"`). -Version ranges or operators (`^`, `~`, `<`, `>`) are not currently supported. +The three required fields for a remote dependency are: -Use [`buffrs add`](../commands/buffrs-add.md) to add a remote dependency from -the command line: +| Field | Description | +|-------|-------------| +| `version` | A SemVer requirement — see [SemVer Compatibility](./semver.md) | +| `registry` | Base URL of the Artifactory registry | +| `repository` | Repository name within that registry | -``` -buffrs add --registry https://your.registry/artifactory my-repo/my-package@1.2.3 +## Adding dependencies via the CLI + +The `buffrs add` command writes the manifest entry for you: + +```bash +# Caret range (recommended): resolves to the highest 1.x.y +buffrs add --registry https://my-registry.example.com my-repo/my-lib@^1.0.0 + +# Exact pin: resolves to exactly 1.2.3 +buffrs add --registry https://my-registry.example.com my-repo/my-lib@=1.2.3 + +# Latest: omitting the version resolves to the latest available +buffrs add --registry https://my-registry.example.com my-repo/my-lib ``` -## Local Dependencies +After adding a dependency, run `buffrs install` to resolve and download it. -Local dependencies are resolved from the local filesystem relative to the -manifest. They are useful for multi-package repositories where packages depend -on each other without going through a remote registry. +## Version requirements + +buffrs supports the full range of SemVer requirement operators: ```toml -[dependencies] -my-lib = { path = "../my-lib" } +version = "^1.0.0" # >=1.0.0, <2.0.0 (recommended for most deps) +version = "~1.2.0" # >=1.2.0, <1.3.0 (patch updates only) +version = ">=1.5.0" # any version at or above 1.5.0 +version = "=1.2.3" # exactly 1.2.3 +version = ">=1.0, <2.0" # explicit intersection ``` -The `path` field is a relative path from the manifest file to the dependency's -root directory (the directory containing the dependency's `Proto.toml`). +The resolver queries the registry and selects the **highest** available version +satisfying the requirement. See [SemVer Compatibility](./semver.md) for the +full operator reference. + +## Local dependencies + +You can depend on a package in a local directory (useful in monorepos or during +development): + +```toml +[dependencies.my-lib] +path = "../my-lib" +``` -See [Local Dependencies](../guide/local-dependencies.md) for more information. +Local dependencies do not have a version requirement — the package at that +path is used as-is. They cannot be mixed with a remote entry for the same +package name. -## Lockfile +## The lockfile -After adding or modifying dependencies in the manifest, run -[`buffrs install`](../commands/buffrs-install.md) to resolve and lock them. -The lockfile (`Proto.lock`) records the exact resolved versions and checksums -and should be committed to version control. +Once resolved, the concrete version is recorded in `Proto.lock`. Subsequent +installs use the locked version without re-querying the registry, ensuring +reproducible builds across machines and CI environments. -See [Manifest vs Lockfile](../guide/manifest-vs-lockfile.md) for more -information. +Commit `Proto.lock` to version control for applications and services. For +libraries intended to be consumed by others, committing the lockfile is +optional — consumers will resolve their own versions. +See [Manifest vs Lockfile](../guide/manifest-vs-lockfile.md) for more detail. From 95326c5a85978999ef584b9d100a9ecc17612024 Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:46:45 +1000 Subject: [PATCH 04/10] fix(cli): Incomplete handling of parts in "name version" Deserialize --- src/lock.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lock.rs b/src/lock.rs index 9ef669c2..7f49e4fb 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -107,13 +107,20 @@ impl<'de> Deserialize<'de> for LockedDependency { let s = String::deserialize(deserializer)?; let parts: Vec<&str> = s.split_whitespace().collect(); - if parts.len() != 2 { + if parts.len() == 1 { let name = PackageName::new(parts[0]) .map_err(|e| serde::de::Error::custom(format!("invalid package name: {}", e)))?; return Ok(Self::Named { name }); } + if parts.len() != 2 { + return Err(serde::de::Error::custom(format!( + "invalid locked dependency format: expected 'name' or 'name version', got '{}'", + s + ))); + } + let name = PackageName::new(parts[0]) .map_err(|e| serde::de::Error::custom(format!("invalid package name: {}", e)))?; From a9ae96cc8f40d9f2a8587038e375e5441202b10c Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:52:29 +1000 Subject: [PATCH 05/10] fix(cli): Incorrect handling of CWD when installing a workspace. --- src/operations/install.rs | 82 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/src/operations/install.rs b/src/operations/install.rs index f72d4ac2..f41a8c5f 100644 --- a/src/operations/install.rs +++ b/src/operations/install.rs @@ -250,18 +250,20 @@ impl Install for WorkspaceManifest { let mut locked = vec![]; for package in packages { - let manifest = Manifest::require_package_manifest(&package).await?; + let member_path = ctx.cwd.join(&package); - if PackageLockfile::exists_at(&package).await? { + let manifest = Manifest::require_package_manifest(&member_path).await?; + + if PackageLockfile::exists_at(&member_path).await? { tracing::warn!( "[warn] package lockfile found at {}. Consider removing it - workspace installs use workspace-level lockfile", - PackageLockfile::resolve(&package)?.display() + PackageLockfile::resolve(&member_path)?.display() ); } - tracing::info!("running install for package: {}", package.display()); + tracing::info!("running install for package: {}", member_path.display()); - let member_ctx = ctx.child(ctx.cwd.join(&package)).await?; + let member_ctx = ctx.child(member_path).await?; let new = manifest.install(&member_ctx).await?; locked.extend_from_slice(&new); @@ -685,4 +687,74 @@ mod tests { assert_eq!(v2.version, Version::new(2, 0, 0)); assert_eq!(v2.dependants, 1); } + + #[tokio::test] + async fn test_workspace_install_uses_ctx_cwd_not_process_cwd() { + use std::collections::HashMap; + use std::fs; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + fs::write( + root.join("Proto.toml"), + "edition = \"0.13\"\n\n[workspace]\nmembers = [\"pkg-a\", \"pkg-b\"]\n", + ) + .unwrap(); + + for name in ["pkg-a", "pkg-b"] { + fs::create_dir(root.join(name)).unwrap(); + fs::write( + root.join(name).join("Proto.toml"), + format!( + "edition = \"0.13\"\n\n[package]\nname = \"{name}\"\ntype = \"lib\"\nversion = \"0.1.0\"\n" + ), + ) + .unwrap(); + } + + // The process CWD (crate root during cargo test) must differ from the + // temp dir — this is what makes the test meaningful. With the old buggy + // code, relative member paths would be resolved against the process CWD + // instead of ctx.cwd, causing a file-not-found failure. + assert_ne!( + std::env::current_dir().unwrap(), + root, + "process CWD must differ from ctx.cwd for this test to be meaningful" + ); + + let manifest = Manifest::load_from(root) + .await + .expect("should parse workspace manifest"); + let workspace_manifest = match manifest { + Manifest::Workspace(ws) => ws, + _ => panic!("expected workspace manifest"), + }; + + let ctx = InstallationContext { + cwd: root.to_path_buf(), + credentials: Credentials { + registry_tokens: HashMap::new(), + }, + cache: Cache::open().await.unwrap(), + store: PackageStore::open(root).await.unwrap(), + lock: Lockfile::Workspace(WorkspaceLockfile::default()), + preserve_mtime: false, + network_mode: NetworkMode::Offline, + }; + + let result = workspace_manifest.install(&ctx).await; + assert!( + result.is_ok(), + "workspace install failed — member paths were likely resolved against \ + process CWD instead of ctx.cwd: {:?}", + result.unwrap_err() + ); + assert_eq!( + result.unwrap(), + vec![], + "no-dep workspace should produce no locked packages" + ); + } } From 65ce1996d2cad41f6a9786b6558f1c497c6dd4ae Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:46:15 +1000 Subject: [PATCH 06/10] chore: fix lint fmt --- src/lock.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lock.rs b/src/lock.rs index 7f49e4fb..379a109c 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -386,11 +386,7 @@ impl WorkspaceLockfile { } /// Finds the highest locked version of `name` that satisfies `req` - pub fn find_satisfying( - &self, - name: &PackageName, - req: &VersionReq, - ) -> Option<&LockedPackage> { + pub fn find_satisfying(&self, name: &PackageName, req: &VersionReq) -> Option<&LockedPackage> { self.packages .values() .filter(|p| p.name == *name && req.matches(&p.version)) From 46604d81e23635de57e25eb2b479a9581b647b6b Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:07:54 +1000 Subject: [PATCH 07/10] chore(cli): re-add debug traces for Artifactory calls --- src/registry/artifactory.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/registry/artifactory.rs b/src/registry/artifactory.rs index 047f2d12..0dbce3fe 100644 --- a/src/registry/artifactory.rs +++ b/src/registry/artifactory.rs @@ -93,6 +93,7 @@ impl Artifactory { tracing::debug!(" repository: {}", repository); tracing::debug!(" registry: {}", self.registry); + // First retrieve all packages matching the given name let search_query_url: Url = { let mut url = self.registry.clone(); url.set_path("artifactory/api/search/artifact"); @@ -102,16 +103,19 @@ impl Artifactory { tracing::debug!("search query URL: {}", search_query_url); + tracing::debug!("sending artifact search request to artifactory"); let response = self .new_request(Method::GET, search_query_url) .send() .await?; let response: reqwest::Response = response.0; + tracing::debug!("received response from artifactory"); let headers = response.headers(); let content_type = headers .get(&reqwest::header::CONTENT_TYPE) .ok_or_else(|| miette!("missing content-type header"))?; + tracing::debug!("response content-type: {:?}", content_type); ensure!( content_type @@ -121,10 +125,13 @@ impl Artifactory { "server response has incorrect mime type: {content_type:?}" ); + tracing::debug!("parsing response body as text"); let response_str = response.text().await.into_diagnostic().wrap_err(miette!( "unexpected error: unable to retrieve response payload" ))?; + tracing::debug!("response body length: {} bytes", response_str.len()); + tracing::debug!("deserializing response to ArtifactSearchResponse"); let parsed_response = serde_json::from_str::(&response_str) .into_diagnostic() .wrap_err(miette!( @@ -136,6 +143,8 @@ impl Artifactory { parsed_response.results.len() ); + // From the package names retrieved from artifactory, list the versions + tracing::debug!("extracting version numbers from artifact URIs"); let mut versions: Vec = parsed_response .results .iter() @@ -148,9 +157,16 @@ impl Artifactory { .next_back() .map(|name_tgz| name_tgz.trim_end_matches(".tgz")); + if let Some(artifact_name) = full_artifact_name { + tracing::debug!(" artifact name: {}", artifact_name); + } + let artifact_version = full_artifact_name .and_then(|name| name.split('-').next_back()) - .and_then(|version_str| Version::parse(version_str).ok()); + .and_then(|version_str| { + tracing::debug!(" parsing version string: {}", version_str); + Version::parse(version_str).ok() + }); // Double-check that the artifact name matches exactly let expected_artifact_name = @@ -158,6 +174,9 @@ impl Artifactory { if full_artifact_name.is_some_and(|actual| { expected_artifact_name.is_some_and(|expected| expected == actual) }) { + if let Some(ref version) = artifact_version { + tracing::debug!(" valid version found: {}", version); + } artifact_version } else { tracing::debug!(" artifact name doesn't match expected format, skipping"); From d33ebc73ab4bccfc853ea52067a3253da4b9fb69 Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:43:00 +1000 Subject: [PATCH 08/10] doc: Re-add incorrectly removed content --- docs/src/reference/specifying-dependencies.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/src/reference/specifying-dependencies.md b/docs/src/reference/specifying-dependencies.md index 935015e4..1b398f0f 100644 --- a/docs/src/reference/specifying-dependencies.md +++ b/docs/src/reference/specifying-dependencies.md @@ -64,10 +64,15 @@ development): path = "../my-lib" ``` +The `path` field is a relative path from the manifest file to the dependency's +root directory (the directory containing the dependency's `Proto.toml`). + Local dependencies do not have a version requirement — the package at that path is used as-is. They cannot be mixed with a remote entry for the same package name. +See [Local Dependencies](../guide/local-dependencies.md) for more information. + ## The lockfile Once resolved, the concrete version is recorded in `Proto.lock`. Subsequent From 19596ee5c874169bcd5188756141b80b983024f5 Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:46:53 +1000 Subject: [PATCH 09/10] Remove unused function. --- src/lock.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lock.rs b/src/lock.rs index 379a109c..5df45d84 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -179,11 +179,6 @@ impl LockedPackage { } } - /// Returns true if the locked version satisfies the given requirement - pub fn satisfies_requirement(&self, req: &VersionReq) -> bool { - req.matches(&self.version) - } - /// Validates if another LockedPackage matches this one pub fn validate(&self, package: &Package) -> miette::Result<()> { let digest: Digest = DigestAlgorithm::SHA256.digest(&package.tgz); From 09e53cf5abfc960d57456917122ffd482bcc51c9 Mon Sep 17 00:00:00 2001 From: Matthew Heaton <325930+heatonmatthew@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:52:43 +1000 Subject: [PATCH 10/10] Tidy-up imports --- src/resolver.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resolver.rs b/src/resolver.rs index 6fa3f68e..6c6b57e7 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -7,7 +7,7 @@ use std::{ use async_recursion::async_recursion; use miette::{Context as _, Diagnostic, bail}; -use semver::VersionReq; +use semver::{Version, VersionReq}; use thiserror::Error; use crate::{ @@ -54,7 +54,7 @@ pub struct DependencyNode { /// Version requirement from the manifest pub version: VersionReq, /// The concrete version that was resolved and downloaded (None for local deps) - pub resolved_version: Option, + pub resolved_version: Option, } /// Maps a package name to metadata describing the package