use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use semver::{Version, VersionReq};
use cabin_core::{DependencyKind, DependencySource, PortDepSource, TargetPlatform};
use cabin_index_http::HttpClient;
use cabin_manifest::{ParsedManifest, load_manifest, parse_manifest_str};
use cabin_port::{
PortCache, PortEntry, PortFetchSource, PortOrigin, PortPlan, PortPrepareOptions, PreparedPort,
load_port, prepare,
};
use cabin_workspace::{PackageGraph, PortPackageSource};
use crate::cli::term_verbosity::Reporter;
#[derive(Clone, Copy)]
pub(crate) struct PortPrepInputs<'a> {
pub seeds: &'a [PathBuf],
pub cache: &'a PortCache,
pub offline: bool,
pub frozen: bool,
pub include_dev: bool,
}
pub(crate) fn discover_and_prepare(inputs: PortPrepInputs<'_>) -> Result<Vec<PreparedPort>> {
if inputs.seeds.is_empty() {
return Ok(Vec::new());
}
let host_platform = TargetPlatform::current();
let mut discovery = PortDiscovery::new(&host_platform);
for seed in inputs.seeds {
discovery
.walk(seed, inputs.include_dev)
.with_context(|| format!("discovering ports from {}", seed.display()))?;
}
if discovery.ports.is_empty() {
return Ok(Vec::new());
}
let entries = build_plan_entries(&discovery, inputs.cache, inputs.offline, inputs.frozen)?;
let plan = PortPlan { entries };
let result = prepare(
&plan,
inputs.cache,
PortPrepareOptions {
frozen: inputs.frozen,
},
)?;
Ok(result.ports)
}
pub(crate) fn is_metadata_recoverable(err: &anyhow::Error) -> bool {
err.downcast_ref::<cabin_port::PortError>()
.is_some_and(|e| {
matches!(
e,
cabin_port::PortError::OfflineCacheMiss { .. }
| cabin_port::PortError::FrozenCacheMiss { .. }
| cabin_port::PortError::MissingArchive { .. }
)
})
}
pub(crate) fn workspace_source(prepared: &PreparedPort) -> PortPackageSource {
PortPackageSource {
name: prepared.name.clone(),
version: prepared.version.clone(),
manifest_path: prepared.source_dir.join("cabin.toml"),
origin: prepared.origin.clone(),
}
}
pub(crate) fn report_downloaded_ports(reporter: Reporter, prepared: &[PreparedPort]) {
for port in prepared.iter().filter(|p| p.downloaded) {
reporter.status(
"Downloaded",
format_args!("{} v{}", port.name, port.version),
);
}
}
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) fn prepare_ports_and_load_initial_graph(
manifest_path: &Path,
cache_dir_override: Option<&Path>,
offline: bool,
frozen: bool,
include_dev: bool,
selection: &cabin_workspace::PackageSelection,
no_patches: bool,
) -> Result<(Vec<PreparedPort>, PackageGraph)> {
let cfg = crate::cli::config::load_effective_config_for_manifest(manifest_path)?;
let cache_dir = match crate::cli::config::resolve_cache_dir(cache_dir_override, &cfg) {
Some((p, _)) => p,
None => crate::cli::cache_dir_for(cache_dir_override)?,
};
let port_cache = PortCache::new(cache_dir.join("ports"));
let light_skeleton = cabin_workspace::load_workspace_skip_ports(manifest_path)?;
let skeleton_config = crate::cli::config::load_effective_config(&light_skeleton)?;
let active_patches =
crate::cli::patch::load_active_patches(&light_skeleton, &skeleton_config, no_patches)?;
let patched_sources = active_patches.workspace_sources();
let skeleton = if patched_sources.is_empty() {
light_skeleton
} else {
cabin_workspace::load_workspace_with_options(
manifest_path,
&cabin_workspace::WorkspaceLoadOptions {
registry: &[],
patches: &patched_sources,
ports: &[],
registry_policy: cabin_workspace::RegistryPolicy::StrictFor(&BTreeSet::new()),
include_dev_for: &BTreeSet::new(),
port_policy: cabin_workspace::PortPolicy::TolerateExcept(&BTreeSet::new()),
},
)?
};
let resolved = cabin_workspace::resolve_package_selection(&skeleton, selection)?;
let strict_port_set: BTreeSet<String> = resolved.closure_package_names(&skeleton);
let mut seeds: Vec<PathBuf> = resolved
.packages
.iter()
.map(|&i| skeleton.packages[i].manifest_path.clone())
.collect();
seeds.extend(
active_patches
.iter()
.filter(|p| strict_port_set.contains(p.name.as_str()))
.map(|p| p.manifest_path.clone()),
);
let prepared = discover_and_prepare(PortPrepInputs {
seeds: &seeds,
cache: &port_cache,
offline,
frozen,
include_dev,
})?;
let port_sources: Vec<PortPackageSource> = prepared.iter().map(workspace_source).collect();
let graph = cabin_workspace::load_workspace_with_options(
manifest_path,
&cabin_workspace::WorkspaceLoadOptions {
registry: &[],
patches: &patched_sources,
ports: &port_sources,
registry_policy: cabin_workspace::RegistryPolicy::StrictFor(&BTreeSet::new()),
include_dev_for: &BTreeSet::new(),
port_policy: cabin_workspace::PortPolicy::TolerateExcept(&strict_port_set),
},
)?;
Ok((prepared, graph))
}
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
enum PortKey {
PortDir(PathBuf),
Builtin(String),
}
#[derive(Debug)]
struct PortDiscovery<'a> {
ports: BTreeSet<PortKey>,
builtin_reqs: BTreeMap<String, Vec<VersionReq>>,
visited: BTreeMap<PathBuf, bool>,
host_platform: &'a TargetPlatform,
}
impl<'a> PortDiscovery<'a> {
fn new(host_platform: &'a TargetPlatform) -> Self {
Self {
ports: BTreeSet::new(),
builtin_reqs: BTreeMap::new(),
visited: BTreeMap::new(),
host_platform,
}
}
fn walk(&mut self, manifest_path: &Path, walk_dev: bool) -> Result<()> {
let Ok(canonical) = std::fs::canonicalize(manifest_path) else {
return Ok(());
};
let prior = self.visited.get(&canonical).copied();
let only_dev = match prior {
Some(true) => return Ok(()),
Some(false) if !walk_dev => return Ok(()),
Some(false) => true,
None => false,
};
self.visited.insert(canonical.clone(), walk_dev);
let manifest_dir = canonical
.parent()
.ok_or_else(|| anyhow!("manifest path {} has no parent", canonical.display()))?
.to_path_buf();
let parsed = load_manifest(&canonical)
.with_context(|| format!("parsing manifest at {}", canonical.display()))?;
if let Some(pkg) = &parsed.package {
for dep in &pkg.dependencies {
if !walk_dev && dep.kind == DependencyKind::Dev {
continue;
}
if only_dev && dep.kind != DependencyKind::Dev {
continue;
}
if !dep.matches_platform(self.host_platform) {
continue;
}
match &dep.source {
DependencySource::Port(PortDepSource::Path(rel)) => {
let port_dir = manifest_dir.join(rel);
let canonical_port_dir =
std::fs::canonicalize(&port_dir).map_err(|source| {
anyhow!(
"manifest at {} declares port-path dependency `{}` but its directory is unreachable at {}: {source}",
canonical.display(),
dep.name.as_str(),
port_dir.display()
)
})?;
self.note_portdir(&canonical_port_dir);
}
DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
self.note_builtin(name.as_str(), version_req.clone());
}
DependencySource::Path(rel) => {
let nested = manifest_dir.join(rel).join("cabin.toml");
if !nested.is_file() {
return Err(anyhow!(
"manifest at {} declares path dependency `{}` but its manifest is missing at {}",
canonical.display(),
dep.name.as_str(),
nested.display()
));
}
self.walk(&nested, false)?;
}
DependencySource::Version(_) | DependencySource::Workspace => {}
}
}
}
Ok(())
}
fn note_portdir(&mut self, canonical_port_dir: &Path) {
if self
.ports
.insert(PortKey::PortDir(canonical_port_dir.to_path_buf()))
{
self.recurse_portdir_overlay(canonical_port_dir);
}
}
fn note_builtin(&mut self, name: &str, version_req: VersionReq) {
self.builtin_reqs
.entry(name.to_owned())
.or_default()
.push(version_req);
if self.ports.insert(PortKey::Builtin(name.to_owned())) {
self.recurse_builtin_overlay(name);
}
}
fn recurse_portdir_overlay(&mut self, port_dir: &Path) {
let Ok(descriptor) = load_port(port_dir.join("port.toml")) else {
return;
};
let overlay_path = port_dir.join(&descriptor.overlay.relative_path);
let Ok(parsed) = load_manifest(&overlay_path) else {
return;
};
let base = overlay_path.parent().map(Path::to_path_buf);
self.walk_overlay_deps(&parsed, base.as_deref());
}
fn recurse_builtin_overlay(&mut self, name: &str) {
let Some(req) = self
.builtin_reqs
.get(name)
.and_then(|reqs| reqs.first())
.cloned()
else {
return;
};
let Some(recipe) = cabin_port::builtin::lookup(name, &req) else {
return;
};
let Ok(parsed) = parse_manifest_str(recipe.overlay_toml) else {
return;
};
self.walk_overlay_deps(&parsed, None);
}
fn walk_overlay_deps(&mut self, parsed: &ParsedManifest, base_dir: Option<&Path>) {
let Some(pkg) = &parsed.package else {
return;
};
for dep in &pkg.dependencies {
if dep.kind == DependencyKind::Dev {
continue;
}
if !dep.matches_platform(self.host_platform) {
continue;
}
match &dep.source {
DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
self.note_builtin(name.as_str(), version_req.clone());
}
DependencySource::Path(rel) => {
if let Some(base) = base_dir {
let nested = base.join(rel).join("cabin.toml");
if nested.is_file() {
let _ = self.walk(&nested, false);
}
}
}
DependencySource::Port(PortDepSource::Path(_))
| DependencySource::Version(_)
| DependencySource::Workspace => {}
}
}
}
}
fn build_plan_entries(
discovery: &PortDiscovery,
cache: &PortCache,
offline: bool,
frozen: bool,
) -> Result<Vec<PortEntry>> {
let mut entries: Vec<PortEntry> = Vec::with_capacity(discovery.ports.len());
let mut http_client: Option<HttpClient> = None;
for key in &discovery.ports {
let (descriptor, origin) = match key {
PortKey::PortDir(port_dir) => {
let descriptor = load_port(port_dir.join("port.toml"))
.with_context(|| format!("loading port at {}", port_dir.display()))?;
(descriptor, PortOrigin::PortDir(port_dir.clone()))
}
PortKey::Builtin(name) => {
let reqs = discovery
.builtin_reqs
.get(name)
.expect("walk inserts builtin_reqs in lockstep with ports");
let primary_req = reqs
.first()
.expect("walk pushes at least one VersionReq per builtin name");
let Some(recipe) = cabin_port::builtin::lookup(name, primary_req) else {
let available: Vec<String> = cabin_port::builtin::iter()
.filter(|p| p.name == name)
.map(|p| p.version.to_owned())
.collect();
return Err(if available.is_empty() {
cabin_port::PortError::UnknownBuiltin { name: name.clone() }.into()
} else {
cabin_port::PortError::BuiltinVersionNotFound {
name: name.clone(),
requirement: primary_req.to_string(),
available,
}
.into()
});
};
let recipe_version = Version::parse(recipe.version).with_context(|| {
format!(
"bundled port `{name}` version `{}` is not valid SemVer",
recipe.version
)
})?;
for extra in reqs.iter().skip(1) {
if !extra.matches(&recipe_version) {
let available: Vec<String> = cabin_port::builtin::iter()
.filter(|p| p.name == name)
.map(|p| p.version.to_owned())
.collect();
return Err(cabin_port::PortError::BuiltinVersionNotFound {
name: name.clone(),
requirement: extra.to_string(),
available,
}
.into());
}
}
let descriptor =
cabin_port::parse_port_str(recipe.port_toml, std::path::Path::new("<builtin>"))
.with_context(|| format!("parsing bundled port `{name}`"))?;
(descriptor, PortOrigin::Builtin(recipe.name))
}
};
let source = resolve_fetch_source(
&origin,
&descriptor,
cache,
offline,
frozen,
&mut http_client,
)?;
entries.push(PortEntry {
descriptor,
origin,
source,
});
}
entries.sort_by_key(|a| port_sort_key(&a.origin));
Ok(entries)
}
fn port_sort_key(origin: &PortOrigin) -> (u8, std::ffi::OsString) {
match origin {
PortOrigin::Builtin(name) => (0, std::ffi::OsString::from(*name)),
PortOrigin::PortDir(p) => (1, p.as_os_str().to_owned()),
}
}
fn resolve_fetch_source(
origin: &PortOrigin,
descriptor: &cabin_port::PortDescriptor,
cache: &PortCache,
offline: bool,
frozen: bool,
http_client: &mut Option<HttpClient>,
) -> Result<PortFetchSource> {
let origin_label = match origin {
PortOrigin::PortDir(p) => p.display().to_string(),
PortOrigin::Builtin(name) => format!("<builtin:{name}>"),
};
let cabin_port::PortSource::Archive { url, sha256, .. } = &descriptor.source;
let expected_hex = sha256.to_hex();
let cached_archive = cache.archive_path(&expected_hex, cabin_port::ArchiveKind::from_url(url));
if archive_matches(&cached_archive, &expected_hex)? {
return Ok(PortFetchSource::LocalArchive(cached_archive));
}
match url.scheme() {
"file" => {
let path = url.to_file_path().map_err(|()| {
anyhow!(
"port at {origin_label} declares a file:// URL that does not map to a filesystem path: {url}"
)
})?;
Ok(PortFetchSource::LocalArchive(path))
}
"http" | "https" => {
if frozen {
return Err(cabin_port::PortError::FrozenCacheMiss {
name: descriptor.name.as_str().to_owned(),
version: descriptor.version.to_string(),
}
.into());
}
if offline {
return Err(cabin_port::PortError::OfflineCacheMiss {
name: descriptor.name.as_str().to_owned(),
version: descriptor.version.to_string(),
url: url.to_string(),
}
.into());
}
let client = http_client.get_or_insert_with(|| HttpClient::with_redirect_budget(5));
let label = format!("{}-{}", descriptor.name.as_str(), descriptor.version);
let bytes = client
.download(url.as_str(), &label)
.map_err(|err| anyhow!("failed to download {url}: {err}"))?;
Ok(PortFetchSource::InMemoryArchive(bytes))
}
other => Err(anyhow!(
"port at {origin_label} declares an unsupported archive URL scheme `{other}`; foundation ports support `file://`, `http://`, and `https://`"
)),
}
}
fn archive_matches(path: &Path, expected_hex: &str) -> Result<bool> {
let f = match std::fs::File::open(path) {
Ok(f) => f,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(err) => {
return Err(anyhow!(
"cached port archive at {} could not be opened: {err}",
path.display()
));
}
};
let actual = cabin_core::hash::hash_reader(f)
.with_context(|| format!("reading cached port archive at {}", path.display()))?;
Ok(actual == expected_hex)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
use assert_fs::prelude::*;
const SHA_ZEROS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
fn port_toml(name: &str) -> String {
format!(
"[port]\nname = \"{name}\"\nversion = \"1.0.0\"\n\n[source]\ntype = \"archive\"\nurl = \"https://example.invalid/{name}.tar.gz\"\nsha256 = \"{SHA_ZEROS}\"\n\n[overlay]\nmanifest = \"cabin.toml\"\n"
)
}
#[test]
fn discovery_recurses_overlay_for_transitive_builtin_dep() {
let tmp = TempDir::new().unwrap();
tmp.child("ports/foo/1.0.0/port.toml")
.write_str(&port_toml("foo"))
.unwrap();
tmp.child("ports/foo/1.0.0/cabin.toml")
.write_str(
"[package]\nname = \"foo\"\nversion = \"1.0.0\"\n\n[dependencies]\nzlib = { port = true, version = \"^1.3\" }\n",
)
.unwrap();
tmp.child("consumer/cabin.toml")
.write_str(
"[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nfoo = { port-path = \"../ports/foo/1.0.0\" }\n",
)
.unwrap();
let host = TargetPlatform::current();
let mut discovery = PortDiscovery::new(&host);
discovery
.walk(&tmp.path().join("consumer/cabin.toml"), false)
.unwrap();
let foo = std::fs::canonicalize(tmp.path().join("ports/foo/1.0.0")).unwrap();
assert!(
discovery.ports.contains(&PortKey::PortDir(foo)),
"foo recorded: {:?}",
discovery.ports
);
assert!(
discovery
.ports
.contains(&PortKey::Builtin("zlib".to_owned())),
"transitive builtin zlib must be discovered: {:?}",
discovery.ports
);
assert!(
discovery.builtin_reqs.contains_key("zlib"),
"transitive builtin requirement must be captured for the conflict check"
);
}
#[test]
fn discovery_does_not_follow_port_path_inside_overlay() {
let tmp = TempDir::new().unwrap();
tmp.child("ports/beta/1.0.0/port.toml")
.write_str(&port_toml("beta"))
.unwrap();
tmp.child("ports/beta/1.0.0/cabin.toml")
.write_str("[package]\nname = \"beta\"\nversion = \"1.0.0\"\n")
.unwrap();
tmp.child("ports/alpha/1.0.0/port.toml")
.write_str(&port_toml("alpha"))
.unwrap();
tmp.child("ports/alpha/1.0.0/cabin.toml")
.write_str(
"[package]\nname = \"alpha\"\nversion = \"1.0.0\"\n\n[dependencies]\nbeta = { port-path = \"../../beta/1.0.0\" }\n",
)
.unwrap();
tmp.child("consumer/cabin.toml")
.write_str(
"[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nalpha = { port-path = \"../ports/alpha/1.0.0\" }\n",
)
.unwrap();
let host = TargetPlatform::current();
let mut discovery = PortDiscovery::new(&host);
discovery
.walk(&tmp.path().join("consumer/cabin.toml"), false)
.unwrap();
let alpha = std::fs::canonicalize(tmp.path().join("ports/alpha/1.0.0")).unwrap();
let beta = std::fs::canonicalize(tmp.path().join("ports/beta/1.0.0")).unwrap();
assert!(
discovery.ports.contains(&PortKey::PortDir(alpha)),
"directly-referenced port alpha must be discovered: {:?}",
discovery.ports
);
assert!(
!discovery.ports.contains(&PortKey::PortDir(beta)),
"port-path inside an overlay must NOT be followed: {:?}",
discovery.ports
);
}
}