mirror of
https://github.com/TECHNOFAB11/bump2version.git
synced 2025-12-12 08:00:09 +01:00
feat: rewrite huge parts
- real config parsing - actually working parts, with support for string parts - handle readonly config (preparation for use with Nix) - cleanup cli (remove colors, fancy stuff etc., keep it minimal) - switch to tracing for logging
This commit is contained in:
parent
5b895b7939
commit
5e120a6ab8
8 changed files with 522 additions and 230 deletions
105
src/cli.rs
105
src/cli.rs
|
|
@ -1,58 +1,8 @@
|
|||
use clap::builder::styling::{AnsiColor, Effects, Styles};
|
||||
use clap::Parser;
|
||||
|
||||
fn styles() -> Styles {
|
||||
Styles::styled()
|
||||
.header(AnsiColor::Red.on_default() | Effects::BOLD)
|
||||
.usage(AnsiColor::Red.on_default() | Effects::BOLD)
|
||||
.literal(AnsiColor::Blue.on_default() | Effects::BOLD)
|
||||
.error(AnsiColor::Red.on_default() | Effects::BOLD)
|
||||
.placeholder(AnsiColor::Green.on_default())
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(
|
||||
author = "Mahmoud Harmouch",
|
||||
version,
|
||||
name = "bump2version",
|
||||
propagate_version = true,
|
||||
styles = styles(),
|
||||
help_template = r#"{before-help}{name} {version}
|
||||
{about-with-newline}
|
||||
|
||||
{usage-heading} {usage}
|
||||
|
||||
{all-args}{after-help}
|
||||
|
||||
AUTHORS:
|
||||
{author}
|
||||
"#,
|
||||
about=r#"
|
||||
🚀 Bump2version CLI
|
||||
===================
|
||||
|
||||
Bump2version CLI is a command-line tool for managing version numbers in your projects.
|
||||
Easily update version strings, create commits, and manage version control tags.
|
||||
|
||||
FEATURES:
|
||||
- Incremental Versioning: Bump major, minor, or patch versions with ease.
|
||||
- Configurability: Use a configuration file or command-line options to customize behavior.
|
||||
- Git Integration: Create commits and tags in your version control system.
|
||||
|
||||
USAGE:
|
||||
bump2version [OPTIONS]
|
||||
|
||||
EXAMPLES:
|
||||
Bump patch version:
|
||||
bump2version --current-version 1.2.3 --bump patch
|
||||
|
||||
Bump minor version and create a commit:
|
||||
bump2version --current-version 1.2.3 --bump minor --commit
|
||||
|
||||
For more information, visit: https://github.com/wiseaidev/bump2version
|
||||
"#
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(version, name = "bump2version", propagate_version = true)]
|
||||
pub(crate) struct Cli {
|
||||
/// Config file to read most of the variables from.
|
||||
#[arg(
|
||||
short = 'c',
|
||||
|
|
@ -60,11 +10,7 @@ pub struct Cli {
|
|||
value_name = "FILE",
|
||||
default_value_t = String::from(".bumpversion.toml")
|
||||
)]
|
||||
pub config_file: String,
|
||||
|
||||
/// Version that needs to be updated.
|
||||
#[arg(long = "current-version", value_name = "VERSION")]
|
||||
pub current_version: Option<String>,
|
||||
pub(crate) config_file: String,
|
||||
|
||||
/// Part of the version to be bumped.
|
||||
#[arg(
|
||||
|
|
@ -72,50 +18,29 @@ pub struct Cli {
|
|||
value_name = "PART",
|
||||
default_value_t = String::from("patch")
|
||||
)]
|
||||
pub bump: String,
|
||||
|
||||
/// Regex parsing the version string.
|
||||
#[arg(
|
||||
long = "parse",
|
||||
value_name = "REGEX",
|
||||
default_value_t = String::from(r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)")
|
||||
)]
|
||||
pub parse: String,
|
||||
|
||||
/// How to format what is parsed back to a version.
|
||||
#[arg(
|
||||
long = "serialize",
|
||||
value_name = "FORMAT",
|
||||
default_value_t = String::from("{major}.{minor}.{patch}")
|
||||
)]
|
||||
pub serialize: String,
|
||||
pub(crate) bump: String,
|
||||
|
||||
/// Don't write any files, just pretend.
|
||||
#[arg(short = 'n', long = "dry-run", default_value_t = false)]
|
||||
pub dry_run: bool,
|
||||
pub(crate) dry_run: bool,
|
||||
|
||||
/// New version that should be in the files.
|
||||
#[arg(long = "new-version", value_name = "VERSION")]
|
||||
pub new_version: Option<String>,
|
||||
pub(crate) new_version: Option<String>,
|
||||
|
||||
/// Create a commit in version control.
|
||||
#[arg(long = "commit", default_value_t = true)]
|
||||
pub commit: bool,
|
||||
#[arg(long = "commit")]
|
||||
pub(crate) commit: Option<bool>,
|
||||
|
||||
/// Create a tag in version control.
|
||||
#[arg(long = "tag")]
|
||||
pub tag: bool,
|
||||
pub(crate) tag: Option<bool>,
|
||||
|
||||
/// Whether to fail on a dirty git repository
|
||||
#[arg(long = "fail-on-dirty", default_value_t = false)]
|
||||
pub(crate) fail_on_dirty: bool,
|
||||
|
||||
/// Commit message.
|
||||
#[arg(
|
||||
short = 'm',
|
||||
long = "message",
|
||||
value_name = "COMMIT_MSG",
|
||||
default_value_t = String::from("Bump version: {current_version} → {new_version}")
|
||||
)]
|
||||
pub message: String,
|
||||
|
||||
/// Files to change.
|
||||
#[arg(value_name = "file")]
|
||||
pub files: Vec<String>,
|
||||
#[arg(short = 'm', long = "message", value_name = "COMMIT_MSG")]
|
||||
pub(crate) message: Option<String>,
|
||||
}
|
||||
|
|
|
|||
48
src/config.rs
Normal file
48
src/config.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct Config {
|
||||
pub(crate) current_version: String,
|
||||
pub(crate) message: Option<String>,
|
||||
pub(crate) commit: bool,
|
||||
pub(crate) tag: bool,
|
||||
#[serde(default = "default_parse")]
|
||||
pub(crate) parse: String,
|
||||
#[serde(default = "default_serialize")]
|
||||
pub(crate) serialize: String,
|
||||
|
||||
pub(crate) part: HashMap<String, Part>,
|
||||
pub(crate) file: HashMap<String, File>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum PartType {
|
||||
String,
|
||||
#[serde(other)]
|
||||
Number,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct Part {
|
||||
/// Type of the part (number or string)
|
||||
pub(crate) r#type: PartType,
|
||||
/// Valid values for the part (only used when type is string)
|
||||
pub(crate) values: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct File {
|
||||
/// Format to replace, eg. `current_version = "{version}"`
|
||||
pub(crate) format: String,
|
||||
}
|
||||
|
||||
fn default_parse() -> String {
|
||||
String::from(r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)")
|
||||
}
|
||||
|
||||
fn default_serialize() -> String {
|
||||
String::from("{major}.{minor}.{patch}")
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod cli;
|
||||
pub mod utils;
|
||||
pub(crate) mod cli;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod utils;
|
||||
|
|
|
|||
120
src/main.rs
120
src/main.rs
|
|
@ -1,47 +1,63 @@
|
|||
use self::cli::Cli;
|
||||
use crate::config::Config;
|
||||
use crate::utils::attempt_version_bump;
|
||||
use crate::utils::get_current_version_from_config;
|
||||
use crate::utils::read_files_from_config;
|
||||
use clap::Parser;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::process::{exit, Command};
|
||||
use tracing::{error, info, level_filters::LevelFilter, warn};
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod utils;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy(),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Cli::parse();
|
||||
let config_file = args.config_file.clone();
|
||||
let config_content = fs::read_to_string(args.config_file.clone()).unwrap();
|
||||
let config_version = get_current_version_from_config(&config_content)
|
||||
.ok_or("failed to get current version from config")?;
|
||||
let current_version = args
|
||||
.current_version
|
||||
.clone()
|
||||
.unwrap_or(config_version)
|
||||
.clone();
|
||||
|
||||
let attempted_new_version = if let Some(version) = args.new_version {
|
||||
Some(version)
|
||||
} else {
|
||||
attempt_version_bump(args.clone())
|
||||
let contents = match fs::read_to_string(&config_file) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
error!(?err, config_file, "Could not read config");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
let config: Config = match toml::from_str(&contents) {
|
||||
Ok(d) => d,
|
||||
Err(err) => {
|
||||
error!(err = err.to_string(), config_file, "Unable to load config");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let current_version = config.current_version.clone();
|
||||
|
||||
let attempted_new_version = args
|
||||
.new_version
|
||||
.clone()
|
||||
.or(attempt_version_bump(args.clone(), config.clone()));
|
||||
|
||||
if attempted_new_version.is_some() {
|
||||
let new_version = attempted_new_version.clone().unwrap();
|
||||
info!(current_version, new_version, "Bumping version");
|
||||
|
||||
let dry_run = args.dry_run;
|
||||
let commit = args.commit;
|
||||
let tag = args.tag;
|
||||
let message = args.message;
|
||||
let commit = args.commit.unwrap_or(config.commit);
|
||||
let tag = args.tag.unwrap_or(config.tag);
|
||||
let message = args
|
||||
.message
|
||||
.or(config.message)
|
||||
.unwrap_or("Bump version: {current_version} → {new_version}".to_string());
|
||||
|
||||
let files: Vec<String> = if args.files.is_empty() {
|
||||
let config_files: HashSet<String> = read_files_from_config(&args.config_file)?;
|
||||
config_files.into_iter().collect()
|
||||
} else {
|
||||
args.files
|
||||
};
|
||||
let files: Vec<&String> = config.file.keys().collect();
|
||||
|
||||
// Check if Git working directory is clean
|
||||
if fs::metadata(".git").is_ok() {
|
||||
|
|
@ -57,19 +73,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.collect();
|
||||
|
||||
if !git_lines.is_empty() {
|
||||
panic!("Git working directory not clean:\n{}", git_lines.join("\n"));
|
||||
warn!("Git working directory not clean:\n{}", git_lines.join("\n"));
|
||||
if args.fail_on_dirty {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update version in specified files
|
||||
for path in &files {
|
||||
info!(amount = &files.len(), "Updating version in files");
|
||||
for path in files.clone() {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let format = &config.file.get(path).unwrap().format;
|
||||
|
||||
if !content.contains(¤t_version) {
|
||||
panic!("Did not find string {} in file {}", current_version, path);
|
||||
let old_line = format.replace("{version}", ¤t_version);
|
||||
if !content.contains(&old_line) {
|
||||
warn!(
|
||||
current_version,
|
||||
path, "Did not find current version in file"
|
||||
);
|
||||
}
|
||||
|
||||
let updated_content = content.replace(¤t_version, &new_version);
|
||||
let new_line = format.replace("{version}", &new_version);
|
||||
let updated_content = content.replace(&old_line, &new_line);
|
||||
|
||||
if !dry_run {
|
||||
fs::write(path, updated_content)?;
|
||||
|
|
@ -78,18 +104,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
let mut commit_files = files.clone();
|
||||
|
||||
// Update config file if applicable
|
||||
if fs::metadata(config_file.clone()).is_ok() {
|
||||
// Update config file if applicable & writable
|
||||
let metadata = fs::metadata(config_file.clone());
|
||||
if metadata.is_ok() && !metadata.unwrap().permissions().readonly() {
|
||||
info!("Updating version in config file");
|
||||
let mut config_content = fs::read_to_string(config_file.clone())?;
|
||||
|
||||
config_content = config_content.replace(
|
||||
&format!("current_version = {}", current_version),
|
||||
&format!("current_version = {}", new_version),
|
||||
&format!("current_version = \"{}\"", current_version),
|
||||
&format!("current_version = \"{}\"", new_version),
|
||||
);
|
||||
|
||||
if !dry_run {
|
||||
fs::write(config_file.clone(), config_content)?;
|
||||
commit_files.push(config_file);
|
||||
commit_files.push(&config_file);
|
||||
}
|
||||
}
|
||||
if commit {
|
||||
|
|
@ -100,11 +128,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
eprintln!("Error during git add:\n{}", stderr);
|
||||
error!(?stderr, "Error during git add");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to execute git add: {}", err);
|
||||
error!(?err, "Failed to execute git add");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,24 +154,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
match commit_output {
|
||||
Ok(commit_output) => {
|
||||
if commit_output.status.success() {
|
||||
println!("Git commit successful");
|
||||
info!("Git commit successful");
|
||||
} else {
|
||||
eprintln!(
|
||||
"Error during git commit:\n{}",
|
||||
String::from_utf8_lossy(&commit_output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&commit_output.stderr);
|
||||
error!(?stderr, "Error during git commit",);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to execute git commit: {}", err);
|
||||
error!(?err, "Failed to execute git commit");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("No changes to commit. Working tree clean.");
|
||||
warn!("No changes to commit. Working tree clean.");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to execute git diff: {}", err);
|
||||
error!(?err, "Failed to execute git diff");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +181,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("No files specified");
|
||||
error!("No new version passed and generating new version failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
132
src/utils.rs
132
src/utils.rs
|
|
@ -1,54 +1,22 @@
|
|||
use crate::cli::Cli;
|
||||
use crate::config::{Config, PartType};
|
||||
use regex::Regex;
|
||||
use std::cmp::min;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::ops::Index;
|
||||
use tracing::{error, trace};
|
||||
|
||||
pub fn get_current_version_from_config(config_content: &str) -> Option<String> {
|
||||
let current_version_regex =
|
||||
Regex::new(r#"\[bumpversion\]\s*current_version\s*=\s*(?P<version>\d+(\.\d+){0,2})\s*"#)
|
||||
.unwrap();
|
||||
|
||||
if let Some(captures) = current_version_regex.captures(config_content) {
|
||||
if let Some(version) = captures.name("version") {
|
||||
return Some(version.as_str().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// Function to read files from the configuration file
|
||||
pub fn read_files_from_config(config_file: &str) -> Result<HashSet<String>, std::io::Error> {
|
||||
let config_content = fs::read_to_string(config_file)?;
|
||||
let mut config_files = HashSet::new();
|
||||
|
||||
for line in config_content.lines() {
|
||||
if let Some(file_section) = line.strip_prefix("[bumpversion:file:") {
|
||||
if let Some(file_name) = file_section.split(']').next() {
|
||||
config_files.insert(file_name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config_files)
|
||||
}
|
||||
|
||||
pub fn attempt_version_bump(args: Cli) -> Option<String> {
|
||||
let parse_regex = args.parse.clone();
|
||||
pub fn attempt_version_bump(args: Cli, config: Config) -> Option<String> {
|
||||
let parse_regex = config.parse;
|
||||
let regex = match Regex::new(&parse_regex) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
eprintln!("--patch '{}' is not a valid regex", args.parse.clone());
|
||||
Err(err) => {
|
||||
error!(?err, parse_regex, "Invalid 'parse' regex");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let config_content = fs::read_to_string(args.config_file.clone()).unwrap();
|
||||
let current_version = get_current_version_from_config(&config_content).unwrap_or_else(|| {
|
||||
panic!("Failed to extract current version from config file");
|
||||
});
|
||||
// let current_version = args.current_version.unwrap_or("".to_string());
|
||||
let current_version = config.current_version;
|
||||
let mut parsed: HashMap<String, String> = HashMap::new();
|
||||
|
||||
if let Some(captures) = regex.captures(¤t_version) {
|
||||
|
|
@ -59,41 +27,83 @@ pub fn attempt_version_bump(args: Cli) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
let order: Vec<&str> = args
|
||||
let order: Vec<&str> = config
|
||||
.serialize
|
||||
.match_indices('{')
|
||||
.map(|(i, _)| args.serialize[i + 1..].split('}').next().unwrap().trim())
|
||||
.map(|(i, _)| config.serialize[i + 1..].split('}').next().unwrap().trim())
|
||||
.collect();
|
||||
|
||||
trace!(?order, "detected version parts");
|
||||
|
||||
let mut bumped = false;
|
||||
|
||||
for label in order {
|
||||
let part_configs = config.part.clone();
|
||||
|
||||
for label in order.clone() {
|
||||
if let Some(part) = parsed.get_mut(label) {
|
||||
let part_cfg = part_configs.get(label);
|
||||
|
||||
if label == args.bump {
|
||||
if let Ok(new_value) = part.parse::<u64>() {
|
||||
*part = (new_value + 1).to_string();
|
||||
bumped = true;
|
||||
} else {
|
||||
eprintln!("Failed to parse '{}' as u64", part);
|
||||
return None;
|
||||
match part_cfg
|
||||
.map(|cfg| cfg.r#type.clone())
|
||||
.unwrap_or(PartType::Number)
|
||||
{
|
||||
PartType::String => {
|
||||
let values = part_cfg
|
||||
.unwrap()
|
||||
.values
|
||||
.clone()
|
||||
.expect("part values do not exist for string type");
|
||||
let old_index: usize = values
|
||||
.iter()
|
||||
.position(|val| val == part)
|
||||
.expect("part value does not exist");
|
||||
let new_index: usize = min(old_index + 1, values.len() - 1);
|
||||
*part = values.index(new_index).to_string();
|
||||
bumped = true;
|
||||
}
|
||||
PartType::Number => {
|
||||
if let Ok(old_value) = part.parse::<u64>() {
|
||||
*part = (old_value + 1).to_string();
|
||||
bumped = true;
|
||||
} else {
|
||||
error!(part, "Failed to parse as u64");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if bumped {
|
||||
*part = "0".to_string();
|
||||
match part_cfg
|
||||
.map(|cfg| cfg.r#type.clone())
|
||||
.unwrap_or(PartType::Number)
|
||||
{
|
||||
PartType::Number => *part = "0".to_string(),
|
||||
PartType::String => {
|
||||
let values = part_cfg
|
||||
.unwrap()
|
||||
.values
|
||||
.clone()
|
||||
.expect("part values do not exist for string type");
|
||||
*part = values.index(0).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trace!(label, "part not found");
|
||||
}
|
||||
}
|
||||
|
||||
if bumped {
|
||||
let new_version = format!(
|
||||
"{}.{}.{}",
|
||||
parsed.get("major").unwrap_or(&"0".to_string()),
|
||||
parsed.get("minor").unwrap_or(&"0".to_string()),
|
||||
parsed.get("patch").unwrap_or(&"0".to_string())
|
||||
);
|
||||
let version = args
|
||||
.serialize
|
||||
.replace("{major}.{minor}.{patch}", &new_version);
|
||||
Some(version)
|
||||
let mut new_version = config.serialize.clone();
|
||||
for part in order {
|
||||
trace!(new_version, part, "building new version");
|
||||
new_version = new_version.replace(
|
||||
&format!("{{{}}}", part),
|
||||
parsed.get(part).expect("unexpected part in version found"),
|
||||
);
|
||||
}
|
||||
trace!(new_version, "created new version");
|
||||
Some(new_version)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue