use crate::protocol::plan::BumpLevel;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BumpError {
pub version: String,
pub reason: String,
}
pub fn bump_version(level: BumpLevel, current: &str) -> Result<String, BumpError> {
let (major, minor, patch) = parse_semver_core(current)?;
let (major, minor, patch) = match level {
BumpLevel::Major => (checked_incr(major, current)?, 0, 0),
BumpLevel::Minor => (major, checked_incr(minor, current)?, 0),
BumpLevel::Patch => (major, minor, checked_incr(patch, current)?),
};
Ok(format!("{major}.{minor}.{patch}"))
}
fn parse_semver_core(v: &str) -> Result<(u64, u64, u64), BumpError> {
let reject = |reason: &str| BumpError {
version: v.to_string(),
reason: reason.to_string(),
};
if v.contains('-') || v.contains('+') {
return Err(reject(
"a pre-release or build-metadata version cannot be bumped; expected a plain \
MAJOR.MINOR.PATCH release version",
));
}
let mut parts = v.split('.');
let mut next = |which: &str| -> Result<u64, BumpError> {
let comp = parts
.next()
.ok_or_else(|| reject("expected MAJOR.MINOR.PATCH (a component is missing)"))?;
parse_component(comp, which, v)
};
let major = next("major")?;
let minor = next("minor")?;
let patch = next("patch")?;
if parts.next().is_some() {
return Err(reject(
"expected exactly MAJOR.MINOR.PATCH (too many components)",
));
}
Ok((major, minor, patch))
}
fn parse_component(comp: &str, which: &str, full: &str) -> Result<u64, BumpError> {
let reject = |reason: String| BumpError {
version: full.to_string(),
reason,
};
if comp.is_empty() {
return Err(reject(format!("the {which} component is empty")));
}
if !comp.bytes().all(|b| b.is_ascii_digit()) {
return Err(reject(format!(
"the {which} component `{comp}` is not a non-negative integer"
)));
}
if comp.len() > 1 && comp.starts_with('0') {
return Err(reject(format!(
"the {which} component `{comp}` has a leading zero"
)));
}
comp.parse::<u64>().map_err(|_| {
reject(format!(
"the {which} component `{comp}` does not fit in a u64"
))
})
}
fn checked_incr(n: u64, full: &str) -> Result<u64, BumpError> {
n.checked_add(1).ok_or_else(|| BumpError {
version: full.to_string(),
reason: "a version component would overflow on bump".to_string(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BumpEditError {
WorkspaceVersionNotFound,
PinNotFound {
dependency: String,
from: String,
},
PinAmbiguous {
dependency: String,
from: String,
count: usize,
},
ChangelogUnreleasedNotFound,
}
impl std::fmt::Display for BumpEditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WorkspaceVersionNotFound => write!(
f,
"could not find a `[workspace.package]` `version = \"…\"` line to bump in the \
workspace root manifest"
),
Self::PinNotFound { dependency, from } => write!(
f,
"no `{dependency} = \"{from}\"` intra-workspace pin found to rewrite (the sealed \
plan's pin does not match the tree)"
),
Self::PinAmbiguous {
dependency,
from,
count,
} => write!(
f,
"the `{dependency} = \"{from}\"` pin matched {count} declarations — refusing to \
rewrite an ambiguous pin"
),
Self::ChangelogUnreleasedNotFound => write!(
f,
"the contract asks the engine to finalize the CHANGELOG, but no `## [Unreleased]` \
section was found to promote"
),
}
}
}
impl std::error::Error for BumpEditError {}
#[must_use]
pub fn workspace_version(manifest: &str) -> Option<String> {
let mut in_section = false;
for line in manifest.lines() {
let trimmed = strip_comment(line).trim();
if let Some(header) = section_header(trimmed) {
in_section = header == "workspace.package";
} else if in_section {
if let Some(v) = scan_key_string(trimmed, "version") {
return Some(v);
}
}
}
None
}
pub fn set_workspace_version(
manifest: &str,
from: &str,
to: &str,
) -> Result<String, BumpEditError> {
let mut out = String::with_capacity(manifest.len() + to.len());
let mut in_section = false;
let mut replaced = false;
let ends_with_newline = manifest.ends_with('\n');
let mut lines = manifest.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = strip_comment(line).trim();
if let Some(header) = section_header(trimmed) {
in_section = header == "workspace.package";
} else if in_section && !replaced {
if let Some(rewritten) = replace_exact_string_value(line, "version", from, to) {
out.push_str(&rewritten);
push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
replaced = true;
continue;
}
}
out.push_str(line);
push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
}
if replaced {
Ok(out)
} else {
Err(BumpEditError::WorkspaceVersionNotFound)
}
}
pub fn rewrite_pin(
manifest: &str,
dependency: &str,
from: &str,
to: &str,
) -> Result<String, BumpEditError> {
let matches = count_pin_matches(manifest, dependency, from);
if matches == 0 {
return Err(BumpEditError::PinNotFound {
dependency: dependency.to_string(),
from: from.to_string(),
});
}
if matches > 1 {
return Err(BumpEditError::PinAmbiguous {
dependency: dependency.to_string(),
from: from.to_string(),
count: matches,
});
}
let mut out = String::with_capacity(manifest.len() + to.len());
let mut in_dep_subtable = false;
let ends_with_newline = manifest.ends_with('\n');
let mut done = false;
let mut lines = manifest.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = strip_comment(line).trim();
let mut rewritten: Option<String> = None;
if let Some(header) = section_header(trimmed) {
in_dep_subtable = dep_subtable_matches(header, dependency);
} else if !done {
if in_dep_subtable {
rewritten = replace_exact_string_value(line, "version", from, to);
} else if line_declares_dep_inline(trimmed, dependency) {
rewritten = replace_exact_string_value(line, "version", from, to);
}
}
match rewritten {
Some(r) => {
out.push_str(&r);
done = true;
}
None => out.push_str(line),
}
push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
}
Ok(out)
}
fn count_pin_matches(manifest: &str, dependency: &str, from: &str) -> usize {
let mut count = 0;
let mut in_dep_subtable = false;
for line in manifest.lines() {
let trimmed = strip_comment(line).trim();
if let Some(header) = section_header(trimmed) {
in_dep_subtable = dep_subtable_matches(header, dependency);
} else if in_dep_subtable {
if key_has_exact_string(trimmed, "version", from) {
count += 1;
}
} else if line_declares_dep_inline(trimmed, dependency)
&& inline_has_exact_version(trimmed, from)
{
count += 1;
}
}
count
}
pub fn finalize_changelog(text: &str, version: &str, date: &str) -> Result<String, BumpEditError> {
let ends_with_newline = text.ends_with('\n');
let mut out = String::with_capacity(text.len() + version.len() + date.len() + 16);
let mut inserted = false;
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
out.push_str(line);
push_line_ending(&mut out, lines.peek().is_some(), ends_with_newline);
if !inserted && is_unreleased_header(line) {
out.push('\n');
out.push_str("## [");
out.push_str(version);
out.push_str("] - ");
out.push_str(date);
out.push('\n');
inserted = true;
}
}
if inserted {
Ok(out)
} else {
Err(BumpEditError::ChangelogUnreleasedNotFound)
}
}
fn is_unreleased_header(line: &str) -> bool {
let t = line.trim();
let Some(rest) = t.strip_prefix("##") else {
return false;
};
let rest = rest.trim();
rest.eq_ignore_ascii_case("[unreleased]")
}
fn section_header(trimmed: &str) -> Option<&str> {
let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
if inner.starts_with('[') || inner.contains('[') {
return None;
}
Some(inner.trim())
}
fn dep_subtable_matches(header: &str, dependency: &str) -> bool {
if header.contains("dev-dependencies") {
return false;
}
for infix in ["dependencies.", "build-dependencies."] {
if let Some(idx) = header.rfind(infix) {
let name = header[idx + infix.len()..].trim().trim_matches(['"', '\'']);
return name == dependency;
}
}
false
}
fn line_declares_dep_inline(trimmed: &str, dependency: &str) -> bool {
let Some(eq) = trimmed.find('=') else {
return false;
};
let key = trimmed[..eq].trim().trim_matches(['"', '\'']);
if key != dependency {
return false;
}
trimmed[eq + 1..].trim_start().starts_with('{')
}
fn inline_has_exact_version(trimmed: &str, from: &str) -> bool {
inline_version_value(trimmed).is_some_and(|v| v == from)
}
fn inline_version_value(inline: &str) -> Option<String> {
scan_key_string(inline, "version")
}
fn key_has_exact_string(trimmed: &str, key: &str, expected: &str) -> bool {
scan_key_string(trimmed, key).is_some_and(|v| v == expected)
}
fn replace_exact_string_value(line: &str, key: &str, old: &str, new: &str) -> Option<String> {
let current = scan_key_string(strip_comment(line).trim(), key)?;
if current != old {
return None;
}
replace_string_value(line, key, new)
}
fn replace_string_value(line: &str, key: &str, new: &str) -> Option<String> {
let (val_start, quote) = locate_key_string(line, key)?;
let after_open = val_start + 1;
let rel_close = line[after_open..].find(quote)?;
let close = after_open + rel_close;
let mut out = String::with_capacity(line.len() + new.len());
out.push_str(&line[..after_open]);
out.push_str(new);
out.push_str(&line[close..]);
Some(out)
}
fn scan_key_string(s: &str, key: &str) -> Option<String> {
let (open, quote) = locate_key_string(s, key)?;
let after_open = open + 1;
let rel_close = s[after_open..].find(quote)?;
Some(s[after_open..after_open + rel_close].to_string())
}
fn locate_key_string(s: &str, key: &str) -> Option<(usize, char)> {
let mut search = 0;
while let Some(rel) = s[search..].find(key) {
let pos = search + rel;
let prev_is_ident = s[..pos]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
let after = &s[pos + key.len()..];
let after_trimmed = after.trim_start();
if !prev_is_ident {
if let Some(rest) = after_trimmed.strip_prefix('=') {
let rest_trimmed = rest.trim_start();
if let Some(q) = rest_trimmed.chars().next() {
if q == '"' || q == '\'' {
let consumed = s.len() - rest_trimmed.len();
return Some((consumed, q));
}
}
}
}
search = pos + key.len();
}
None
}
fn strip_comment(line: &str) -> &str {
let mut in_str: Option<char> = None;
for (i, c) in line.char_indices() {
match in_str {
Some(q) => {
if c == q {
in_str = None;
}
}
None => match c {
'"' | '\'' => in_str = Some(c),
'#' => return &line[..i],
_ => {}
},
}
}
line
}
fn push_line_ending(out: &mut String, more_lines: bool, ends_with_newline: bool) {
if more_lines || ends_with_newline {
out.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patch_minor_major_from_a_normal_version() {
assert_eq!(bump_version(BumpLevel::Patch, "0.4.0").unwrap(), "0.4.1");
assert_eq!(bump_version(BumpLevel::Minor, "0.4.0").unwrap(), "0.5.0");
assert_eq!(bump_version(BumpLevel::Major, "0.4.0").unwrap(), "1.0.0");
}
#[test]
fn minor_and_major_reset_lower_components() {
assert_eq!(bump_version(BumpLevel::Minor, "1.2.3").unwrap(), "1.3.0");
assert_eq!(bump_version(BumpLevel::Major, "1.2.3").unwrap(), "2.0.0");
assert_eq!(bump_version(BumpLevel::Patch, "1.2.3").unwrap(), "1.2.4");
}
#[test]
fn zero_versions_bump_canonically() {
assert_eq!(bump_version(BumpLevel::Patch, "0.0.0").unwrap(), "0.0.1");
assert_eq!(bump_version(BumpLevel::Minor, "0.0.0").unwrap(), "0.1.0");
assert_eq!(bump_version(BumpLevel::Major, "0.0.0").unwrap(), "1.0.0");
}
#[test]
fn a_pre_release_or_build_version_is_refused() {
assert!(bump_version(BumpLevel::Patch, "1.2.3-rc.1").is_err());
assert!(bump_version(BumpLevel::Patch, "1.2.3+build.5").is_err());
}
#[test]
fn a_non_xyz_version_is_refused() {
for bad in ["1.2", "1.2.3.4", "1", "", "v1.2.3", "1.2.x", "1..2", "1.2."] {
assert!(
bump_version(BumpLevel::Patch, bad).is_err(),
"expected `{bad}` to be refused"
);
}
}
#[test]
fn a_leading_zero_component_is_refused() {
assert!(bump_version(BumpLevel::Patch, "1.02.3").is_err());
assert!(bump_version(BumpLevel::Patch, "01.2.3").is_err());
assert!(bump_version(BumpLevel::Patch, "0.1.0").is_ok());
}
#[test]
fn the_error_carries_the_offending_version() {
let err = bump_version(BumpLevel::Patch, "not-semver").unwrap_err();
assert_eq!(err.version, "not-semver");
assert!(!err.reason.is_empty());
}
#[test]
fn sets_the_workspace_package_version_only() {
let manifest = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.4.0\"\nedition = \"2021\"\n";
let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
assert!(out.contains("version = \"0.5.0\""));
assert!(!out.contains("0.4.0"));
assert!(out.contains("edition = \"2021\""));
assert!(out.ends_with('\n'));
}
#[test]
fn does_not_touch_a_version_in_another_section() {
let manifest =
"[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.4.0\"\n";
let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
assert!(out.contains("[package]\nversion = \"9.9.9\""));
assert!(out.contains("[workspace.package]\nversion = \"0.5.0\""));
}
#[test]
fn does_not_match_a_version_inside_a_description_string() {
let manifest =
"[workspace.package]\ndescription = \"needs version = 1.0\"\nversion = \"0.4.0\"\n";
let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
assert!(
out.contains("needs version = 1.0"),
"description untouched: {out}"
);
assert!(
out.contains("version = \"0.5.0\""),
"real version bumped: {out}"
);
}
#[test]
fn fails_closed_when_the_current_version_does_not_match_from() {
let manifest = "[workspace.package]\nversion = \"1.2.3\"\n";
assert_eq!(
set_workspace_version(manifest, "0.4.0", "0.5.0"),
Err(BumpEditError::WorkspaceVersionNotFound)
);
}
#[test]
fn fails_closed_when_no_workspace_package_version() {
let manifest = "[package]\nversion = \"1.0.0\"\n";
assert_eq!(
set_workspace_version(manifest, "1.0.0", "2.0.0"),
Err(BumpEditError::WorkspaceVersionNotFound)
);
}
#[test]
fn rewrites_an_inline_table_pin() {
let manifest = "[dependencies]\nossctl-core = { path = \"../ossctl-core\", version = \"=0.4.0\" }\nserde = \"1\"\n";
let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""));
assert!(out.contains("path = \"../ossctl-core\""));
assert!(out.contains("serde = \"1\""));
}
#[test]
fn rewrites_a_subtable_pin() {
let manifest =
"[dependencies.ossctl-core]\npath = \"../ossctl-core\"\nversion = \"=0.4.0\"\n";
let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""));
}
#[test]
fn pin_rewrite_fails_closed_when_absent() {
let manifest =
"[dependencies]\nossctl-core = { path = \"../ossctl-core\", version = \"^0.4\" }\n";
assert_eq!(
rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0"),
Err(BumpEditError::PinNotFound {
dependency: "ossctl-core".into(),
from: "=0.4.0".into(),
})
);
}
#[test]
fn pin_rewrite_leaves_a_caret_dep_untouched_even_with_same_crate() {
let manifest = "[dependencies]\nossctl-core = { path = \"../c\", version = \"=0.4.0\" }\nother = \"=0.4.0\"\n";
let out = rewrite_pin(manifest, "ossctl-core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("ossctl-core = { path = \"../c\", version = \"=0.5.0\" }"));
assert!(out.contains("other = \"=0.4.0\""));
}
#[test]
fn pin_rewrite_fails_closed_on_multiple_matches() {
let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n";
let err = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap_err();
assert!(matches!(err, BumpEditError::PinAmbiguous { count: 2, .. }));
}
#[test]
fn finalizes_the_unreleased_section() {
let text = "# Changelog\n\n## [Unreleased]\n### Added\n- a thing\n";
let out = finalize_changelog(text, "0.5.0", "2026-08-13").unwrap();
assert!(out.contains("## [Unreleased]\n\n## [0.5.0] - 2026-08-13"));
assert!(out.contains("- a thing"));
}
#[test]
fn changelog_finalize_fails_closed_without_unreleased() {
let text = "# Changelog\n\n## [0.4.0] - 2026-01-01\n";
assert_eq!(
finalize_changelog(text, "0.5.0", "2026-08-13"),
Err(BumpEditError::ChangelogUnreleasedNotFound)
);
}
}