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::load_manifest;
use cabin_port::{
PortCache, PortEntry, PortFetchSource, PortOrigin, PortPlan, PortPrepareOptions, PreparedPort,
load_port, prepare,
};
use cabin_workspace::{PackageGraph, PortPackageSource};
#[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)?;
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(),
}
}
#[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::config_glue::load_effective_config_for_manifest(manifest_path)?;
let cache_dir = match crate::config_glue::resolve_cache_dir(cache_dir_override, &cfg) {
Some((p, _)) => p,
None => crate::cli::cache_dir_for(manifest_path, 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::config_glue::load_effective_config(&light_skeleton)?;
let active_patches =
crate::patch_glue::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.ports.insert(PortKey::PortDir(canonical_port_dir));
}
DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
let key = name.as_str().to_owned();
self.builtin_reqs
.entry(key.clone())
.or_default()
.push(version_req.clone());
self.ports.insert(PortKey::Builtin(key));
}
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 build_plan_entries(
discovery: &PortDiscovery,
cache: &PortCache,
offline: 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, &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,
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);
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 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)
}