use bumpversion::{
BumpError,
command::Error as CommandError,
config, hooks,
vcs::git::{self, GitRepository},
};
use colored::Colorize;
use std::path::PathBuf;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("could not determine the current working directory")]
CurrentDir(#[source] std::io::Error),
#[error("could not resolve directory {}", path.display())]
ResolveDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("config file {} does not exist", path.display())]
ConfigFileNotFound {
path: PathBuf,
},
#[error("no bumpversion configuration found in {}", path.display())]
ConfigFileEmpty {
path: PathBuf,
},
#[error("no bumpversion configuration found in {}", dir.display())]
ConfigNotFound {
dir: PathBuf,
},
#[error("working directory is not clean")]
Dirty {
files: Vec<PathBuf>,
},
#[error("missing version component to bump")]
MissingComponent,
#[error("missing current version")]
MissingCurrentVersion,
#[error("could not parse current version {version:?}")]
InvalidCurrentVersion {
version: String,
},
#[error(transparent)]
Options(#[from] crate::options::Error),
#[error(transparent)]
Logging(#[from] crate::logging::Error),
#[error(transparent)]
Config(#[from] config::Error),
#[error(transparent)]
Files(#[from] bumpversion::files::Error),
#[error(transparent)]
Git(#[from] git::Error),
#[error(transparent)]
Bump(#[from] BumpError<GitRepository>),
#[error("failed to bump version")]
VersionBump(#[from] bumpversion::version::BumpError),
#[error("failed to serialize version")]
Serialize(#[from] bumpversion::version::SerializeError),
}
const RECOVER: &str = "The version changes are still in your working tree. \
Either revert them and start over, or fix the issue and run:";
const RECOVER_COMMAND: &str = "bumpversion finalize --allow-dirty";
impl Error {
fn failed_command(&self) -> Option<&CommandError> {
match self {
Self::Bump(
BumpError::SetupHook(hook)
| BumpError::PreCommitHook(hook)
| BumpError::PostCommitHook(hook),
) => match hook {
hooks::Error::Command(command) => Some(command),
hooks::Error::Shell(_) => None,
},
Self::Bump(BumpError::Add(git) | BumpError::Commit(git) | BumpError::Tag(git))
| Self::Git(git) => match git {
git::Error::CommandFailed(command) => Some(command),
_ => None,
},
_ => None,
}
}
fn guidance(&self) -> Option<String> {
match self {
Self::Dirty { .. } => Some(
"Commit or stash these changes, or pass --allow-dirty to bump anyway.".to_string(),
),
Self::ConfigNotFound { dir } => {
let candidates: Vec<String> = config::config_file_locations(dir)
.filter(|file| !matches!(file, config::ConfigFile::CargoToml(_)))
.filter_map(|file| {
let name = file.path().file_name()?;
Some(name.to_string_lossy().into_owned())
})
.collect();
Some(format!(
"bumpversion reads {}. Pass --config-file to use another file.",
candidates.join(", ")
))
}
Self::MissingComponent => Some(
"Name the component to bump, for example `bumpversion patch`, or pass --new-version."
.to_string(),
),
Self::Bump(BumpError::PreCommitHook(_) | BumpError::Add(_) | BumpError::Commit(_)) => {
Some(format!("{RECOVER}\n\n {}", RECOVER_COMMAND.bold()))
}
Self::Bump(BumpError::Tag(_)) => Some(
"The release commit was created, but the tag was not. \
Fix the issue and create the tag yourself."
.to_string(),
),
Self::Bump(BumpError::PostCommitHook(_)) => Some(
"The release commit and tag were already created. Only the hook failed.".to_string(),
),
Self::Bump(BumpError::MissingPreviousVersion) => Some(
"finalize takes the previous version from the latest tag, and the repository has none."
.to_string(),
),
_ => None,
}
}
fn is_internal(&self) -> bool {
matches!(
self,
Self::Logging(_) | Self::Config(config::Error::Join(_) | config::Error::Diagnostics(_))
)
}
}
fn causes(error: &Error) -> Vec<String> {
let mut causes = Vec::new();
let mut source = std::error::Error::source(error);
while let Some(cause) = source {
causes.push(cause.to_string());
source = cause.source();
}
causes
}
fn indent(text: &str) -> String {
text.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn exit_status(status: std::process::ExitStatus) -> String {
match status.code() {
Some(code) => format!(" with exit code {code}"),
None => " without an exit code".to_string(),
}
}
pub fn render(error: &Error) -> String {
let command = error.failed_command();
let mut headline = match error {
Error::Git(git::Error::CommandFailed(CommandError::Failed { .. })) => {
"git command failed".to_string()
}
_ => error.to_string(),
};
if let Some(CommandError::Failed { output, .. }) = command {
headline.push_str(&exit_status(output.status));
}
let label = "error:".red().bold();
let head = match command {
Some(CommandError::Failed { command, .. }) => format!("{label} {headline}\n {command}"),
_ => format!("{label} {headline}"),
};
let mut sections = vec![head];
if let Error::Dirty { files } = error {
let files: Vec<String> = files
.iter()
.map(|file| file.display().to_string().cyan().to_string())
.collect();
sections.push(indent(&files.join("\n")));
}
if let Some(CommandError::Failed { output, .. }) = command {
for (label, text) in [("stdout:", &output.stdout), ("stderr:", &output.stderr)] {
if !text.trim().is_empty() {
sections.push(format!("{}\n{}", label.dimmed(), indent(text.trim_end())));
}
}
} else {
let causes = causes(error);
if !causes.is_empty() {
sections.push(format!(
"{}\n{}",
"Caused by:".dimmed(),
indent(&causes.join("\n"))
));
}
}
if let Some(guidance) = error.guidance() {
sections.push(guidance);
}
if error.is_internal() {
sections.push(format!(
"This looks like a bug in bumpversion. Please report it at {}.",
"https://github.com/romnn/bumpversion/issues".underline()
));
}
let mut report = sections.join("\n\n");
report.push('\n');
report
}
#[cfg(test)]
mod tests {
use super::{Error, render};
use indoc::indoc;
use std::path::PathBuf;
#[test]
fn dirty_working_directory_lists_files_and_the_way_out() {
colored::control::set_override(false);
let error = Error::Dirty {
files: vec![PathBuf::from("Cargo.lock"), PathBuf::from("docs/ci.md")],
};
assert_eq!(
render(&error),
indoc! {"
error: working directory is not clean
Cargo.lock
docs/ci.md
Commit or stash these changes, or pass --allow-dirty to bump anyway.
"}
);
}
#[test]
fn causes_are_listed_below_the_headline() {
colored::control::set_override(false);
let error = Error::VersionBump(bumpversion::version::BumpError::InvalidComponent(
"flavor".to_string(),
));
assert_eq!(
render(&error),
indoc! {r#"
error: failed to bump version
Caused by:
invalid version component "flavor"
"#}
);
}
}