use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::strategies::{PublishDistConfig, PublishProjectConfig};
use crate::utils::command_exists;
const DIST_TOOL_CANDIDATES: &[&str] = &["dist", "cargo-dist"];
#[derive(Debug, Clone)]
pub(crate) struct CargoDistReleasePlan {
pub(crate) enabled: bool,
pub(crate) reason: String,
pub(crate) tool: String,
pub(crate) tag_name: String,
pub(crate) installers: Vec<String>,
pub(crate) targets: Vec<String>,
pub(crate) artifacts_modes: Vec<String>,
pub(crate) allow_dirty: bool,
pub(crate) generate_ci: bool,
pub(crate) packages: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CargoDistBuiltArtifact {
pub(crate) name: String,
pub(crate) path: PathBuf,
pub(crate) kind: String,
}
#[derive(Debug, Clone)]
pub(crate) struct CargoDistBuildResult {
pub(crate) artifacts: Vec<CargoDistBuiltArtifact>,
pub(crate) announcement_github_body: Option<String>,
pub(crate) install_methods: Vec<CargoDistInstallMethod>,
pub(crate) modes_run: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct CargoDistInstallMethod {
pub(crate) package_name: String,
pub(crate) title: String,
pub(crate) command: String,
}
#[derive(Debug, Deserialize)]
struct DistManifest {
#[serde(default)]
announcement_github_body: Option<String>,
#[serde(default)]
artifacts: BTreeMap<String, DistArtifact>,
}
#[derive(Debug, Deserialize)]
struct DistArtifact {
#[serde(default)]
name: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
install_hint: Option<String>,
}
pub(crate) fn project_has_cargo_dist_config(project_root: &Path) -> bool {
project_root.join("dist-workspace.toml").is_file()
|| project_root.join("dist.toml").is_file()
|| workspace_cargo_has_dist_metadata(project_root)
}
fn workspace_cargo_has_dist_metadata(project_root: &Path) -> bool {
let cargo_toml = project_root.join("Cargo.toml");
let Ok(content) = fs::read_to_string(cargo_toml) else {
return false;
};
content.contains("metadata.dist") || content.contains("[workspace.metadata.dist]")
}
pub(crate) fn resolve_cargo_dist_release_plan(
project_root: &Path,
publish_config: Option<&PublishProjectConfig>,
tag_name: &str,
preferred_packages: &[String],
) -> CargoDistReleasePlan {
let dist_cfg = publish_config.and_then(|publish| publish.dist.as_ref());
let explicitly_disabled = dist_cfg
.and_then(|cfg| cfg.enabled)
.is_some_and(|enabled| !enabled);
let explicitly_enabled = dist_cfg.and_then(|cfg| cfg.enabled).unwrap_or(false);
let has_config = project_has_cargo_dist_config(project_root);
if explicitly_disabled {
return CargoDistReleasePlan {
enabled: false,
reason: "publish.dist.enabled is false".to_string(),
tool: String::new(),
tag_name: tag_name.to_string(),
installers: Vec::new(),
targets: Vec::new(),
artifacts_modes: Vec::new(),
allow_dirty: true,
generate_ci: false,
packages: Vec::new(),
};
}
if !explicitly_enabled && !has_config {
return CargoDistReleasePlan {
enabled: false,
reason: "no dist-workspace.toml / dist config and publish.dist not enabled".to_string(),
tool: String::new(),
tag_name: tag_name.to_string(),
installers: Vec::new(),
targets: Vec::new(),
artifacts_modes: Vec::new(),
allow_dirty: true,
generate_ci: false,
packages: Vec::new(),
};
}
let tool = dist_cfg
.and_then(|cfg| cfg.tool.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(find_dist_tool)
.unwrap_or_default();
if tool.is_empty() {
return CargoDistReleasePlan {
enabled: false,
reason: "cargo-dist CLI not found (install with `cargo install cargo-dist`)".to_string(),
tool: String::new(),
tag_name: tag_name.to_string(),
installers: Vec::new(),
targets: Vec::new(),
artifacts_modes: Vec::new(),
allow_dirty: true,
generate_ci: false,
packages: Vec::new(),
};
}
let installers = dist_cfg
.map(|cfg| cfg.installers.clone())
.filter(|values| !values.is_empty())
.unwrap_or_else(|| vec!["shell".to_string(), "powershell".to_string()]);
let targets = dist_cfg.map(|cfg| cfg.targets.clone()).unwrap_or_default();
let artifacts_modes = dist_cfg
.map(|cfg| cfg.artifacts_modes.clone())
.filter(|values| !values.is_empty())
.unwrap_or_else(|| vec!["host".to_string(), "global".to_string()]);
let allow_dirty = dist_cfg.and_then(|cfg| cfg.allow_dirty).unwrap_or(true);
let generate_ci = dist_cfg.and_then(|cfg| cfg.generate_ci).unwrap_or(true);
let packages = {
let configured = dist_cfg
.map(|cfg| cfg.packages.clone())
.unwrap_or_default()
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if !configured.is_empty() {
configured
} else {
preferred_packages
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
};
CargoDistReleasePlan {
enabled: true,
reason: if has_config {
"dist config present".to_string()
} else {
"publish.dist.enabled".to_string()
},
tool,
tag_name: tag_name.to_string(),
installers,
targets,
artifacts_modes,
allow_dirty,
generate_ci,
packages,
}
}
fn find_dist_tool() -> Option<String> {
DIST_TOOL_CANDIDATES
.iter()
.find(|name| command_exists(name))
.map(|name| (*name).to_string())
}
pub(crate) fn ensure_dist_workspace_config(
project_root: &Path,
plan: &CargoDistReleasePlan,
) -> Result<Option<PathBuf>, String> {
let path = project_root.join("dist-workspace.toml");
if path.is_file() {
return Ok(None);
}
if !plan.enabled {
return Ok(None);
}
let installers = if plan.installers.is_empty() {
r#"["shell", "powershell"]"#.to_string()
} else {
let items = plan
.installers
.iter()
.map(|value| format!("\"{}\"", value.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(", ");
format!("[{items}]")
};
let targets = if plan.targets.is_empty() {
r#"[
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]"#
.to_string()
} else {
let items = plan
.targets
.iter()
.map(|value| format!(" \"{}\"", value.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(",\n");
format!("[\n{items}\n]")
};
let content = format!(
r#"[workspace]
members = ["cargo:."]
[dist]
cargo-dist-version = "0.32.0"
ci = "github"
installers = {installers}
targets = {targets}
hosting = "github"
install-updater = false
pr-run-mode = "plan"
install-path = "CARGO_HOME"
[dist.github]
# XBP creates the GitHub Release; dist CI only uploads assets.
create = false
"#
);
fs::write(&path, content).map_err(|error| {
format!(
"Failed to write default dist-workspace.toml at {}: {}",
path.display(),
error
)
})?;
Ok(Some(path))
}
pub(crate) fn run_cargo_dist_generate_ci(
project_root: &Path,
plan: &CargoDistReleasePlan,
) -> Result<(), String> {
if !plan.enabled || !plan.generate_ci {
return Ok(());
}
let mut command = Command::new(&plan.tool);
command.current_dir(project_root);
command.args(["generate", "--mode", "ci"]);
if plan.allow_dirty {
command.arg("--allow-dirty");
}
for installer in &plan.installers {
command.arg("--installer").arg(installer);
}
let output = command
.output()
.map_err(|error| format!("Failed to run `{} generate`: {}", plan.tool, error))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if plan.allow_dirty
&& (stderr.contains("out of date")
|| stderr.contains("already exists")
|| stderr.contains("dirty"))
{
return Ok(());
}
Err(format!(
"`{} generate --mode ci` failed:\n{}",
plan.tool,
stderr.trim()
))
}
pub(crate) fn run_cargo_dist_plan_manifest(
project_root: &Path,
plan: &CargoDistReleasePlan,
) -> Result<(Option<String>, Vec<CargoDistInstallMethod>), String> {
if !plan.enabled {
return Ok((None, Vec::new()));
}
let mut command = Command::new(&plan.tool);
command.current_dir(project_root);
command.args(["plan", "-o", "json"]);
command.arg("--tag").arg(&plan.tag_name);
if plan.allow_dirty {
command.arg("--allow-dirty");
}
for installer in &plan.installers {
command.arg("--installer").arg(installer);
}
for target in &plan.targets {
command.arg("--target").arg(target);
}
let output = command
.output()
.map_err(|error| format!("Failed to run `{} plan`: {}", plan.tool, error))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"`{} plan` failed:\n{}\n{}",
plan.tool,
stdout.trim(),
stderr.trim()
));
}
let manifest = parse_dist_manifest_json(stdout.trim()).or_else(|_| {
stdout
.find('{')
.map(|idx| parse_dist_manifest_json(&stdout[idx..]))
.transpose()
.map_err(|error| error.to_string())?
.ok_or_else(|| "cargo-dist plan produced no JSON object".to_string())
})?;
let body = manifest
.announcement_github_body
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut methods = install_methods_from_manifest(&manifest);
if !plan.packages.is_empty() {
methods.retain(|method| {
plan.packages.iter().any(|package| {
method.package_name == *package
|| method.package_name.replace('_', "-") == package.replace('_', "-")
})
});
}
Ok((body, methods))
}
pub(crate) fn run_cargo_dist_build(
project_root: &Path,
plan: &CargoDistReleasePlan,
) -> Result<CargoDistBuildResult, String> {
if !plan.enabled {
return Ok(CargoDistBuildResult {
artifacts: Vec::new(),
announcement_github_body: None,
install_methods: Vec::new(),
modes_run: Vec::new(),
});
}
let mut all_artifacts: BTreeMap<String, CargoDistBuiltArtifact> = BTreeMap::new();
let mut announcement_github_body = None;
let mut install_methods = Vec::new();
let mut modes_run = Vec::new();
for mode in &plan.artifacts_modes {
let mode = mode.trim();
if mode.is_empty() {
continue;
}
modes_run.push(mode.to_string());
let manifest = run_dist_build_mode(project_root, plan, mode)?;
if announcement_github_body.is_none() {
announcement_github_body = manifest
.announcement_github_body
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
}
for method in install_methods_from_manifest(&manifest) {
if !install_methods.iter().any(|existing: &CargoDistInstallMethod| {
existing.package_name == method.package_name && existing.title == method.title
}) {
install_methods.push(method);
}
}
for (key, artifact) in manifest.artifacts {
let Some(path_str) = artifact.path.as_deref().map(str::trim).filter(|v| !v.is_empty())
else {
continue;
};
let path = PathBuf::from(path_str);
if !path.is_file() {
continue;
}
let name = artifact
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(key.as_str())
.to_string();
all_artifacts.insert(
name.clone(),
CargoDistBuiltArtifact {
name,
path,
kind: artifact
.kind
.unwrap_or_else(|| "artifact".to_string())
.trim()
.to_string(),
},
);
}
}
let distrib_dir = project_root.join("target").join("distrib");
if distrib_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&distrib_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
all_artifacts
.entry(name.to_string())
.or_insert_with(|| CargoDistBuiltArtifact {
name: name.to_string(),
path: path.clone(),
kind: "artifact".to_string(),
});
}
}
}
if all_artifacts.is_empty() {
return Err(format!(
"cargo-dist build completed but produced no uploadable artifacts under {}.",
distrib_dir.display()
));
}
if !plan.packages.is_empty() {
install_methods.retain(|method| {
plan.packages.iter().any(|package| {
method.package_name == *package
|| method.package_name.replace('_', "-") == package.replace('_', "-")
})
});
all_artifacts.retain(|name, artifact| {
if matches!(
artifact.kind.as_str(),
"checksum" | "unified-checksum" | "source-tarball"
) {
return true;
}
plan.packages.iter().any(|package| {
name == package
|| name.starts_with(&format!("{package}-"))
|| name.starts_with(&format!("{package}_"))
|| name.replace('_', "-").starts_with(&format!(
"{}-",
package.replace('_', "-")
))
})
});
}
Ok(CargoDistBuildResult {
artifacts: all_artifacts.into_values().collect(),
announcement_github_body,
install_methods,
modes_run,
})
}
fn run_dist_build_mode(
project_root: &Path,
plan: &CargoDistReleasePlan,
mode: &str,
) -> Result<DistManifest, String> {
let mut command = Command::new(&plan.tool);
command.current_dir(project_root);
command.args(["build", "-o", "json", "--artifacts", mode]);
command.arg("--tag").arg(&plan.tag_name);
if plan.allow_dirty {
command.arg("--allow-dirty");
}
for installer in &plan.installers {
command.arg("--installer").arg(installer);
}
if mode != "host" {
for target in &plan.targets {
command.arg("--target").arg(target);
}
}
let output = command.output().map_err(|error| {
format!(
"Failed to run `{} build --artifacts={}`: {}",
plan.tool, mode, error
)
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"`{} build --artifacts={}` failed:\n{}\n{}",
plan.tool,
mode,
stdout.trim(),
stderr.trim()
));
}
parse_dist_manifest_json(&stdout).or_else(|parse_error| {
if let Some(json_start) = stdout.find('{') {
parse_dist_manifest_json(&stdout[json_start..]).map_err(|inner| {
format!(
"Failed to parse cargo-dist build JSON for mode `{mode}`: {inner} (after human prefix). stderr: {}",
stderr.trim()
)
})
} else {
Err(format!(
"Failed to parse cargo-dist build JSON for mode `{mode}`: {parse_error}. stderr: {}",
stderr.trim()
))
}
})
}
fn parse_dist_manifest_json(raw: &str) -> Result<DistManifest, String> {
let trimmed = raw.trim().trim_start_matches('\u{feff}');
serde_json::from_str(trimmed).map_err(|error| error.to_string())
}
fn install_methods_from_manifest(manifest: &DistManifest) -> Vec<CargoDistInstallMethod> {
let mut methods = Vec::new();
for artifact in manifest.artifacts.values() {
if artifact.kind.as_deref() != Some("installer") {
continue;
}
let Some(hint) = artifact
.install_hint
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let title = artifact
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Install prebuilt binaries")
.to_string();
let package_name = artifact
.name
.as_deref()
.and_then(|name| name.strip_suffix("-installer.sh"))
.or_else(|| {
artifact
.name
.as_deref()
.and_then(|name| name.strip_suffix("-installer.ps1"))
})
.unwrap_or("package")
.to_string();
methods.push(CargoDistInstallMethod {
package_name,
title,
command: hint.to_string(),
});
}
methods
}
pub(crate) fn merge_dist_install_into_release_notes(
release_notes: &str,
announcement_github_body: Option<&str>,
) -> String {
let Some(body) = announcement_github_body.map(str::trim).filter(|v| !v.is_empty()) else {
return release_notes.to_string();
};
let notes = release_notes.trim_end();
if notes.contains("Install prebuilt binaries via shell script")
|| notes.contains("## Download ")
{
return notes.to_string();
}
if let Some(idx) = notes.rfind("\n---\n") {
let (head, tail) = notes.split_at(idx);
format!("{}\n\n{}\n{}", head.trim_end(), body, tail.trim_start())
} else {
format!("{notes}\n\n{body}\n")
}
}
pub(crate) fn preferred_dist_packages_from_publish_targets(
publish_targets: &[(String, Option<String>)],
) -> Vec<String> {
let mut packages = BTreeSet::new();
for (kind, package_name) in publish_targets {
if kind != "crates" {
continue;
}
if let Some(name) = package_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
packages.insert(name.to_string());
}
}
packages.into_iter().collect()
}
pub(crate) fn uploaded_asset_names_from_details(details: &JsonValue) -> BTreeSet<String> {
details
.get("uploaded_assets")
.and_then(JsonValue::as_array)
.map(|values| {
values
.iter()
.filter_map(JsonValue::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_dist_install_inserts_before_footer() {
let notes = "# Title\n\n## What's Changed\n\n- fix\n\n---\n\nRelease: v1\n";
let body = "## Install xbp 1.0.0\n\n### Install prebuilt binaries via shell script\n\n```sh\ncurl ...\n```\n";
let merged = merge_dist_install_into_release_notes(notes, Some(body));
assert!(merged.contains("Install prebuilt binaries via shell script"));
assert!(merged.find("## Install xbp").unwrap() < merged.find("\n---\n").unwrap());
}
#[test]
fn resolve_plan_disabled_without_config() {
let _plan = resolve_cargo_dist_release_plan(Path::new("."), None, "v1.0.0", &[]);
let cfg = PublishProjectConfig {
npm: None,
crates: None,
dist: Some(PublishDistConfig {
enabled: Some(false),
..PublishDistConfig::default()
}),
};
let plan = resolve_cargo_dist_release_plan(Path::new("."), Some(&cfg), "v1.0.0", &[]);
assert!(!plan.enabled);
assert!(plan.reason.contains("false"));
}
}