use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::{PkgFormat, SuiteSelection};
use crate::commands::install::presets;
use crate::config::{Config, PluginDef, ResolvedSuite};
use crate::{CargoTruceError, Res, load_config, project_root, read_workspace_version};
use truce_build::BundleManifest;
const INSTALL_SH_TEMPLATE: &str = include_str!("install.sh.tmpl");
#[allow(clippy::too_many_lines)]
pub(crate) fn cmd_package_linux(args: &[String], selection: &SuiteSelection) -> Res {
let mut no_build = false;
let mut targets: Vec<String> = Vec::new();
let mut formats: Option<Vec<PkgFormat>> = None;
let mut plugin_filter: Option<String> = None;
let mut target_cpu_arg: Option<String> = None;
let mut i = 0;
let mut leftover: Vec<&str> = Vec::new();
while i < args.len() {
match args[i].as_str() {
"" | "--no-sign" | "--no-pace-sign" | "--no-notarize" => {}
"--no-build" => no_build = true,
"--target" => {
i += 1;
let v = args.get(i).ok_or("--target requires a value")?;
targets.push(v.clone());
}
"--formats" => {
i += 1;
let v = args.get(i).ok_or("--formats requires a value")?;
formats = Some(PkgFormat::parse_list(v)?);
}
"-p" => {
i += 1;
let v = args.get(i).ok_or("-p requires a plugin crate name")?;
plugin_filter = Some(v.clone());
}
"--target-cpu" => {
i += 1;
let v = args.get(i).ok_or("--target-cpu requires a value")?;
target_cpu_arg = Some(v.clone());
}
other => leftover.push(other),
}
i += 1;
}
if let Some(unknown) = leftover.first() {
return Err(format!(
"unknown flag: {unknown}\n\
Linux `cargo truce package` accepts -p <crate>, the \
suite-selection flags (--suite, --no-suite, --no-per-plugin), \
--target <triple> (repeatable), --formats <list>, --target-cpu \
<value>, and --no-build."
)
.into());
}
if no_build && formats.is_some() {
return Err("--formats cannot be combined with --no-build on Linux: \
`--formats` drives the implicit `cargo truce build`, so \
with `--no-build` the existing manifest is consumed as-is."
.into());
}
let config = load_config()?;
let root = project_root();
let version = read_workspace_version(&root).unwrap_or_else(|e| {
eprintln!("WARNING: {e}; defaulting tarball version to 0.0.0");
"0.0.0".to_string()
});
if config.plugin.is_empty() {
return Err("no [[plugin]] entries in truce.toml".into());
}
if !no_build {
eprintln!("Building bundles...");
let mut build_args: Vec<String> = Vec::new();
for t in &targets {
build_args.push("--target".into());
build_args.push(t.clone());
}
if let Some(ref fmts) = formats {
for f in fmts {
if let Some(flag) = build_flag_for_format(f) {
build_args.push(flag.into());
}
}
}
if let Some(ref p) = plugin_filter {
build_args.push("-p".into());
build_args.push(p.clone());
}
if let Some(ref v) = target_cpu_arg {
build_args.push("--target-cpu".into());
build_args.push(v.clone());
}
super::super::build::cmd_build(&build_args)?;
eprintln!();
}
let dist_dir = truce_build::target_dir(&root).join("dist");
fs::create_dir_all(&dist_dir)?;
let bundles_root = truce_build::target_dir(&root).join("bundles");
let plans: Vec<(String, PathBuf, BundleManifest)> = if targets.is_empty() {
let manifest = BundleManifest::load(&bundles_root).map_err(CargoTruceError::from)?;
validate_manifest_triple(&manifest, &bundles_root, truce_build::host_triple())?;
vec![(
manifest.target_triple.clone(),
bundles_root.clone(),
manifest,
)]
} else {
let mut out = Vec::new();
for t in &targets {
let dir = bundles_root.join(t);
let manifest = BundleManifest::load(&dir).map_err(CargoTruceError::from)?;
validate_manifest_triple(&manifest, &dir, t)?;
out.push((t.clone(), dir, manifest));
}
out
};
let selected_plugins = crate::commands::pick_plugins(&config, plugin_filter.as_deref())?;
let suites: Vec<ResolvedSuite<'_>> = if plugin_filter.is_some() {
if !config.suites.is_empty() {
eprintln!("(-p set; skipping suite tarballs - they need every member plugin staged)");
}
Vec::new()
} else {
config
.suites
.iter()
.filter(|s| selection.want_suite(&s.name))
.map(|s| s.resolve(&config.plugin))
.collect::<Result<_, _>>()?
};
let build_standalone = formats
.as_ref()
.is_some_and(|f| f.contains(&PkgFormat::Standalone));
for (triple, bundles_dir, manifest) in &plans {
let arch = arch_from_triple(triple);
if plans.len() > 1 {
eprintln!("Target: {triple}");
}
if !no_build && build_standalone {
let cross = (triple.as_str() != truce_build::host_triple()).then_some(triple.as_str());
for plugin in &selected_plugins {
build_standalone_binary(&root, plugin, cross, bundles_dir)?;
}
}
let ctx = TarballCtx {
root: &root,
config: &config,
dist_dir: &dist_dir,
version: &version,
bundles_dir,
manifest,
arch,
};
if selection.want_per_plugin() {
eprintln!("Per-plugin tarballs");
for plugin in &selected_plugins {
build_per_plugin_tarball(&ctx, plugin)?;
}
} else {
eprintln!("Skipping per-plugin tarballs (--no-per-plugin).");
}
if !suites.is_empty() {
eprintln!("\nSuite tarballs");
for suite in &suites {
build_suite_tarball(&ctx, suite)?;
}
}
if plans.len() > 1 {
eprintln!();
}
}
eprintln!("\nDone. Tarballs in {}", dist_dir.display());
Ok(())
}
fn validate_manifest_triple(manifest: &BundleManifest, bundles_dir: &Path, expected: &str) -> Res {
if manifest.target_triple != expected {
return Err(format!(
"build manifest at {} is for target {} but expected {}. \
Re-run `cargo truce build` for the matching target.",
BundleManifest::manifest_path(bundles_dir).display(),
manifest.target_triple,
expected,
)
.into());
}
Ok(())
}
fn arch_from_triple(triple: &str) -> &str {
triple.split('-').next().unwrap_or("unknown")
}
struct TarballCtx<'a> {
root: &'a Path,
config: &'a Config,
dist_dir: &'a Path,
version: &'a str,
bundles_dir: &'a Path,
manifest: &'a BundleManifest,
arch: &'a str,
}
fn build_per_plugin_tarball(ctx: &TarballCtx<'_>, plugin: &PluginDef) -> Res {
let stem = format!("{}-{}-linux-{}", plugin.crate_name, ctx.version, ctx.arch);
let staging = plugin_stage_dir(ctx.root, &plugin.bundle_id, ctx.arch)?;
let plugin_summary = stage_plugin_payload(
ctx.root,
ctx.config,
plugin,
&staging,
ctx.bundles_dir,
ctx.manifest,
)?;
let install_paths = expected_tarball_paths(&stem, &[&plugin_summary]);
write_install_sh(&staging, ctx.config, &[plugin_summary], None)?;
write_readme(&staging, ctx.config, ctx.version, &[plugin], None)?;
let out = ctx.dist_dir.join(format!("{stem}.tar.gz"));
create_tarball(&staging, &out, &stem)?;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let expected: Vec<&str> = install_paths.iter().map(String::as_str).collect();
super::verify::assert_tarball_contains(&out, &expected)?;
}
let _ = install_paths;
eprintln!(" {} → {}", plugin.name, out.display());
Ok(())
}
fn build_suite_tarball(ctx: &TarballCtx<'_>, suite: &ResolvedSuite<'_>) -> Res {
let suite_version = suite.def.version.as_deref().unwrap_or(ctx.version);
let stem = format!(
"{}-{}-linux-{}",
suite.def.bundle_id, suite_version, ctx.arch
);
let staging = suite_stage_dir(ctx.root, &suite.def.bundle_id, ctx.arch)?;
let mut summaries = Vec::with_capacity(suite.plugins.len());
for plugin in &suite.plugins {
summaries.push(stage_plugin_payload(
ctx.root,
ctx.config,
plugin,
&staging,
ctx.bundles_dir,
ctx.manifest,
)?);
}
let summary_refs: Vec<&PluginSummary> = summaries.iter().collect();
let install_paths = expected_tarball_paths(&stem, &summary_refs);
write_install_sh(&staging, ctx.config, &summaries, Some(suite))?;
write_readme(
&staging,
ctx.config,
suite_version,
&suite.plugins,
Some(suite),
)?;
let out = ctx.dist_dir.join(format!("{stem}.tar.gz"));
create_tarball(&staging, &out, &stem)?;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let expected: Vec<&str> = install_paths.iter().map(String::as_str).collect();
super::verify::assert_tarball_contains(&out, &expected)?;
}
let _ = install_paths;
eprintln!(" {} → {}", suite.def.name, out.display());
Ok(())
}
fn expected_tarball_paths(stem: &str, summaries: &[&PluginSummary]) -> Vec<String> {
let mut paths = vec![format!("{stem}/install.sh")];
for s in summaries {
for b in &s.bundles {
paths.push(format!("{stem}/{}/{}", b.format, b.name));
}
if let Some(bin) = &s.standalone {
paths.push(format!("{stem}/standalone/{bin}"));
}
}
paths
}
struct PluginSummary {
bundle_id: String,
display_name: String,
bundles: Vec<BundleEntry>,
standalone: Option<String>,
clap_presets: Option<String>,
vst3_presets: Option<String>,
standalone_presets: Option<String>,
}
struct BundleEntry {
format: &'static str,
name: String,
}
fn stage_plugin_payload(
root: &Path,
config: &Config,
plugin: &PluginDef,
staging: &Path,
bundles_dir: &Path,
manifest: &BundleManifest,
) -> Result<PluginSummary, CargoTruceError> {
let mut bundles = Vec::new();
for entry in manifest.bundles_for_plugin(&plugin.crate_name) {
let Some(slug) = linux_install_slug(&entry.format) else {
continue;
};
let src = bundles_dir.join(&entry.filename);
if !src.exists() {
return Err(format!(
"build manifest lists {} for {} but {} is missing on disk. \
Re-run `cargo truce build`.",
entry.filename,
plugin.name,
src.display(),
)
.into());
}
let format_dir = staging.join(slug);
fs::create_dir_all(&format_dir)?;
let dst = format_dir.join(&entry.filename);
if src.is_dir() {
copy_dir_all(&src, &dst)?;
} else {
fs::copy(&src, &dst)?;
}
bundles.push(BundleEntry {
format: slug,
name: entry.filename.clone(),
});
}
let mut clap_presets = None;
let mut vst3_presets = None;
if let Some(fp) = presets::load_factory_presets(root, plugin, config)? {
for b in &bundles {
match b.format {
"clap" => {
let dir = format!("{}.presets", plugin.file_stem());
presets::emit_trucepreset_tree(
&fp,
&staging.join("clap").join(&dir),
false,
&format!("{}-clap", plugin.bundle_id),
)?;
clap_presets = Some(format!("clap/{dir}"));
}
"lv2" => {
let uri = truce_build::lv2::plugin_uri(
config.vendor.url.as_deref().unwrap_or(""),
&plugin.bundle_id,
);
presets::emit_lv2_presets(&fp, &staging.join("lv2").join(&b.name), &uri)?;
}
"vst3" => {
let payload = presets::vst3_preset_payload(&fp, plugin, config);
if let Some((first, _)) = payload.first() {
let plugin_dir: PathBuf = first.iter().take(2).collect();
for (rel, bytes) in &payload {
let dst = staging.join("vst3-presets").join(rel);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dst, bytes)?;
}
vst3_presets = Some(format!("vst3-presets/{}", plugin_dir.display()));
}
}
_ => {}
}
}
}
let standalone = stage_standalone_payload(plugin, staging, bundles_dir)?;
let standalone_presets = if let Some(bin) = &standalone {
let exe = staging.join("standalone").join(bin);
presets::emit_standalone_factory(root, plugin, config, &exe)?;
let dir = staging.join("standalone").join(format!("{bin}.presets"));
dir.is_dir().then(|| format!("standalone/{bin}.presets"))
} else {
None
};
if bundles.is_empty() && standalone.is_none() {
return Err(format!(
"no bundles or standalone for {} in {}. \
The build manifest doesn't list this plugin's formats - \
re-run `cargo truce build` (optionally with `-p {}`).",
plugin.name,
bundles_dir.display(),
plugin.crate_name,
)
.into());
}
Ok(PluginSummary {
bundle_id: plugin.bundle_id.clone(),
display_name: plugin.name.clone(),
bundles,
standalone,
clap_presets,
vst3_presets,
standalone_presets,
})
}
fn build_flag_for_format(f: &PkgFormat) -> Option<&'static str> {
match f {
PkgFormat::Clap => Some("--clap"),
PkgFormat::Vst3 => Some("--vst3"),
PkgFormat::Vst2 => Some("--vst2"),
PkgFormat::Lv2 => Some("--lv2"),
PkgFormat::Au2 => Some("--au2"),
PkgFormat::Au3 => Some("--au3"),
PkgFormat::Aax => Some("--aax"),
PkgFormat::Standalone => None,
}
}
fn linux_install_slug(format: &str) -> Option<&'static str> {
match format {
"clap" => Some("clap"),
"vst3" => Some("vst3"),
"vst2" => Some("vst"),
"lv2" => Some("lv2"),
_ => None,
}
}
fn stage_standalone_payload(
plugin: &PluginDef,
staging: &Path,
bundles_dir: &Path,
) -> Result<Option<String>, CargoTruceError> {
let candidate = bundles_dir.join(format!("{}.standalone", plugin.file_stem()));
if !candidate.exists() {
return Ok(None);
}
let bin_name = standalone_binary_name(plugin);
let dst_dir = staging.join("standalone");
fs::create_dir_all(&dst_dir)?;
fs::copy(&candidate, dst_dir.join(&bin_name))?;
set_executable(&dst_dir.join(&bin_name))?;
Ok(Some(bin_name))
}
fn standalone_binary_name(plugin: &PluginDef) -> String {
crate::read_standalone_bin_name(&plugin.crate_name)
.unwrap_or_else(|| format!("{}-standalone", plugin.crate_name))
}
fn build_standalone_binary(
root: &Path,
plugin: &PluginDef,
cross: Option<&str>,
bundles_dir: &Path,
) -> Res {
let bin_stem = standalone_binary_name(plugin);
eprintln!("Building {} standalone...", plugin.name);
let mut args: Vec<String> = vec![
"-p".into(),
plugin.crate_name.clone(),
"--features".into(),
"standalone".into(),
];
if let Some(triple) = cross {
args.push("--target".into());
args.push(triple.to_string());
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
crate::cargo_build(&[], &arg_refs, "")?;
let release = match cross {
Some(triple) => truce_build::target_dir(root).join(triple).join("release"),
None => truce_build::target_dir(root).join("release"),
};
let built = release.join(&bin_stem);
if !built.exists() {
return Err(format!(
"standalone binary not found at {} after build. Does {} declare \
a [[bin]] named '{bin_stem}' gated on the `standalone` feature?",
built.display(),
plugin.crate_name,
)
.into());
}
fs::create_dir_all(bundles_dir)?;
let dst = bundles_dir.join(format!("{}.standalone", plugin.file_stem()));
fs::copy(&built, &dst)?;
set_executable(&dst)?;
Ok(())
}
fn write_install_sh(
staging: &Path,
config: &Config,
summaries: &[PluginSummary],
suite: Option<&ResolvedSuite<'_>>,
) -> Res {
let project_label = match suite {
Some(s) => s.def.name.clone(),
None if summaries.len() == 1 => summaries[0].display_name.clone(),
None => config.vendor.name.clone(),
};
let plugin_cases = summaries
.iter()
.map(format_plugin_case)
.collect::<Vec<_>>()
.join("\n\n");
let plugin_names = summaries
.iter()
.map(|s| s.bundle_id.as_str())
.collect::<Vec<_>>()
.join(" ");
let rendered = INSTALL_SH_TEMPLATE
.replace("{{PROJECT}}", &project_label)
.replace("{{VENDOR}}", &config.vendor.name)
.replace("{{PLUGIN_NAMES}}", &plugin_names)
.replace("{{PLUGIN_CASES}}", &plugin_cases);
let path = staging.join("install.sh");
fs::write(&path, rendered)?;
set_executable(&path)?;
Ok(())
}
fn format_plugin_case(p: &PluginSummary) -> String {
let mut s = String::new();
let _ = writeln!(s, " {})", p.bundle_id);
let _ = writeln!(s, " echo \" Installing {} ...\"", p.display_name);
for b in &p.bundles {
let _ = writeln!(
s,
" install_bundle \"{format}\" \"{format}/{name}\"",
format = b.format,
name = b.name,
);
}
if let Some(src) = &p.clap_presets {
let _ = writeln!(s, " install_clap_presets \"{src}\"");
}
if let Some(src) = &p.vst3_presets {
let _ = writeln!(s, " install_vst3_presets \"{src}\"");
}
if let Some(bin) = &p.standalone {
let _ = writeln!(
s,
" install_standalone \"standalone/{bin}\" \"{bin}\""
);
if let Some(src) = &p.standalone_presets {
let _ = writeln!(s, " install_standalone_presets \"{src}\" \"{bin}\"");
}
}
s.push_str(" ;;");
s
}
fn write_readme(
staging: &Path,
config: &Config,
version: &str,
plugins: &[&PluginDef],
suite: Option<&ResolvedSuite<'_>>,
) -> Res {
let title = match suite {
Some(s) => format!("{} {}", s.def.name, version),
None if plugins.len() == 1 => format!("{} {}", plugins[0].name, version),
None => format!("{} {}", config.vendor.name, version),
};
let mut readme = String::new();
readme.push_str(&title);
readme.push('\n');
readme.push_str(&"=".repeat(title.len()));
readme.push_str("\n\n");
if let Some(s) = suite
&& let Some(d) = &s.def.description
{
readme.push_str(d);
readme.push_str("\n\n");
}
readme.push_str("Contents:\n");
for p in plugins {
let _ = writeln!(readme, " - {} ({})", p.name, p.category);
}
readme.push_str(
"\nInstall:\n ./install.sh # interactive\n \
./install.sh --plugin <name> # one plugin (repeatable)\n \
./install.sh --all # everything, user scope\n \
./install.sh --system # system-wide install\n \
./install.sh --help # full options\n",
);
fs::write(staging.join("README.txt"), readme)?;
Ok(())
}
fn plugin_stage_dir(root: &Path, bundle_id: &str, arch: &str) -> Result<PathBuf, CargoTruceError> {
stage_dir(root, "plugin", bundle_id, arch)
}
fn suite_stage_dir(
root: &Path,
suite_bundle_id: &str,
arch: &str,
) -> Result<PathBuf, CargoTruceError> {
stage_dir(root, "suite", suite_bundle_id, arch)
}
fn stage_dir(
root: &Path,
kind: &str,
bundle_id: &str,
arch: &str,
) -> Result<PathBuf, CargoTruceError> {
let staging = truce_build::target_dir(root)
.join("package/linux")
.join(arch)
.join(kind)
.join(bundle_id);
let _ = fs::remove_dir_all(&staging);
fs::create_dir_all(&staging)?;
Ok(staging)
}
fn create_tarball(staging: &Path, out: &Path, stem: &str) -> Res {
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
let _ = fs::remove_file(out);
let parent = staging
.parent()
.ok_or_else(|| CargoTruceError::from("staging dir has no parent"))?;
let basename = staging
.file_name()
.ok_or_else(|| CargoTruceError::from("staging dir has no name"))?
.to_string_lossy()
.into_owned();
let status = Command::new("tar")
.arg("-czf")
.arg(out)
.arg("-C")
.arg(parent)
.arg("--transform")
.arg(format!("s,^{basename},{stem},"))
.arg(&basename)
.status();
let status = match status {
Ok(s) => s,
Err(e) => {
return Err(format!("failed to spawn tar: {e}").into());
}
};
if !status.success() {
let renamed = parent.join(stem);
let _ = fs::remove_dir_all(&renamed);
fs::rename(staging, &renamed)?;
let status2 = Command::new("tar")
.arg("-czf")
.arg(out)
.arg("-C")
.arg(parent)
.arg(stem)
.status()?;
if !status2.success() {
return Err(format!("tar failed for {}", out.display()).into());
}
fs::rename(&renamed, staging)?;
}
Ok(())
}
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&from, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
#[allow(clippy::unnecessary_wraps)]
fn set_executable(_path: &Path) -> std::io::Result<()> {
Ok(())
}