use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::contract::schema::{ChangelogMode, ChangelogSource};
use crate::protocol::plan::{BumpPlan, ChangelogFinalizePlan};
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),
ChangelogCompile(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::ChangelogCompile(m) => write!(f, "compiling changelog notes 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 root manifest 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 = if bump::workspace_version(&text).is_some() {
bump::set_workspace_version(&text, &bump.from_version, &bump.to_version)?
} else {
bump::set_package_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 = if pin.workspace_root {
&root_manifest
} else {
members.get(&pin.in_package).ok_or_else(|| {
BumpExecError::MemberManifestNotFound {
package: pin.in_package.clone(),
}
})?
};
let text = read(manifest)?;
let rewritten = if pin.workspace_root {
bump::rewrite_workspace_pin(&text, &pin.dependency, &pin.from, &pin.to)?
} else {
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.is_file() {
let text = read(&changelog)?;
let (finalized, consumed) = if let Some(plan) = &bump.changelog {
let (compiled, consumed) = compile_changelog(ctx, plan)?;
(
bump::finalize_marker_changelog(
&text,
&bump.to_version,
effective_date,
&compiled,
)?,
consumed,
)
} else {
(
bump::finalize_changelog(&text, &bump.to_version, effective_date)?,
Vec::new(),
)
};
write(&changelog, &finalized)?;
for fragment in consumed {
std::fs::remove_file(&fragment).map_err(|source| BumpExecError::Fs {
path: fragment,
source,
})?;
}
} else if bump.changelog.is_some() {
return Err(BumpEditError::ChangelogUnreleasedNotFound.into());
}
}
if let Some(hook) = &bump.bump_hook {
run_hook(ctx, hook)?;
let after = read(&root_manifest)?;
let found = bump::root_manifest_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 compile_changelog(
ctx: &EffectCtx<'_>,
plan: &ChangelogFinalizePlan,
) -> Result<(String, Vec<PathBuf>), BumpExecError> {
let mut sources = Vec::new();
let mut consumed = Vec::new();
match plan.mode {
ChangelogMode::Fragment => {
collect_fragments(ctx.repo_root, plan, &mut sources, &mut consumed)?;
}
ChangelogMode::Curated => {}
ChangelogMode::Automated => {
return Err(BumpExecError::ChangelogCompile(
"an automated changelog cannot carry engine finalization intent".into(),
));
}
}
match plan.source {
ChangelogSource::IssuectlTrailers => {
let range = plan.issuectl_range.as_deref().ok_or_else(|| {
BumpExecError::ChangelogCompile(
"the sealed changelog plan has no issuectl revision range".into(),
)
})?;
let root = ctx.repo_root.to_string_lossy();
if let Ok(output) = ctx.runner.run(
"issuectl",
&["changelog", range, "--json", "--root", &root],
ctx.repo_root,
) {
if output.status == Some(0) {
if let Ok(notes) = render_issuectl_notes(&output.stdout) {
if !notes.is_empty() {
sources.push(notes);
}
}
}
}
}
ChangelogSource::Manual | ChangelogSource::ConventionalCommits => {}
}
Ok((
sources
.into_iter()
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
consumed,
))
}
fn collect_fragments(
root: &Path,
plan: &ChangelogFinalizePlan,
sources: &mut Vec<String>,
consumed: &mut Vec<PathBuf>,
) -> Result<(), BumpExecError> {
let dir = root.join(&plan.fragment_dir);
if !dir.exists() {
return Ok(());
}
let metadata = std::fs::symlink_metadata(&dir).map_err(|source| BumpExecError::Fs {
path: dir.clone(),
source,
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(BumpExecError::ChangelogCompile(format!(
"fragment directory `{}` must be a real directory inside the checkout",
plan.fragment_dir
)));
}
let canonical_root = std::fs::canonicalize(root).map_err(|source| BumpExecError::Fs {
path: root.to_path_buf(),
source,
})?;
let canonical_dir = std::fs::canonicalize(&dir).map_err(|source| BumpExecError::Fs {
path: dir.clone(),
source,
})?;
if !canonical_dir.starts_with(&canonical_root) {
return Err(BumpExecError::ChangelogCompile(format!(
"fragment directory `{}` resolves outside the checkout",
plan.fragment_dir
)));
}
let entries = std::fs::read_dir(&dir).map_err(|source| BumpExecError::Fs {
path: dir.clone(),
source,
})?;
let mut paths = entries
.map(|entry| {
entry
.map(|entry| entry.path())
.map_err(|source| BumpExecError::Fs {
path: dir.clone(),
source,
})
})
.collect::<Result<Vec<_>, _>>()?;
paths.sort();
for path in paths {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name.starts_with('.')
|| name.eq_ignore_ascii_case("README.md")
|| !path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
{
continue;
}
let metadata = std::fs::symlink_metadata(&path).map_err(|source| BumpExecError::Fs {
path: path.clone(),
source,
})?;
if !metadata.file_type().is_file() {
continue;
}
let contents = read(&path)?;
if !contents.trim().is_empty() {
sources.push(contents);
consumed.push(path);
}
}
Ok(())
}
fn render_issuectl_notes(json: &str) -> Result<String, BumpExecError> {
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|e| BumpExecError::ChangelogCompile(format!("invalid issuectl JSON: {e}")))?;
if value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
!= Some(1)
{
return Err(BumpExecError::ChangelogCompile(
"issuectl JSON has an unsupported schema_version".into(),
));
}
let groups = value
.get("data")
.and_then(|data| data.get("groups"))
.and_then(serde_json::Value::as_object)
.ok_or_else(|| {
BumpExecError::ChangelogCompile("issuectl JSON has no data.groups object".into())
})?;
let mut categories: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
for (kind, issues) in groups {
let heading = match kind.as_str() {
"feature" => "Added",
"bug" => "Fixed",
_ => "Changed",
};
let issues = issues.as_array().ok_or_else(|| {
BumpExecError::ChangelogCompile(format!("issuectl group `{kind}` is not an array"))
})?;
for issue in issues {
let title = issue
.get("title")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
BumpExecError::ChangelogCompile(format!(
"issuectl group `{kind}` has an item without title"
))
})?;
let slug = issue
.get("slug")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
BumpExecError::ChangelogCompile(format!(
"issuectl group `{kind}` has an item without slug"
))
})?;
categories
.entry(heading)
.or_default()
.push(format!("- {title} (`{slug}`)."));
}
}
let mut rendered = Vec::new();
for (heading, mut bullets) in categories {
bullets.sort();
bullets.dedup();
if !bullets.is_empty() {
rendered.push(format!("### {heading}\n\n{}", bullets.join("\n")));
}
}
Ok(rendered.join("\n\n"))
}
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;