use super::manifest::{PackageDependency, PackageManifest};
use super::remote::{self, VersionInfo};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedDep {
pub name: String,
pub namespace: String,
pub slug: String,
pub version: String,
pub artifact_sha256: String,
}
pub fn resolve_dependencies(
manifest: &PackageManifest,
base: &str,
token: Option<&str>,
) -> Result<Vec<ResolvedDep>, String> {
let mut resolved = Vec::new();
for dep in &manifest.dependencies {
if dep.optional {
continue;
}
resolved.push(resolve_one(&manifest.name, dep, base, token)?);
}
Ok(resolved)
}
pub fn resolve_one(
root_name: &str,
dep: &PackageDependency,
base: &str,
token: Option<&str>,
) -> Result<ResolvedDep, String> {
let Some(remote_ref) = remote::parse_remote_ref(&dep.name) else {
return Err(format!(
"dependency `{}` is not a scoped @ns/name reference — unresolvable",
dep.name
));
};
if dep.name.trim_start_matches('@') == root_name.trim_start_matches('@') {
return Err(format!(
"package depends on itself (`{}`) — refused",
dep.name
));
}
let req = parse_version_req(&dep.version_req)
.map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
let versions = remote::fetch_versions(base, &remote_ref.namespace, &remote_ref.name, token)
.map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
let best = pick_highest_match(&versions, &req).ok_or_else(|| {
format!(
"dependency `{}`: no installable version matches `{}` (available: {})",
dep.name,
dep.version_req,
versions
.iter()
.map(|v| v.version.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
Ok(ResolvedDep {
name: dep.name.clone(),
namespace: remote_ref.namespace,
slug: remote_ref.name,
version: best.version.clone(),
artifact_sha256: best.artifact_sha256.clone(),
})
}
pub fn parse_version_req(req: &str) -> Result<semver::VersionReq, String> {
let trimmed = req.trim();
if trimmed.is_empty() || trimmed == "*" {
return Ok(semver::VersionReq::STAR);
}
semver::VersionReq::parse(trimmed).map_err(|e| format!("invalid version range `{req}`: {e}"))
}
pub fn pick_highest_match<'a>(
versions: &'a [VersionInfo],
req: &semver::VersionReq,
) -> Option<&'a VersionInfo> {
versions
.iter()
.filter(|v| !v.yanked)
.filter_map(|v| Some((semver::Version::parse(&v.version).ok()?, v)))
.filter(|(parsed, _)| req.matches(parsed))
.max_by(|(a, _), (b, _)| a.cmp(b))
.map(|(_, v)| v)
}
pub fn locked_version(name: &str, project_root: &std::path::Path) -> Option<String> {
let lock = super::lockfile::load(project_root).ok()?;
lock.packages
.iter()
.find(|p| p.name == name)
.map(|p| p.version.clone())
}
pub fn already_satisfied(
project_root: &std::path::Path,
registry: &super::registry::LocalRegistry,
dep: &PackageDependency,
) -> Option<String> {
let lock = super::lockfile::load(project_root).ok()?;
let locked = lock.packages.iter().find(|p| p.name == dep.name)?;
let req = parse_version_req(&dep.version_req).ok()?;
let version = semver::Version::parse(&locked.version).ok()?;
if !req.matches(&version) {
return None;
}
let installed = registry.get(&dep.name, Some(&locked.version)).ok()??;
Some(installed.version)
}
#[cfg(test)]
mod tests {
use super::*;
fn v(version: &str, yanked: bool) -> VersionInfo {
VersionInfo {
version: version.into(),
artifact_sha256: "a".repeat(64),
yanked,
}
}
#[test]
fn picks_highest_matching_version() {
let versions = [v("1.0.0", false), v("1.2.0", false), v("2.0.0", false)];
let req = parse_version_req("^1.0").unwrap();
assert_eq!(
pick_highest_match(&versions, &req).unwrap().version,
"1.2.0"
);
}
#[test]
fn yanked_versions_never_match() {
let versions = [v("1.0.0", false), v("1.3.0", true)];
let req = parse_version_req("^1.0").unwrap();
assert_eq!(
pick_highest_match(&versions, &req).unwrap().version,
"1.0.0"
);
}
#[test]
fn no_match_yields_none() {
let versions = [v("1.0.0", false)];
let req = parse_version_req("^2.0").unwrap();
assert!(pick_highest_match(&versions, &req).is_none());
}
#[test]
fn star_and_empty_match_anything() {
let versions = [v("0.3.7", false)];
for raw in ["", "*", " "] {
let req = parse_version_req(raw).unwrap();
assert_eq!(
pick_highest_match(&versions, &req).unwrap().version,
"0.3.7",
"req `{raw}`"
);
}
}
#[test]
fn non_semver_index_entries_are_skipped() {
let versions = [v("not-a-version", false), v("1.1.0", false)];
let req = parse_version_req("^1").unwrap();
assert_eq!(
pick_highest_match(&versions, &req).unwrap().version,
"1.1.0"
);
}
#[test]
fn invalid_range_is_an_error() {
assert!(parse_version_req(">>nope<<").is_err());
}
#[test]
fn self_dependency_is_refused() {
let mut manifest = crate::core::context_package::manifest::PackageManifest {
dependencies: vec![PackageDependency {
name: "@acme/root".into(),
version_req: "^1".into(),
optional: false,
}],
..minimal("@acme/root")
};
let err = resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap_err();
assert!(err.contains("depends on itself"), "got: {err}");
manifest.dependencies[0].optional = true;
assert_eq!(
resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap(),
Vec::new()
);
}
#[test]
fn unscoped_dependency_is_refused() {
let manifest = crate::core::context_package::manifest::PackageManifest {
dependencies: vec![PackageDependency {
name: "plain-name".into(),
version_req: "^1".into(),
optional: false,
}],
..minimal("@acme/root")
};
let err = resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap_err();
assert!(err.contains("not a scoped"), "got: {err}");
}
fn minimal(name: &str) -> crate::core::context_package::manifest::PackageManifest {
use crate::core::context_package::manifest::*;
PackageManifest {
schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
conformance_level: None,
kind: PackageKind::default(),
name: name.into(),
version: "1.0.0".into(),
description: "d".into(),
author: None,
scope: None,
created_at: chrono::Utc::now(),
updated_at: None,
layers: vec![],
dependencies: vec![],
tags: vec![],
visibility: None,
integrity: PackageIntegrity {
sha256: "a".repeat(64),
content_hash: "b".repeat(64),
byte_size: 1,
},
provenance: PackageProvenance {
tool: "lean-ctx".into(),
tool_version: "0".into(),
project_hash: None,
source_session_id: None,
},
compatibility: CompatibilitySpec::default(),
stats: PackageStats::default(),
signature: None,
graph_summary: None,
marketplace: None,
}
}
}