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,
RootManifestVersionNotFound,
PinNotFound {
dependency: String,
from: String,
},
PinAmbiguous {
dependency: String,
from: String,
count: usize,
},
ManifestUnparseable {
reason: String,
},
ChangelogUnreleasedNotFound,
ChangelogMarkersMalformed,
ChangelogReleaseConflict {
version: String,
},
ChangelogNotesEmpty,
}
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 matching the \
sealed bump in the workspace root manifest"
),
Self::RootManifestVersionNotFound => write!(
f,
"could not find a root `[package]` `version = \"…\"` line matching the sealed \
bump after no `[workspace.package]` version source was found"
),
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,
"`{dependency}` has {count} explicit version declarations that are not all \
`{from}` — refusing to rewrite an ambiguous pin set"
),
Self::ManifestUnparseable { reason } => {
write!(
f,
"could not parse Cargo manifest while discovering exact pins: {reason}"
)
}
Self::ChangelogUnreleasedNotFound => write!(
f,
"the contract asks the engine to finalize the CHANGELOG, but no `## [Unreleased]` \
section was found to promote"
),
Self::ChangelogMarkersMalformed => write!(
f,
"the CHANGELOG must contain exactly one ordered shipshape-changelog Unreleased marker pair"
),
Self::ChangelogReleaseConflict { version } => write!(
f,
"the CHANGELOG already contains a release heading for `{version}` while pending notes remain"
),
Self::ChangelogNotesEmpty => write!(
f,
"the CHANGELOG has no authored, fragment, or trailer-derived notes to release"
),
}
}
}
impl std::error::Error for BumpEditError {}
#[must_use]
pub fn workspace_version(manifest: &str) -> Option<String> {
section_version(manifest, "workspace.package")
}
#[must_use]
pub fn package_version(manifest: &str) -> Option<String> {
section_version(manifest, "package")
}
#[must_use]
pub fn root_manifest_version(manifest: &str) -> Option<String> {
workspace_version(manifest).or_else(|| package_version(manifest))
}
fn section_version(manifest: &str, section: &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 == section;
} else if in_section && line_starts_with_key(trimmed, "version") {
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> {
set_section_version(manifest, "workspace.package", from, to)
.ok_or(BumpEditError::WorkspaceVersionNotFound)
}
pub fn set_package_version(manifest: &str, from: &str, to: &str) -> Result<String, BumpEditError> {
set_section_version(manifest, "package", from, to)
.ok_or(BumpEditError::RootManifestVersionNotFound)
}
fn set_section_version(manifest: &str, section: &str, from: &str, to: &str) -> Option<String> {
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 == section;
} else if in_section && !replaced && line_starts_with_key(trimmed, "version") {
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);
}
replaced.then_some(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PinDeclaration {
pub(crate) package: String,
pub(crate) requirement: Option<String>,
}
pub(crate) fn cargo_pin_declarations(manifest: &str) -> Result<Vec<PinDeclaration>, String> {
pin_declarations(manifest, false)
}
pub(crate) fn cargo_workspace_pin_declarations(
manifest: &str,
) -> Result<Vec<PinDeclaration>, String> {
pin_declarations(manifest, true)
}
fn pin_declarations(manifest: &str, workspace_only: bool) -> Result<Vec<PinDeclaration>, String> {
use toml_edit::{DocumentMut, Item};
fn declaration(key: &str, item: &Item, require_local: bool) -> Option<PinDeclaration> {
let fields = item.as_table_like()?;
let local = fields.get("path").and_then(Item::as_str).is_some()
|| fields.get("workspace").and_then(Item::as_bool) == Some(true);
if require_local && !local {
return None;
}
Some(PinDeclaration {
package: fields
.get("package")
.and_then(Item::as_str)
.unwrap_or(key)
.to_string(),
requirement: fields
.get("version")
.and_then(Item::as_str)
.map(str::to_string),
})
}
fn collect_member_tables(doc: &DocumentMut, out: &mut Vec<PinDeclaration>) {
const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
for kind in KINDS {
if let Some(deps) = doc.get(kind).and_then(Item::as_table_like) {
out.extend(
deps.iter()
.filter_map(|(name, dep)| declaration(name, dep, true)),
);
}
}
if let Some(targets) = doc.get("target").and_then(Item::as_table_like) {
for (_, target) in targets.iter() {
let Some(target) = target.as_table_like() else {
continue;
};
for kind in KINDS {
if let Some(deps) = target.get(kind).and_then(Item::as_table_like) {
out.extend(
deps.iter()
.filter_map(|(name, dep)| declaration(name, dep, true)),
);
}
}
}
}
}
let doc = manifest
.parse::<DocumentMut>()
.map_err(|error| format!("Cargo manifest TOML could not be parsed: {error}"))?;
let mut out = Vec::new();
if workspace_only {
if let Some(deps) = doc
.get("workspace")
.and_then(Item::as_table_like)
.and_then(|workspace| workspace.get("dependencies"))
.and_then(Item::as_table_like)
{
out.extend(
deps.iter()
.filter_map(|(name, dep)| declaration(name, dep, false)),
);
}
} else {
collect_member_tables(&doc, &mut out);
}
Ok(out)
}
pub fn rewrite_pin(
manifest: &str,
dependency: &str,
from: &str,
to: &str,
) -> Result<String, BumpEditError> {
rewrite_pin_inner(manifest, dependency, from, to, false)
}
pub fn rewrite_workspace_pin(
manifest: &str,
dependency: &str,
from: &str,
to: &str,
) -> Result<String, BumpEditError> {
rewrite_pin_inner(manifest, dependency, from, to, true)
}
fn rewrite_deps(
deps: &mut dyn toml_edit::TableLike,
dependency: &str,
from: &str,
to: &str,
require_local: bool,
) -> usize {
use toml_edit::{Item, Value};
let mut rewritten = 0;
for (key, item) in deps.iter_mut() {
let Some(fields) = item.as_table_like_mut() else {
continue;
};
let local = fields.get("path").and_then(Item::as_str).is_some()
|| fields.get("workspace").and_then(Item::as_bool) == Some(true);
let package = fields
.get("package")
.and_then(Item::as_str)
.unwrap_or(key.get());
if package == dependency
&& (!require_local || local)
&& fields.get("version").and_then(Item::as_str) == Some(from)
{
let version = fields
.get_mut("version")
.and_then(Item::as_value_mut)
.expect("a string version is a value");
let decor = version.decor().clone();
*version = Value::from(to);
*version.decor_mut() = decor;
rewritten += 1;
}
}
rewritten
}
fn rewrite_member_tables(
doc: &mut toml_edit::DocumentMut,
dependency: &str,
from: &str,
to: &str,
) -> usize {
use toml_edit::Item;
const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
let mut rewritten = 0;
for kind in KINDS {
if let Some(deps) = doc.get_mut(kind).and_then(Item::as_table_like_mut) {
rewritten += rewrite_deps(deps, dependency, from, to, true);
}
}
if let Some(targets) = doc.get_mut("target").and_then(Item::as_table_like_mut) {
for (_, target) in targets.iter_mut() {
let Some(target) = target.as_table_like_mut() else {
continue;
};
for kind in KINDS {
if let Some(deps) = target.get_mut(kind).and_then(Item::as_table_like_mut) {
rewritten += rewrite_deps(deps, dependency, from, to, true);
}
}
}
}
rewritten
}
fn rewrite_pin_inner(
manifest: &str,
dependency: &str,
from: &str,
to: &str,
workspace_only: bool,
) -> Result<String, BumpEditError> {
use toml_edit::{DocumentMut, Item};
let declarations: Vec<PinDeclaration> = (if workspace_only {
cargo_workspace_pin_declarations(manifest)
} else {
cargo_pin_declarations(manifest)
})
.map_err(|reason| BumpEditError::ManifestUnparseable { reason })?
.into_iter()
.filter(|d| d.package == dependency)
.collect();
let explicit = declarations
.iter()
.filter(|d| d.requirement.is_some())
.count();
let matching = declarations
.iter()
.filter(|d| d.requirement.as_deref() == Some(from))
.count();
if matching == 0 {
return Err(BumpEditError::PinNotFound {
dependency: dependency.to_string(),
from: from.to_string(),
});
}
if matching != explicit {
return Err(BumpEditError::PinAmbiguous {
dependency: dependency.to_string(),
from: from.to_string(),
count: explicit,
});
}
let mut doc =
manifest
.parse::<DocumentMut>()
.map_err(|error| BumpEditError::ManifestUnparseable {
reason: error.to_string(),
})?;
let rewritten = if workspace_only {
doc.get_mut("workspace")
.and_then(Item::as_table_like_mut)
.and_then(|workspace| workspace.get_mut("dependencies"))
.and_then(Item::as_table_like_mut)
.map_or(0, |deps| rewrite_deps(deps, dependency, from, to, false))
} else {
rewrite_member_tables(&mut doc, dependency, from, to)
};
if rewritten != matching {
return Err(BumpEditError::PinAmbiguous {
dependency: dependency.to_string(),
from: from.to_string(),
count: explicit,
});
}
Ok(doc.to_string())
}
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)
}
}
pub fn finalize_marker_changelog(
text: &str,
version: &str,
date: &str,
compiled_notes: &str,
) -> Result<String, BumpEditError> {
const START: &str = "<!-- oss-changelog:unreleased-start -->";
const END: &str = "<!-- oss-changelog:unreleased-end -->";
const SKELETON: &str = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n<!-- oss-changelog:unreleased-end -->";
let starts: Vec<_> = text.match_indices(START).map(|(i, _)| i).collect();
let ends: Vec<_> = text.match_indices(END).map(|(i, _)| i).collect();
if starts.is_empty() && ends.is_empty() {
let marked = wrap_unreleased_markers(text)?;
return finalize_marker_changelog(&marked, version, date, compiled_notes);
}
if starts.len() != 1 || ends.len() != 1 || starts[0] >= ends[0] {
return Err(BumpEditError::ChangelogMarkersMalformed);
}
let start = starts[0];
let end = ends[0];
let region = &text[start + START.len()..end];
if !region.lines().any(is_unreleased_header) {
return Err(BumpEditError::ChangelogUnreleasedNotFound);
}
if region.lines().any(is_release_heading) {
return Err(BumpEditError::ChangelogMarkersMalformed);
}
let notes = release_note_content(&[region, compiled_notes]);
let heading_prefix = format!("## [{version}]");
let existing = text
.lines()
.any(|line| line.trim().starts_with(&heading_prefix));
if existing {
if notes.is_empty() {
return Ok(text.to_string());
}
return Err(BumpEditError::ChangelogReleaseConflict {
version: version.to_string(),
});
}
if notes.is_empty() {
return Err(BumpEditError::ChangelogNotesEmpty);
}
let after_marker = end + END.len();
let prefix = text[..start].trim_end_matches('\n');
let suffix = text[after_marker..].trim_start_matches('\n');
let mut out = String::with_capacity(text.len() + notes.len() + version.len() + 64);
if !prefix.is_empty() {
out.push_str(prefix);
out.push_str("\n\n");
}
out.push_str(SKELETON);
out.push_str("\n\n## [");
out.push_str(version);
out.push_str("] - ");
out.push_str(date);
out.push_str("\n\n");
out.push_str(¬es);
if !suffix.is_empty() {
out.push_str("\n\n");
out.push_str(suffix.trim_end_matches('\n'));
}
if text.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn release_note_content(sources: &[&str]) -> String {
use std::collections::BTreeMap;
const START: &str = "<!-- oss-changelog:unreleased-start -->";
const END: &str = "<!-- oss-changelog:unreleased-end -->";
const ORDER: [&str; 6] = [
"Added",
"Changed",
"Deprecated",
"Removed",
"Fixed",
"Security",
];
let mut preamble = Vec::new();
let mut sections: BTreeMap<String, Vec<String>> = BTreeMap::new();
for source in sources {
let mut current: Option<String> = None;
for line in source.lines() {
let trimmed = line.trim();
if trimmed == START || trimmed == END || is_unreleased_header(line) {
continue;
}
if let Some(heading) = trimmed.strip_prefix("### ") {
current = Some(heading.to_string());
sections.entry(heading.to_string()).or_default();
} else if let Some(heading) = ¤t {
sections
.entry(heading.clone())
.or_default()
.push(line.to_string());
} else {
preamble.push(line.to_string());
}
}
}
let mut kept = Vec::new();
let preamble = preamble.join("\n").trim().to_string();
if !preamble.is_empty() {
kept.push(preamble);
}
let mut headings: Vec<_> = sections.keys().cloned().collect();
headings.sort_by_key(|heading| {
ORDER
.iter()
.position(|candidate| candidate == heading)
.unwrap_or(ORDER.len())
});
for heading in headings {
let body = sections.remove(&heading).expect("heading came from map");
let body = collapse_blank_lines(&body.join("\n"));
if !body.is_empty() {
kept.push(format!("### {heading}\n\n{body}"));
}
}
kept.join("\n\n")
}
fn collapse_blank_lines(text: &str) -> String {
let mut out = Vec::new();
let mut previous_blank = false;
for line in text.trim().lines() {
let blank = line.trim().is_empty();
if blank && previous_blank {
continue;
}
out.push(line);
previous_blank = blank;
}
out.join("\n")
}
fn wrap_unreleased_markers(text: &str) -> Result<String, BumpEditError> {
let mut offset = 0;
let mut header_start = None;
let mut section_end = text.len();
for line in text.split_inclusive('\n') {
if header_start.is_none() && is_unreleased_header(line.trim_end_matches('\n')) {
header_start = Some(offset);
} else if header_start.is_some()
&& (is_release_heading(line) || is_link_definition(line.trim()))
{
section_end = offset;
break;
}
offset += line.len();
}
let header_start = header_start.ok_or(BumpEditError::ChangelogUnreleasedNotFound)?;
let mut marked = String::with_capacity(text.len() + 100);
marked.push_str(&text[..header_start]);
marked.push_str("<!-- oss-changelog:unreleased-start -->\n");
marked.push_str(&text[header_start..section_end]);
if !marked.ends_with('\n') {
marked.push('\n');
}
marked.push_str("<!-- oss-changelog:unreleased-end -->\n");
marked.push_str(text[section_end..].trim_start_matches('\n'));
Ok(marked)
}
fn is_release_heading(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with("## ") && !trimmed.starts_with("### ") && !is_unreleased_header(line)
}
fn is_link_definition(line: &str) -> bool {
line.starts_with('[') && line.contains("]: ")
}
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 line_starts_with_key(line: &str, key: &str) -> bool {
line.strip_prefix(key)
.is_some_and(|rest| rest.trim_start().starts_with('='))
}
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 = 'requires version = \"0.4.0\"'\nversion = \"0.4.0\"\n";
let out = set_workspace_version(manifest, "0.4.0", "0.5.0").unwrap();
assert!(
out.contains("requires version = \"0.4.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 package_version_is_available_for_a_plain_single_crate_manifest() {
let manifest = "[package]\nname = \"acme\"\nversion = \"1.0.0\"\n";
assert_eq!(package_version(manifest).as_deref(), Some("1.0.0"));
assert_eq!(root_manifest_version(manifest).as_deref(), Some("1.0.0"));
assert_eq!(
set_package_version(manifest, "1.0.0", "2.0.0").unwrap(),
"[package]\nname = \"acme\"\nversion = \"2.0.0\"\n"
);
let with_description =
"[package]\ndescription = 'requires version = \"1.0.0\"'\nversion = \"1.0.0\"\n";
let out = set_package_version(with_description, "1.0.0", "2.0.0").unwrap();
assert!(out.contains("requires version = \"1.0.0\""));
assert_eq!(package_version(&out).as_deref(), Some("2.0.0"));
}
#[test]
fn root_manifest_version_prefers_workspace_inheritance() {
let manifest =
"[package]\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"1.0.0\"\n";
assert_eq!(root_manifest_version(manifest).as_deref(), Some("1.0.0"));
}
#[test]
fn package_rewrite_fails_closed_when_neither_root_version_shape_matches() {
let manifest = "[package]\nname = \"acme\"\n";
assert_eq!(
set_package_version(manifest, "1.0.0", "2.0.0"),
Err(BumpEditError::RootManifestVersionNotFound)
);
}
#[test]
fn rewrites_an_inline_table_pin() {
let manifest = "[dependencies]\nshipshape-core = { path = \"../shipshape-core\", version = \"=0.4.0\" }\nserde = \"1\"\n";
let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""));
assert!(out.contains("path = \"../shipshape-core\""));
assert!(out.contains("serde = \"1\""));
}
#[test]
fn rewrites_a_subtable_pin() {
let manifest =
"[dependencies.shipshape-core]\npath = \"../shipshape-core\"\nversion = \"=0.4.0\"\n";
let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""));
}
#[test]
fn rewrites_dotted_dependency_keys() {
let manifest = "[dependencies]\ncore.path = \"../core\"\ncore.version = \"=0.4.0\"\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("core.version = \"=0.5.0\""), "{out}");
}
#[test]
fn rewrites_multiline_inline_workspace_dependency() {
let manifest = "[workspace.dependencies]\ncore = {\n path = \"crates/core\",\n version = \"=0.4.0\"\n}\n";
let declarations = cargo_workspace_pin_declarations(manifest).unwrap();
assert_eq!(declarations.len(), 1);
let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""), "{out}");
}
#[test]
fn rewrites_dotted_workspace_dependency_keys() {
let manifest =
"[workspace.dependencies]\ncore.path = \"crates/core\"\ncore.version = \"=0.4.0\"\n";
let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("core.version = \"=0.5.0\""), "{out}");
}
#[test]
fn root_exact_pin_without_path_is_still_an_edit_target() {
let manifest = "[workspace.dependencies]\ncore = { version = \"=0.4.0\" }\n";
let declarations = cargo_workspace_pin_declarations(manifest).unwrap();
assert_eq!(declarations[0].requirement.as_deref(), Some("=0.4.0"));
let out = rewrite_workspace_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""), "{out}");
}
#[test]
fn rewrite_is_scoped_to_cargo_tables_and_local_declarations() {
let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\nregistry-core = { package = \"core\", version = \"=0.4.0\" }\n[package.metadata.tool.dependencies]\ncore = { path = \"schema/core\", version = \"=0.4.0\" }\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert_eq!(out.matches("=0.5.0").count(), 1, "{out}");
assert_eq!(out.matches("=0.4.0").count(), 2, "{out}");
}
#[test]
fn rewrite_preserves_version_value_comments() {
let manifest =
"[dependencies.core]\npath = \"../core\"\nversion = \"=0.4.0\" # release-managed\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert!(
out.contains("version = \"=0.5.0\" # release-managed"),
"{out}"
);
}
#[test]
fn pin_rewrite_fails_closed_when_absent() {
let manifest =
"[dependencies]\nshipshape-core = { path = \"../shipshape-core\", version = \"^0.4\" }\n";
assert_eq!(
rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0"),
Err(BumpEditError::PinNotFound {
dependency: "shipshape-core".into(),
from: "=0.4.0".into(),
})
);
}
#[test]
fn pin_rewrite_leaves_a_caret_dep_untouched_even_with_same_crate() {
let manifest = "[dependencies]\nshipshape-core = { path = \"../c\", version = \"=0.4.0\" }\nother = \"=0.4.0\"\n";
let out = rewrite_pin(manifest, "shipshape-core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("shipshape-core = { path = \"../c\", version = \"=0.5.0\" }"));
assert!(out.contains("other = \"=0.4.0\""));
}
#[test]
fn pin_rewrite_updates_every_equivalent_dependency_table() {
let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert_eq!(out.matches("version = \"=0.5.0\"").count(), 4);
assert!(!out.contains("version = \"=0.4.0\""));
}
#[test]
fn pin_rewrite_fails_closed_on_non_equivalent_matches() {
let manifest = "[dependencies]\ncore = { path = \"a\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"a\", version = \"^0.4\" }\n";
let err = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap_err();
assert!(matches!(err, BumpEditError::PinAmbiguous { count: 2, .. }));
}
#[test]
fn pin_rewrite_uses_resolved_package_aliases() {
let manifest = "[dependencies]\nalias = { package = \"core\", path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies.alias]\npackage = \"core\"\npath = \"../core\"\nversion = \"=0.4.0\"\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert_eq!(out.matches("version = \"=0.5.0\"").count(), 2);
}
#[test]
fn pin_rewrite_ignores_non_dependency_tables_and_registry_dependencies() {
let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { version = \"^0.4\" }\n[package.metadata.release]\ncore = { version = \"=999.0.0\" }\n[patch.crates-io]\ncore = { path = \"vendor/core\", version = \"=999.0.0\" }\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert!(out.contains("version = \"=0.5.0\""));
assert!(out.contains("core = { version = \"^0.4\" }"));
assert_eq!(out.matches("version = \"=999.0.0\"").count(), 2);
}
#[test]
fn path_only_duplicates_are_neutral() {
let manifest = "[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dev-dependencies.core]\npath = \"../core\"\n";
let out = rewrite_pin(manifest, "core", "=0.4.0", "=0.5.0").unwrap();
assert_eq!(out.matches("=0.5.0").count(), 1);
assert!(out.contains("[target.'cfg(unix)'.dev-dependencies.core]\npath"));
}
#[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)
);
}
#[test]
fn marker_finalize_places_release_outside_markers_and_strips_markers_from_notes() {
let text = "# Changelog\n\n<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n\n## [0.6.1] - 2026-08-21\n\nOld.\n";
let compiled =
"### Changed\n\n- Agent Skills terminology.\n<!-- oss-changelog:unreleased-end -->\n";
let out = finalize_marker_changelog(text, "0.6.2", "2026-08-23", compiled).unwrap();
let end = out.find("<!-- oss-changelog:unreleased-end -->").unwrap();
let release = out.find("## [0.6.2] - 2026-08-23").unwrap();
assert!(
release > end,
"released section must be outside markers: {out}"
);
assert_eq!(
out.matches("<!-- oss-changelog:unreleased-end -->").count(),
1
);
assert!(out.contains("### Changed\n\n- Agent Skills terminology."));
assert_eq!(out.matches("### Changed").count(), 2, "skeleton + release");
assert!(out.contains("## [0.6.1] - 2026-08-21"));
}
#[test]
fn marker_finalize_is_idempotent_only_when_no_notes_are_pending() {
let text = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n### Added\n### Changed\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n\n## [0.6.2] - 2026-08-23\n\n- shipped\n";
assert_eq!(
finalize_marker_changelog(text, "0.6.2", "2026-08-23", "").unwrap(),
text
);
assert!(matches!(
finalize_marker_changelog(text, "0.6.2", "2026-08-23", "- pending"),
Err(BumpEditError::ChangelogReleaseConflict { .. })
));
}
#[test]
fn marker_finalize_migrates_a_markerless_changelog() {
let text = "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Authored fix.\n\n## 0.9.0 - 2026-08-01\n\nOld.\n\n[unreleased]: https://example.test/compare/v0.9.0...HEAD\n";
let out =
finalize_marker_changelog(text, "1.0.0", "2026-08-23", "### Fixed\n\n- Trailer fix.")
.unwrap();
assert!(out.contains("<!-- oss-changelog:unreleased-start -->"));
assert!(
out.contains(
"## [1.0.0] - 2026-08-23\n\n### Fixed\n\n- Authored fix.\n\n- Trailer fix."
),
"{out}"
);
assert_eq!(out.matches("### Fixed").count(), 2, "skeleton + release");
assert!(out.contains("## 0.9.0 - 2026-08-01"));
assert!(out.contains("[unreleased]: https://example.test/compare/v0.9.0...HEAD"));
}
#[test]
fn markerless_unreleased_does_not_promote_link_definitions() {
let text = "## [Unreleased]\n\n### Added\n\n- First release.\n\n[unreleased]: https://example.test/compare/v0.1.0...HEAD\n";
let out = finalize_marker_changelog(text, "0.1.0", "2026-08-23", "").unwrap();
let release = out.find("## [0.1.0] - 2026-08-23").unwrap();
let link = out.find("[unreleased]: https://example.test").unwrap();
assert!(link > release);
assert!(!out[release..link].contains("[unreleased]:"));
}
#[test]
fn marker_finalize_refuses_a_dated_release_inside_unreleased() {
let broken = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n\n## [0.6.2] - 2026-08-23\n\n### Fixed\n- old\n<!-- oss-changelog:unreleased-end -->\n";
assert_eq!(
finalize_marker_changelog(broken, "0.6.3", "2026-08-24", "- new"),
Err(BumpEditError::ChangelogMarkersMalformed)
);
}
#[test]
fn marker_finalize_refuses_malformed_markers_and_empty_releases() {
let malformed = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n";
assert_eq!(
finalize_marker_changelog(malformed, "1.0.0", "2026-08-23", "- note"),
Err(BumpEditError::ChangelogMarkersMalformed)
);
let empty = "<!-- oss-changelog:unreleased-start -->\n## [Unreleased]\n### Added\n### Changed\n### Fixed\n<!-- oss-changelog:unreleased-end -->\n";
assert_eq!(
finalize_marker_changelog(empty, "1.0.0", "2026-08-23", ""),
Err(BumpEditError::ChangelogNotesEmpty)
);
}
}