use std::env;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
fn main() -> Result<()> {
let args: Vec<String> = env::args().collect();
let part = args.get(1).map(String::as_str).unwrap_or("");
let dry_run = args.iter().any(|arg| arg == "--dry-run" || arg == "-n");
if !matches!(part, "major" | "minor" | "patch") {
eprintln!(
"Usage: cargo run --features dev-tools --bin git-stk-bump -- <major|minor|patch> [--dry-run|-n]"
);
std::process::exit(1);
}
let cargo_path = Path::new("Cargo.toml");
let cargo_text = std::fs::read_to_string(cargo_path).context("failed to read Cargo.toml")?;
let current = extract_cargo_version(&cargo_text)
.context("unable to find [package] version in Cargo.toml")?;
let next = bump_version(¤t, part)?;
if dry_run {
println!("Dry run: would bump version from {current} to {next}");
println!("Dry run: would refresh Cargo.lock");
print_next_steps(&next);
return Ok(());
}
let updated = replace_cargo_version(&cargo_text, &next)
.context("unable to replace [package] version in Cargo.toml")?;
std::fs::write(cargo_path, updated).context("failed to write Cargo.toml")?;
refresh_lockfile().context("failed to refresh Cargo.lock")?;
println!("Bumped version from {current} to {next}");
print_next_steps(&next);
Ok(())
}
fn refresh_lockfile() -> Result<()> {
let status = Command::new(env!("CARGO"))
.args(["update", "--package", "git-stk", "--offline"])
.status()
.context("failed to run cargo update")?;
if !status.success() {
bail!("cargo update exited with status {status}");
}
Ok(())
}
fn print_next_steps(version: &str) {
println!();
println!("Next steps (run manually):");
println!(" git add Cargo.toml Cargo.lock");
println!(" git commit -m \"chore(release): bump version to {version}\"");
println!(" git tag v{version}");
println!(" git push --follow-tags");
}
fn extract_cargo_version(contents: &str) -> Option<String> {
let mut in_package = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
continue;
}
if in_package && trimmed.starts_with("version = ") {
let rest = trimmed.trim_start_matches("version = ").trim();
let rest = rest.strip_prefix('"')?;
let closing = rest.find('"')?;
return Some(rest[..closing].to_string());
}
}
None
}
fn replace_cargo_version(contents: &str, new_version: &str) -> Option<String> {
let mut replaced = false;
let mut updated = String::new();
let mut in_package = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
updated.push_str(line);
updated.push('\n');
continue;
}
if in_package && !replaced && trimmed.starts_with("version = ") {
let indent = &line[..line.len() - line.trim_start().len()];
updated.push_str(indent);
updated.push_str(&format!("version = \"{new_version}\""));
updated.push('\n');
replaced = true;
continue;
}
updated.push_str(line);
updated.push('\n');
}
replaced.then_some(updated)
}
fn bump_version(version: &str, part: &str) -> Result<String> {
let mut parts = version.split('.');
let mut next = || -> Result<u64> {
parts
.next()
.context("version is missing a component")?
.parse::<u64>()
.context("version component is not a number")
};
let major = next()?;
let minor = next()?;
let patch = next()?;
let (major, minor, patch) = match part {
"major" => (major + 1, 0, 0),
"minor" => (major, minor + 1, 0),
_ => (major, minor, patch + 1),
};
Ok(format!("{major}.{minor}.{patch}"))
}
#[cfg(test)]
mod tests {
use super::{bump_version, extract_cargo_version, replace_cargo_version};
const CARGO: &str = r#"[package]
name = "git-stk"
version = "0.1.1"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
clap = { version = "4.5.53", features = ["derive"] }
"#;
#[test]
fn extracts_package_version() {
assert_eq!(extract_cargo_version(CARGO), Some("0.1.1".to_string()));
}
#[test]
fn replaces_only_package_version() {
let updated = replace_cargo_version(CARGO, "0.2.0").unwrap();
assert!(updated.contains("version = \"0.2.0\""));
assert!(updated.contains("anyhow = \"1.0.100\""));
assert!(updated.contains("clap = { version = \"4.5.53\""));
assert_eq!(updated.matches("version = \"0.2.0\"").count(), 1);
}
#[test]
fn bumps_each_part() {
assert_eq!(bump_version("1.2.3", "major").unwrap(), "2.0.0");
assert_eq!(bump_version("1.2.3", "minor").unwrap(), "1.3.0");
assert_eq!(bump_version("1.2.3", "patch").unwrap(), "1.2.4");
}
}