use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::protocol::plan::BumpPlan;
use crate::release::adapters::EffectCtx;
use crate::release::bump::{self, BumpEditError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BumpOutcome {
pub commit: String,
pub effective_date: String,
}
#[derive(Debug)]
pub enum BumpExecError {
Edit(BumpEditError),
Fs {
path: PathBuf,
source: std::io::Error,
},
MemberManifestNotFound {
package: String,
},
LockRefresh(String),
Hook {
status: String,
stderr: String,
},
HookViolatedVersion {
expected: String,
found: String,
},
Git(String),
}
impl std::fmt::Display for BumpExecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Edit(e) => write!(f, "{e}"),
Self::Fs { path, source } => {
write!(
f,
"cannot access `{}` in the sealed checkout: {source}",
path.display()
)
}
Self::MemberManifestNotFound { package } => write!(
f,
"the bump names crate `{package}` but its manifest could not be located in the \
sealed checkout"
),
Self::LockRefresh(m) => write!(f, "refreshing Cargo.lock failed: {m}"),
Self::Hook { status, stderr } => {
write!(f, "the bump_hook failed ({status}): {stderr}")
}
Self::HookViolatedVersion { expected, found } => write!(
f,
"the bump_hook changed the workspace version to `{found}`, but the bump set \
`{expected}` — refusing to publish a hook-altered version"
),
Self::Git(m) => write!(f, "git step failed during the bump: {m}"),
}
}
}
impl std::error::Error for BumpExecError {}
impl From<BumpEditError> for BumpExecError {
fn from(e: BumpEditError) -> Self {
Self::Edit(e)
}
}
pub fn apply_bump(
ctx: &EffectCtx<'_>,
bump: &BumpPlan,
effective_date: &str,
) -> Result<BumpOutcome, BumpExecError> {
let root = ctx.repo_root;
let root_manifest = root.join("Cargo.toml");
let text = read(&root_manifest)?;
let bumped = bump::set_workspace_version(&text, &bump.from_version, &bump.to_version)?;
write(&root_manifest, &bumped)?;
if !bump.pin_rewrites.is_empty() {
let members = member_manifest_paths(root)?;
for pin in &bump.pin_rewrites {
let manifest = members.get(&pin.in_package).ok_or_else(|| {
BumpExecError::MemberManifestNotFound {
package: pin.in_package.clone(),
}
})?;
let text = read(manifest)?;
let rewritten = bump::rewrite_pin(&text, &pin.dependency, &pin.from, &pin.to)?;
write(manifest, &rewritten)?;
}
}
if root.join("Cargo.lock").exists() {
refresh_lockfile(ctx)?;
}
if bump.changelog_finalize {
let changelog = root.join("CHANGELOG.md");
if changelog.exists() {
let text = read(&changelog)?;
let finalized = bump::finalize_changelog(&text, &bump.to_version, effective_date)?;
write(&changelog, &finalized)?;
}
}
if let Some(hook) = &bump.bump_hook {
run_hook(ctx, hook)?;
let after = read(&root_manifest)?;
let found = bump::workspace_version(&after);
if found.as_deref() != Some(bump.to_version.as_str()) {
return Err(BumpExecError::HookViolatedVersion {
expected: bump.to_version.clone(),
found: found.unwrap_or_default(),
});
}
}
let commit = commit_bump(ctx, &bump.to_version)?;
Ok(BumpOutcome {
commit,
effective_date: effective_date.to_string(),
})
}
fn read(path: &Path) -> Result<String, BumpExecError> {
std::fs::read_to_string(path).map_err(|source| BumpExecError::Fs {
path: path.to_path_buf(),
source,
})
}
fn write(path: &Path, contents: &str) -> Result<(), BumpExecError> {
std::fs::write(path, contents).map_err(|source| BumpExecError::Fs {
path: path.to_path_buf(),
source,
})
}
fn refresh_lockfile(ctx: &EffectCtx<'_>) -> Result<(), BumpExecError> {
let out = ctx
.runner
.run("cargo", &["update", "--workspace"], ctx.repo_root)
.map_err(|e| BumpExecError::LockRefresh(format!("cannot run cargo: {e}")))?;
if out.status != Some(0) {
return Err(BumpExecError::LockRefresh(format!(
"exit {}: {}",
status_str(out.status),
out.stderr.trim()
)));
}
Ok(())
}
fn run_hook(ctx: &EffectCtx<'_>, hook: &str) -> Result<(), BumpExecError> {
let out = ctx
.runner
.run("sh", &["-c", hook], ctx.repo_root)
.map_err(|e| BumpExecError::Hook {
status: "spawn failed".to_string(),
stderr: e.to_string(),
})?;
if out.status != Some(0) {
return Err(BumpExecError::Hook {
status: status_str(out.status),
stderr: out.stderr.trim().to_string(),
});
}
Ok(())
}
fn commit_bump(ctx: &EffectCtx<'_>, version: &str) -> Result<String, BumpExecError> {
let root = ctx.repo_root;
run_git(ctx, &["add", "-A"], root)?;
let message = format!("release: v{version}");
run_git(ctx, &["commit", "-m", &message], root)?;
let out = ctx
.runner
.run("git", &["rev-parse", "HEAD"], root)
.map_err(|e| BumpExecError::Git(format!("rev-parse HEAD: {e}")))?;
if out.status != Some(0) {
return Err(BumpExecError::Git(format!(
"rev-parse HEAD exit {}: {}",
status_str(out.status),
out.stderr.trim()
)));
}
let sha = out.stdout.trim().to_string();
if sha.is_empty() {
return Err(BumpExecError::Git(
"git rev-parse HEAD returned no commit sha after the bump commit".to_string(),
));
}
Ok(sha)
}
fn run_git(ctx: &EffectCtx<'_>, args: &[&str], cwd: &Path) -> Result<(), BumpExecError> {
let out = ctx
.runner
.run("git", args, cwd)
.map_err(|e| BumpExecError::Git(format!("`git {}`: {e}", args.join(" "))))?;
if out.status != Some(0) {
return Err(BumpExecError::Git(format!(
"`git {}` exit {}: {}",
args.join(" "),
status_str(out.status),
out.stderr.trim()
)));
}
Ok(())
}
fn member_manifest_paths(root: &Path) -> Result<BTreeMap<String, PathBuf>, BumpExecError> {
let root_manifest = root.join("Cargo.toml");
let text = read(&root_manifest)?;
let mut map = BTreeMap::new();
for rel in workspace_member_dirs(root, &text) {
let manifest = root.join(&rel).join("Cargo.toml");
let Ok(member_text) = std::fs::read_to_string(&manifest) else {
continue;
};
if let Some(name) = package_name(&member_text) {
map.insert(name, manifest);
}
}
Ok(map)
}
fn workspace_member_dirs(root: &Path, root_text: &str) -> Vec<String> {
let Some(members) = toml_string_array(root_text, "members") else {
return Vec::new();
};
let mut dirs = Vec::new();
for entry in members {
if let Some(parent) = entry.strip_suffix("/*") {
if let Ok(read_dir) = std::fs::read_dir(root.join(parent)) {
for e in read_dir.flatten() {
if e.path().is_dir() {
dirs.push(format!("{parent}/{}", e.file_name().to_string_lossy()));
}
}
}
} else if !entry.contains('*') {
dirs.push(entry);
}
}
dirs
}
fn toml_string_array(text: &str, key: &str) -> Option<Vec<String>> {
let mut in_workspace = false;
let mut collecting = false;
let mut buf = String::new();
for line in text.lines() {
let t = line.trim();
if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
in_workspace = h.trim() == "workspace";
continue;
}
if collecting {
buf.push_str(line);
if line.contains(']') {
break;
}
continue;
}
if in_workspace {
if let Some(rest) = strip_key(t, key) {
if let Some(after) = rest.trim_start().strip_prefix('[') {
buf.push_str(after);
if t.contains(']') {
break;
}
collecting = true;
}
}
}
}
if buf.is_empty() && !collecting {
return None;
}
let inner = buf.split(']').next().unwrap_or("");
let items: Vec<String> = inner
.split(',')
.filter_map(|s| {
let s = s.trim().trim_matches(['"', '\'']);
(!s.is_empty()).then(|| s.to_string())
})
.collect();
Some(items)
}
fn strip_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.strip_prefix(key)?;
let rest = rest.trim_start();
rest.strip_prefix('=')
}
fn package_name(text: &str) -> Option<String> {
let mut in_package = false;
for line in text.lines() {
let t = line.trim();
if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
in_package = h.trim() == "package";
continue;
}
if in_package {
if let Some(rest) = strip_key(t, "name") {
return Some(rest.trim().trim_matches(['"', '\'']).to_string());
}
}
}
None
}
fn status_str(status: Option<i32>) -> String {
status.map_or_else(|| "signal".to_string(), |c| c.to_string())
}
#[must_use]
pub fn civil_date(unix_secs: u64) -> String {
let days = i64::try_from(unix_secs / 86_400).unwrap_or(i64::MAX);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}
#[cfg(test)]
mod tests;