use crate::git::git_command;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, thiserror::Error)]
pub enum VersionError {
#[error("version file I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("version parse failed: {0}")]
Parse(String),
#[error("git command failed: {0}")]
Git(String),
#[error(
"highest semver tag `{tag}` is not reachable from HEAD — merge its branch into \
the current branch (or, if a develop/main sync was squashed instead of merged, \
re-run `scripts/sync-main-to-develop.sh`), then retry"
)]
UnreachableBaseline {
tag: String,
},
}
pub fn detect_version_file(project_root: &Path) -> Option<PathBuf> {
for name in ["Cargo.toml", "pyproject.toml", "package.json"] {
let path = project_root.join(name);
if path.exists() {
return Some(path);
}
}
None
}
fn field_for(path: &Path, contents: &str) -> &'static str {
match path.file_name().and_then(|n| n.to_str()) {
Some("Cargo.toml") => {
if contents.contains("[workspace.package]") {
"workspace.package.version"
} else {
"package.version"
}
}
Some("pyproject.toml") => "project.version",
Some("package.json") => "version",
_ => "version",
}
}
pub fn read_major_version(path: &Path) -> Result<u32, VersionError> {
let contents = std::fs::read_to_string(path)?;
let field = field_for(path, &contents);
let version = find_version_in_contents(&contents, field)
.ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
let major = version
.split(['.', '+', '-'])
.next()
.unwrap_or("0")
.parse::<u32>()
.map_err(|err| VersionError::Parse(format!("invalid major in `{version}`: {err}")))?;
Ok(major)
}
#[deprecated(note = "superseded by `reachable_semver_baseline` (D-07)")]
pub fn count_git_tags(project_root: &Path) -> Result<u32, VersionError> {
let output = git_command(project_root)
.arg("tag")
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Err(VersionError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
let count = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count();
Ok(count as u32)
}
#[deprecated(note = "superseded by `classify_range_bump` (D-08)")]
pub fn commits_since_last_minor_tag(project_root: &Path) -> Result<u32, VersionError> {
let last_tag = git_command(project_root)
.args(["describe", "--tags", "--abbrev=0"])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
let range = if last_tag.status.success() {
let tag = String::from_utf8_lossy(&last_tag.stdout).trim().to_string();
format!("{tag}..HEAD")
} else {
"HEAD".to_string()
};
let output = git_command(project_root)
.args(["rev-list", "--count", &range])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Ok(0);
}
let count = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.unwrap_or(0);
Ok(count)
}
pub fn highest_semver_tag(project_root: &Path) -> Result<Option<semver::Version>, VersionError> {
let output = git_command(project_root)
.arg("tag")
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Err(VersionError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().strip_prefix('v'))
.filter_map(|stripped| semver::Version::parse(stripped).ok())
.max())
}
pub fn reachable_semver_baseline(
project_root: &Path,
) -> Result<Option<semver::Version>, VersionError> {
let output = git_command(project_root)
.args(["tag", "--merged", "HEAD"])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Err(VersionError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().strip_prefix('v'))
.filter_map(|stripped| semver::Version::parse(stripped).ok())
.max())
}
fn first_parent(project_root: &Path, commit: &str) -> Result<Option<String>, VersionError> {
let output = git_command(project_root)
.args(["rev-parse", &format!("{commit}^1")])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
}
pub fn release_range_start(
project_root: &Path,
baseline_tag: &str,
) -> Result<String, VersionError> {
let ancestry = git_command(project_root)
.args([
"rev-list",
"--ancestry-path",
"--reverse",
&format!("{baseline_tag}..HEAD"),
])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !ancestry.status.success() {
return Err(VersionError::Git(
String::from_utf8_lossy(&ancestry.stderr).trim().to_string(),
));
}
let path: Vec<String> = String::from_utf8_lossy(&ancestry.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect();
if path.is_empty() {
return Ok(baseline_tag.to_string());
}
for candidate in &path {
let Some(first_parent) = first_parent(project_root, candidate)? else {
return Ok(candidate.clone());
};
let tag_is_ancestor_of_first_parent = git_command(project_root)
.args(["merge-base", "--is-ancestor", baseline_tag, &first_parent])
.output()
.map(|out| out.status.success())
.unwrap_or(false);
if !tag_is_ancestor_of_first_parent {
return Ok(candidate.clone());
}
}
Ok(baseline_tag.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Bump {
None,
Patch,
Minor,
Major,
}
pub fn classify_range_bump(project_root: &Path, range_start: &str) -> Result<Bump, VersionError> {
let range = if range_start.is_empty() {
"HEAD".to_string()
} else {
format!("{range_start}..HEAD")
};
let output = git_command(project_root)
.args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Err(VersionError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut bump = Bump::None;
for record in stdout.split('\u{1e}') {
let record = record.trim_matches('\n');
if record.is_empty() {
continue;
}
let Some((_hash, message)) = record.split_once('\u{1f}') else {
continue;
};
let this_bump = classify_commit_message(message.trim());
bump = bump.max(this_bump);
}
Ok(bump)
}
fn classify_commit_message(message: &str) -> Bump {
let Ok(commit) = git_conventional::Commit::parse(message) else {
return Bump::Patch;
};
if commit.breaking() {
return Bump::Major;
}
let ty = commit.type_();
if ty == git_conventional::Type::FEAT {
Bump::Minor
} else if ty == git_conventional::Type::FIX || ty == git_conventional::Type::PERF {
Bump::Patch
} else if ty == git_conventional::Type::DOCS
|| ty == git_conventional::Type::TEST
|| ty == git_conventional::Type::CHORE
|| ty == "ci"
|| ty == git_conventional::Type::REFACTOR
|| ty == git_conventional::Type::STYLE
{
Bump::None
} else {
Bump::Patch
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChangelogHeading {
Breaking,
Added,
Fixed,
Changed,
}
impl ChangelogHeading {
pub fn as_markdown_heading(self) -> &'static str {
match self {
ChangelogHeading::Breaking => "### Breaking",
ChangelogHeading::Added => "### Added",
ChangelogHeading::Fixed => "### Fixed",
ChangelogHeading::Changed => "### Changed",
}
}
}
pub const CHANGELOG_SUBJECT_MAX_CHARS: usize = 200;
pub fn sanitize_changelog_subject(subject: &str) -> String {
const MARKER: &str = "… [truncated]";
let sanitized: String = subject
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect();
if sanitized.chars().count() <= CHANGELOG_SUBJECT_MAX_CHARS {
return sanitized;
}
let marker_len = MARKER.chars().count().min(CHANGELOG_SUBJECT_MAX_CHARS);
let head_len = CHANGELOG_SUBJECT_MAX_CHARS.saturating_sub(marker_len);
let head: String = sanitized.chars().take(head_len).collect();
let marker: String = MARKER.chars().take(marker_len).collect();
format!("{head}{marker}")
}
pub fn changelog_sections(
project_root: &Path,
range_start: &str,
) -> Result<Vec<(ChangelogHeading, Vec<String>)>, VersionError> {
let range = if range_start.is_empty() {
"HEAD".to_string()
} else {
format!("{range_start}..HEAD")
};
let output = git_command(project_root)
.args(["log", "--no-merges", &range, "--format=%H%x1f%B%x1e"])
.output()
.map_err(|err| VersionError::Git(err.to_string()))?;
if !output.status.success() {
return Err(VersionError::Git(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut breaking: Vec<String> = Vec::new();
let mut added: Vec<String> = Vec::new();
let mut fixed: Vec<String> = Vec::new();
let mut changed: Vec<String> = Vec::new();
for record in stdout.split('\u{1e}') {
let record = record.trim_matches('\n');
if record.is_empty() {
continue;
}
let Some((_hash, message)) = record.split_once('\u{1f}') else {
continue;
};
let message = message.trim();
let Ok(commit) = git_conventional::Commit::parse(message) else {
let first_line = message.lines().next().unwrap_or(message);
changed.push(sanitize_changelog_subject(first_line));
continue;
};
let subject = sanitize_changelog_subject(commit.description());
if commit.breaking() {
breaking.push(subject);
} else if commit.type_() == git_conventional::Type::FEAT {
added.push(subject);
} else if commit.type_() == git_conventional::Type::FIX
|| commit.type_() == git_conventional::Type::PERF
{
fixed.push(subject);
} else {
changed.push(subject);
}
}
let mut sections = Vec::new();
if !breaking.is_empty() {
sections.push((ChangelogHeading::Breaking, breaking));
}
if !added.is_empty() {
sections.push((ChangelogHeading::Added, added));
}
if !fixed.is_empty() {
sections.push((ChangelogHeading::Fixed, fixed));
}
if !changed.is_empty() {
sections.push((ChangelogHeading::Changed, changed));
}
Ok(sections)
}
pub fn render_changelog_body(sections: &[(ChangelogHeading, Vec<String>)]) -> String {
let mut body = String::new();
for (index, (heading, bullets)) in sections.iter().enumerate() {
if index > 0 {
body.push('\n');
}
body.push_str(heading.as_markdown_heading());
body.push_str("\n\n");
for bullet in bullets {
body.push_str("- ");
body.push_str(bullet);
body.push('\n');
}
}
body
}
fn apply_bump(baseline: &semver::Version, bump: Bump) -> semver::Version {
match bump {
Bump::Major => semver::Version::new(baseline.major + 1, 0, 0),
Bump::Minor => semver::Version::new(baseline.major, baseline.minor + 1, 0),
Bump::Patch | Bump::None => {
semver::Version::new(baseline.major, baseline.minor, baseline.patch + 1)
}
}
}
pub fn compute_version(project_root: &Path) -> Result<Version, VersionError> {
let highest = highest_semver_tag(project_root)?;
let baseline = reachable_semver_baseline(project_root)?;
if let Some(highest) = &highest {
let unreachable = match &baseline {
Some(reachable) => highest > reachable,
None => true,
};
if unreachable {
return Err(VersionError::UnreachableBaseline {
tag: format!("v{highest}"),
});
}
}
let baseline_version = baseline
.clone()
.unwrap_or_else(|| semver::Version::new(0, 0, 0));
let range_start = match &baseline {
Some(tag) => release_range_start(project_root, &format!("v{tag}"))?,
None => String::new(),
};
let bump = classify_range_bump(project_root, &range_start)?;
let bumped = apply_bump(&baseline_version, bump);
Ok(Version {
major: bumped.major as u32,
minor: bumped.minor as u32,
patch: bumped.patch as u32,
})
}
pub fn read_version(project_root: &Path) -> Result<Version, VersionError> {
let path = detect_version_file(project_root)
.ok_or_else(|| VersionError::Parse("no version file found".into()))?;
let contents = std::fs::read_to_string(&path)?;
let field = field_for(&path, &contents);
let version_str = find_version_in_contents(&contents, field)
.ok_or_else(|| VersionError::Parse(format!("field `{field}` not found in {path:?}")))?;
parse_version_str(&version_str)
}
fn parse_version_str(version: &str) -> Result<Version, VersionError> {
let mut parts = version.split(['.', '+', '-']);
let mut next =
|label: &str| -> Result<u32, VersionError> {
parts.next().unwrap_or("0").parse::<u32>().map_err(|err| {
VersionError::Parse(format!("invalid {label} in `{version}`: {err}"))
})
};
let major = next("major")?;
let minor = next("minor")?;
let patch = next("patch")?;
Ok(Version {
major,
minor,
patch,
})
}
pub fn write_version(project_root: &Path, version: &Version) -> Result<PathBuf, VersionError> {
let path = detect_version_file(project_root)
.ok_or_else(|| VersionError::Parse("no version file found".into()))?;
let contents = std::fs::read_to_string(&path)?;
let field = field_for(&path, &contents);
let replaced = replace_version_in_contents(&contents, field, &version.to_string())
.ok_or_else(|| VersionError::Parse(format!("field `{field}` not found")))?;
let replaced = if field == "workspace.package.version" {
rewrite_workspace_member_pins(&replaced, &version.to_string())
} else {
replaced
};
std::fs::write(&path, replaced)?;
Ok(path)
}
fn rewrite_workspace_member_pins(contents: &str, new_version: &str) -> String {
let mut current = String::new();
let mut output = String::new();
for line in contents.lines() {
let trimmed = line.trim();
if let Some(header) = parse_section_header(trimmed) {
current = header.to_string();
output.push_str(line);
output.push('\n');
continue;
}
if current == "workspace.dependencies"
&& trimmed.contains('{')
&& trimmed.contains('}')
&& workspace_dependency_has_local_path(trimmed)
&& let Some(rewritten) = rewrite_inline_table_version(line, new_version)
{
output.push_str(&rewritten);
output.push('\n');
continue;
}
output.push_str(line);
output.push('\n');
}
output
}
fn inline_table_fragments(line: &str) -> Option<Vec<(usize, &str)>> {
let brace_start = line.find('{')?;
let brace_end = line.rfind('}')?;
if brace_end <= brace_start {
return None;
}
let inner = &line[brace_start + 1..brace_end];
let mut fragments = Vec::new();
let mut offset = brace_start + 1;
for fragment in inner.split(',') {
fragments.push((offset, fragment));
offset += fragment.len() + 1; }
Some(fragments)
}
fn workspace_dependency_has_local_path(line: &str) -> bool {
let Some(fragments) = inline_table_fragments(line) else {
return false;
};
for (_, fragment) in fragments {
let trimmed = fragment.trim();
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
if key.trim() != "path" {
continue;
}
let value = value.trim();
let Some(quote) = value.chars().next() else {
return false;
};
if quote != '"' && quote != '\'' {
return false;
}
let inner_value = &value[1..value.len().saturating_sub(1)];
return inner_value.starts_with("crates/");
}
false
}
fn rewrite_inline_table_version(line: &str, new_version: &str) -> Option<String> {
let fragments = inline_table_fragments(line)?;
for (frag_start, fragment) in fragments {
let trimmed = fragment.trim();
let Some((key, _value)) = trimmed.split_once('=') else {
continue;
};
if key.trim() != "version" {
continue;
}
let eq_rel = fragment.find('=')?;
let eq_abs = frag_start + eq_rel;
let after_eq = eq_abs + 1;
let rest = &line[after_eq..];
let ws_len = rest.len() - rest.trim_start().len();
let value_start = after_eq + ws_len;
let value_rest = &line[value_start..];
let quote_char = value_rest.chars().next()?;
if quote_char != '"' && quote_char != '\'' {
return None;
}
let after_quote = &value_rest[1..];
let end_rel = after_quote.find(quote_char)?;
let value_end = value_start + 1 + end_rel + 1;
let remainder = &line[value_end..];
let mut rewritten = String::with_capacity(line.len() + new_version.len());
rewritten.push_str(&line[..value_start]);
rewritten.push(quote_char);
rewritten.push_str(new_version);
rewritten.push(quote_char);
rewritten.push_str(remainder);
return Some(rewritten);
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelfPin {
pub name: String,
pub version: String,
}
pub fn read_workspace_self_pins(contents: &str) -> (Option<String>, Vec<SelfPin>) {
let workspace_version = find_version_in_contents(contents, "workspace.package.version");
let mut current = String::new();
let mut pins = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
if let Some(header) = parse_section_header(trimmed) {
current = header.to_string();
continue;
}
if current == "workspace.dependencies"
&& trimmed.contains('{')
&& trimmed.contains('}')
&& workspace_dependency_has_local_path(trimmed)
&& let Some(fragments) = inline_table_fragments(trimmed)
{
let name = trimmed
.split_once('=')
.map(|(n, _)| n.trim().to_string())
.unwrap_or_default();
for (_, fragment) in fragments {
let frag = fragment.trim();
let Some((key, value)) = frag.split_once('=') else {
continue;
};
if key.trim() != "version" {
continue;
}
let value = value.trim().trim_matches(['"', '\'']);
pins.push(SelfPin {
name: name.clone(),
version: value.to_string(),
});
}
}
}
(workspace_version, pins)
}
fn split_field(field: &str) -> (&str, &str) {
match field.rsplit_once('.') {
Some((section, key)) => (section, key),
None => ("", field),
}
}
fn parse_section_header(trimmed: &str) -> Option<&str> {
let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
trimmed.strip_prefix("[[")?.strip_suffix("]]")?
} else {
trimmed.strip_prefix('[')?.strip_suffix(']')?
};
Some(inner.trim())
}
fn find_version_in_contents(contents: &str, field: &str) -> Option<String> {
let (section, key) = split_field(field);
let mut current = "";
for line in contents.lines() {
let trimmed = line.trim();
if let Some(header) = parse_section_header(trimmed) {
current = header;
continue;
}
if current != section {
continue;
}
if let Some((lhs, value)) = trimmed.split_once(['=', ':']) {
let lhs_key = lhs.trim().trim_matches('"').trim_matches('\'');
if lhs_key != key {
continue;
}
let value = value.trim();
if value.starts_with('{') {
continue;
}
return match value.chars().next() {
Some(q @ ('"' | '\'')) => {
value[1..].find(q).map(|end| value[1..1 + end].to_string())
}
_ => {
let end = value.find([' ', '\t', ',', '#']).unwrap_or(value.len());
Some(value[..end].to_string())
}
};
}
}
None
}
fn replace_version_in_contents(contents: &str, field: &str, new_version: &str) -> Option<String> {
let (section, key) = split_field(field);
let mut current = "";
let mut changed = false;
let mut output = String::new();
for line in contents.lines() {
let trimmed = line.trim();
if let Some(header) = parse_section_header(trimmed) {
current = header;
output.push_str(line);
output.push('\n');
continue;
}
if !changed
&& current == section
&& let Some((left, value)) = line.split_once(['=', ':'])
{
let left_key = left.trim().trim_matches('"').trim_matches('\'');
if left_key == key && !value.trim().starts_with('{') {
let separator: &str = if trimmed.contains('=') { " = " } else { ": " };
let trimmed_value = value.trim();
let needs_quote = trimmed_value.starts_with('"') || trimmed_value.starts_with('\'');
let quote_char: &str = if trimmed_value.starts_with('\'') {
"'"
} else {
"\""
};
let remainder = if needs_quote {
trimmed_value[1..]
.find(quote_char)
.map(|end| &trimmed_value[end + 2..])
.unwrap_or("")
} else {
let end = trimmed_value
.find([' ', '\t', ',', '#'])
.unwrap_or(trimmed_value.len());
&trimmed_value[end..]
};
output.push_str(left.trim_end());
output.push_str(separator);
if needs_quote {
output.push_str(quote_char);
output.push_str(new_version);
output.push_str(quote_char);
} else {
output.push_str(new_version);
}
output.push_str(remainder.trim_end());
output.push('\n');
changed = true;
continue;
}
}
output.push_str(line);
output.push('\n');
}
changed.then_some(output)
}
#[cfg(test)]
mod tests {
use super::*;
fn git(root: &Path, args: &[&str]) {
let ok = crate::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn init_repo(root: &Path) {
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "test@example.com"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "tag.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
}
fn commit(root: &Path, name: &str) {
std::fs::write(root.join(name), name).unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", &format!("add {name}")]);
}
fn commit_msg(root: &Path, name: &str, message: &str) {
std::fs::write(root.join(name), name).unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", message]);
}
fn tag(root: &Path, name: &str) {
git(root, &["tag", name]);
}
fn current_branch(root: &Path) -> String {
let output = crate::test_support::git_command(root)
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.unwrap();
assert!(output.status.success(), "symbolic-ref --short HEAD failed");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn checkout_new(root: &Path, branch: &str) {
git(root, &["checkout", "-b", branch]);
}
fn checkout(root: &Path, branch: &str) {
git(root, &["checkout", branch]);
}
fn merge_ours(root: &Path, branch: &str, message: &str) {
git(root, &["merge", "-s", "ours", "-m", message, branch]);
}
fn merge_no_ff(root: &Path, branch: &str, message: &str) {
git(root, &["merge", "--no-ff", "-m", message, branch]);
}
fn head_sha(root: &Path) -> String {
let output = crate::test_support::git_command(root)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
assert!(output.status.success(), "rev-parse HEAD failed");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn detect_prefers_cargo_then_pyproject_then_package_json() {
let dir = tempfile::tempdir().unwrap();
assert!(detect_version_file(dir.path()).is_none());
std::fs::write(dir.path().join("package.json"), "{\"version\":\"1.0.0\"}").unwrap();
assert!(
detect_version_file(dir.path())
.unwrap()
.ends_with("package.json")
);
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion=\"1.0.0\"",
)
.unwrap();
assert!(
detect_version_file(dir.path())
.unwrap()
.ends_with("Cargo.toml")
);
}
#[test]
fn read_major_from_workspace_package() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("Cargo.toml");
std::fs::write(
&file,
"[workspace.package]\nversion = \"2.5.7\"\nedition = \"2024\"\n",
)
.unwrap();
assert_eq!(read_major_version(&file).unwrap(), 2);
}
#[test]
fn inline_table_version_does_not_shadow_workspace_package() {
assert_eq!(parse_section_header("[[bin]]"), Some("bin"));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("Cargo.toml");
std::fs::write(
&file,
"[[bin]]\nname = \"devflow\"\n\
[workspace.dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\n\
[workspace.package]\nversion = \"1.2.0\"\n",
)
.unwrap();
assert_eq!(read_major_version(&file).unwrap(), 1);
write_version(
dir.path(),
&Version {
major: 2,
minor: 3,
patch: 4,
},
)
.unwrap();
let contents = std::fs::read_to_string(file).unwrap();
assert!(contents.contains("serde = { version = \"1\""));
assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
}
#[test]
fn read_major_from_package_json() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("package.json");
std::fs::write(&file, "{\n \"version\": \"3.1.0\"\n}\n").unwrap();
assert_eq!(read_major_version(&file).unwrap(), 3);
}
#[test]
fn docs_only_commits_after_tag_yield_patch_floor() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v2.0.0");
commit_msg(root, "b.txt", "docs: update readme");
commit_msg(root, "c.txt", "docs: fix typo");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 0,
patch: 1
}
);
}
#[test]
fn feat_commit_after_tag_yields_minor_bump() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v2.0.0");
commit_msg(root, "b.txt", "docs: update readme");
commit_msg(root, "c.txt", "feat(x): add new capability");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 1,
patch: 0
}
);
}
#[test]
fn fix_commit_after_tag_yields_patch_bump() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v2.0.0");
commit_msg(root, "b.txt", "fix(x): correct off-by-one");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 0,
patch: 1
}
);
}
#[test]
fn no_semver_tag_at_all_yields_documented_empty_repo_contract() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "feat: initial capability");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 0,
minor: 1,
patch: 0
}
);
}
#[test]
fn squash_sync_topology_classifies_only_post_merge_commits() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "base.txt", "chore: init");
let trunk = current_branch(root);
checkout_new(root, "develop");
commit_msg(root, "d1.txt", "feat: develop work one");
commit_msg(root, "d2.txt", "feat: develop work two");
checkout(root, &trunk);
commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
tag(root, "v2.0.0");
checkout(root, "develop");
merge_ours(
root,
&trunk,
"merge: sync main back into develop after release",
);
commit_msg(root, "f1.txt", "fix: patch after sync");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 0,
patch: 1
}
);
}
#[test]
fn two_squash_sync_cycles_anchor_to_the_second_merge_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "base.txt", "chore: init");
let trunk = current_branch(root);
checkout_new(root, "develop");
commit_msg(root, "d1.txt", "feat: first cycle work");
checkout(root, &trunk);
commit_msg(root, "sq1.txt", "feat: first squashed release");
tag(root, "v2.0.0");
checkout(root, "develop");
merge_ours(
root,
&trunk,
"merge: sync main back into develop after release (1)",
);
commit_msg(root, "d3.txt", "feat: second cycle work");
checkout(root, &trunk);
commit_msg(root, "sq2.txt", "feat: second squashed release");
tag(root, "v2.1.0");
checkout(root, "develop");
merge_ours(
root,
&trunk,
"merge: sync main back into develop after release (2)",
);
commit_msg(root, "f1.txt", "fix: patch after second sync");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 1,
patch: 1
}
);
}
#[test]
fn trunk_commit_between_tag_and_sync_merge_still_anchors_at_the_sync_merge() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "base.txt", "chore: init");
let trunk = current_branch(root);
checkout_new(root, "develop");
commit_msg(root, "d1.txt", "feat: develop work one");
commit_msg(root, "d2.txt", "feat: develop work two");
checkout(root, &trunk);
commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
tag(root, "v2.0.0");
commit_msg(root, "hot.txt", "fix: hotfix pushed straight to main");
checkout(root, "develop");
merge_ours(
root,
&trunk,
"merge: sync main back into develop after release",
);
let sync_merge = head_sha(root);
commit_msg(root, "f1.txt", "fix: patch after sync");
assert_eq!(
release_range_start(root, "v2.0.0").unwrap(),
sync_merge,
"anchor must be the sync merge, not the hotfix's tag-ancestor first parent"
);
assert_eq!(
compute_version(root).unwrap(),
Version {
major: 2,
minor: 0,
patch: 1
},
"pre-fix this yields 2.1.0: the range collapses to tag..HEAD and \
re-admits d1/d2's two feat commits"
);
}
#[test]
fn feature_merge_after_sync_merge_does_not_move_the_anchor() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "base.txt", "chore: init");
let trunk = current_branch(root);
checkout_new(root, "develop");
commit_msg(root, "d1.txt", "feat: develop work one");
checkout(root, &trunk);
commit_msg(root, "sq1.txt", "feat: squashed release of develop work");
tag(root, "v2.0.0");
checkout(root, "develop");
merge_ours(
root,
&trunk,
"merge: sync main back into develop after release",
);
let sync_merge = head_sha(root);
commit_msg(root, "tail.txt", "chore: continue develop work after sync");
checkout_new(root, "feature/phase-99");
commit_msg(root, "ft1.txt", "feat: post-release capability");
checkout(root, "develop");
merge_no_ff(
root,
"feature/phase-99",
"Merge pull request #99 from feature/phase-99",
);
commit_msg(root, "f1.txt", "fix: patch after the feature merge");
assert_eq!(
release_range_start(root, "v2.0.0").unwrap(),
sync_merge,
"anchor must be the sync merge, not the later feature-branch pull-request merge"
);
assert_eq!(
compute_version(root).unwrap(),
Version {
major: 2,
minor: 1,
patch: 0
},
"ft1's feat must be inside the classified range"
);
}
#[test]
fn unreachable_highest_tag_refuses_rather_than_falling_back() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
let main_branch = current_branch(root);
git(root, &["checkout", "--orphan", "orphan-release"]);
git(
root,
&["commit", "--allow-empty", "-q", "-m", "chore: orphan"],
);
tag(root, "v9.9.9");
git(root, &["checkout", &main_branch]);
let err = compute_version(root).unwrap_err();
match err {
VersionError::UnreachableBaseline { tag } => {
assert_eq!(tag, "v9.9.9", "refusal must name the unreachable tag");
}
other => {
panic!("expected UnreachableBaseline (never a silent smaller Ok), got: {other:?}")
}
}
}
#[test]
fn range_with_no_bumping_commits_yields_patch_floor() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "docs: update readme");
commit_msg(root, "c.txt", "chore: tidy up");
commit_msg(root, "d.txt", "ci: tweak workflow");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 1,
minor: 0,
patch: 1
}
);
}
#[test]
fn malformed_commit_message_yields_patch_not_crash_or_major() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(
root,
"b.txt",
"just a plain message with no conventional type prefix!!!",
);
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 1,
minor: 0,
patch: 1
}
);
}
#[test]
fn exclamation_before_colon_yields_major() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "feat(scope)!: drop legacy api");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 0,
patch: 0
}
);
}
#[test]
fn breaking_change_footer_yields_major_even_with_fix_subject() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
git(
root,
&[
"commit",
"--allow-empty",
"-q",
"-m",
"fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
],
);
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 2,
minor: 0,
patch: 0
}
);
}
#[test]
fn exclamation_only_in_description_does_not_yield_major() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "fix: stop the crash!!!");
let v = compute_version(root).unwrap();
assert_eq!(
v,
Version {
major: 1,
minor: 0,
patch: 1
}
);
}
#[test]
fn write_version_replaces_in_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"0.1.0\"\n",
)
.unwrap();
let path = write_version(
dir.path(),
&Version {
major: 2,
minor: 3,
patch: 4,
},
)
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = \"2.3.4\""));
}
#[test]
fn write_version_replaces_in_workspace_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
let path = write_version(
dir.path(),
&Version {
major: 2,
minor: 3,
patch: 4,
},
)
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("[workspace.package]\nversion = \"2.3.4\""));
}
#[test]
fn write_version_errors_without_version_file() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
write_version(
dir.path(),
&Version {
major: 1,
minor: 0,
patch: 0
}
),
Err(VersionError::Parse(_))
));
}
#[test]
fn read_version_round_trips_through_write_version_in_plain_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"0.1.0\"\n",
)
.unwrap();
let written = Version {
major: 2,
minor: 3,
patch: 4,
};
write_version(dir.path(), &written).unwrap();
assert_eq!(read_version(dir.path()).unwrap(), written);
}
#[test]
fn read_version_round_trips_through_write_version_in_workspace_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
let written = Version {
major: 5,
minor: 6,
patch: 7,
};
write_version(dir.path(), &written).unwrap();
assert_eq!(read_version(dir.path()).unwrap(), written);
}
#[test]
fn read_version_round_trips_through_write_version_in_package_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
"{\n \"version\": \"0.1.0\"\n}\n",
)
.unwrap();
let written = Version {
major: 1,
minor: 9,
patch: 12,
};
write_version(dir.path(), &written).unwrap();
assert_eq!(read_version(dir.path()).unwrap(), written);
}
#[test]
fn read_version_errors_without_version_file() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
read_version(dir.path()),
Err(VersionError::Parse(_))
));
}
#[test]
fn write_version_preserves_trailing_comma_in_package_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
"{\n \"name\": \"x\",\n \"version\": \"0.1.0\",\n \"private\": true\n}\n",
)
.unwrap();
write_version(
dir.path(),
&Version {
major: 2,
minor: 3,
patch: 4,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|err| {
panic!("package.json no longer parses as JSON: {err}\n{contents}")
});
assert_eq!(parsed["name"], "x");
assert_eq!(parsed["private"], true);
assert_eq!(parsed["version"], "2.3.4");
}
#[test]
fn write_version_preserves_trailing_comment_in_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"0.1.0\" # pinned\n",
)
.unwrap();
write_version(
dir.path(),
&Version {
major: 2,
minor: 3,
patch: 4,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents.contains("version = \"2.3.4\" # pinned"),
"expected trailing comment to survive, got: {contents}"
);
}
#[test]
fn write_version_preserves_trailing_comment_in_single_quoted_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = '0.1.0' # pinned\n",
)
.unwrap();
write_version(
dir.path(),
&Version {
major: 2,
minor: 3,
patch: 4,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents.contains("version = '2.3.4' # pinned"),
"expected single-quoted value and trailing comment to survive, got: {contents}"
);
}
#[test]
fn read_version_extracts_clean_value_with_trailing_comment() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"1.7.0\" # pinned release version\n",
)
.unwrap();
assert_eq!(
read_version(dir.path()).unwrap(),
Version {
major: 1,
minor: 7,
patch: 0
}
);
}
#[test]
fn read_version_extracts_clean_value_without_trailing_comment() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nversion = \"1.7.0\"\n",
)
.unwrap();
assert_eq!(
read_version(dir.path()).unwrap(),
Version {
major: 1,
minor: 7,
patch: 0
}
);
}
#[test]
fn read_workspace_self_pins_extracts_clean_workspace_version_with_trailing_comment() {
let (workspace_version, _pins) = read_workspace_self_pins(
"[workspace.package]\nversion = \"1.7.0\" # pinned release version\nedition = \"2024\"\n",
);
assert_eq!(workspace_version.as_deref(), Some("1.7.0"));
}
#[test]
fn read_version_does_not_recompute_from_git_tags() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
std::fs::write(root.join("Cargo.toml"), "[package]\nversion = \"2.0.0\"\n").unwrap();
commit(root, "a.txt");
write_version(
root,
&Version {
major: 2,
minor: 0,
patch: 0,
},
)
.unwrap();
git(root, &["tag", "v2.0.0"]);
commit(root, "b.txt");
commit(root, "c.txt");
assert_eq!(
read_version(root).unwrap(),
Version {
major: 2,
minor: 0,
patch: 0
}
);
}
#[test]
fn write_version_rewrites_workspace_dependency_self_pin() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
[workspace.dependencies]\n\
devflow-core = { path = \"crates/devflow-core\", version = \"1.6.0\" }\n",
)
.unwrap();
write_version(
dir.path(),
&Version {
major: 1,
minor: 7,
patch: 0,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents.contains("[workspace.package]\nversion = \"1.7.0\""),
"expected [workspace.package] version to be rewritten, got: {contents}"
);
assert!(
contents
.contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
"expected the [workspace.dependencies] self-pin to be rewritten to 1.7.0 \
alongside [workspace.package] version, got: {contents}"
);
}
#[test]
fn write_version_no_ops_on_missing_workspace_dependencies_section() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n",
)
.unwrap();
write_version(
dir.path(),
&Version {
major: 1,
minor: 7,
patch: 0,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert_eq!(
contents,
"[workspace.package]\nversion = \"1.7.0\"\nedition = \"2024\"\n"
);
}
#[test]
fn write_version_no_ops_on_member_with_no_version_key() {
let dir = tempfile::tempdir().unwrap();
let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
[workspace.dependencies]\n\
devflow-core = { path = \"crates/devflow-core\" }\n";
std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
write_version(
dir.path(),
&Version {
major: 1,
minor: 7,
patch: 0,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents.contains("devflow-core = { path = \"crates/devflow-core\" }"),
"expected the version-less path member to be left byte-identical, got: {contents}"
);
}
#[test]
fn write_version_leaves_third_party_version_only_dep_untouched() {
let dir = tempfile::tempdir().unwrap();
let third_party_line = "serde = { version = \"1\", features = [\"derive\"] }";
let toml = format!(
"[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
[workspace.dependencies]\n\
devflow-core = {{ path = \"crates/devflow-core\", version = \"1.6.0\" }}\n\
{third_party_line}\n"
);
std::fs::write(dir.path().join("Cargo.toml"), &toml).unwrap();
write_version(
dir.path(),
&Version {
major: 1,
minor: 7,
patch: 0,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents
.contains("devflow-core = { path = \"crates/devflow-core\", version = \"1.7.0\" }"),
"expected the local path member's version to be rewritten, got: {contents}"
);
assert!(
contents.contains(third_party_line),
"expected the third-party version-only dep to be byte-identical, got: {contents}"
);
}
#[test]
fn write_version_preserves_comment_and_quote_in_workspace_dependency_pin() {
let dir = tempfile::tempdir().unwrap();
let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
[workspace.dependencies]\n\
devflow-core = { path = 'crates/devflow-core', version = '1.6.0' } # pinned\n";
std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
write_version(
dir.path(),
&Version {
major: 1,
minor: 7,
patch: 0,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents.contains(
"devflow-core = { path = 'crates/devflow-core', version = '1.7.0' } # pinned"
),
"expected single-quote style and trailing comment to survive the rewrite, got: {contents}"
);
}
#[test]
fn write_version_rewrites_self_pin_regardless_of_key_order() {
let dir = tempfile::tempdir().unwrap();
let toml = "[workspace.package]\nversion = \"1.6.0\"\nedition = \"2024\"\n\n\
[workspace.dependencies]\n\
devflow-core = { version = \"1.6.0\", path = \"crates/devflow-core\" }\n";
std::fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
write_version(
dir.path(),
&Version {
major: 1,
minor: 7,
patch: 0,
},
)
.unwrap();
let contents = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(
contents
.contains("devflow-core = { version = \"1.7.0\", path = \"crates/devflow-core\" }"),
"expected version to be rewritten regardless of key order, got: {contents}"
);
}
#[test]
fn changelog_sections_groups_a_feat_commit_under_added() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "feat: add the widget endpoint");
let sections = changelog_sections(root, "v1.0.0").unwrap();
assert_eq!(
sections,
vec![(
ChangelogHeading::Added,
vec!["add the widget endpoint".to_string()]
)]
);
}
#[test]
fn render_changelog_body_renders_heading_and_bullets() {
let sections = vec![(
ChangelogHeading::Added,
vec!["add the widget endpoint".to_string()],
)];
let body = render_changelog_body(§ions);
assert_eq!(body, "### Added\n\n- add the widget endpoint\n");
}
#[test]
fn changelog_sections_maps_every_recognized_type() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "fix: correct y");
commit_msg(root, "c.txt", "perf: speed up z");
commit_msg(root, "d.txt", "docs: clarify readme");
commit_msg(root, "e.txt", "chore: bump dep");
commit_msg(root, "f.txt", "test: add case");
commit_msg(root, "g.txt", "ci: pin image");
commit_msg(root, "h.txt", "refactor: extract helper");
commit_msg(root, "i.txt", "style: reformat");
let sections = changelog_sections(root, "v1.0.0").unwrap();
assert_eq!(
sections,
vec![
(
ChangelogHeading::Fixed,
vec!["speed up z".to_string(), "correct y".to_string()]
),
(
ChangelogHeading::Changed,
vec![
"reformat".to_string(),
"extract helper".to_string(),
"pin image".to_string(),
"add case".to_string(),
"bump dep".to_string(),
"clarify readme".to_string(),
]
),
]
);
}
#[test]
fn changelog_sections_routes_breaking_changes_to_their_own_heading() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "feat(api)!: drop the legacy flag");
git(
root,
&[
"commit",
"--allow-empty",
"-q",
"-m",
"fix: patch a thing\n\nBREAKING CHANGE: removes an implicit default",
],
);
let sections = changelog_sections(root, "v1.0.0").unwrap();
assert_eq!(
sections,
vec![(
ChangelogHeading::Breaking,
vec![
"patch a thing".to_string(),
"drop the legacy flag".to_string()
]
)]
);
}
#[test]
fn changelog_sections_treats_unparseable_messages_as_changed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(
root,
"b.txt",
"just a plain message with no conventional type prefix!!!",
);
let sections = changelog_sections(root, "v1.0.0").unwrap();
assert_eq!(
sections,
vec![(
ChangelogHeading::Changed,
vec!["just a plain message with no conventional type prefix!!!".to_string()]
)]
);
}
#[test]
fn changelog_sections_returns_no_sections_for_an_empty_range() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
let sections = changelog_sections(root, "v1.0.0").unwrap();
assert_eq!(sections, Vec::new());
assert_eq!(render_changelog_body(§ions), "");
}
#[test]
fn sanitize_changelog_subject_neutralizes_controls_and_caps_length() {
let controls = "line 1\u{1b}[2J\tline 2\u{7}";
let sanitized = sanitize_changelog_subject(controls);
assert!(
sanitized.chars().all(|c| !c.is_control()),
"expected no control characters, got: {sanitized:?}"
);
let long = "x".repeat(5000);
let capped = sanitize_changelog_subject(&long);
assert!(capped.chars().count() <= CHANGELOG_SUBJECT_MAX_CHARS);
assert!(capped.ends_with("… [truncated]"));
let short = "add the widget endpoint";
assert_eq!(sanitize_changelog_subject(short), short);
}
#[test]
fn changelog_sections_sanitizes_subjects_before_grouping() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit_msg(root, "a.txt", "chore: init");
tag(root, "v1.0.0");
commit_msg(root, "b.txt", "feat: add \u{1b}[31mcolored\u{1b}[0m widget");
let sections = changelog_sections(root, "v1.0.0").unwrap();
assert_eq!(sections.len(), 1);
let (heading, bullets) = §ions[0];
assert_eq!(*heading, ChangelogHeading::Added);
assert_eq!(bullets.len(), 1);
assert!(
bullets[0].chars().all(|c| !c.is_control()),
"expected no control characters in the grouped bullet, got: {:?}",
bullets[0]
);
}
#[test]
#[allow(deprecated)]
fn tag_reads_resolve_caller_root_under_a_hostile_git_dir() {
const INNER_ROOT: &str = "DEVFLOW_27_03_TAG_READS_INNER_ROOT";
if let Ok(root) = std::env::var(INNER_ROOT) {
let root = std::path::PathBuf::from(root);
assert_eq!(
count_git_tags(&root).unwrap(),
2,
"count_git_tags must resolve root's own two tags, not a \
hostile GIT_DIR's repository"
);
assert_eq!(
highest_semver_tag(&root).unwrap(),
Some(semver::Version::new(0, 2, 0)),
"highest_semver_tag must resolve root's own highest tag, not \
a hostile GIT_DIR's repository"
);
return;
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
init_repo(root);
commit(root, "a.txt");
tag(root, "v0.1.0");
commit(root, "b.txt");
tag(root, "v0.2.0");
let foreign = tempfile::tempdir().unwrap();
init_repo(foreign.path());
let exe = std::env::current_exe().expect("current_exe for child re-invocation");
let out = std::process::Command::new(&exe)
.arg("tag_reads_resolve_caller_root_under_a_hostile_git_dir")
.arg("--test-threads=1")
.env(INNER_ROOT, root.to_str().unwrap())
.env("GIT_DIR", foreign.path().join(".git"))
.output()
.expect("spawn hostile child test process");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("1 passed"),
"child test process must have run exactly the inner test; \
stdout:\n{stdout}"
);
assert!(
out.status.success(),
"child test process (hostile GIT_DIR pointed at an unrelated \
foreign repository with no tags) must still resolve root's own \
tags; child exit status {:?}\nstdout:\n{stdout}",
out.status
);
}
}