use std::collections::BTreeSet;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::Args;
use cabin_artifact::FetchedPackage;
use cabin_vendor::{
DEFAULT_VENDOR_DIRNAME, VendorEntry, VendorOptions, VendorPlan,
materialize as vendor_materialize,
};
use cabin_workspace::collect_patched_versioned_deps;
use crate::cli::{
ArtifactPipelineRequest, WorkspaceSelectionArgs, absolutise, build_selection_request,
build_workspace_selection, closure_has_versioned_deps_excluding_patches,
compute_feature_resolution, resolve_invocation_manifest, run_artifact_pipeline,
};
use crate::plural;
#[derive(Debug, Args)]
pub(crate) struct VendorArgs {
#[arg(long, value_name = "PATH")]
pub manifest_path: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub vendor_dir: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub index_path: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub cache_dir: Option<PathBuf>,
#[arg(long, conflicts_with = "frozen")]
pub locked: bool,
#[arg(long)]
pub frozen: bool,
#[arg(long)]
pub offline: bool,
#[command(flatten)]
pub workspace_selection: WorkspaceSelectionArgs,
#[arg(long, value_name = "FEATURES")]
pub features: Vec<String>,
#[arg(long)]
pub all_features: bool,
#[arg(long)]
pub no_default_features: bool,
#[arg(long)]
pub no_patches: bool,
}
pub(crate) fn vendor(
args: &VendorArgs,
reporter: crate::cli::term_verbosity::Reporter,
) -> Result<()> {
let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
let offline = crate::cli::config::effective_offline(args.offline)?;
let vendor_selection = build_workspace_selection(&args.workspace_selection);
let (_prepared_ports, initial_graph) = crate::cli::port::prepare_ports_and_load_initial_graph(
&manifest_path,
args.cache_dir.as_deref(),
offline,
args.frozen,
false,
&vendor_selection,
args.no_patches,
)?;
let effective_config = crate::cli::config::load_effective_config(&initial_graph)?;
let active_patches =
crate::cli::patch::load_active_patches(&initial_graph, &effective_config, args.no_patches)?;
let patched_names = active_patches.owned_patched_names();
let workspace_selection = build_workspace_selection(&args.workspace_selection);
let resolved_selection =
cabin_workspace::resolve_package_selection(&initial_graph, &workspace_selection)?;
let selection_request =
build_selection_request(&args.features, args.all_features, args.no_default_features);
let initial_features = compute_feature_resolution(
&initial_graph,
&resolved_selection,
&selection_request,
&BTreeSet::new(),
)?;
let dev_for: BTreeSet<String> = BTreeSet::new();
let patched_root_deps_preview =
collect_patched_versioned_deps(&active_patches, &patched_names)?;
let has_versioned = !patched_root_deps_preview.is_empty()
|| closure_has_versioned_deps_excluding_patches(
&initial_graph,
&resolved_selection,
&initial_features,
&patched_names,
&dev_for,
);
let vendor_dir = resolve_vendor_dir(args, &manifest_path)?;
if !has_versioned {
let plan = VendorPlan::default();
let report = vendor_materialize(
&plan,
&vendor_dir,
&VendorOptions {
frozen: args.frozen,
},
)
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
emit_vendor_summary(&report, reporter);
return Ok(());
}
let resolved_index_source = crate::cli::config::resolve_index_source(
args.index_path.as_deref(),
None,
&effective_config,
)?;
let offline = crate::cli::config::effective_offline(args.offline)?;
crate::cli::config::enforce_offline_index_source(offline, resolved_index_source.as_ref())?;
let resolved_cache_dir =
crate::cli::config::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config);
let Some(index_source) = resolved_index_source.as_ref() else {
bail!(
"versioned dependencies require --index-path or a `[registry] index-path` config setting"
);
};
if matches!(
index_source.kind,
crate::cli::config::IndexSourceKind::Url(_)
) {
bail!(
"`cabin vendor` requires a local `--index-path` source so per-package metadata can be copied verbatim into the vendor directory"
);
}
let inputs = crate::cli::config::resolve_pipeline_inputs(
index_source,
&effective_config,
args.cache_dir.as_deref(),
resolved_cache_dir.as_ref(),
offline,
args.locked,
args.frozen,
args.no_patches,
true,
)?;
let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest {
manifest_path: &manifest_path,
initial_graph: &initial_graph,
index_path: inputs.index_path.as_deref(),
index_url: inputs.index_url.as_deref(),
mode: inputs.mode,
allow_write: inputs.allow_write,
frozen: args.frozen,
cache_dir: &inputs.cache_dir,
reporter,
selection: workspace_selection,
selection_request: &selection_request,
patched_names: &patched_names,
active_patches: &active_patches,
source_replacements: &effective_config.source_replacements,
incompatible_standards: crate::cli::config::resolve_incompatible_standards(
&effective_config,
)?,
no_patches: args.no_patches,
dev_for: &dev_for,
})?;
let index_dir = match inputs.index_path.as_deref() {
Some(p) => p.to_path_buf(),
None => bail!(
"`cabin vendor` requires a local `--index-path` source so per-package metadata can be copied verbatim into the vendor directory"
),
};
let plan = build_vendor_plan(&pipeline.fetched, &index_dir)?;
let report = vendor_materialize(
&plan,
&vendor_dir,
&VendorOptions {
frozen: args.frozen,
},
)
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
emit_vendor_summary(&report, reporter);
Ok(())
}
fn resolve_vendor_dir(args: &VendorArgs, manifest_path: &std::path::Path) -> Result<PathBuf> {
let candidate = match args.vendor_dir.as_deref() {
Some(p) => p.to_path_buf(),
None => manifest_path.parent().map_or_else(
|| PathBuf::from(DEFAULT_VENDOR_DIRNAME),
|p| p.join(DEFAULT_VENDOR_DIRNAME),
),
};
absolutise(&candidate)
.with_context(|| format!("failed to resolve vendor dir {}", candidate.display()))
}
fn build_vendor_plan(
fetched: &[FetchedPackage],
index_dir: &std::path::Path,
) -> Result<VendorPlan> {
let mut entries: Vec<VendorEntry> = Vec::with_capacity(fetched.len());
let mut by_name: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
for pkg in fetched {
let name = pkg.name.as_str().to_owned();
let parsed = if let Some(v) = by_name.get(&name) {
v.clone()
} else {
let path = index_dir.join("packages").join(format!("{name}.json"));
let body = std::fs::read_to_string(&path).with_context(|| {
format!(
"vendoring requires the source index to expose `packages/{name}.json` at `{}`",
path.display()
)
})?;
let parsed: serde_json::Value = serde_json::from_str(&body)
.with_context(|| format!("failed to parse {}", path.display()))?;
by_name.insert(name.clone(), parsed.clone());
parsed
};
let version_entry = parsed
.get("versions")
.and_then(|v| v.get(pkg.version.to_string()))
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"source index has no `{name}` version `{}` to vendor; the index file may be stale",
pkg.version
)
})?;
entries.push(VendorEntry {
name: pkg.name.clone(),
version: pkg.version.clone(),
checksum: pkg.checksum.clone(),
archive_source: pkg.archive_path.clone(),
index_entry: version_entry,
});
}
VendorPlan::new(entries).map_err(|err| anyhow::anyhow!(err.to_string()))
}
fn emit_vendor_summary(
report: &cabin_vendor::VendorReport,
reporter: crate::cli::term_verbosity::Reporter,
) {
if report.written.is_empty() {
reporter.status(
"Vendored",
format_args!(
"no versioned dependencies in the selected closure (skeleton at {})",
report.vendor_dir.display()
),
);
} else {
reporter.status(
"Vendored",
format_args!(
"{} package{} to {}",
report.written.len(),
plural(report.written.len()),
report.vendor_dir.display()
),
);
for entry in &report.written {
let action = if entry.artifact_was_written {
"wrote"
} else {
"verified"
};
reporter.verbose(format_args!(
" {action} {} {} -> {}",
entry.name.as_str(),
entry.version,
entry.artifact_relative_path
));
}
}
reporter.note(format_args!(
"To build offline, run: cabin build --offline --index-path {}",
report.vendor_dir.display()
));
if report.frozen {
reporter.verbose(format_args!(
"cabin: --frozen: lockfile and artifact cache were not modified"
));
}
}