use anyhow::{Context, Result};
use clap::Parser;
use log::{debug, info};
use rand::prelude::IndexedRandom;
use rand::Rng;
use regex_lite::Regex;
use std::fs;
use std::path::PathBuf;
fn default_names() -> Vec<String> {
include_str!("names.txt")
.split('\n')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
fn default_commit_messages() -> Vec<String> {
include_str!("commit_messages.txt")
.split('\n')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short = 'n', long = "names", value_name = "FILE")]
names: Option<PathBuf>,
#[arg(short = 'c', long = "commit-messages-template", value_name = "FILE")]
commit_messages_template: Option<PathBuf>,
}
fn load_lines_or_default(
file_path: &Option<PathBuf>,
default_fn: fn() -> Vec<String>,
file_type: &str,
) -> Result<Vec<String>> {
match file_path {
None => {
debug!("Using default {}", file_type);
Ok(default_fn())
}
Some(path) => {
debug!("Loading {} from: {:?}", file_type, path);
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {} file: {:?}", file_type, path))?;
let lines: Vec<String> = content
.lines()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.collect();
if lines.is_empty() {
anyhow::bail!("{} file is empty or contains only empty lines", file_type);
}
info!("Loaded {} {} from {:?}", lines.len(), file_type, path);
Ok(lines)
}
}
}
fn generate_commit_message<R>(
names: &[String],
commit_messages: &[String],
rng: &mut R,
) -> Result<String>
where
R: Rng + ?Sized,
{
let name = names.choose(rng).context("Failed to select any names")?;
let template = commit_messages
.choose(rng)
.context("Failed to select any commit messages")?;
Ok(substitute_placeholders(template, name, rng))
}
fn parse_number_range(value_str: &str) -> (u32, u32) {
if value_str.is_empty() {
return (1, 999);
}
if !value_str.contains(',') {
let end = value_str.parse::<u32>().unwrap_or(999);
return (1, end);
}
let comma_pos = value_str.find(',').unwrap();
if comma_pos == 0 {
let end_str = &value_str[1..];
let end = end_str.parse::<u32>().unwrap_or(999);
(1, end)
} else if comma_pos == value_str.len() - 1 {
let start_str = &value_str[..comma_pos];
let start = start_str.parse::<u32>().unwrap_or(1);
(start, 999)
} else {
let before_comma = &value_str[..comma_pos];
let after_comma = &value_str[comma_pos + 1..];
let start = before_comma.parse::<u32>().unwrap_or(1);
let end = after_comma.parse::<u32>().unwrap_or(999);
(start, end)
}
}
fn generate_random_in_range<R>(start: u32, end: u32, rng: &mut R) -> u32
where
R: Rng + ?Sized,
{
let final_end = if start > end { start * 2 } else { end };
if final_end > start {
rng.random_range(start..=final_end)
} else {
start
}
}
fn substitute_number_placeholders<R>(template: &str, rng: &mut R) -> String
where
R: Rng + ?Sized,
{
let num_re = Regex::new(r"XNUM([0-9,]*)X").unwrap();
num_re
.replace_all(template, |caps: ®ex_lite::Captures| {
let value_str = &caps[1];
let (start, end) = parse_number_range(value_str);
let random_num = generate_random_in_range(start, end, rng);
random_num.to_string()
})
.into_owned()
}
fn substitute_name_placeholders(template: &str, name: &str) -> String {
template
.replace("XUPPERNAMEX", &name.to_ascii_uppercase())
.replace("XLOWERNAMEX", &name.to_ascii_lowercase())
.replace("XNAMEX", name)
}
fn substitute_placeholders<R>(template: &str, name: &str, rng: &mut R) -> String
where
R: Rng + ?Sized,
{
let with_numbers = substitute_number_placeholders(template, rng);
substitute_name_placeholders(&with_numbers, name)
}
fn main() -> Result<()> {
env_logger::init();
let args = Args::parse();
let names = load_lines_or_default(&args.names, default_names, "names")?;
let commit_messages = load_lines_or_default(
&args.commit_messages_template,
default_commit_messages,
"commit messages",
)?;
let mut rng = rand::rng();
let message = generate_commit_message(&names, &commit_messages, &mut rng)?;
println!("{}", message);
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use rand::rngs::StdRng;
use rand::SeedableRng;
#[test]
fn t_substitute_single_placeholder() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Fixed a bug cause XNAMEX said to";
let expected = "Fixed a bug cause John said to";
assert_eq!(
substitute_placeholders(original, "John", &mut rng),
expected
);
}
#[test]
fn t_substitute_upper_placeholder() {
let mut rng = StdRng::seed_from_u64(42);
let original = "XUPPERNAMEX, WE WENT OVER THIS. CHECK WHAT COPILOT PRODUCES FIRST.";
let expected = "ALEX, WE WENT OVER THIS. CHECK WHAT COPILOT PRODUCES FIRST.";
assert_eq!(
substitute_placeholders(original, "Alex", &mut rng),
expected
);
}
#[test]
fn t_substitute_lower_placeholder() {
let mut rng = StdRng::seed_from_u64(42);
let original = "blame it on XLOWERNAMEX";
let expected = "blame it on john";
assert_eq!(
substitute_placeholders(original, "John", &mut rng),
expected
);
}
#[test]
fn t_substitute_multiple_placeholders() {
let mut rng = StdRng::seed_from_u64(42);
let original = "XNAMEX told XLOWERNAMEX that XUPPERNAMEX was wrong";
let expected = "Bob told bob that BOB was wrong";
assert_eq!(substitute_placeholders(original, "Bob", &mut rng), expected);
}
#[test]
fn t_substitute_number_placeholders() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Fixed XNUM10X bugs";
let expected = "Fixed 2 bugs";
assert_eq!(
substitute_placeholders(original, "John", &mut rng),
expected
);
}
#[test]
fn t_substitute_number_with_comma() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Deleted XNUM1,000X lines of code";
let result = substitute_placeholders(original, "John", &mut rng);
let num: u32 = result.split_whitespace().nth(1).unwrap().parse().unwrap();
assert!(num >= 1 && num <= 2);
assert_eq!(result, "Deleted 1 lines of code");
}
#[test]
fn t_substitute_number_default() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Improved performance by XNUMX%";
let result = substitute_placeholders(original, "John", &mut rng);
assert!(result.contains("Improved performance by "));
assert!(result.contains("%"));
let num_str = result
.strip_prefix("Improved performance by ")
.unwrap()
.strip_suffix("%")
.unwrap();
let num: u32 = num_str.parse().unwrap();
assert!(num >= 1 && num <= 999);
}
#[test]
fn t_substitute_mixed_placeholders() {
let mut rng = StdRng::seed_from_u64(42);
let original = "XNAMEX fixed XNUM50X bugs that XLOWERNAMEX found";
let expected = "Alice fixed 7 bugs that alice found";
assert_eq!(
substitute_placeholders(original, "Alice", &mut rng),
expected
);
}
#[test]
fn t_no_placeholders() {
let mut rng = StdRng::seed_from_u64(42);
let original = "This is just a regular commit message";
let expected = "This is just a regular commit message";
assert_eq!(
substitute_placeholders(original, "John", &mut rng),
expected
);
}
#[test]
fn t_substitute_number_range_syntax() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Fixed XNUM1,5X bugs";
let result = substitute_placeholders(original, "John", &mut rng);
let num: u32 = result.split_whitespace().nth(1).unwrap().parse().unwrap();
assert!(num >= 1 && num <= 5);
assert_eq!(result, "Fixed 1 bugs"); }
#[test]
fn t_substitute_number_range_start_only() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Fixed XNUM5,X bugs";
let result = substitute_placeholders(original, "John", &mut rng);
let num: u32 = result.split_whitespace().nth(1).unwrap().parse().unwrap();
assert!(num >= 5 && num <= 999);
}
#[test]
fn t_substitute_number_range_end_only() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Fixed XNUM,5X bugs";
let result = substitute_placeholders(original, "John", &mut rng);
let num: u32 = result.split_whitespace().nth(1).unwrap().parse().unwrap();
assert!(num >= 1 && num <= 5);
assert_eq!(result, "Fixed 1 bugs"); }
#[test]
fn t_substitute_number_inverted_range() {
let mut rng = StdRng::seed_from_u64(42);
let original = "Fixed XNUM10,5X bugs";
let result = substitute_placeholders(original, "John", &mut rng);
let num: u32 = result.split_whitespace().nth(1).unwrap().parse().unwrap();
assert!(num >= 10 && num <= 20);
}
}