use semver::Version;
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::PublishProjectConfig;
use crate::utils::{canonicalize_for_subprocess, command_exists};
use super::adapters::{
cargo_package_name_from_content_optional, parse_version, read_cargo_toml_version_from_content,
};
use super::VersionScope;
fn dist_working_dir(project_root: &Path) -> PathBuf {
canonicalize_for_subprocess(project_root)
}
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) announcement_tag: 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]")
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CargoDistReleaseContext<'a> {
pub project_root: &'a Path,
pub publish_config: Option<&'a PublishProjectConfig>,
pub tag_name: &'a str,
pub release_version: &'a Version,
pub version_scope: &'a VersionScope,
pub preferred_packages: &'a [String],
pub skip_version_alignment: bool,
}
fn disabled_dist_plan(tag_name: &str, reason: impl Into<String>) -> CargoDistReleasePlan {
CargoDistReleasePlan {
enabled: false,
reason: reason.into(),
tool: String::new(),
tag_name: tag_name.to_string(),
announcement_tag: tag_name.to_string(),
installers: Vec::new(),
targets: Vec::new(),
artifacts_modes: Vec::new(),
allow_dirty: true,
generate_ci: false,
packages: Vec::new(),
}
}
pub(crate) fn cargo_packages_owned_by_scope(
project_root: &Path,
version_scope: &VersionScope,
) -> Vec<String> {
let mut packages = BTreeSet::new();
match version_scope {
VersionScope::Repository => {
}
VersionScope::Crate { package_name, crate_root, .. } => {
if crate_root.join("Cargo.toml").is_file() {
packages.insert(package_name.clone());
}
}
VersionScope::Service {
service_root,
cargo_package_name,
version_targets,
..
} => {
if let Some(name) = cargo_package_name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
if service_root.join("Cargo.toml").is_file() {
packages.insert(name.to_string());
}
}
for relative in version_targets {
let normalized = relative.replace('\\', "/");
if !normalized.ends_with("Cargo.toml") && !normalized.ends_with("Cargo.lock") {
continue;
}
let path = if Path::new(&normalized).is_absolute() {
PathBuf::from(&normalized)
} else {
project_root.join(&normalized)
};
let cargo_toml = if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("Cargo.lock"))
{
path.with_file_name("Cargo.toml")
} else {
path
};
if let Ok(content) = fs::read_to_string(&cargo_toml) {
if let Ok(Some(name)) = cargo_package_name_from_content_optional(&content) {
packages.insert(name);
}
}
}
let root_cargo = service_root.join("Cargo.toml");
if root_cargo.is_file() {
if let Ok(content) = fs::read_to_string(&root_cargo) {
if let Ok(Some(name)) = cargo_package_name_from_content_optional(&content) {
packages.insert(name);
}
}
}
}
}
packages.into_iter().collect()
}
pub(crate) fn read_cargo_package_versions(
project_root: &Path,
package_names: &[String],
) -> BTreeMap<String, Version> {
let mut out = BTreeMap::new();
if package_names.is_empty() {
return out;
}
let wanted: BTreeSet<&str> = package_names.iter().map(String::as_str).collect();
let mut candidates = Vec::new();
candidates.push(project_root.join("Cargo.toml"));
let crates_root = project_root.join("crates");
if let Ok(entries) = fs::read_dir(&crates_root) {
for entry in entries.flatten() {
let cargo = entry.path().join("Cargo.toml");
if cargo.is_file() {
candidates.push(cargo);
}
}
}
for cargo_toml in candidates {
let Ok(content) = fs::read_to_string(&cargo_toml) else {
continue;
};
let Ok(Some(name)) = cargo_package_name_from_content_optional(&content) else {
continue;
};
if !wanted.contains(name.as_str()) {
continue;
}
if let Ok(Some(raw)) = read_cargo_toml_version_from_content(&content) {
if let Ok(version) = parse_version(&raw) {
out.insert(name, Version::new(version.major, version.minor, version.patch));
}
}
}
out
}
pub(crate) fn effective_dist_packages_for_release(
project_root: &Path,
version_scope: &VersionScope,
preferred_packages: &[String],
configured_packages: &[String],
release_version: &Version,
) -> (Vec<String>, Option<String>) {
let mut owned: BTreeSet<String> = cargo_packages_owned_by_scope(project_root, version_scope)
.into_iter()
.collect();
for pkg in preferred_packages {
let pkg = pkg.trim();
if !pkg.is_empty() {
owned.insert(pkg.to_string());
}
}
if owned.is_empty() && matches!(version_scope, VersionScope::Repository) {
let configured: Vec<String> = configured_packages
.iter()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
if !configured.is_empty() {
let versions = read_cargo_package_versions(project_root, &configured);
let release_core = Version::new(
release_version.major,
release_version.minor,
release_version.patch,
);
for pkg in &configured {
if versions.get(pkg).is_some_and(|v| *v == release_core) {
owned.insert(pkg.clone());
}
}
if owned.is_empty() {
return (
Vec::new(),
Some(
"repository release: no configured dist package matches this release version"
.to_string(),
),
);
}
}
}
if owned.is_empty() {
return (
Vec::new(),
Some(
"scope does not own a Rust/dist package (cargo-dist only applies to Cargo packages)"
.to_string(),
),
);
}
let configured: Vec<String> = configured_packages
.iter()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let mut effective: Vec<String> = if configured.is_empty() {
owned.into_iter().collect()
} else {
let configured_set: BTreeSet<String> = configured.into_iter().collect();
owned
.into_iter()
.filter(|pkg| configured_set.contains(pkg))
.collect()
};
effective.sort();
effective.dedup();
if effective.is_empty() {
return (
Vec::new(),
Some(
"owned packages are not in publish.dist.packages (not a dist crate for this release)"
.to_string(),
),
);
}
let versions = read_cargo_package_versions(project_root, &effective);
let release_core = Version::new(
release_version.major,
release_version.minor,
release_version.patch,
);
let before = effective.len();
effective.retain(|pkg| match versions.get(pkg) {
Some(v) => *v == release_core,
None => false,
});
if effective.is_empty() {
return (
Vec::new(),
Some(format!(
"dist package version does not match release {release_core} (or package not found); skipping cargo-dist"
)),
);
}
if effective.len() < before {
}
let before_bins = effective.len();
effective.retain(|pkg| cargo_package_is_distable(project_root, pkg));
if effective.is_empty() {
return (
Vec::new(),
Some(if before_bins > 0 {
"selected Rust package(s) are library-only (no [[bin]] / src/main.rs / src/bin); cargo-dist has nothing to package"
.to_string()
} else {
"no distable (binary) packages for this release".to_string()
}),
);
}
(effective, None)
}
pub(crate) fn cargo_package_is_distable(project_root: &Path, package_name: &str) -> bool {
let package_name = package_name.trim();
if package_name.is_empty() {
return false;
}
let Some(cargo_toml) = find_cargo_toml_for_package(project_root, package_name) else {
return true;
};
cargo_manifest_looks_distable(&cargo_toml)
}
fn find_cargo_toml_for_package(project_root: &Path, package_name: &str) -> Option<PathBuf> {
let mut candidates = Vec::new();
candidates.push(project_root.join("Cargo.toml"));
for sub in ["crates", "packages", "apps", "services", "bins", "bin"] {
let dir = project_root.join(sub);
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let cargo = entry.path().join("Cargo.toml");
if cargo.is_file() {
candidates.push(cargo);
}
}
}
}
for hyphen_variant in [
package_name.to_string(),
package_name.replace('_', "-"),
package_name.replace('-', "_"),
] {
for sub in ["crates", "packages", "apps", "services"] {
let cargo = project_root.join(sub).join(&hyphen_variant).join("Cargo.toml");
if cargo.is_file() {
candidates.push(cargo);
}
}
}
for cargo_toml in candidates {
let Ok(content) = fs::read_to_string(&cargo_toml) else {
continue;
};
let Ok(Some(name)) = cargo_package_name_from_content_optional(&content) else {
continue;
};
if name == package_name
|| name.replace('_', "-") == package_name.replace('_', "-")
{
return Some(cargo_toml);
}
}
None
}
fn cargo_manifest_looks_distable(cargo_toml: &Path) -> bool {
let Ok(content) = fs::read_to_string(cargo_toml) else {
return true;
};
if content.lines().any(|line| line.trim() == "[[bin]]") {
return true;
}
if content.contains("crate-type")
&& (content.contains("\"cdylib\"")
|| content.contains("'cdylib'")
|| content.contains("\"staticlib\"")
|| content.contains("'staticlib'"))
{
return true;
}
let package_dir = cargo_toml.parent().unwrap_or(cargo_toml);
if package_dir.join("src").join("main.rs").is_file() {
return true;
}
let bin_dir = package_dir.join("src").join("bin");
if bin_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&bin_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("rs") || path.is_dir() {
return true;
}
}
}
}
false
}
pub(crate) fn resolve_cargo_dist_release_plan(
project_root: &Path,
publish_config: Option<&PublishProjectConfig>,
tag_name: &str,
preferred_packages: &[String],
) -> CargoDistReleasePlan {
let release_version = Version::new(0, 0, 0);
let scope = VersionScope::Repository;
if preferred_packages
.iter()
.all(|p| p.trim().is_empty())
{
return disabled_dist_plan(
tag_name,
"no crates packages selected for cargo-dist",
);
}
resolve_cargo_dist_release_plan_for_context(CargoDistReleaseContext {
project_root,
publish_config,
tag_name,
release_version: &release_version,
version_scope: &scope,
preferred_packages,
skip_version_alignment: true,
})
}
pub(crate) fn resolve_cargo_dist_release_plan_for_context(
ctx: CargoDistReleaseContext<'_>,
) -> CargoDistReleasePlan {
let dist_cfg = ctx.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(ctx.project_root);
let tag_name = ctx.tag_name;
if explicitly_disabled {
return disabled_dist_plan(tag_name, "publish.dist.enabled is false");
}
if !explicitly_enabled && !has_config {
return disabled_dist_plan(
tag_name,
"no dist-workspace.toml / dist config and publish.dist not enabled",
);
}
let configured = dist_cfg
.map(|cfg| cfg.packages.clone())
.unwrap_or_default();
let (mut packages, skip_reason) = if ctx.skip_version_alignment {
let mut pkgs: Vec<String> = ctx
.preferred_packages
.iter()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let owned = cargo_packages_owned_by_scope(ctx.project_root, ctx.version_scope);
for p in owned {
if !pkgs.iter().any(|x| x == &p) {
pkgs.push(p);
}
}
if !configured.is_empty() {
let cfg: BTreeSet<_> = configured
.iter()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
pkgs.retain(|p| cfg.contains(p));
}
pkgs.sort();
pkgs.dedup();
if pkgs.is_empty() {
(
Vec::new(),
Some("no Rust/dist packages for this release scope".to_string()),
)
} else {
(pkgs, None)
}
} else {
effective_dist_packages_for_release(
ctx.project_root,
ctx.version_scope,
ctx.preferred_packages,
&configured,
ctx.release_version,
)
};
if !packages.is_empty() {
packages.retain(|pkg| cargo_package_is_distable(ctx.project_root, pkg));
}
if packages.is_empty() {
return disabled_dist_plan(
tag_name,
skip_reason.unwrap_or_else(|| {
"scope does not own a distable binary package (library-only crates skip cargo-dist)"
.to_string()
}),
);
}
if !release_tag_is_distable(tag_name, &packages) {
return disabled_dist_plan(
tag_name,
format!(
"release tag `{tag_name}` is not a cargo-dist package tag (packages: {}); skipping dist for non-Rust scope",
packages.join(", ")
),
);
}
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 disabled_dist_plan(
tag_name,
"cargo-dist CLI not found (install with `cargo install cargo-dist`)",
);
}
let installers = dist_cfg
.map(|cfg| cfg.installers.clone())
.filter(|values| !values.is_empty())
.unwrap_or_else(|| vec!["shell".to_string(), "msi".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 announcement_tag = cargo_dist_announcement_tag(tag_name, &packages);
if !release_tag_is_distable(&announcement_tag, &packages) {
return disabled_dist_plan(
tag_name,
format!(
"announcement tag `{announcement_tag}` does not match dist packages ({}); not a Rust release",
packages.join(", ")
),
);
}
CargoDistReleasePlan {
enabled: true,
reason: if has_config {
format!("dist packages for this scope: {}", packages.join(", "))
} else {
format!("publish.dist.enabled; packages: {}", packages.join(", "))
},
tool,
tag_name: tag_name.to_string(),
announcement_tag,
installers,
targets,
artifacts_modes,
allow_dirty,
generate_ci,
packages,
}
}
pub(crate) fn release_tag_is_distable(tag: &str, packages: &[String]) -> bool {
let tag = tag.trim();
if tag.is_empty() || packages.is_empty() {
return false;
}
if Version::parse(tag).is_ok() {
return true;
}
if let Some(rest) = tag.strip_prefix('v') {
if Version::parse(rest).is_ok() {
return true;
}
}
for package in packages {
let package = package.trim();
if package.is_empty() {
continue;
}
for sep in ['-', '/'] {
if let Some(rest) = tag.strip_prefix(package).and_then(|r| r.strip_prefix(sep)) {
let rest = rest.strip_prefix('v').unwrap_or(rest);
if Version::parse(rest).is_ok() {
return true;
}
if let Some((core, _)) = rest.split_once('+') {
if Version::parse(core).is_ok() {
return true;
}
}
if let Some((core, _)) = rest.split_once('-') {
if Version::parse(core).is_ok() {
return true;
}
}
}
}
}
false
}
pub(crate) fn cargo_dist_error_is_non_applicable(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
let compact = lower
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>();
lower.contains("doesn't have anything for dist to release")
|| lower.contains("does not have anything for dist to release")
|| compact.contains("doesn'thaveanythingfordisttorelease")
|| compact.contains("doesnothaveanythingfordisttorelease")
|| lower.contains("produced no uploadable artifacts")
|| lower.contains("no uploadable artifacts")
|| lower.contains("library-only")
|| lower.contains("claims we're releasing")
|| lower.contains("but that package is version")
|| lower.contains("you may need to pass the current version as --tag")
|| lower.contains("will announce:")
|| lower.contains("requires you to specify the url of your repository")
|| lower.contains("cargo-dist requires a repository url")
|| compact.contains("githubcisupportrequiresyou")
}
pub(crate) fn cargo_dist_announcement_tag(github_tag: &str, packages: &[String]) -> String {
let tag = github_tag.trim();
if tag.is_empty() {
return tag.to_string();
}
if announcement_tag_is_cargo_dist_compatible(tag, packages) {
return tag.to_string();
}
let Some(version) = extract_semver_core_from_tag(tag) else {
if tag.starts_with('v') {
return tag.to_string();
}
return format!("v{tag}");
};
if packages.len() == 1 {
let package = packages[0].trim();
if !package.is_empty() {
return format!("{package}-{version}");
}
}
format!("v{version}")
}
fn announcement_tag_is_cargo_dist_compatible(tag: &str, packages: &[String]) -> bool {
if Version::parse(tag).is_ok() {
return true;
}
if let Some(rest) = tag.strip_prefix('v') {
if Version::parse(rest).is_ok() {
return true;
}
}
if let Some(last) = tag.rsplit('/').next() {
let last = last.strip_prefix('v').unwrap_or(last);
if tag.contains('/') && Version::parse(last).is_ok() {
return true;
}
}
for package in packages {
let package = package.trim();
if package.is_empty() {
continue;
}
for sep in ['-', '/'] {
if let Some(rest) = tag.strip_prefix(package).and_then(|r| r.strip_prefix(sep)) {
let rest = rest.strip_prefix('v').unwrap_or(rest);
if Version::parse(rest).is_ok() {
return true;
}
}
}
}
false
}
fn extract_semver_core_from_tag(tag: &str) -> Option<String> {
let tag = tag.trim();
if let Ok(version) = Version::parse(tag) {
return Some(version.to_string());
}
if let Some(rest) = tag.strip_prefix('v') {
if let Ok(version) = Version::parse(rest) {
return Some(version.to_string());
}
}
if let Some(last) = tag.rsplit('/').next() {
let last = last.strip_prefix('v').unwrap_or(last);
if let Ok(version) = Version::parse(last) {
return Some(version.to_string());
}
}
let digit_idx = tag.find(|ch: char| ch.is_ascii_digit())?;
let rest = &tag[digit_idx..];
if let Ok(version) = Version::parse(rest) {
return Some(release_version_core_string(&version));
}
if let Some((core, _)) = rest.split_once('+') {
if let Ok(version) = Version::parse(core) {
return Some(release_version_core_string(&version));
}
}
if let Some((core, _flag)) = rest.rsplit_once('-') {
if let Ok(version) = Version::parse(core) {
return Some(release_version_core_string(&version));
}
}
None
}
fn release_version_core_string(version: &Version) -> String {
format!("{}.{}.{}", version.major, version.minor, version.patch)
}
pub(crate) fn align_dist_body_to_github_tag(
body: &str,
announcement_tag: &str,
github_tag: &str,
) -> String {
if announcement_tag == github_tag || announcement_tag.is_empty() || github_tag.is_empty() {
return body.to_string();
}
body.replace(
&format!("/releases/download/{announcement_tag}/"),
&format!("/releases/download/{github_tag}/"),
)
}
fn find_dist_tool() -> Option<String> {
DIST_TOOL_CANDIDATES
.iter()
.find(|name| command_exists(name))
.map(|name| (*name).to_string())
}
pub(crate) fn preflight_feature_gated_bins(project_root: &Path) -> Option<String> {
let cargo_toml = project_root.join("Cargo.toml");
let content = fs::read_to_string(&cargo_toml).ok()?;
let mut gated: Vec<String> = Vec::new();
let mut current_bin: Option<String> = None;
let mut in_bin = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[[bin]]" {
in_bin = true;
current_bin = None;
continue;
}
if trimmed.starts_with('[') {
in_bin = false;
current_bin = None;
continue;
}
if !in_bin {
continue;
}
if let Some(rest) = trimmed.strip_prefix("name") {
let value = rest
.trim()
.trim_start_matches('=')
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if !value.is_empty() {
current_bin = Some(value);
}
}
if trimmed.starts_with("required-features") {
if let Some(name) = current_bin.take() {
gated.push(name);
} else {
gated.push("<unnamed-bin>".to_string());
}
}
}
if gated.is_empty() {
return None;
}
Some(format!(
"cargo-dist cannot reliably package feature-gated binaries: {}. \
Move them to `[[example]]` (or drop `required-features` on the bin), commit on the \
branch used for release (including `releases/…`), then re-run. \
Host builds otherwise fail with \"failed to find bin <name>.exe\".",
gated.join(", ")
))
}
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", "msi"]"#.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"
create-release = false
install-updater = false
pr-run-mode = "plan"
install-path = "CARGO_HOME"
"#
);
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 ensure_cargo_repository_from_git(
project_root: &Path,
) -> Result<Option<PathBuf>, String> {
if cargo_manifest_has_repository(project_root) {
return Ok(None);
}
let Some(url) = git_origin_https_url(project_root) else {
return Ok(None);
};
ensure_cargo_repository_field(project_root, &url)
}
pub(crate) fn cargo_manifest_has_repository(project_root: &Path) -> bool {
let path = project_root.join("Cargo.toml");
let Ok(content) = fs::read_to_string(path) else {
return false;
};
cargo_toml_content_has_repository(&content)
}
pub(crate) fn cargo_toml_content_has_repository(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("repository") {
let rest = rest.trim_start();
if rest.starts_with('=') {
let val = rest.trim_start_matches('=').trim();
if val.len() > 2 {
return true;
}
}
}
}
false
}
pub(crate) fn git_origin_https_url(project_root: &Path) -> Option<String> {
let raw = super::git_ops::git_remote_url(project_root, "origin").ok()?;
normalize_github_https_url(&raw)
}
pub(crate) fn normalize_github_https_url(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Some((owner, repo)) = crate::utils::parse_github_repo_from_remote_url(raw) {
return Some(format!("https://github.com/{owner}/{repo}"));
}
if raw.starts_with("https://") || raw.starts_with("http://") {
return Some(raw.trim_end_matches(".git").to_string());
}
None
}
pub(crate) fn ensure_cargo_repository_field(
project_root: &Path,
url: &str,
) -> Result<Option<PathBuf>, String> {
let path = project_root.join("Cargo.toml");
let content = fs::read_to_string(&path).map_err(|e| {
format!(
"Failed to read {}: {e}",
path.display()
)
})?;
if cargo_toml_content_has_repository(&content) {
return Ok(None);
}
let Some(updated) = insert_repository_into_cargo_toml(&content, url) else {
return Ok(None);
};
fs::write(&path, updated).map_err(|e| {
format!(
"Failed to write repository into {}: {e}",
path.display()
)
})?;
Ok(Some(path))
}
pub(crate) fn insert_repository_into_cargo_toml(content: &str, url: &str) -> Option<String> {
if cargo_toml_content_has_repository(content) {
return None;
}
let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
let line = format!("repository = \"{escaped}\"\n");
if let Some(idx) = find_toml_table_header(content, "workspace.package") {
return Some(insert_after_table_header(content, idx, &line));
}
if let Some(idx) = find_toml_table_header(content, "package") {
if !is_virtual_workspace_manifest(content) {
return Some(insert_after_table_header(content, idx, &line));
}
}
if is_virtual_workspace_manifest(content) {
if let Some(idx) = find_toml_table_header(content, "workspace") {
let insert_at = end_of_toml_table(content, idx);
let mut out = String::with_capacity(content.len() + line.len() + 32);
out.push_str(&content[..insert_at]);
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str("\n[workspace.package]\n");
out.push_str(&line);
out.push_str(&content[insert_at..]);
return Some(out);
}
}
let mut out = content.to_string();
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str("\n[package]\n");
out.push_str(&line);
Some(out)
}
fn is_virtual_workspace_manifest(content: &str) -> bool {
let has_workspace = find_toml_table_header(content, "workspace").is_some();
if !has_workspace {
return false;
}
let has_members = content.contains("members")
&& (content.contains("members =") || content.contains("members="));
let has_package = find_toml_table_header(content, "package").is_some();
has_members && !has_package
}
fn end_of_toml_table(content: &str, header_idx: usize) -> usize {
let rest = &content[header_idx..];
let mut offset = rest.find('\n').map(|n| n + 1).unwrap_or(rest.len());
while offset < rest.len() {
let line_start = offset;
let line_end = rest[line_start..]
.find('\n')
.map(|n| line_start + n + 1)
.unwrap_or(rest.len());
let line = rest[line_start..line_end].trim();
if line.starts_with('[') && !line.starts_with("[[") {
return header_idx + line_start;
}
offset = line_end;
}
content.len()
}
fn find_toml_table_header(content: &str, table: &str) -> Option<usize> {
let needle = format!("[{table}]");
content.find(&needle)
}
fn insert_after_table_header(content: &str, header_idx: usize, line: &str) -> String {
let after_header = content[header_idx..]
.find('\n')
.map(|n| header_idx + n + 1)
.unwrap_or(content.len());
let mut out = String::with_capacity(content.len() + line.len());
out.push_str(&content[..after_header]);
out.push_str(line);
out.push_str(&content[after_header..]);
out
}
pub(crate) fn run_cargo_dist_generate_ci(
project_root: &Path,
plan: &CargoDistReleasePlan,
) -> Result<(), String> {
if !plan.enabled || !plan.generate_ci {
return Ok(());
}
if !cargo_manifest_has_repository(project_root) {
return Err(
"cargo-dist CI generate skipped: Cargo.toml has no repository URL (set package.repository or workspace.package.repository)"
.to_string(),
);
}
let working_dir = dist_working_dir(project_root);
let mut command = Command::new(&plan.tool);
command.current_dir(&working_dir);
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);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}\n{}", stdout.trim(), stderr.trim());
if plan.allow_dirty
&& (combined.contains("out of date")
|| combined.contains("already exists")
|| combined.contains("dirty"))
{
return Ok(());
}
let human = format_cargo_dist_error(&combined);
Err(format!(
"`{} generate --mode ci` failed: {}",
plan.tool, human
))
}
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 working_dir = dist_working_dir(project_root);
let mut command = Command::new(&plan.tool);
command.current_dir(&working_dir);
command.args(["plan", "-o", "json"]);
command.arg("--tag").arg(&plan.announcement_tag);
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() {
let combined = format!("{}\n{}", stdout.trim(), stderr.trim());
let human = format_cargo_dist_error(&combined);
return Err(format!("`{} plan` failed: {}", plan.tool, human));
}
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())
.map(|value| {
align_dist_body_to_github_tag(&value, &plan.announcement_tag, &plan.tag_name)
});
let mut methods = install_methods_from_manifest(&manifest, plan);
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))
}
#[derive(Debug, Clone)]
pub(crate) struct DistModeTiming {
pub mode: String,
pub elapsed: std::time::Duration,
pub artifact_count: usize,
pub ok: bool,
}
pub(crate) enum DistProgressEvent {
Start {
mode: String,
index: usize,
total: usize,
targets_hint: String,
},
Finish {
mode: String,
elapsed: std::time::Duration,
artifact_count: usize,
},
Fail {
mode: String,
elapsed: std::time::Duration,
message: String,
},
}
pub(crate) fn dist_build_modes(plan: &CargoDistReleasePlan) -> Vec<String> {
plan.artifacts_modes
.iter()
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.collect()
}
pub(crate) fn run_cargo_dist_build(
project_root: &Path,
plan: &CargoDistReleasePlan,
) -> Result<CargoDistBuildResult, String> {
run_cargo_dist_build_with_progress(project_root, plan, &mut |_| {}).map(|(result, _)| result)
}
pub(crate) fn run_cargo_dist_build_with_progress(
project_root: &Path,
plan: &CargoDistReleasePlan,
on_event: &mut dyn FnMut(DistProgressEvent),
) -> Result<(CargoDistBuildResult, Vec<DistModeTiming>), String> {
if !plan.enabled {
return Ok((
CargoDistBuildResult {
artifacts: Vec::new(),
announcement_github_body: None,
install_methods: Vec::new(),
modes_run: Vec::new(),
},
Vec::new(),
));
}
if let Ok(Some(path)) = ensure_cargo_repository_from_git(project_root) {
eprintln!(
" {} Set Cargo.toml repository from git origin ({})",
"✓",
path.display()
);
}
if !cargo_manifest_has_repository(project_root) {
let hint = git_origin_https_url(project_root)
.map(|u| format!("Add to Cargo.toml: repository = \"{u}\""))
.unwrap_or_else(|| {
"Set package.repository or workspace.package.repository in Cargo.toml".to_string()
});
return Err(format!(
"cargo-dist requires a repository URL in Cargo.toml. {hint}"
));
}
if let Some(warning) = preflight_feature_gated_bins(project_root) {
return Err(warning);
}
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();
let mut timings = Vec::new();
let modes = dist_build_modes(plan);
let total = modes.len();
for (index, mode) in modes.iter().enumerate() {
let mode = mode.as_str();
modes_run.push(mode.to_string());
let targets_hint = if mode != "host" && !plan.targets.is_empty() {
format!("{} target(s)", plan.targets.len())
} else {
String::new()
};
on_event(DistProgressEvent::Start {
mode: mode.to_string(),
index,
total,
targets_hint,
});
let started = std::time::Instant::now();
let manifest = match run_dist_build_mode(project_root, plan, mode) {
Ok(m) => m,
Err(error) => {
let human = format_cargo_dist_error(&error);
let elapsed = started.elapsed();
on_event(DistProgressEvent::Fail {
mode: mode.to_string(),
elapsed,
message: human.clone(),
});
timings.push(DistModeTiming {
mode: mode.to_string(),
elapsed,
artifact_count: 0,
ok: false,
});
return Err(human);
}
};
let before = all_artifacts.len();
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())
.map(|value| {
align_dist_body_to_github_tag(&value, &plan.announcement_tag, &plan.tag_name)
});
}
for method in install_methods_from_manifest(&manifest, plan) {
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 artifact_count = all_artifacts.len().saturating_sub(before);
let elapsed = started.elapsed();
on_event(DistProgressEvent::Finish {
mode: mode.to_string(),
elapsed,
artifact_count,
});
timings.push(DistModeTiming {
mode: mode.to_string(),
elapsed,
artifact_count,
ok: true,
});
}
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 !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('_', "-")
))
})
});
}
if all_artifacts.is_empty() {
return Ok((
CargoDistBuildResult {
artifacts: Vec::new(),
announcement_github_body,
install_methods,
modes_run,
},
timings,
));
}
Ok((
CargoDistBuildResult {
artifacts: all_artifacts.into_values().collect(),
announcement_github_body,
install_methods,
modes_run,
},
timings,
))
}
pub(crate) fn format_cargo_dist_error(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.find('{').is_some() {
for (idx, _) in trimmed.match_indices('{') {
let slice = &trimmed[idx..];
let end = slice.rfind('}').map(|e| e + 1).unwrap_or(slice.len());
let candidate = &slice[..end];
if let Ok(value) = serde_json::from_str::<JsonValue>(candidate) {
if let Some(message) = value
.pointer("/diagnostic/message")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
let help = value
.pointer("/diagnostic/help")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
return match help {
Some(h) => {
let hint = h
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.take(3)
.collect::<Vec<_>>()
.join(" ");
format!("{message}\n hint: {hint}")
}
None => message.to_string(),
};
}
if let Some(message) = value
.get("message")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
return message.to_string();
}
}
}
}
let mut without_json = String::new();
let mut rest = trimmed;
while let Some(start) = rest.find('{') {
without_json.push_str(&rest[..start]);
if let Some(end_rel) = rest[start..].rfind('}') {
rest = &rest[start + end_rel + 1..];
} else {
rest = "";
break;
}
}
without_json.push_str(rest);
let mut lines: Vec<&str> = without_json
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.filter(|l| !l.starts_with('{') && !l.starts_with('['))
.collect();
if lines.is_empty() {
let first = trimmed.lines().next().unwrap_or(trimmed);
if first.len() > 200 {
return format!("{}…", &first[..200]);
}
return first
.chars()
.filter(|c| *c != '{' && *c != '}')
.collect::<String>()
.trim()
.to_string();
}
lines.truncate(6);
lines.join("\n")
}
fn run_dist_build_mode(
project_root: &Path,
plan: &CargoDistReleasePlan,
mode: &str,
) -> Result<DistManifest, String> {
let working_dir = dist_working_dir(project_root);
let mut command = Command::new(&plan.tool);
command.current_dir(&working_dir);
command.args(["build", "-o", "json", "--artifacts", mode]);
command.arg("--tag").arg(&plan.announcement_tag);
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() {
let combined = format!("{}\n{}", stdout.trim(), stderr.trim());
let human = format_cargo_dist_error(&combined);
return Err(format!(
"`{} build --artifacts={}` failed: {}",
plan.tool, mode, human
));
}
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,
plan: &CargoDistReleasePlan,
) -> 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 = installer_package_name(artifact.name.as_deref());
methods.push(CargoDistInstallMethod {
package_name,
title,
command: align_dist_body_to_github_tag(
hint,
&plan.announcement_tag,
&plan.tag_name,
),
});
}
methods
}
fn installer_package_name(name: Option<&str>) -> String {
let Some(name) = name.map(str::trim).filter(|value| !value.is_empty()) else {
return "package".to_string();
};
for suffix in [
"-installer.sh",
"-installer.ps1",
"-x86_64-pc-windows-msvc.msi",
"-aarch64-pc-windows-msvc.msi",
".msi",
] {
if let Some(package) = name.strip_suffix(suffix) {
if !package.is_empty() {
return package.to_string();
}
}
}
"package".to_string()
}
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("Install prebuilt binaries via")
|| notes.contains("## Download ")
|| notes.contains(".msi")
{
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::*;
use crate::strategies::PublishDistConfig;
#[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",
&["xbp".to_string()],
);
assert!(!plan.enabled);
assert!(
plan.reason.contains("false"),
"expected enabled=false reason, got: {}",
plan.reason
);
}
#[test]
fn announcement_tag_rewrites_service_scope_to_package() {
let packages = vec!["xbp".to_string()];
assert_eq!(
cargo_dist_announcement_tag("crates-cli-10.38.2", &packages),
"xbp-10.38.2"
);
assert_eq!(
cargo_dist_announcement_tag("xbp-10.38.2", &packages),
"xbp-10.38.2"
);
assert_eq!(
cargo_dist_announcement_tag("v10.38.2", &packages),
"v10.38.2"
);
assert_eq!(
cargo_dist_announcement_tag("crates-cli-10.38.2", &[]),
"v10.38.2"
);
}
#[test]
fn npm_docs_scope_does_not_own_cargo_packages() {
let root = Path::new("/tmp/xbp-repo");
let scope = VersionScope::Service {
service_root: root.join("apps/docs"),
service_relative_root: "apps/docs".to_string(),
service_name: "@xylex-group/xbp-docs".to_string(),
tag_prefix: "xbp-docs-".to_string(),
cargo_package_name: None,
version_targets: vec![
"apps/docs/package.json".to_string(),
"apps/docs/README.md".to_string(),
],
watch_paths: vec!["apps/docs".to_string()],
};
assert!(cargo_packages_owned_by_scope(root, &scope).is_empty());
}
#[test]
fn athena_js_style_scope_does_not_own_cargo_packages() {
let root = Path::new("/tmp/athena");
let scope = VersionScope::Service {
service_root: root.join("packages/athena-js"),
service_relative_root: "packages/athena-js".to_string(),
service_name: "athena-js".to_string(),
tag_prefix: "athena-js-".to_string(),
cargo_package_name: None,
version_targets: vec!["packages/athena-js/package.json".to_string()],
watch_paths: vec!["packages/athena-js".to_string()],
};
assert!(cargo_packages_owned_by_scope(root, &scope).is_empty());
let release = Version::new(3, 1, 1);
let (effective, reason) = effective_dist_packages_for_release(
root,
&scope,
&[],
&["athena-billing".to_string(), "athena_rs".to_string()],
&release,
);
assert!(effective.is_empty(), "expected no dist packages");
assert!(reason.is_some());
}
#[test]
fn athena_js_tag_is_not_distable_for_rust_packages() {
let packages = vec!["athena_rs".to_string(), "athena-billing".to_string()];
assert!(!release_tag_is_distable("athena-js-3.1.1", &packages));
assert!(release_tag_is_distable("athena_rs-4.0.3", &packages));
assert!(release_tag_is_distable("v4.0.3", &packages));
assert!(!release_tag_is_distable("xylex-group-athena-auth-ui-2.2.1", &packages));
}
#[test]
fn plan_disables_dist_for_athena_js_tag_even_with_configured_packages() {
let cfg = PublishProjectConfig {
npm: None,
crates: None,
dist: Some(PublishDistConfig {
enabled: Some(true),
packages: vec!["athena_rs".to_string()],
..PublishDistConfig::default()
}),
};
let root = Path::new(".");
let release = Version::new(3, 1, 1);
let scope = VersionScope::Service {
service_root: root.join("packages/athena-js"),
service_relative_root: "packages/athena-js".to_string(),
service_name: "athena-js".to_string(),
tag_prefix: "athena-js-".to_string(),
cargo_package_name: None,
version_targets: vec!["packages/athena-js/package.json".to_string()],
watch_paths: vec!["packages/athena-js".to_string()],
};
let plan = resolve_cargo_dist_release_plan_for_context(CargoDistReleaseContext {
project_root: root,
publish_config: Some(&cfg),
tag_name: "athena-js-3.1.1",
release_version: &release,
version_scope: &scope,
preferred_packages: &[],
skip_version_alignment: false,
});
assert!(
!plan.enabled,
"athena-js must never enable cargo-dist: {}",
plan.reason
);
}
#[test]
fn configured_dist_packages_do_not_force_docs_release() {
let cfg = PublishProjectConfig {
npm: None,
crates: None,
dist: Some(PublishDistConfig {
enabled: Some(true),
packages: vec!["xbp".to_string()],
..PublishDistConfig::default()
}),
};
let root = Path::new(".");
let release = Version::new(10, 46, 1);
let scope = VersionScope::Service {
service_root: root.join("apps/docs"),
service_relative_root: "apps/docs".to_string(),
service_name: "@xylex-group/xbp-docs".to_string(),
tag_prefix: "xbp-docs-".to_string(),
cargo_package_name: None,
version_targets: vec!["apps/docs/package.json".to_string()],
watch_paths: vec!["apps/docs".to_string()],
};
let plan = resolve_cargo_dist_release_plan_for_context(CargoDistReleaseContext {
project_root: root,
publish_config: Some(&cfg),
tag_name: "xbp-docs-10.46.1",
release_version: &release,
version_scope: &scope,
preferred_packages: &[],
skip_version_alignment: false,
});
assert!(!plan.enabled, "docs must not run cargo-dist: {}", plan.reason);
assert!(
plan.reason.contains("Rust")
|| plan.reason.contains("scope")
|| plan.reason.contains("dist package"),
"unexpected reason: {}",
plan.reason
);
}
#[test]
fn dist_error_classifier_catches_mismatch_and_empty_workspace() {
assert!(cargo_dist_error_is_non_applicable(
"This workspace doesn't have anything for dist to Release!"
));
assert!(cargo_dist_error_is_non_applicable(
"The provided announcement tag (xbp-10.46.1) claims we're releasing xbp 10.46.1, but that package is version 10.47.0"
));
assert!(cargo_dist_error_is_non_applicable(
"`dist build --artifacts=host` failed:\n{\"diagnostic\":{\"message\":\"This workspace doesn't have anything for dist to Release!\",\"help\":\"--tag=v4.0.3 will Announce: athena_rs\"}}"
));
assert!(cargo_dist_error_is_non_applicable(
"cargo-dist build completed but produced no uploadable artifacts under C:\\Users\\floris\\Documents\\GitHub\\athena\\target\\distrib."
));
assert!(!cargo_dist_error_is_non_applicable("linker failed"));
}
#[test]
fn library_only_manifest_is_not_distable() {
let dir = std::env::temp_dir().join(format!(
"xbp-dist-lib-only-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("src")).expect("mkdir");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"athena-scheduler\"\nversion = \"4.0.3\"\nedition = \"2021\"\n",
)
.expect("write cargo");
fs::write(dir.join("src").join("lib.rs"), "// lib\n").expect("write lib");
assert!(!cargo_manifest_looks_distable(&dir.join("Cargo.toml")));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn binary_manifest_is_distable() {
let dir = std::env::temp_dir().join(format!(
"xbp-dist-bin-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("src")).expect("mkdir");
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"athena-cli\"\nversion = \"4.0.3\"\nedition = \"2021\"\n",
)
.expect("write cargo");
fs::write(dir.join("src").join("main.rs"), "fn main() {}\n").expect("write main");
assert!(cargo_manifest_looks_distable(&dir.join("Cargo.toml")));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn format_cargo_dist_error_extracts_diagnostic_message() {
let raw = r#"{"diagnostic":{"message":"This workspace doesn't have anything for dist to Release!","severity":"error","help":"use --tag=v1.0.0"}}"#;
let human = format_cargo_dist_error(raw);
assert!(human.contains("doesn't have anything"));
assert!(!human.contains("\"severity\""));
assert!(human.contains("hint:") || human.contains("v1.0.0"));
}
#[test]
fn format_plan_repository_diagnostic_is_human() {
let raw = r#"{"diagnostic": {"message": "Github CI support requires you to specify the URL of your repository","severity": "error","causes": [],"help": "Set the repository = \"https://github.com/...\" key in these manifests: \nC:\\Users\\floris\\Documents\\GitHub\\athena\\Cargo.toml","labels": [],"related": []}}"#;
let human = format_cargo_dist_error(raw);
assert!(human.contains("repository"));
assert!(!human.contains("\"severity\""));
assert!(!human.contains("\"labels\""));
}
#[test]
fn insert_repository_into_package_table() {
let input = "[package]\nname = \"athena\"\nversion = \"4.0.3\"\n";
let out = insert_repository_into_cargo_toml(input, "https://github.com/xylex-group/athena")
.expect("insert");
assert!(out.contains("repository = \"https://github.com/xylex-group/athena\""));
assert!(out.contains("name = \"athena\""));
assert!(cargo_toml_content_has_repository(&out));
assert!(insert_repository_into_cargo_toml(&out, "https://example.com").is_none());
}
#[test]
fn insert_repository_into_workspace_package_table() {
let input = "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n";
let out = insert_repository_into_cargo_toml(input, "https://github.com/org/repo")
.expect("insert");
assert!(out.contains("[workspace.package]"));
assert!(out.contains("repository = \"https://github.com/org/repo\""));
}
#[test]
fn insert_repository_creates_workspace_package_for_virtual_workspace() {
let input = "[workspace]\nmembers = [\"crates/*\", \"packages/*\"]\nresolver = \"2\"\n";
let out = insert_repository_into_cargo_toml(input, "https://github.com/xylex-group/athena")
.expect("insert");
assert!(out.contains("[workspace.package]"));
assert!(out.contains("repository = \"https://github.com/xylex-group/athena\""));
assert!(is_virtual_workspace_manifest(input));
}
#[test]
fn format_strips_embedded_json_with_prefix() {
let raw = "`dist build --artifacts=host` failed:\n{\"diagnostic\": {\"message\": \"Github CI support requires you to specify the URL of your repository\",\"severity\": \"error\",\"help\": \"Set repository in Cargo.toml\"}}";
let human = format_cargo_dist_error(raw);
assert!(human.contains("repository"));
assert!(!human.contains("\"severity\""));
assert!(!human.contains("\"diagnostic\""));
}
#[test]
fn normalize_github_ssh_to_https() {
assert_eq!(
normalize_github_https_url("git@github.com:xylex-group/athena.git").as_deref(),
Some("https://github.com/xylex-group/athena")
);
}
#[test]
fn align_body_rewrites_download_urls_to_github_tag() {
let body = "https://github.com/xylex-group/xbp/releases/download/xbp-10.38.2/xbp-installer.sh";
let aligned = align_dist_body_to_github_tag(body, "xbp-10.38.2", "crates-cli-10.38.2");
assert!(aligned.contains("/download/crates-cli-10.38.2/xbp-installer.sh"));
assert!(!aligned.contains("/download/xbp-10.38.2/"));
}
#[test]
fn installer_package_name_from_shell_ps1_and_msi() {
assert_eq!(
installer_package_name(Some("xbp-installer.sh")),
"xbp"
);
assert_eq!(
installer_package_name(Some("xbp-installer.ps1")),
"xbp"
);
assert_eq!(
installer_package_name(Some("xbp-x86_64-pc-windows-msvc.msi")),
"xbp"
);
assert_eq!(installer_package_name(None), "package");
}
#[test]
fn default_installers_are_shell_and_msi() {
let cfg = PublishProjectConfig {
npm: None,
crates: None,
dist: Some(PublishDistConfig {
enabled: Some(true),
..PublishDistConfig::default()
}),
};
let plan = resolve_cargo_dist_release_plan(
Path::new("."),
Some(&cfg),
"v1.0.0",
&["xbp".to_string()],
);
if plan.enabled {
assert_eq!(plan.installers, vec!["shell".to_string(), "msi".to_string()]);
}
}
#[test]
fn legacy_resolve_without_preferred_packages_is_disabled() {
let cfg = PublishProjectConfig {
npm: None,
crates: None,
dist: Some(PublishDistConfig {
enabled: Some(true),
packages: vec!["xbp".to_string()],
..PublishDistConfig::default()
}),
};
let plan = resolve_cargo_dist_release_plan(Path::new("."), Some(&cfg), "v1.0.0", &[]);
assert!(!plan.enabled);
}
}