use crate::config::{Config, DodConfig};
use crate::git::RunOpts;
use crate::{config, git, intent, radar, review};
use anyhow::Result;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
use std::path::PathBuf;
pub struct CommitParams {
pub r#type: String,
pub scope: Option<String>,
pub message: String,
pub body: Option<String>,
pub breaking: bool,
pub breaking_description: Option<String>,
pub tag: Option<String>,
pub issue: Option<String>,
pub include_projects: bool,
pub no_verify: bool,
}
pub fn run_checklist_interactive(checklist: &[String]) -> Result<Vec<usize>> {
let selections = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Please confirm each item before committing:")
.items(checklist)
.interact()?;
Ok(selections)
}
pub fn build_todo_footer(checklist: &[String], checked_indices: &[usize]) -> String {
let unchecked_items: Vec<String> = checklist
.iter()
.enumerate()
.filter(|(i, _)| !checked_indices.contains(i))
.map(|(_, item)| format!("- [ ] {}", item))
.collect();
if unchecked_items.is_empty() {
String::new()
} else {
format!("\n\nTODO:\n{}", unchecked_items.join("\n"))
}
}
pub fn handle_interactive_commit(
config: &DodConfig,
base_message: &str,
) -> Result<Option<String>, anyhow::Error> {
let mut commit_message = base_message.to_string();
let checked = run_checklist_interactive(&config.checklist)?;
if checked.len() != config.checklist.len() {
if Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Warning: Not all DoD items were checked. Proceed by adding a 'TODO' list to the commit message?")
.interact()?
{
let todo_footer = build_todo_footer(&config.checklist, &checked);
commit_message.push_str(&todo_footer);
} else {
println!("Commit aborted.");
return Ok(None);
}
}
Ok(Some(commit_message))
}
pub fn handle_interactive_dod(config: &DodConfig) -> Result<Option<String>> {
let checked = run_checklist_interactive(&config.checklist)?;
if checked.len() != config.checklist.len() {
if Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Warning: Not all DoD items were checked. Proceed by adding a 'TODO' list to the commit message?")
.interact()?
{
let todo_footer = build_todo_footer(&config.checklist, &checked);
Ok(Some(todo_footer))
} else {
println!("Commit aborted.");
Ok(None)
}
} else {
Ok(Some(String::new())) }
}
pub fn is_valid_commit_type(commit_type: &str, config: &Config) -> bool {
if let Some(lint_config) = &config.lint {
if let Some(conventional_commit_type) = &lint_config.conventional_commit_type {
if let Some(enabled) = conventional_commit_type.enabled {
if !enabled {
return true; }
}
if let Some(allowed_types) = &conventional_commit_type.allowed_types {
return allowed_types.iter().any(|t| t == commit_type);
}
}
}
true
}
pub fn is_valid_issue_key(issue_key: &Option<String>, config: &Config) -> Result<bool> {
if let Some(lint_config) = &config.lint {
if let Some(issue_key_config) = &lint_config.issue_key_missing {
if let Some(enabled) = issue_key_config.enabled {
if !enabled {
return Ok(true); }
}
if let Some(issue_key_pattern) = &issue_key_config.pattern {
let re = regex::Regex::new(issue_key_pattern).map_err(|e| {
anyhow::anyhow!("Invalid issue_key pattern '{}': {}", issue_key_pattern, e)
})?;
return Ok(re.is_match(issue_key.as_ref().unwrap_or(&"".to_string())));
}
}
}
Ok(true)
}
pub fn is_valid_scope(scope: &Option<String>, config: &Config) -> bool {
if let Some(lint_config) = &config.lint {
if let Some(scope_config) = &lint_config.scope {
if let Some(enabled) = scope_config.enabled {
if !enabled {
return true; }
}
if let Some(enforce_lowercase) = scope_config.enforce_lowercase {
if enforce_lowercase {
if let Some(s) = scope {
return s.chars().all(|c| c.is_lowercase());
}
}
}
}
}
true
}
pub fn is_valid_subject_line(subject: &str, config: &Config) -> Result<(), String> {
if let Some(lint) = &config.lint {
if let Some(rules) = &lint.subject_line_rules {
if let Some(max_len) = rules.max_length {
if subject.len() > max_len {
return Err(format!(
"Subject line exceeds maximum length of {} characters.",
max_len
));
}
}
if let Some(enforce_lowercase) = rules.enforce_lowercase {
if enforce_lowercase {
if let Some(first) = subject.chars().next() {
if first.is_uppercase() {
return Err(
"Subject line must not start with a capital letter.".to_string()
);
}
}
}
}
if let Some(no_period) = rules.no_period {
if no_period && subject.trim_end().ends_with('.') {
return Err("Subject line should not end with a period.".to_string());
}
}
}
}
Ok(())
}
pub fn is_valid_body_lines(body: &str, config: &Config) -> bool {
if let Some(lint) = &config.lint {
if let Some(rules) = &lint.body_line_rules {
if let Some(max_len) = rules.max_line_length {
for line in body.lines() {
if line.len() > max_len {
return false;
}
}
}
}
}
true
}
pub fn handle_commit(opts: RunOpts, config: &Config, params: CommitParams) -> Result<()> {
println!("{}", "--- Committing changes ---".blue());
if config.issue_handling.strategy == config::IssueHandlingStrategy::CommitScope
&& params.scope.is_some()
&& params.issue.is_some()
{
println!(
"{}",
"Error: Cannot use both --scope and --issue when the 'commit-scope' strategy is active.".red()
);
println!(
"{}",
"Hint: To associate this commit with the issue, please provide only the --issue flag."
.yellow()
);
return Err(anyhow::anyhow!(
"Aborted: Conflicting flags for commit-scope strategy."
));
}
if !is_valid_commit_type(¶ms.r#type, config) {
println!(
"{}",
format!(
"Error: '{}' is not a valid Conventional Commit type.",
params.r#type
)
.red()
);
return Err(anyhow::anyhow!("Aborted: Invalid commit type."));
}
if !is_valid_issue_key(¶ms.issue, config)? {
println!(
"{}",
"Issue reference is required by your .tbdflow.yml config.".red()
);
return Err(anyhow::anyhow!("Aborted: Issue reference required."));
}
if let Err(e) = is_valid_subject_line(¶ms.message, config) {
println!("{}", format!("Commit message subject error: {}", e).red());
return Err(anyhow::anyhow!("Aborted: Invalid commit message subject."));
}
if let Some(body_text) = ¶ms.body {
if !is_valid_body_lines(body_text, config) {
println!(
"{}",
"Commit message body contains lines that exceed the maximum length.".red()
);
return Err(anyhow::anyhow!("Aborted: Invalid commit message body."));
}
}
if let Some(s) = ¶ms.scope {
if !is_valid_scope(&Some(s.clone()), config) {
println!("{}", "Scope must be lowercase.".red());
return Err(anyhow::anyhow!("Aborted: Invalid commit scope."));
}
}
let scope_part = params.scope.map_or("".to_string(), |s| format!("({})", s));
let breaking_part = if params.breaking { "!" } else { "" };
let header = format!(
"{}{}{}: {}",
params.r#type, scope_part, breaking_part, params.message
);
let dod_config = config::load_dod_config().unwrap_or_default();
let todo_footer_result = if params.no_verify || dod_config.checklist.is_empty() {
Ok(Some(String::new()))
} else {
handle_interactive_dod(&dod_config)
};
if let Some(todo_footer) = todo_footer_result? {
let git_root = PathBuf::from(git::get_git_root(opts)?);
let intent_log = intent::load_intent_log(&git_root)?;
let intent_section = intent_log
.as_ref()
.and_then(|log| intent::format_for_commit(log));
let mut commit_message = header;
if let Some(body_text) = params.body {
commit_message.push_str("\n\n");
commit_message.push_str(&body_text);
}
if let Some(intent_text) = &intent_section {
commit_message.push_str("\n\n");
commit_message.push_str(intent_text);
}
if let Some(desc) = params.breaking_description {
commit_message.push_str(&format!("\n\nBREAKING CHANGE: {}", desc));
}
if let Some(issue_ref) = ¶ms.issue {
commit_message.push_str(&format!("\n\nRefs: {}", issue_ref));
}
commit_message.push_str(&todo_footer);
println!(
"{}",
format!("Commit message will be:\n---\n{}\n---", commit_message).blue()
);
if opts.verbose {
let current_dir = std::env::current_dir()?;
println!("Git root: {:?}", git_root);
println!("Current dir: {:?}", current_dir);
println!("monorepo: {:?}", config.monorepo);
}
git::stage_scoped_changes(config, params.include_projects, opts)?;
if !git::has_staged_changes(opts)? {
println!("{}", "No changes added to commit.".yellow());
return Ok(());
}
if !radar::check_before_commit(config, opts)? {
println!("{}", "Commit aborted by user.".yellow());
return Ok(());
}
let current_branch = git::get_current_branch(opts)?;
if current_branch == config.main_branch_name {
println!("--- Committing directly to main branch ---");
git::pull_latest_with_rebase(opts)?;
git::commit(&commit_message, opts)?;
git::push(opts)?;
println!(
"\n{}",
"Successfully committed and pushed changes to main.".green()
);
if intent_section.is_some() {
if let Ok(Some(log)) = intent::load_intent_log(&git_root) {
let snapshot_count = log
.notes
.iter()
.filter(|n| n.snapshot_hash.is_some())
.count();
if snapshot_count > 0 {
println!(
"{}",
format!(
"Releasing {} WIP snapshot(s), your work is now in git history.",
snapshot_count
)
.dimmed()
);
}
}
intent::cleanup_intent_log(&git_root)?;
println!("{}", "Intent log consumed and cleared.".dimmed());
}
let commit_hash = git::get_head_commit_hash(opts)?;
if review::should_auto_trigger_review(config, &commit_hash, opts)? {
let author = git::get_user_name(opts)?;
review::trigger_review(config, None, &commit_hash, &commit_message, &author, opts)?;
}
} else {
println!("--- Committing to feature branch '{}' ---", current_branch);
git::commit(&commit_message, opts)?;
git::push(opts)?;
println!(
"\n{}",
format!("Successfully pushed changes to '{}'.", current_branch).green()
);
}
if let Some(tag_name) = params.tag {
let commit_hash = git::get_head_commit_hash(opts)?;
git::create_tag(&tag_name, &commit_message, &commit_hash, opts)?;
git::push_tags(opts)?;
println!(
"{}",
format!("Success! Created and pushed tag '{}'", tag_name).green()
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::*;
fn config_with_defaults() -> Config {
Config::default()
}
fn config_without_lint() -> Config {
Config {
lint: None,
..Default::default()
}
}
fn config_with_allowed_types(types: Vec<&str>) -> Config {
Config {
lint: Some(LintConfig {
conventional_commit_type: Some(ConventionalCommitTypeConfig {
enabled: Some(true),
allowed_types: Some(types.iter().map(|s| s.to_string()).collect()),
}),
..config_with_defaults().lint.unwrap()
}),
..Default::default()
}
}
#[test]
fn commit_type_accepts_allowed_type() {
let config = config_with_defaults();
assert!(is_valid_commit_type("feat", &config));
assert!(is_valid_commit_type("fix", &config));
assert!(is_valid_commit_type("chore", &config));
}
#[test]
fn commit_type_rejects_unknown_type() {
let config = config_with_defaults();
assert!(!is_valid_commit_type("yolo", &config));
assert!(!is_valid_commit_type("", &config));
}
#[test]
fn commit_type_accepts_anything_when_lint_disabled() {
let config = config_without_lint();
assert!(is_valid_commit_type("anything", &config));
}
#[test]
fn commit_type_accepts_anything_when_rule_disabled() {
let config = Config {
lint: Some(LintConfig {
conventional_commit_type: Some(ConventionalCommitTypeConfig {
enabled: Some(false),
allowed_types: Some(vec!["feat".to_string()]),
}),
..config_with_defaults().lint.unwrap()
}),
..Default::default()
};
assert!(is_valid_commit_type("yolo", &config));
}
#[test]
fn commit_type_respects_custom_allowed_list() {
let config = config_with_allowed_types(vec!["feat", "hotfix"]);
assert!(is_valid_commit_type("feat", &config));
assert!(is_valid_commit_type("hotfix", &config));
assert!(!is_valid_commit_type("fix", &config));
}
#[test]
fn scope_accepts_lowercase() {
let config = config_with_defaults();
assert!(is_valid_scope(&Some("api".to_string()), &config));
}
#[test]
fn scope_rejects_uppercase_when_enforced() {
let config = config_with_defaults();
assert!(!is_valid_scope(&Some("API".to_string()), &config));
assert!(!is_valid_scope(&Some("Api".to_string()), &config));
}
#[test]
fn scope_accepts_none() {
let config = config_with_defaults();
assert!(is_valid_scope(&None, &config));
}
#[test]
fn scope_accepts_anything_when_lint_disabled() {
let config = config_without_lint();
assert!(is_valid_scope(&Some("UPPER".to_string()), &config));
}
#[test]
fn subject_accepts_valid_message() {
let config = config_with_defaults();
assert!(is_valid_subject_line("add user endpoint", &config).is_ok());
}
#[test]
fn subject_rejects_too_long() {
let config = config_with_defaults();
let long_subject = "a".repeat(73);
assert!(is_valid_subject_line(&long_subject, &config).is_err());
}
#[test]
fn subject_accepts_exactly_max_length() {
let config = config_with_defaults();
let exact = "a".repeat(72);
assert!(is_valid_subject_line(&exact, &config).is_ok());
}
#[test]
fn subject_rejects_uppercase_start() {
let config = config_with_defaults();
assert!(is_valid_subject_line("Add user endpoint", &config).is_err());
}
#[test]
fn subject_rejects_trailing_period() {
let config = config_with_defaults();
assert!(is_valid_subject_line("add user endpoint.", &config).is_err());
}
#[test]
fn subject_accepts_anything_when_lint_disabled() {
let config = config_without_lint();
assert!(is_valid_subject_line("Whatever. YOLO.", &config).is_ok());
}
#[test]
fn body_accepts_short_lines() {
let config = config_with_defaults();
assert!(is_valid_body_lines(
"short line\nanother short line",
&config
));
}
#[test]
fn body_rejects_line_exceeding_max_length() {
let config = config_with_defaults();
let long_line = "x".repeat(81);
assert!(!is_valid_body_lines(&long_line, &config));
}
#[test]
fn body_accepts_exactly_max_length() {
let config = config_with_defaults();
let exact = "x".repeat(80);
assert!(is_valid_body_lines(&exact, &config));
}
#[test]
fn body_rejects_if_any_line_too_long() {
let config = config_with_defaults();
let body = format!("short\n{}\nshort", "x".repeat(81));
assert!(!is_valid_body_lines(&body, &config));
}
#[test]
fn body_accepts_anything_when_lint_disabled() {
let config = config_without_lint();
let long = "x".repeat(200);
assert!(is_valid_body_lines(&long, &config));
}
#[test]
fn issue_key_accepts_valid_key() {
let config = Config {
lint: Some(LintConfig {
issue_key_missing: Some(IssueKeyConfig {
enabled: Some(true),
pattern: Some(r"^[A-Z]+-\d+$".to_string()),
}),
..config_with_defaults().lint.unwrap()
}),
..Default::default()
};
assert!(is_valid_issue_key(&Some("PROJ-123".to_string()), &config).unwrap());
}
#[test]
fn issue_key_rejects_invalid_key() {
let config = Config {
lint: Some(LintConfig {
issue_key_missing: Some(IssueKeyConfig {
enabled: Some(true),
pattern: Some(r"^[A-Z]+-\d+$".to_string()),
}),
..config_with_defaults().lint.unwrap()
}),
..Default::default()
};
assert!(!is_valid_issue_key(&Some("bad".to_string()), &config).unwrap());
}
#[test]
fn issue_key_rejects_none_when_required() {
let config = Config {
lint: Some(LintConfig {
issue_key_missing: Some(IssueKeyConfig {
enabled: Some(true),
pattern: Some(r"^[A-Z]+-\d+$".to_string()),
}),
..config_with_defaults().lint.unwrap()
}),
..Default::default()
};
assert!(!is_valid_issue_key(&None, &config).unwrap());
}
#[test]
fn issue_key_accepts_anything_when_disabled() {
let config = config_with_defaults();
assert!(is_valid_issue_key(&None, &config).unwrap());
assert!(is_valid_issue_key(&Some("whatever".to_string()), &config).unwrap());
}
#[test]
fn issue_key_returns_error_on_invalid_regex() {
let config = Config {
lint: Some(LintConfig {
issue_key_missing: Some(IssueKeyConfig {
enabled: Some(true),
pattern: Some(r"[unclosed".to_string()),
}),
..config_with_defaults().lint.unwrap()
}),
..Default::default()
};
assert!(is_valid_issue_key(&Some("PROJ-1".to_string()), &config).is_err());
}
#[test]
fn todo_footer_empty_when_all_checked() {
let checklist = vec!["item1".to_string(), "item2".to_string()];
assert_eq!(build_todo_footer(&checklist, &[0, 1]), "");
}
#[test]
fn todo_footer_lists_unchecked_items() {
let checklist = vec![
"first".to_string(),
"second".to_string(),
"third".to_string(),
];
let footer = build_todo_footer(&checklist, &[1]);
assert!(footer.contains("- [ ] first"));
assert!(!footer.contains("- [ ] second"));
assert!(footer.contains("- [ ] third"));
}
#[test]
fn todo_footer_lists_all_when_none_checked() {
let checklist = vec!["a".to_string(), "b".to_string()];
let footer = build_todo_footer(&checklist, &[]);
assert!(footer.contains("- [ ] a"));
assert!(footer.contains("- [ ] b"));
assert!(footer.starts_with("\n\nTODO:\n"));
}
}