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:
TECHNOFAB 2024-12-27 13:46:25 +01:00
parent 5b895b7939
commit 5e120a6ab8
No known key found for this signature in database
GPG key ID: D06FBA11BA6FF836
8 changed files with 522 additions and 230 deletions

View file

@ -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
View 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}")
}

View file

@ -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;

View file

@ -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(&current_version) {
panic!("Did not find string {} in file {}", current_version, path);
let old_line = format.replace("{version}", &current_version);
if !content.contains(&old_line) {
warn!(
current_version,
path, "Did not find current version in file"
);
}
let updated_content = content.replace(&current_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(())

View file

@ -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(&current_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
}