use clap::Parser;
use std::process;
mod analyser;
mod changelog;
mod config;
mod error;
mod git;
mod output;
mod strategy;
mod versioner;
use analyser::{analyse_commits, BumpType};
use config::Config;
use error::{BumperError, BumperResult};
use output::Output;
use strategy::load_strategy;
#[derive(Debug)]
enum ExitCode {
Ok,
NoBump,
}
#[derive(Parser, Debug)]
#[command(name = "grubble")]
#[command(
about = "Automatic semantic versioning based on conventional commits",
long_about = "Grubble - Automatic Semantic Versioning
Grubble analyzes conventional commits since the last version tag and automatically
bumps the semantic version accordingly.
Version Bump Rules:
- feat: -> minor bump
- fix: -> patch bump
- feat!: / !: -> major bump (breaking change)
Common Usage:
grubble # Analyze and bump version
grubble --push --tag # Bump and push to remote with tag
grubble --dry-run # Check if bump is needed (exit 0 if yes, 1 if no)
grubble --bump-type # Output bump type (major/minor/patch/none)
grubble --preset rust # Use Rust Cargo.toml versioning
grubble --changelog # Generate CHANGELOG.md"
)]
struct Args {
#[arg(short, long)]
push: bool,
#[arg(short, long)]
quiet: bool,
#[arg(short, long)]
tag: bool,
#[arg(short = 'r', long)]
release_notes: bool,
#[arg(long)]
raw: bool,
#[arg(long)]
preset: Option<String>,
#[arg(long)]
tag_prefix: Option<String>,
#[arg(long)]
commit_prefix: Option<String>,
#[arg(long)]
package_files: Option<String>,
#[arg(long)]
git_user_name: Option<String>,
#[arg(long)]
git_user_email: Option<String>,
#[arg(long)]
update_major_tag: bool,
#[arg(long)]
update_minor_tag: bool,
#[arg(long)]
changelog: bool,
#[arg(long)]
bump_type: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_enum, default_value_t = Output::Text)]
output: Output,
}
fn log(msg: &str, is_raw: bool) {
if !is_raw {
println!("{}", msg);
}
}
fn emit_raw(version: &versioner::Version, preset: &str, output: Output) {
if output == Output::Json {
let json = serde_json::json!({
"version": version.to_string(),
"preset": preset,
});
println!(
"{}",
serde_json::to_string_pretty(&json).expect("failed to serialize raw JSON output")
);
} else {
println!("{}", version);
}
}
fn run() -> BumperResult<ExitCode> {
let args = Args::parse();
let is_bump_type = args.bump_type;
let is_dry_run = args.dry_run;
let is_raw = args.raw;
let output = args.output;
if output == Output::Json && !is_bump_type && !is_raw {
return Err(BumperError::InvalidConfig(
"--output json is only valid with --bump-type or --raw".to_string(),
));
}
if is_bump_type {
run_bump_type(&args, output)?;
return Ok(ExitCode::Ok);
}
let mut config = Config::load();
if let Some(preset) = args.preset {
config.preset = preset;
}
if args.package_files.is_none() {
config.package_files = match config.preset.as_str() {
"rust" => vec!["Cargo.toml".to_string()],
"node" => vec!["package.json".to_string()],
"git" => vec![],
_ => vec!["package.json".to_string()],
};
}
if let Some(tag_prefix) = args.tag_prefix {
config.tag_prefix = tag_prefix;
}
if let Some(commit_prefix) = args.commit_prefix {
config.commit_prefix = commit_prefix;
}
if let Some(package_files) = args.package_files {
config.package_files = package_files.split(',').map(|s| s.to_string()).collect();
}
if args.push {
config.push = true;
}
if args.tag {
config.tag = true;
}
if args.release_notes {
config.release_notes = true;
}
if let Some(git_user_name) = args.git_user_name {
config.git_user_name = git_user_name;
}
if let Some(git_user_email) = args.git_user_email {
config.git_user_email = git_user_email;
}
if args.update_major_tag {
config.update_major_tag = true;
}
if args.update_minor_tag {
config.update_minor_tag = true;
}
if args.changelog {
config.changelog = true;
}
let quiet = args.quiet;
if is_raw {
config.raw = true;
config.push = false;
config.tag = false;
}
if is_dry_run {
config.raw = true;
config.push = false;
config.tag = false;
config.changelog = false;
config.release_notes = false;
}
if config.release_notes && !config.tag {
log(
"Warning: --release-notes requires --tag to be effective.",
is_raw,
);
}
git::set_git_config(&config.git_user_name, &config.git_user_email)?;
let strategy = load_strategy(&config);
let mut current_version = strategy.get_current_version()?;
log(&format!("Current version: {}", current_version), is_raw);
let last_tag = git::get_last_tag()?;
log(
&format!("Last tag: {}", last_tag.as_deref().unwrap_or("none")),
is_raw,
);
let last_tag_version = git::get_last_tag_version(&config)?;
if !config.raw {
if let Some(tag_ver) = last_tag_version {
if config.preset != "git" {
if current_version > tag_ver {
let file_path = config
.package_files
.first()
.map(String::as_str)
.unwrap_or("(unknown)");
return Err(BumperError::InvalidConfig(format!(
"package version {} (in {}) is ahead of latest tag v{}. \
Refusing to bump.\n\n\
To fix, align the file and tag. Either:\n - revert {} to match the latest tag, or\n - create the missing tag: git tag v{} && git push origin v{}",
current_version,
file_path,
tag_ver,
file_path,
current_version,
current_version
)));
}
if current_version < tag_ver {
log(
&format!(
"Package version {} is behind latest tag version {}, syncing...",
current_version, tag_ver
),
is_raw,
);
let updated_files = strategy.update_files(&tag_ver)?;
if !updated_files.is_empty() {
git::commit_changes(
&format!("v{}", tag_ver),
&updated_files,
"chore: sync package version",
)?;
log(&format!("Synced package to version {}", tag_ver), is_raw);
}
current_version = tag_ver;
}
}
}
}
let commits = git::get_commits_since_tag(last_tag.as_deref())?;
if !quiet {
log("Commits to analyse:", is_raw);
for commit in &commits {
log(&format!(" - {}", commit), is_raw);
}
}
let release_notes_message = if config.release_notes && !commits.is_empty() {
Some(
commits
.iter()
.map(|c| format!("- {}", c))
.collect::<Vec<_>>()
.join("\n"),
)
} else {
None
};
if commits.is_empty() {
log("No commits since last tag.", is_raw);
if is_raw {
emit_raw(¤t_version, &config.preset, output);
}
return Ok(ExitCode::NoBump);
}
let analysis = analyse_commits(&commits, &config);
log(
&format!("Version bump: {}", analysis.bump.as_str().to_uppercase()),
is_raw,
);
if analysis.bump == BumpType::None {
log("No version bump required.", is_raw);
if is_raw {
emit_raw(¤t_version, &config.preset, output);
}
return Ok(ExitCode::NoBump);
}
log("Triggering commits:", is_raw);
if !is_raw {
for commit in &analysis.triggering_commits {
log(&format!(" - {}", commit), is_raw);
}
}
if !analysis.unknown_commits.is_empty() && !is_raw {
log("Warning: The following commits have unknown or unconfigured types and did not trigger a version bump:", is_raw);
for commit in &analysis.unknown_commits {
log(&format!(" - {}", commit), is_raw);
}
log("Consider configuring these types in .versionrc.json or using standard Conventional Commits types.", is_raw);
}
let new_version = current_version.bump(analysis.bump);
if config.raw {
emit_raw(&new_version, &config.preset, output);
return Ok(ExitCode::Ok);
}
let updated_files = strategy.update_files(&new_version)?;
log(&format!("Updated to {}", new_version), is_raw);
if config.changelog {
changelog::generate_changelog_entry(&new_version, &commits, analysis.bump)?;
log("Updated CHANGELOG.md", is_raw);
}
let mut all_updated_files = updated_files.clone();
if config.changelog {
all_updated_files.push("CHANGELOG.md".to_string());
}
if !all_updated_files.is_empty() {
git::commit_changes(
&new_version.to_string(),
&all_updated_files,
&config.commit_prefix,
)?;
}
if config.tag {
git::create_tag(
&new_version.to_string(),
&config.tag_prefix,
release_notes_message.as_deref(),
)?;
if config.update_major_tag || config.update_minor_tag {
git::update_movable_tags(
&new_version,
&config.tag_prefix,
config.update_major_tag,
config.update_minor_tag,
)?;
}
}
if config.push {
if config.update_major_tag || config.update_minor_tag {
git::push_with_force_tags()?;
} else {
git::push()?;
}
let mut actions = vec!["Pushed changes"];
if config.tag {
actions.push("and tags");
}
log(&format!("{}.", actions.join(" ")), is_raw);
} else {
if !updated_files.is_empty() || config.tag {
let mut actions = vec!["Committed"];
if config.tag {
actions.push("and tagged");
}
log(&format!("{} locally.", actions.join(" ")), is_raw);
}
}
Ok(ExitCode::Ok)
}
fn run_bump_type(args: &Args, output: Output) -> BumperResult<()> {
let mut config = Config::load();
if let Some(preset) = &args.preset {
config.preset = preset.clone();
}
if args.package_files.is_none() {
config.package_files = match config.preset.as_str() {
"rust" => vec!["Cargo.toml".to_string()],
"node" => vec!["package.json".to_string()],
"git" => vec![],
_ => vec!["package.json".to_string()],
};
}
if let Some(tag_prefix) = &args.tag_prefix {
config.tag_prefix = tag_prefix.clone();
}
if let Some(package_files) = &args.package_files {
config.package_files = package_files.split(',').map(|s| s.to_string()).collect();
}
if let Some(git_user_name) = &args.git_user_name {
config.git_user_name = git_user_name.clone();
}
if let Some(git_user_email) = &args.git_user_email {
config.git_user_email = git_user_email.clone();
}
git::set_git_config(&config.git_user_name, &config.git_user_email)?;
let strategy = load_strategy(&config);
let current_version = strategy.get_current_version()?;
let last_tag = git::get_last_tag()?;
let commits = git::get_commits_since_tag(last_tag.as_deref())?;
if output == Output::Json {
let analysis = analyse_commits(&commits, &config);
let json = serde_json::json!({
"bump_type": analysis.bump.as_str(),
"current_version": current_version.to_string(),
"triggering_commits": analysis.triggering_commits,
"unknown_commits": analysis.unknown_commits,
});
println!(
"{}",
serde_json::to_string_pretty(&json).expect("failed to serialize bump-type JSON output")
);
return Ok(());
}
if commits.is_empty() {
println!("none");
return Ok(());
}
let analysis = analyse_commits(&commits, &config);
println!("{}", analysis.bump.as_str());
Ok(())
}
fn main() {
match run() {
Ok(ExitCode::Ok) => process::exit(0),
Ok(ExitCode::NoBump) => process::exit(0),
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
}