use chrono::Local;
use regex::Regex;
use std::{collections::HashMap, hash::BuildHasher};
use crate::errors::{Result, RonaError};
#[derive(Debug, Clone)]
pub struct TemplateVariables {
pub commit_number: Option<u32>,
pub commit_type: String,
pub branch_name: String,
pub message: String,
pub date: String,
pub time: String,
pub author: String,
pub email: String,
}
impl TemplateVariables {
pub fn new(
commit_number: Option<u32>,
commit_type: String,
branch_name: String,
message: String,
) -> Result<Self> {
let (date, time) = {
let now = Local::now();
(
now.format("%Y-%m-%d").to_string(),
now.format("%H:%M:%S").to_string(),
)
};
let (author, email) = get_git_author_info()?;
Ok(Self {
commit_number,
commit_type,
branch_name,
message,
date,
time,
author,
email,
})
}
#[must_use]
pub fn to_map(&self) -> HashMap<String, String> {
let mut map = HashMap::new();
map.insert("commit_type".to_string(), self.commit_type.clone());
map.insert("branch_name".to_string(), self.branch_name.clone());
map.insert("message".to_string(), self.message.clone());
map.insert("date".to_string(), self.date.clone());
map.insert("time".to_string(), self.time.clone());
map.insert("author".to_string(), self.author.clone());
map.insert("email".to_string(), self.email.clone());
if let Some(commit_number) = self.commit_number {
map.insert("commit_number".to_string(), commit_number.to_string());
} else {
map.insert("commit_number".to_string(), String::new());
}
map
}
}
fn process_conditional_blocks<S: BuildHasher>(
template: &str,
variables: &TemplateVariables,
extra_variables: &HashMap<String, String, S>,
) -> Result<String> {
let mut variable_map = variables.to_map();
variable_map.extend(extra_variables.iter().map(|(k, v)| (k.clone(), v.clone())));
let mut result = template.to_string();
let open_regex = Regex::new(r"\{\?(\w+)\}").map_err(|e| {
RonaError::Io(std::io::Error::other(format!(
"Invalid conditional regex: {e}"
)))
})?;
while let Some(open_match) = open_regex.find(&result) {
let open_start = open_match.start();
let open_end = open_match.end();
if let Some(captures) = open_regex.captures(&result[open_start..open_end]) {
let Some(cap) = captures.get(1) else {
break;
};
let var_name = cap.as_str();
let close_pattern = format!("{{/{var_name}}}");
if let Some(close_pos) = result[open_end..].find(&close_pattern) {
let close_start = open_end + close_pos;
let close_end = close_start + close_pattern.len();
let content = &result[open_end..close_start];
let has_value = variable_map.get(var_name).is_some_and(|v| !v.is_empty());
let replacement = if has_value { content } else { "" };
let full_block = &result[open_start..close_end];
result = result.replace(full_block, replacement);
} else {
return Err(RonaError::Io(std::io::Error::other(format!(
"Unclosed conditional block: {{?{var_name}}}"
))));
}
}
}
Ok(result)
}
pub fn process_template<S: BuildHasher>(
template: &str,
variables: &TemplateVariables,
extra_variables: &HashMap<String, String, S>,
) -> Result<String> {
let after_conditionals = process_conditional_blocks(template, variables, extra_variables)?;
let mut variable_map = variables.to_map();
variable_map.extend(extra_variables.iter().map(|(k, v)| (k.clone(), v.clone())));
let regex = Regex::new(r"\{([^}]+)\}").map_err(|e| {
RonaError::Io(std::io::Error::other(format!(
"Invalid template regex: {e}"
)))
})?;
let mut result = after_conditionals.clone();
for capture in regex.captures_iter(&after_conditionals) {
if let Some(variable_name) = capture.get(1) {
let var_name = variable_name.as_str();
let empty_string = String::new();
let value = variable_map.get(var_name).unwrap_or(&empty_string);
result = result.replace(&capture[0], value);
}
}
Ok(result)
}
pub fn validate_template(template: &str, extra_variable_names: &[&str]) -> Result<()> {
let mut valid_variables: Vec<&str> = vec![
"commit_number",
"commit_type",
"branch_name",
"message",
"date",
"time",
"author",
"email",
];
valid_variables.extend_from_slice(extra_variable_names);
let conditional_regex = Regex::new(r"\{\?(\w+)\}").map_err(|e| {
RonaError::Io(std::io::Error::other(format!(
"Invalid conditional regex: {e}"
)))
})?;
let closing_regex = Regex::new(r"\{/(\w+)\}")
.map_err(|e| RonaError::Io(std::io::Error::other(format!("Invalid closing regex: {e}"))))?;
let open_tags: Vec<(usize, &str)> = conditional_regex
.captures_iter(template)
.filter_map(|cap| {
let pos = cap.get(0)?.start();
let name = cap.get(1)?.as_str();
Some((pos, name))
})
.collect();
let mut close_tags: Vec<(usize, &str)> = closing_regex
.captures_iter(template)
.filter_map(|cap| {
let pos = cap.get(0)?.start();
let name = cap.get(1)?.as_str();
Some((pos, name))
})
.collect();
for (open_pos, open_name) in &open_tags {
let matching_close = close_tags
.iter()
.position(|(close_pos, close_name)| close_pos > open_pos && close_name == open_name);
let Some(matching_close_idx) = matching_close else {
return Err(RonaError::Io(std::io::Error::other(format!(
"Unclosed conditional block: {{?{open_name}}}"
))));
};
if !valid_variables.contains(open_name) {
return Err(RonaError::Io(std::io::Error::other(format!(
"Unknown variable in conditional block: {{?{open_name}}}. Valid variables are: {}",
valid_variables.join(", ")
))));
}
close_tags.remove(matching_close_idx);
}
if !close_tags.is_empty() {
let (_, unmatched_name) = close_tags[0];
return Err(RonaError::Io(std::io::Error::other(format!(
"Unmatched closing tag: {{/{unmatched_name}}}"
))));
}
let regex = Regex::new(r"\{([^}?/]+)\}").map_err(|e| {
RonaError::Io(std::io::Error::other(format!(
"Invalid template regex: {e}"
)))
})?;
for capture in regex.captures_iter(template) {
if let Some(variable_name) = capture.get(1) {
let var_name = variable_name.as_str();
if var_name.starts_with('?') || var_name.starts_with('/') {
continue;
}
if !valid_variables.contains(&var_name) {
return Err(RonaError::Io(std::io::Error::other(format!(
"Unknown template variable: {{{var_name}}}. Valid variables are: {}",
valid_variables.join(", ")
))));
}
}
}
Ok(())
}
fn get_git_author_info() -> Result<(String, String)> {
use std::process::Command;
let name_output = Command::new("git")
.args(["config", "--get", "user.name"])
.output()
.map_err(RonaError::Io)?;
let name = if name_output.status.success() {
String::from_utf8_lossy(&name_output.stdout)
.trim()
.to_string()
} else {
String::new()
};
let email_output = Command::new("git")
.args(["config", "--get", "user.email"])
.output()
.map_err(RonaError::Io)?;
let email = if email_output.status.success() {
String::from_utf8_lossy(&email_output.stdout)
.trim()
.to_string()
} else {
String::new()
};
Ok((name, email))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn test_template_processing() -> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "[{commit_number}] ({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: Some(42),
commit_type: "feat".to_string(),
branch_name: "feature/new-feature".to_string(),
message: "Add new functionality".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(
result,
"[42] (feat on feature/new-feature) Add new functionality"
);
Ok(())
}
#[test]
fn test_template_without_commit_number() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let template = "({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "fix".to_string(),
branch_name: "main".to_string(),
message: "Fix bug".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "(fix on main) Fix bug");
Ok(())
}
#[test]
fn test_template_validation_valid() {
let template = "[{commit_number}] ({commit_type} on {branch_name}) {message}";
assert!(validate_template(template, &[]).is_ok());
}
#[test]
fn test_template_validation_invalid() {
let template = "[{commit_number}] ({invalid_var} on {branch_name}) {message}";
assert!(validate_template(template, &[]).is_err());
}
#[test]
fn test_template_variables_to_map() -> std::result::Result<(), Box<dyn std::error::Error>> {
let variables = TemplateVariables {
commit_number: Some(42),
commit_type: "feat".to_string(),
branch_name: "feature/test".to_string(),
message: "Test message".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Test Author".to_string(),
email: "test@example.com".to_string(),
};
let map = variables.to_map();
assert_eq!(
map.get("commit_number").ok_or("commit_number not found")?,
"42"
);
assert_eq!(
map.get("commit_type").ok_or("commit_type not found")?,
"feat"
);
assert_eq!(
map.get("branch_name").ok_or("branch_name not found")?,
"feature/test"
);
assert_eq!(
map.get("message").ok_or("message not found")?,
"Test message"
);
assert_eq!(map.get("date").ok_or("date not found")?, "2024-01-15");
assert_eq!(map.get("time").ok_or("time not found")?, "14:30:00");
assert_eq!(map.get("author").ok_or("author not found")?, "Test Author");
assert_eq!(
map.get("email").ok_or("email not found")?,
"test@example.com"
);
Ok(())
}
#[test]
fn test_template_with_all_variables() -> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{commit_type}: {message} by {author} <{email}> on {branch_name} at {date} {time} (#{commit_number})";
let variables = TemplateVariables {
commit_number: Some(123),
commit_type: "fix".to_string(),
branch_name: "hotfix/critical-bug".to_string(),
message: "Fix critical authentication bug".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Jane Doe".to_string(),
email: "jane@company.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(
result,
"fix: Fix critical authentication bug by Jane Doe <jane@company.com> on hotfix/critical-bug at 2024-01-15 14:30:00 (#123)"
);
Ok(())
}
#[test]
fn test_template_with_special_chars() -> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "* {commit_type}: {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "feat".to_string(),
branch_name: "feature/new-feature".to_string(),
message: "Add new feature".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "* feat: Add new feature");
Ok(())
}
#[test]
fn test_template_without_commit_number_variable()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "docs".to_string(),
branch_name: "main".to_string(),
message: "Update documentation".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "(docs on main) Update documentation");
Ok(())
}
#[test]
fn test_template_validation_with_unknown_variable()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "[{commit_number}] ({unknown_var} on {branch_name}) {message}";
let result = validate_template(template, &[]);
assert!(result.is_err());
let Err(e) = result else {
return Err("Expected error".into());
};
assert!(e.to_string().contains("Unknown template variable"));
Ok(())
}
#[test]
fn test_default_template_with_none_commit_number_produces_empty_brackets()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "[{commit_number}] ({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "docs".to_string(),
branch_name: "main".to_string(),
message: "Update docs".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "[] (docs on main) Update docs");
assert!(
result.contains("[]"),
"This test documents the bug: empty brackets appear when commit_number is None"
);
Ok(())
}
#[test]
fn test_template_without_commit_number_placeholder_avoids_empty_brackets()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "docs".to_string(),
branch_name: "main".to_string(),
message: "Update docs".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "(docs on main) Update docs");
assert!(
!result.contains("[]"),
"Output should not contain empty brackets"
);
assert!(
!result.contains("[{"),
"Output should not contain unprocessed template variables"
);
Ok(())
}
#[test]
fn test_various_templates_with_none_commit_number()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let variables = TemplateVariables {
commit_number: None,
commit_type: "feat".to_string(),
branch_name: "new-feature".to_string(),
message: "Add feature".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Jane Doe".to_string(),
email: "jane@example.com".to_string(),
};
let template_with = "[{commit_number}] {commit_type}: {message}";
let result_with = process_template(template_with, &variables, &HashMap::new())?;
assert!(
result_with.starts_with("[]"),
"Bug: produces empty brackets"
);
let template_without = "{commit_type}: {message}";
let result_without = process_template(template_without, &variables, &HashMap::new())?;
assert_eq!(result_without, "feat: Add feature");
assert!(
!result_without.contains("[]"),
"Should not contain empty brackets"
);
let template_prefix = "#{commit_number} {commit_type}: {message}";
let result_prefix = process_template(template_prefix, &variables, &HashMap::new())?;
assert_eq!(
result_prefix, "# feat: Add feature",
"Empty string for None values"
);
Ok(())
}
#[test]
fn test_variables_to_map_with_none_commit_number()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let variables = TemplateVariables {
commit_number: None,
commit_type: "test".to_string(),
branch_name: "testing".to_string(),
message: "Test message".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Test User".to_string(),
email: "test@example.com".to_string(),
};
let map = variables.to_map();
assert_eq!(
map.get("commit_number").ok_or("commit_number not found")?,
""
);
assert_eq!(
map.get("commit_type").ok_or("commit_type not found")?,
"test"
);
Ok(())
}
#[test]
fn test_conditional_block_with_value() -> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}[{commit_number}] {/commit_number}({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: Some(42),
commit_type: "feat".to_string(),
branch_name: "new-feature".to_string(),
message: "Add feature".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "[42] (feat on new-feature) Add feature");
Ok(())
}
#[test]
fn test_conditional_block_without_value() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let template = "{?commit_number}[{commit_number}] {/commit_number}({commit_type} on {branch_name}) {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "feat".to_string(),
branch_name: "new-feature".to_string(),
message: "Add feature".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "John Doe".to_string(),
email: "john@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "(feat on new-feature) Add feature");
assert!(!result.contains("[]"));
Ok(())
}
#[test]
fn test_multiple_conditional_blocks() -> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}[{commit_number}]{/commit_number} {?date}on {date}{/date} ({commit_type}) {message}";
let variables = TemplateVariables {
commit_number: Some(5),
commit_type: "fix".to_string(),
branch_name: "bugfix".to_string(),
message: "Fix bug".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Jane Doe".to_string(),
email: "jane@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "[5] on 2024-01-15 (fix) Fix bug");
Ok(())
}
#[test]
fn test_multiple_conditional_blocks_partial()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}[{commit_number}]{/commit_number} {?author}by {author}{/author} - {message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "docs".to_string(),
branch_name: "docs".to_string(),
message: "Update docs".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Alice".to_string(),
email: "alice@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, " by Alice - Update docs");
Ok(())
}
#[test]
fn test_conditional_block_with_static_text()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}Commit #{commit_number}: {/commit_number}{message}";
let variables = TemplateVariables {
commit_number: Some(100),
commit_type: "chore".to_string(),
branch_name: "main".to_string(),
message: "Update dependencies".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Bob".to_string(),
email: "bob@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "Commit #100: Update dependencies");
Ok(())
}
#[test]
fn test_conditional_block_validation_valid() {
let template =
"{?commit_number}[{commit_number}] {/commit_number}({commit_type}) {message}";
assert!(validate_template(template, &[]).is_ok());
}
#[test]
fn test_conditional_block_validation_unclosed()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}[{commit_number}] ({commit_type}) {message}";
let result = validate_template(template, &[]);
assert!(result.is_err());
let Err(e) = result else {
return Err("Expected error".into());
};
assert!(e.to_string().contains("Unclosed conditional block"));
Ok(())
}
#[test]
fn test_conditional_block_validation_unmatched_closing()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "[{commit_number}] {/commit_number}({commit_type}) {message}";
let result = validate_template(template, &[]);
assert!(result.is_err());
let Err(e) = result else {
return Err("Expected error".into());
};
assert!(e.to_string().contains("Unmatched closing tag"));
Ok(())
}
#[test]
fn test_conditional_block_validation_invalid_variable()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?invalid_var}[{invalid_var}]{/invalid_var} {message}";
let result = validate_template(template, &[]);
assert!(result.is_err());
let Err(e) = result else {
return Err("Expected error".into());
};
assert!(
e.to_string()
.contains("Unknown variable in conditional block")
);
Ok(())
}
#[test]
fn test_conditional_block_empty_string_variable()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}[{commit_number}] {/commit_number}{message}";
let variables = TemplateVariables {
commit_number: None,
commit_type: "test".to_string(),
branch_name: "test".to_string(),
message: "Test".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Tester".to_string(),
email: "test@example.com".to_string(),
};
let result = process_template(template, &variables, &HashMap::new())?;
assert_eq!(result, "Test");
assert!(!result.contains("[]"));
Ok(())
}
#[test]
fn test_original_bug_fix() -> std::result::Result<(), Box<dyn std::error::Error>> {
let template = "{?commit_number}[{commit_number}] {/commit_number}({commit_type} on {branch_name}) {message}";
let with_number = TemplateVariables {
commit_number: Some(42),
commit_type: "feat".to_string(),
branch_name: "new-feature".to_string(),
message: "Add feature".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Dev".to_string(),
email: "dev@example.com".to_string(),
};
let result_with = process_template(template, &with_number, &HashMap::new())?;
assert_eq!(result_with, "[42] (feat on new-feature) Add feature");
let without_number = TemplateVariables {
commit_number: None,
commit_type: "feat".to_string(),
branch_name: "new-feature".to_string(),
message: "Add feature".to_string(),
date: "2024-01-15".to_string(),
time: "14:30:00".to_string(),
author: "Dev".to_string(),
email: "dev@example.com".to_string(),
};
let result_without = process_template(template, &without_number, &HashMap::new())?;
assert_eq!(result_without, "(feat on new-feature) Add feature");
assert!(!result_without.contains("[]"));
assert!(!result_without.starts_with("[]"));
Ok(())
}
}