use clap::{Command as ClapCommand, CommandFactory, Parser, Subcommand, ValueEnum, ValueHint};
use clap_complete::{Shell, generate};
use colored::Colorize;
use glob::Pattern;
use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet, Styled};
use inquire::{Confirm, MultiSelect, Select, Text};
use std::{collections::HashMap, fs::read_to_string, io, process::Command};
use crate::{
config::{Config, find_config_sources},
errors::{Result, RonaError},
extra_fields::{
BuiltInFieldConfig, ExtraField, MessagePrefetchConfig, prompt_extra_field,
run_message_prefetch,
},
git::{
COMMIT_MESSAGE_FILE_PATH, COMMIT_TYPES, add_to_git_exclude, create_needed_files,
format_branch_name, generate_commit_message, get_current_branch, get_current_commit_nb,
get_restorable_files, get_stageable_files, get_staged_files, get_status_files,
get_top_level_path, git_add_files, git_add_with_exclude_patterns, git_branch_only,
git_commit, git_create_branch, git_push, git_restore_files, git_unstage_files,
sanitize_branch_name,
},
template::{
BranchTemplateVariables, TemplateVariables, process_branch_template, process_template,
validate_branch_template, validate_template,
},
};
#[derive(Clone, Copy, Debug, ValueEnum)]
pub(crate) enum ConfigScope {
Local,
Global,
}
#[derive(Subcommand)]
pub(crate) enum ConfigSubcommand {
#[command(short_flag = 'c', name = "create")]
Create {
#[arg(value_enum)]
scope: ConfigScope,
#[arg(short = 'e', long, default_value_t = false)]
exclude: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(short_flag = 'w', name = "which", visible_alias = "find")]
Which {
#[arg(value_name = "PATH", value_hint = ValueHint::DirPath)]
path: Option<String>,
#[arg(short = 'e', long = "effective", default_value_t = false)]
show_effective: bool,
},
}
#[derive(Subcommand)]
pub(crate) enum CliCommand {
#[command(name = "branch")]
Branch {
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(long = "no-switch", default_value_t = false)]
no_switch: bool,
},
#[command(short_flag = 'a', name = "add-with-exclude")]
AddWithExclude {
#[arg(value_name = "PATTERNS", value_hint = ValueHint::AnyPath)]
to_exclude: Vec<String>,
#[arg(short = 'i', long = "interactive", default_value_t = false)]
interactive: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(short_flag = 'c')]
Commit {
#[arg(short = 'p', long = "push", default_value_t = false)]
push: bool,
#[arg(short = 'd', long, default_value_t = false)]
dry_run: bool,
#[arg(short = 'u', long = "unsigned", default_value_t = false)]
unsigned: bool,
#[arg(short = 'y', long = "yes", default_value_t = false)]
yes: bool,
#[arg(long = "copy", default_value_t = false)]
copy: bool,
#[arg(allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(name = "completion")]
Completion {
#[arg(value_enum)]
shell: Shell,
},
#[command(name = "config")]
Config {
#[command(subcommand)]
subcommand: ConfigSubcommand,
},
#[command(short_flag = 'g')]
Generate {
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(short = 'i', long = "interactive", default_value_t = false)]
interactive: bool,
#[arg(short = 'n', long = "no-commit-number", default_value_t = false)]
no_commit_number: bool,
},
#[command(short_flag = 'i', name = "init")]
Initialize {
#[arg(default_value_t = String::from("nano"))]
editor: String,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(short_flag = 'l')]
ListStatus,
#[command(short_flag = 'p')]
Push {
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(name = "reset")]
Reset {
#[arg(value_name = "FILES", value_hint = ValueHint::AnyPath)]
files: Vec<String>,
#[arg(short = 'i', long = "interactive", default_value_t = false)]
interactive: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(name = "restore")]
Restore {
#[arg(value_name = "FILES", value_hint = ValueHint::AnyPath)]
files: Vec<String>,
#[arg(short = 'i', long = "interactive", default_value_t = false)]
interactive: bool,
#[arg(short = 'y', long = "yes", default_value_t = false)]
yes: bool,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(short_flag = 's', name = "set-editor")]
Set {
#[arg(value_name = "EDITOR")]
editor: String,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(name = "sync")]
Sync {
#[arg(short = 'b', long = "branch", default_value = "main")]
source_branch: String,
#[arg(short = 'r', long = "rebase", default_value_t = false)]
rebase: bool,
#[arg(short = 'n', long = "new-branch")]
new_branch: Option<String>,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
#[derive(Parser)]
#[command(about = "Simple program that can:\n\
\t- Commit with the current 'commit_message.md' file text.\n\
\t- Generate the 'commit_message.md' file.\n\
\t- Push to git repository.\n\
\t- Add files with pattern exclusion.\n\
\nAll commands support --dry-run to preview changes.")]
#[command(author = "Tom Planche <tomplanche@proton.me>")]
#[command(help_template = "{about}\nMade by: {author}\n\nUSAGE:\n{usage}\n\n{all-args}\n")]
#[command(name = "rona")]
#[command(version)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: CliCommand,
#[arg(short, long, default_value = "false")]
verbose: bool,
#[arg(short = 'f', long = "config-file", value_name = "PATH", value_hint = ValueHint::FilePath, global = true)]
config: Option<String>,
}
#[doc(hidden)]
fn build_cli() -> ClapCommand {
Cli::command()
}
fn get_render_config() -> RenderConfig<'static> {
let mut render_config = RenderConfig::default();
render_config.prompt_prefix = Styled::new("$").with_fg(Color::LightRed);
render_config.answered_prompt_prefix = Styled::new("✔").with_fg(Color::LightGreen);
render_config.highlighted_option_prefix = Styled::new("âž ").with_fg(Color::LightBlue);
render_config.selected_checkbox = Styled::new("[x]").with_fg(Color::LightGreen);
render_config.unselected_checkbox = Styled::new("[ ]").with_fg(Color::Black);
render_config.scroll_up_prefix = Styled::new("⇞").with_fg(Color::Black);
render_config.scroll_down_prefix = Styled::new("⇟").with_fg(Color::Black);
render_config.prompt = StyleSheet::new()
.with_fg(Color::LightCyan)
.with_attr(Attributes::BOLD);
render_config.help_message = StyleSheet::new()
.with_fg(Color::DarkYellow)
.with_attr(Attributes::ITALIC);
render_config.error_message = render_config
.error_message
.with_prefix(Styled::new("✗").with_fg(Color::LightRed));
render_config.answer = StyleSheet::new()
.with_fg(Color::LightMagenta)
.with_attr(Attributes::BOLD);
render_config.default_value = StyleSheet::new().with_fg(Color::LightBlue);
render_config.placeholder = StyleSheet::new().with_fg(Color::Black);
render_config
}
#[doc(hidden)]
fn print_fish_custom_completions() {
println!();
println!("# === CUSTOM RONA COMPLETIONS ===");
println!("# Helper function to get git status files");
println!("function __rona_status_files");
println!(" rona -l");
println!("end");
println!();
println!("# Command-specific completions");
println!("# add-with-exclude: Complete with git status files");
println!(
"complete -c rona -n '__fish_seen_subcommand_from add-with-exclude -a' -xa '(__rona_status_files)'"
);
println!("# reset / restore: Complete with git status files");
println!("complete -c rona -n '__fish_seen_subcommand_from reset' -xa '(__rona_status_files)'");
println!(
"complete -c rona -n '__fish_seen_subcommand_from restore' -xa '(__rona_status_files)'"
);
}
fn prompt_branch_fields(
extra_fields: &[ExtraField],
field_order: &[String],
needs_description: bool,
description_config: Option<&BuiltInFieldConfig>,
) -> Result<(String, HashMap<String, String>)> {
use inquire::validator::Validation;
const DESCRIPTION_KEY: &str = "description";
let description_disabled = description_config.is_some_and(|c| c.disabled);
let effective_needs_description = needs_description && !description_disabled;
let ordered: Vec<String> = if field_order.is_empty() {
let mut v: Vec<String> = extra_fields.iter().map(|f| f.name.clone()).collect();
if effective_needs_description {
v.push(DESCRIPTION_KEY.to_string());
}
v
} else {
let mut v: Vec<String> = field_order.to_vec();
for f in extra_fields {
if !v.iter().any(|s| s == &f.name) {
v.push(f.name.clone());
}
}
if effective_needs_description && !v.iter().any(|s| s == DESCRIPTION_KEY) {
v.push(DESCRIPTION_KEY.to_string());
}
v
};
let mut description: Option<String> = None;
let mut extra_values: HashMap<String, String> = HashMap::new();
for name in &ordered {
if name == DESCRIPTION_KEY {
let prompt_text = description_config
.and_then(|c| c.prompt.as_deref())
.unwrap_or("Branch description");
let validator_pattern = description_config.and_then(|c| c.validation.as_deref());
let value = if let Some(pattern) = validator_pattern {
let re = regex::Regex::new(pattern).map_err(|e| {
RonaError::InvalidInput(format!(
"Invalid validation regex for branch description: {e}"
))
})?;
let pattern_owned = pattern.to_string();
Text::new(prompt_text)
.with_validator(move |input: &str| {
if !re.is_match(input) {
return Ok(Validation::Invalid(
format!("Must match pattern: {pattern_owned}").into(),
));
}
Ok(Validation::Valid)
})
.prompt()
.map_err(|_| RonaError::UserCancelled)?
} else {
Text::new(prompt_text)
.prompt()
.map_err(|_| RonaError::UserCancelled)?
};
description = Some(value);
} else if let Some(field) = extra_fields.iter().find(|f| f.name == *name)
&& let Some(value) = prompt_extra_field(field)?
{
extra_values.insert(field.name.clone(), value);
}
}
Ok((description.unwrap_or_default(), extra_values))
}
fn branch_effective_types(config: &Config) -> Vec<String> {
let commit: Vec<String> = config.project_config.commit_types.as_ref().map_or_else(
|| COMMIT_TYPES.iter().map(|s| (*s).to_string()).collect(),
Clone::clone,
);
match &config.project_config.branch_types {
None => commit,
Some(branch) => {
if config.project_config.merge_branch_and_commit_types {
let mut merged = branch.clone();
for ct in &commit {
if !merged.contains(ct) {
merged.push(ct.clone());
}
}
merged
} else {
branch.clone()
}
}
}
}
#[allow(clippy::literal_string_with_formatting_args)]
fn handle_branch(no_switch: bool, config: &Config) -> Result<()> {
let effective_types = branch_effective_types(config);
let types_for_branch: Vec<&str> = effective_types.iter().map(String::as_str).collect();
let default_template = "{branch_type}/{description}";
let template = config
.project_config
.branch_template
.as_deref()
.unwrap_or(default_template);
let needs_branch_type =
template.contains("{branch_type}") || template.contains("{?branch_type}");
let needs_description =
template.contains("{description}") || template.contains("{?description}");
let mut effective_branch_fields: Vec<ExtraField> =
config.project_config.branch_extra_fields.clone();
for commit_field in &config.project_config.commit_extra_fields {
let in_template = template.contains(&format!("{{{}}}", commit_field.name))
|| template.contains(&format!("{{?{}}}", commit_field.name));
let already_present = effective_branch_fields
.iter()
.any(|f| f.name == commit_field.name);
if in_template && !already_present {
effective_branch_fields.push(commit_field.clone());
}
}
let extra_names: Vec<&str> = effective_branch_fields
.iter()
.map(|f| f.name.as_str())
.collect();
if let Err(e) = validate_branch_template(template, &extra_names) {
return Err(RonaError::InvalidInput(format!(
"Branch template validation error: {e}"
)));
}
for field in &config.project_config.branch_extra_fields {
let referenced = template.contains(&format!("{{{}}}", field.name))
|| template.contains(&format!("{{?{}}}", field.name));
if !referenced {
println!(
"[WARNING] Branch extra field '{}' is configured but not referenced in the template.",
field.name
);
}
}
let branch_type = if needs_branch_type {
Select::new("Select branch type", types_for_branch)
.with_starting_cursor(0)
.prompt()
.map_err(|_| RonaError::UserCancelled)?
.to_string()
} else {
String::new()
};
let (description, extra_values) = prompt_branch_fields(
&effective_branch_fields,
&config.project_config.branch_field_order,
needs_description,
config.project_config.branch_description.as_ref(),
)?;
if needs_description && description.trim().is_empty() {
println!(
"{} Empty description provided. Exiting.",
"WARNING:".yellow().bold()
);
return Ok(());
}
let variables = BranchTemplateVariables::new(branch_type, description.trim().to_owned())?;
let raw_name = process_branch_template(template, &variables, &extra_values)?;
let branch_name = sanitize_branch_name(&raw_name);
if branch_name.is_empty() {
return Err(RonaError::InvalidInput(
"Generated branch name is empty after sanitization.".to_string(),
));
}
if config.dry_run {
println!("Would create branch: {branch_name}");
if no_switch {
println!("Would not switch to the new branch.");
} else {
println!("Would switch to the new branch.");
}
return Ok(());
}
if no_switch {
git_branch_only(&branch_name)?;
println!("Branch created: {branch_name}");
} else {
git_create_branch(&branch_name)?;
println!("Switched to new branch: {branch_name}");
}
Ok(())
}
fn handle_add_with_exclude(exclude: &[String], interactive: bool, config: &Config) -> Result<()> {
if interactive {
return handle_add_interactive(exclude, config);
}
let patterns: Vec<Pattern> = exclude
.iter()
.map(|p| {
Pattern::new(p)
.map_err(|e| RonaError::InvalidInput(format!("Invalid glob pattern '{p}': {e}")))
})
.collect::<Result<Vec<Pattern>>>()?;
git_add_with_exclude_patterns(&patterns, config.verbose, config.dry_run)?;
Ok(())
}
fn handle_add_interactive(exclude: &[String], config: &Config) -> Result<()> {
if !exclude.is_empty() {
println!(
"{} Exclude patterns are ignored in interactive mode (-i).",
"WARNING:".yellow().bold()
);
}
let entries = get_stageable_files()?;
if entries.is_empty() {
println!("No changes to stage.");
return Ok(());
}
let selected = MultiSelect::new("Select files to stage", entries)
.prompt()
.map_err(|_| RonaError::UserCancelled)?;
let paths: Vec<String> = selected.into_iter().map(|entry| entry.path).collect();
git_add_files(&paths, config.dry_run)?;
Ok(())
}
fn handle_reset(files: &[String], interactive: bool, config: &Config) -> Result<()> {
if interactive {
return handle_reset_interactive(config);
}
if !files.is_empty() {
return git_unstage_files(files, config.dry_run);
}
let staged: Vec<String> = get_staged_files()?
.into_iter()
.map(|entry| entry.path)
.collect();
git_unstage_files(&staged, config.dry_run)
}
fn handle_reset_interactive(config: &Config) -> Result<()> {
let entries = get_staged_files()?;
if entries.is_empty() {
println!("No staged files to unstage.");
return Ok(());
}
let selected = MultiSelect::new("Select files to unstage", entries)
.prompt()
.map_err(|_| RonaError::UserCancelled)?;
let paths: Vec<String> = selected.into_iter().map(|entry| entry.path).collect();
git_unstage_files(&paths, config.dry_run)
}
fn handle_restore(files: &[String], interactive: bool, yes: bool, config: &Config) -> Result<()> {
let paths: Vec<String> = if interactive {
let entries = get_restorable_files()?;
if entries.is_empty() {
println!("No changes to restore.");
return Ok(());
}
let selected = MultiSelect::new("Select files to restore", entries)
.prompt()
.map_err(|_| RonaError::UserCancelled)?;
selected.into_iter().map(|entry| entry.path).collect()
} else if files.is_empty() {
println!(
"{} Specify files to restore or use -i/--interactive to pick them.",
"WARNING:".yellow().bold()
);
return Ok(());
} else {
files.to_vec()
};
if paths.is_empty() {
println!("No files selected.");
return Ok(());
}
if !yes && !config.dry_run {
let message = format!(
"Discard working-tree changes to {} file(s)? This cannot be undone.",
paths.len()
);
let confirmed = Confirm::new(&message)
.with_default(false)
.prompt()
.unwrap_or(false);
if !confirmed {
println!("Restore cancelled.");
return Ok(());
}
}
git_restore_files(&paths, config.dry_run)
}
#[allow(clippy::fn_params_excessive_bools)]
fn handle_commit(
args: &[String],
push: bool,
unsigned: bool,
yes: bool,
copy: bool,
config: &Config,
) -> Result<()> {
let project_root = get_top_level_path()?;
let commit_file_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);
if !commit_file_path.exists() {
return Err(crate::errors::RonaError::Git(
crate::errors::GitError::CommitMessageNotFound,
));
}
let commit_message = read_to_string(&commit_file_path)?;
if copy {
use arboard::Clipboard;
let mut clipboard = Clipboard::new().map_err(|e| {
crate::errors::RonaError::Io(std::io::Error::other(format!(
"Failed to access clipboard: {e}"
)))
})?;
clipboard.set_text(&commit_message).map_err(|e| {
crate::errors::RonaError::Io(std::io::Error::other(format!(
"Failed to copy to clipboard: {e}"
)))
})?;
println!("Commit message copied to clipboard");
return Ok(());
}
if !yes && !config.dry_run {
let confirmation_message = format!("Commit with message:\n{}", commit_message.trim());
let confirm = Confirm::new(&confirmation_message)
.with_default(true)
.prompt()
.unwrap_or(false);
if !confirm {
println!("Commit cancelled.");
return Ok(());
}
}
git_commit(args, unsigned, config.dry_run)?;
if push {
git_push(args, config.verbose, config.dry_run)?;
}
Ok(())
}
#[doc(hidden)]
fn handle_completion(shell: Shell) {
let mut cmd = build_cli();
generate(shell, &mut cmd, "rona", &mut io::stdout());
if matches!(shell, Shell::Fish) {
print_fish_custom_completions();
}
}
fn prompt_interactive_fields(
extra_fields: &[ExtraField],
field_order: &[String],
message_prefetch: Option<&MessagePrefetchConfig>,
message_config: Option<&BuiltInFieldConfig>,
) -> Result<(String, HashMap<String, String>)> {
use inquire::validator::Validation;
const MESSAGE_KEY: &str = "message";
let message_disabled = message_config.is_some_and(|c| c.disabled);
let ordered: Vec<String> = if field_order.is_empty() {
let mut v: Vec<String> = extra_fields.iter().map(|f| f.name.clone()).collect();
if !message_disabled {
v.push(MESSAGE_KEY.to_string());
}
v
} else {
let mut v: Vec<String> = field_order.to_vec();
for f in extra_fields {
if !v.iter().any(|s| s == &f.name) {
v.push(f.name.clone());
}
}
if !message_disabled && !v.iter().any(|s| s == MESSAGE_KEY) {
v.push(MESSAGE_KEY.to_string());
}
v
};
let mut message: Option<String> = None;
let mut extra_values: HashMap<String, String> = HashMap::new();
for name in &ordered {
if name == MESSAGE_KEY {
let prompt_text = message_config
.and_then(|c| c.prompt.as_deref())
.unwrap_or("Message");
let default = message_prefetch
.map(run_message_prefetch)
.transpose()?
.flatten();
let validator_pattern = message_config.and_then(|c| c.validation.as_deref());
let value = if let Some(pattern) = validator_pattern {
let re = regex::Regex::new(pattern).map_err(|e| {
RonaError::InvalidInput(format!("Invalid validation regex for message: {e}"))
})?;
let pattern_owned = pattern.to_string();
let mut text_prompt = Text::new(prompt_text);
if let Some(ref d) = default {
text_prompt = text_prompt.with_default(d.as_str());
}
text_prompt
.with_validator(move |input: &str| {
if !re.is_match(input) {
return Ok(Validation::Invalid(
format!("Must match pattern: {pattern_owned}").into(),
));
}
Ok(Validation::Valid)
})
.prompt()
.map_err(|_| RonaError::UserCancelled)?
} else {
let mut text_prompt = Text::new(prompt_text);
if let Some(ref d) = default {
text_prompt = text_prompt.with_default(d.as_str());
}
text_prompt.prompt().map_err(|_| RonaError::UserCancelled)?
};
message = Some(value);
} else if let Some(field) = extra_fields.iter().find(|f| f.name == *name)
&& let Some(value) = prompt_extra_field(field)?
{
extra_values.insert(field.name.clone(), value);
}
}
let message = message
.ok_or_else(|| RonaError::InvalidInput("message prompt was not executed".to_string()))?;
Ok((message, extra_values))
}
fn handle_generate(interactive: bool, no_commit_number: bool, config: &Config) -> Result<()> {
if config.dry_run {
println!("Would create files: commit_message.md, .commitignore");
println!("Would add files to .git/info/exclude");
return Ok(());
}
create_needed_files()?;
let commit_type = {
let commit_types_vec = config.project_config.commit_types.as_ref().map_or_else(
|| COMMIT_TYPES.to_vec(),
|v| v.iter().map(String::as_str).collect::<Vec<&str>>(),
);
Select::new("Select commit type", commit_types_vec)
.with_starting_cursor(0)
.prompt()
.map_err(|_| RonaError::UserCancelled)?
};
if interactive {
let (message, extra_values) = prompt_interactive_fields(
&config.project_config.commit_extra_fields,
&config.project_config.commit_fields_order,
config.project_config.message_prefetch.as_ref(),
config.project_config.commit_message.as_ref(),
)?;
handle_interactive_mode(
commit_type,
no_commit_number,
&message,
&extra_values,
config,
)?;
} else {
generate_commit_message(commit_type, no_commit_number)?;
handle_editor_mode(config)?;
}
Ok(())
}
fn handle_interactive_mode(
commit_type: &str,
no_commit_number: bool,
message: &str,
extra_values: &HashMap<String, String>,
config: &Config,
) -> Result<()> {
use std::fs;
let project_root = get_top_level_path()?;
let commit_file_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);
if message.trim().is_empty() {
println!(
"{} Empty message provided. Exiting.",
"WARNING:".yellow().bold()
);
return Ok(());
}
let branch_name = format_branch_name(&COMMIT_TYPES, &get_current_branch()?);
let commit_number = if no_commit_number {
None
} else {
Some(get_current_commit_nb()? + 1)
};
let default_template = "{?commit_number}[{commit_number}] {/commit_number}({commit_type} on {branch_name}) {message}";
let template = config
.project_config
.commit_template
.as_deref()
.unwrap_or(default_template);
for field in &config.project_config.commit_extra_fields {
let referenced = template.contains(&format!("{{{}}}", field.name))
|| template.contains(&format!("{{?{}}}", field.name));
if !referenced {
println!(
"[WARNING] Extra field '{}' is configured but not referenced in the template.",
field.name
);
}
}
let extra_names: Vec<&str> = extra_values.keys().map(String::as_str).collect();
if let Err(e) = validate_template(template, &extra_names) {
println!(
"{} Template validation error: {e}",
"WARNING:".yellow().bold()
);
println!("Using fallback format...");
let formatted_message = if no_commit_number {
format!("({} on {}) {}", commit_type, branch_name, message.trim())
} else {
format!(
"[{}] ({} on {}) {}",
commit_number.unwrap_or(0),
commit_type,
branch_name,
message.trim()
)
};
fs::write(&commit_file_path, &formatted_message)?;
println!("\n{} Commit message created!", "✔".green());
println!("Message: {formatted_message}");
return Ok(());
}
let variables = TemplateVariables::new(
commit_number,
commit_type.to_string(),
branch_name,
message.trim().to_string(),
)?;
let formatted_message = process_template(template, &variables, extra_values)?;
fs::write(&commit_file_path, &formatted_message)?;
println!("\n{} Commit message created!", "✔".green());
println!("Message: {formatted_message}");
Ok(())
}
fn handle_editor_mode(config: &Config) -> Result<()> {
let editor = config.get_editor()?;
let project_root = get_top_level_path()?;
let commit_file_path = project_root.join(COMMIT_MESSAGE_FILE_PATH);
Command::new(&editor)
.arg(&commit_file_path)
.spawn()
.map_err(|e| RonaError::CommandFailed {
command: format!("Failed to spawn editor '{editor}': {e}"),
})?
.wait()
.map_err(|e| RonaError::CommandFailed {
command: format!("Failed to wait for editor '{editor}': {e}"),
})?;
Ok(())
}
fn handle_initialize(editor: &str, config: &Config) -> Result<()> {
if config.dry_run {
println!("Would create config file with editor: {editor}");
return Ok(());
}
config.create_config_file(editor)?;
Ok(())
}
fn handle_list_status() -> Result<()> {
let files = get_status_files()?;
for file in files {
println!("{file}");
}
Ok(())
}
fn handle_push(args: &[String], config: &Config) -> Result<()> {
git_push(args, config.verbose, config.dry_run)?;
Ok(())
}
fn handle_set(editor: &str, config: &Config) -> Result<()> {
if config.dry_run {
println!("Would set editor to: {editor}");
return Ok(());
}
config.set_editor(editor)?;
Ok(())
}
fn handle_sync(
source_branch: &str,
rebase: bool,
new_branch: Option<&str>,
config: &Config,
) -> Result<()> {
use crate::git::{git_create_branch, git_merge, git_pull, git_rebase, git_switch};
let original_branch = get_current_branch()?;
if config.dry_run {
if let Some(branch_name) = new_branch {
println!("Would create new branch: {branch_name}");
}
println!("Would switch to: {source_branch}");
println!("Would pull latest changes");
println!(
"Would switch back to: {}",
new_branch.unwrap_or(&original_branch)
);
if rebase {
println!("Would rebase with: {source_branch}");
} else {
println!("Would merge with: {source_branch}");
}
return Ok(());
}
if let Some(branch_name) = new_branch {
git_create_branch(branch_name)?;
git_switch(branch_name)?;
}
let target_branch = new_branch.unwrap_or(&original_branch);
git_switch(source_branch)?;
git_pull(config.verbose)?;
git_switch(target_branch)?;
if rebase {
git_rebase(source_branch, config.verbose)?;
} else {
git_merge(source_branch, config.verbose)?;
}
println!("\nSuccessfully synced '{target_branch}' with '{source_branch}'");
Ok(())
}
fn handle_which_config(path: Option<&str>, show_effective: bool) -> Result<()> {
use std::path::Path;
let search_path = match path {
Some(p) => {
let path = Path::new(p);
if !path.exists() {
return Err(crate::errors::RonaError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Directory not found: {p}"),
)));
}
Some(path)
}
None => None,
};
let config_info = find_config_sources(search_path)?;
println!("Searching from: {}", config_info.search_directory.display());
println!();
let active_sources: Vec<_> = config_info.sources.iter().filter(|s| s.exists).collect();
if active_sources.is_empty() {
println!("! No configuration files found.");
println!();
println!("Possible config locations (in loading order):");
for source in &config_info.sources {
println!(
" â—‹ [priority {}] {}",
source.priority,
source.path.display()
);
println!(" └─ {}", source.description);
}
println!();
println!("Run 'rona init' or 'rona config local/global' to create a config file.");
return Ok(());
}
println!("Configuration sources (in loading order, later overrides earlier):");
println!();
for source in &config_info.sources {
let status = if source.exists { "✓" } else { "○" };
let exists_text = if source.exists {
"(active)"
} else {
"(not found)"
};
println!(
" {} [priority {}] {}",
status,
source.priority,
source.path.display()
);
println!(" └─ {} {}", source.description, exists_text);
}
if let Some(highest) = active_sources.iter().max_by_key(|s| s.priority) {
println!();
println!("Effective config from: {}", highest.path.display());
}
if show_effective {
println!();
println!("Effective configuration values:");
println!();
if let Some(cfg) = &config_info.effective_config {
if let Some(editor) = &cfg.editor {
println!("- editor = \"{editor}\"");
}
if let Some(commit_types) = &cfg.commit_types {
println!("- commit_types = {commit_types:?}");
}
if let Some(template) = &cfg.commit_template {
println!("- commit_template = \"{template}\"");
}
} else {
println!(" (using defaults)");
}
}
Ok(())
}
fn generate_commented_config() -> String {
let default_commit_types = r#"["feat", "fix", "perf", "revert", "docs", "quality", "style", "chore", "refactor", "test", "build", "ci"]"#;
format!(
r#"# Editor used to open commit_message.md in non-interactive mode.
editor = "nano"
# Commit types shown in the selector.
commit_types = {default_commit_types}
##########
# COMMIT #
##########
# Template applied to the final commit message.
# Built-in variables:
# {{commit_number}} - sequential commit count on the current branch
# {{commit_type}} - the type chosen in the selector
# {{branch_name}} - current branch (prefix stripped, e.g. feat/x -> x)
# {{message}} - the message entered by the user
# {{date}} - YYYY-MM-DD
# {{time}} - HH:MM:SS
# {{author}} - git user.name
# {{email}} - git user.email
# Conditional blocks: {{?var}}...{{/var}} renders only when var has a value.
# Extra variables: add with [[commit_extra_fields]].
commit_template = "{{?commit_number}}[{{commit_number}}] {{/commit_number}}({{commit_type}} on {{branch_name}}) {{message}}"
# Order of prompts in interactive mode (-i).
# Use the reserved name "message" to position the built-in message prompt.
# Fields not listed are appended after all listed items.
# commit_fields_order = ["scope", "message", "ticket"]
# Overrides for the built-in message prompt (uncomment to customise or disable).
# [commit_message]
# prompt = "Commit message"
# validation = ""
# disabled = false
# [[commit_extra_fields]]
# name = "scope"
# prompt = "Select scope"
# kind = "select"
# required = false
# prefetch.source = "command"
# prefetch.command = "git log -50 --pretty=format:%s"
# prefetch.extract_regex = "\\w+\\((?P<value>[^)]*)\\):"
# prefetch.deduplicate = true
# [[commit_extra_fields]]
# name = "ticket"
# prompt = "Ticket:"
# kind = "text"
# required = false
# validation = "^[A-Z]+-[0-9]+$"
# prefetch.source = "branch"
# prefetch.extract_regex = "[A-Z]+-[0-9]+"
##########
# BRANCH #
##########
# Template applied to the generated branch name.
# Built-in variables:
# {{branch_type}} - the type chosen in the selector
# {{description}} - the description entered by the user
# {{date}} - YYYY-MM-DD
# {{time}} - HH:MM:SS
# {{author}} - git user.name
# Conditional blocks: {{?var}}...{{/var}} renders only when var has a value.
# Extra variables: add with [[branch_extra_fields]].
# Commit extra fields (from [[commit_extra_fields]]) can also be referenced here.
branch_template = "{{branch_type}}/{{description}}"
# Dedicated branch types (when absent, commit_types is used).
# branch_types = ["feat", "fix", "chore"]
# When true, branch_types and commit_types are merged in the selector.
# merge_branch_and_commit_types = false
# Order of prompts for branch creation.
# Use the reserved name "description" to position the built-in description prompt.
# branch_field_order = ["description", "ticket"]
# Overrides for the built-in description prompt (uncomment to customise or disable).
# [branch_description]
# prompt = "Branch description"
# validation = ""
# disabled = false
# [[branch_extra_fields]]
# name = "description"
# prompt = "Small description in kebab-case"
# kind = "text"
# required = true
# validation = "^[a-z][a-z0-9-]+$"
"#
)
}
fn handle_config_command(scope: ConfigScope, exclude: bool, config: &Config) -> Result<()> {
use std::io::Write;
let config_path = {
match scope {
ConfigScope::Local => {
let project_root = get_top_level_path()?;
project_root.join(".rona.toml")
}
ConfigScope::Global => {
let home = dirs::home_dir().ok_or(crate::errors::ConfigError::ConfigNotFound)?;
home.join(".config/rona.toml")
}
}
};
if config.dry_run {
println!(
"Would create {} configuration file at: {}",
match scope {
ConfigScope::Local => "local",
ConfigScope::Global => "global",
},
config_path.display()
);
if exclude {
match scope {
ConfigScope::Local => println!("Would add .rona.toml to .git/info/exclude"),
ConfigScope::Global => {
println!("--exclude only applies to local scope, ignoring");
}
}
}
return Ok(());
}
if config_path.exists() {
println!(
"Configuration file already exists at: {}",
config_path.display()
);
println!("Use 'rona set-editor <editor>' to modify the editor setting.");
} else {
if let Some(parent) = config_path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
let toml_content = generate_commented_config();
let mut file = std::fs::File::create(&config_path)?;
file.write_all(toml_content.as_bytes())?;
println!("Configuration file created at: {}", config_path.display());
println!("You can now edit this file to customize your settings.");
}
if exclude {
match scope {
ConfigScope::Local => {
add_to_git_exclude(&[".rona.toml"])?;
println!("Added .rona.toml to .git/info/exclude");
}
ConfigScope::Global => {
println!("--exclude only applies to local scope, ignoring");
}
}
}
Ok(())
}
fn init_logging(verbose: bool) {
let log_level = if verbose { "debug" } else { "warn" };
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.try_init()
.ok();
}
pub fn run() -> Result<()> {
inquire::set_global_render_config(get_render_config());
let cli = Cli::parse();
init_logging(cli.verbose);
let mut config = if let Some(ref config_path) = cli.config {
Config::new_with_config_file(std::path::Path::new(config_path))?
} else {
Config::new()?
};
config.set_verbose(cli.verbose);
match cli.command {
CliCommand::Branch { dry_run, no_switch } => {
config.set_dry_run(dry_run);
handle_branch(no_switch, &config)
}
CliCommand::AddWithExclude {
to_exclude: exclude,
interactive,
dry_run,
} => {
config.set_dry_run(dry_run);
handle_add_with_exclude(&exclude, interactive, &config)
}
CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} => {
config.set_dry_run(dry_run);
handle_commit(&args, push, unsigned, yes, copy, &config)
}
CliCommand::Completion { shell } => {
handle_completion(shell);
Ok(())
}
CliCommand::Config { subcommand } => match subcommand {
ConfigSubcommand::Create {
scope,
exclude,
dry_run,
} => {
config.set_dry_run(dry_run);
handle_config_command(scope, exclude, &config)
}
ConfigSubcommand::Which {
path,
show_effective,
} => handle_which_config(path.as_deref(), show_effective),
},
CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} => {
config.set_dry_run(dry_run);
handle_generate(interactive, no_commit_number, &config)
}
CliCommand::Initialize { editor, dry_run } => {
config.set_dry_run(dry_run);
handle_initialize(&editor, &config)
}
CliCommand::ListStatus => handle_list_status(),
CliCommand::Push { args, dry_run } => {
config.set_dry_run(dry_run);
handle_push(&args, &config)
}
CliCommand::Reset {
files,
interactive,
dry_run,
} => {
config.set_dry_run(dry_run);
handle_reset(&files, interactive, &config)
}
CliCommand::Restore {
files,
interactive,
yes,
dry_run,
} => {
config.set_dry_run(dry_run);
handle_restore(&files, interactive, yes, &config)
}
CliCommand::Set { editor, dry_run } => {
config.set_dry_run(dry_run);
handle_set(&editor, &config)
}
CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} => {
config.set_dry_run(dry_run);
handle_sync(&source_branch, rebase, new_branch.as_deref(), &config)
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::Parser;
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
#[test]
fn test_add_basic() -> TestResult {
let args = vec!["rona", "-a"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::AddWithExclude {
to_exclude: exclude,
interactive,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(exclude.is_empty());
assert!(!interactive);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_add_single_pattern() -> TestResult {
let args = vec!["rona", "-a", "*.txt"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::AddWithExclude {
to_exclude: exclude,
interactive,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(exclude, vec!["*.txt"]);
assert!(!interactive);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_add_multiple_patterns() -> TestResult {
let args = vec!["rona", "-a", "*.txt", "*.log", "target/*"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::AddWithExclude {
to_exclude: exclude,
interactive,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(exclude, vec!["*.txt", "*.log", "target/*"]);
assert!(!interactive);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_add_with_long_name() -> TestResult {
let args = vec!["rona", "add-with-exclude", "*.txt"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::AddWithExclude {
to_exclude: exclude,
interactive,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(exclude, vec!["*.txt"]);
assert!(!interactive);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_add_interactive() -> TestResult {
let args = vec!["rona", "-a", "-i"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::AddWithExclude {
to_exclude: exclude,
interactive,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(exclude.is_empty());
assert!(interactive);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_add_interactive_long_flag() -> TestResult {
let args = vec!["rona", "-a", "--interactive"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::AddWithExclude { interactive, .. } = cli.command else {
return Err("Wrong command parsed".into());
};
assert!(interactive);
Ok(())
}
#[test]
fn test_reset_basic() -> TestResult {
let cli = Cli::try_parse_from(["rona", "reset"])?;
let CliCommand::Reset {
files,
interactive,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(files.is_empty());
assert!(!interactive);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_reset_with_files() -> TestResult {
let cli = Cli::try_parse_from(["rona", "reset", "src/main.rs", "README.md"])?;
let CliCommand::Reset { files, .. } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(files, vec!["src/main.rs", "README.md"]);
Ok(())
}
#[test]
fn test_reset_interactive() -> TestResult {
let cli = Cli::try_parse_from(["rona", "reset", "-i"])?;
let CliCommand::Reset { interactive, .. } = cli.command else {
return Err("Wrong command parsed".into());
};
assert!(interactive);
Ok(())
}
#[test]
fn test_reset_dry_run() -> TestResult {
let cli = Cli::try_parse_from(["rona", "reset", "--dry-run"])?;
let CliCommand::Reset { dry_run, .. } = cli.command else {
return Err("Wrong command parsed".into());
};
assert!(dry_run);
Ok(())
}
#[test]
fn test_restore_basic() -> TestResult {
let cli = Cli::try_parse_from(["rona", "restore"])?;
let CliCommand::Restore {
files,
interactive,
yes,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(files.is_empty());
assert!(!interactive);
assert!(!yes);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_restore_with_files() -> TestResult {
let cli = Cli::try_parse_from(["rona", "restore", "src/main.rs"])?;
let CliCommand::Restore { files, .. } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(files, vec!["src/main.rs"]);
Ok(())
}
#[test]
fn test_restore_interactive_and_yes() -> TestResult {
let cli = Cli::try_parse_from(["rona", "restore", "-i", "-y"])?;
let CliCommand::Restore {
interactive, yes, ..
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(interactive);
assert!(yes);
Ok(())
}
#[test]
fn test_commit_basic() -> TestResult {
let args = vec!["rona", "-c"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_push_flag() -> TestResult {
let args = vec!["rona", "-c", "--push"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(push);
assert!(args.is_empty());
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_message() -> TestResult {
let args = vec!["rona", "-c", "Regular commit message"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert_eq!(args, vec!["Regular commit message"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_git_flag() -> TestResult {
let args = vec!["rona", "-c", "--amend"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert_eq!(args, vec!["--amend"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_multiple_git_flags() -> TestResult {
let args = vec!["rona", "-c", "--amend", "--no-edit"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert_eq!(args, vec!["--amend", "--no-edit"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_push_and_git_flags() -> TestResult {
let args = vec!["rona", "-c", "--push", "--amend", "--no-edit"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(push);
assert_eq!(args, vec!["--amend", "--no-edit"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_message_and_push() -> TestResult {
let args = vec!["rona", "-c", "--push", "Commit message"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(push);
assert_eq!(args, vec!["Commit message"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_push_basic() -> TestResult {
let args = vec!["rona", "-p"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Push { args, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert!(args.is_empty());
assert!(!dry_run);
Ok(())
}
#[test]
fn test_push_with_force() -> TestResult {
let args = vec!["rona", "-p", "--force"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Push { args, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(args, vec!["--force"]);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_push_with_multiple_args() -> TestResult {
let args = vec!["rona", "-p", "--force", "--set-upstream", "origin", "main"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Push { args, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(args, vec!["--force", "--set-upstream", "origin", "main"]);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_push_with_remote_and_branch() -> TestResult {
let args = vec!["rona", "-p", "origin", "feature/branch"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Push { args, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(args, vec!["origin", "feature/branch"]);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_push_with_upstream_tracking() -> TestResult {
let args = vec!["rona", "-p", "-u", "origin", "main"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Push { args, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(args, vec!["-u", "origin", "main"]);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_generate_command() -> TestResult {
let args = vec!["rona", "-g"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!dry_run);
assert!(!interactive);
assert!(!no_commit_number);
Ok(())
}
#[test]
fn test_generate_interactive_command() -> TestResult {
let args = vec!["rona", "-g", "-i"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!dry_run);
assert!(interactive);
assert!(!no_commit_number);
Ok(())
}
#[test]
fn test_generate_interactive_long_form() -> TestResult {
let args = vec!["rona", "-g", "--interactive"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!dry_run);
assert!(interactive);
assert!(!no_commit_number);
Ok(())
}
#[test]
fn test_generate_no_commit_number() -> TestResult {
let args = vec!["rona", "-g", "-n"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!dry_run);
assert!(!interactive);
assert!(no_commit_number);
Ok(())
}
#[test]
fn test_generate_no_commit_number_long_form() -> TestResult {
let args = vec!["rona", "-g", "--no-commit-number"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!dry_run);
assert!(!interactive);
assert!(no_commit_number);
Ok(())
}
#[test]
fn test_generate_interactive_no_commit_number() -> TestResult {
let args = vec!["rona", "-g", "-i", "-n"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Generate {
dry_run,
interactive,
no_commit_number,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!dry_run);
assert!(interactive);
assert!(no_commit_number);
Ok(())
}
#[test]
fn test_list_status_command() -> TestResult {
let args = vec!["rona", "-l"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::ListStatus = cli.command else {
return Err("Wrong command parsed".into());
};
Ok(())
}
#[test]
fn test_init_default_editor() -> TestResult {
let args = vec!["rona", "-i"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Initialize { editor, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(editor, "nano");
assert!(!dry_run);
Ok(())
}
#[test]
fn test_init_custom_editor() -> TestResult {
let args = vec!["rona", "-i", "zed"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Initialize { editor, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(editor, "zed");
assert!(!dry_run);
Ok(())
}
#[test]
fn test_set_editor() -> TestResult {
let args = vec!["rona", "-s", "vim"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Set { editor, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(editor, "vim");
assert!(!dry_run);
Ok(())
}
#[test]
fn test_set_editor_with_spaces() -> TestResult {
let args = vec!["rona", "-s", "\"Visual Studio Code\""];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Set { editor, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(editor, "\"Visual Studio Code\"");
assert!(!dry_run);
Ok(())
}
#[test]
fn test_set_editor_with_path() -> TestResult {
let args = vec!["rona", "-s", "/usr/bin/vim"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Set { editor, dry_run } = cli.command else {
return Err("Wrong command parsed".into());
};
assert_eq!(editor, "/usr/bin/vim");
assert!(!dry_run);
Ok(())
}
#[test]
fn test_verbose_with_commit() -> TestResult {
let args = vec!["rona", "-v", "-c"];
let cli = Cli::try_parse_from(args)?;
assert!(cli.verbose);
Ok(())
}
#[test]
fn test_verbose_with_push() -> TestResult {
let args = vec!["rona", "-v", "-p"];
let cli = Cli::try_parse_from(args)?;
assert!(cli.verbose);
Ok(())
}
#[test]
fn test_verbose_long_form() -> TestResult {
let args = vec!["rona", "--verbose", "-c"];
let cli = Cli::try_parse_from(args)?;
assert!(cli.verbose);
Ok(())
}
#[test]
fn test_commit_flag_order_sensitivity() -> TestResult {
let args = vec!["rona", "-c", "--amend", "--push"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push); assert_eq!(args, vec!["--amend", "--push"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_similar_looking_args() -> TestResult {
let args = vec!["rona", "-c", "--push-to-upstream"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert_eq!(args, vec!["--push-to-upstream"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_invalid_command() {
let args = vec!["rona", "--invalid"];
assert!(Cli::try_parse_from(args).is_err());
}
#[test]
fn test_missing_required_value() {
let args = vec!["rona", "-s"]; assert!(Cli::try_parse_from(args).is_err());
}
#[test]
fn test_complex_command_combination() -> TestResult {
let args = vec!["rona", "-v", "-c", "--push", "--amend", "--no-edit"];
let cli = Cli::try_parse_from(args)?;
assert!(cli.verbose);
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(push);
assert_eq!(args, vec!["--amend", "--no-edit"]);
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_unsigned_short_flag() -> TestResult {
let args = vec!["rona", "-c", "-u"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(!dry_run);
assert!(unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_unsigned_long_flag() -> TestResult {
let args = vec!["rona", "-c", "--unsigned"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(!dry_run);
assert!(unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_unsigned_with_push_and_args() -> TestResult {
let args = vec!["rona", "-c", "-u", "--push", "--amend"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(push);
assert_eq!(args, vec!["--amend"]);
assert!(!dry_run);
assert!(unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_dry_run_short_flag() -> TestResult {
let args = vec!["rona", "-c", "-d"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_dry_run_long_flag() -> TestResult {
let args = vec!["rona", "-c", "--dry-run"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_dry_run_with_push() -> TestResult {
let args = vec!["rona", "-c", "-d", "--push"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(push);
assert!(args.is_empty());
assert!(dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(!copy);
Ok(())
}
#[test]
fn test_commit_with_copy_flag() -> TestResult {
let args = vec!["rona", "-c", "--copy"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(!dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(copy);
Ok(())
}
#[test]
fn test_commit_copy_flag_with_other_flags() -> TestResult {
let args = vec!["rona", "-c", "--copy", "--dry-run"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Commit {
args,
push,
dry_run,
unsigned,
yes,
copy,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert!(!push);
assert!(args.is_empty());
assert!(dry_run);
assert!(!unsigned);
assert!(!yes);
assert!(copy);
Ok(())
}
fn unwrap_config_create(
cli: Cli,
) -> std::result::Result<(ConfigScope, bool, bool), Box<dyn std::error::Error>> {
let CliCommand::Config { subcommand } = cli.command else {
return Err("Wrong command parsed".into());
};
let ConfigSubcommand::Create {
scope,
exclude,
dry_run,
} = subcommand
else {
return Err("Wrong subcommand parsed".into());
};
Ok((scope, exclude, dry_run))
}
#[test]
fn test_config_local() -> TestResult {
let args = vec!["rona", "config", "create", "local"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Local));
assert!(!exclude);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_config_local_short() -> TestResult {
let args = vec!["rona", "config", "-c", "local"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Local));
assert!(!exclude);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_config_global() -> TestResult {
let args = vec!["rona", "config", "create", "global"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Global));
assert!(!exclude);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_config_local_dry_run() -> TestResult {
let args = vec!["rona", "config", "create", "local", "--dry-run"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Local));
assert!(!exclude);
assert!(dry_run);
Ok(())
}
#[test]
fn test_config_global_dry_run() -> TestResult {
let args = vec!["rona", "config", "create", "global", "--dry-run"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Global));
assert!(!exclude);
assert!(dry_run);
Ok(())
}
#[test]
fn test_config_local_exclude() -> TestResult {
let args = vec!["rona", "config", "create", "local", "--exclude"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Local));
assert!(exclude);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_config_local_exclude_short() -> TestResult {
let args = vec!["rona", "config", "-c", "local", "-e"];
let cli = Cli::try_parse_from(args)?;
let (scope, exclude, dry_run) = unwrap_config_create(cli)?;
assert!(matches!(scope, ConfigScope::Local));
assert!(exclude);
assert!(!dry_run);
Ok(())
}
#[test]
fn test_config_which() -> TestResult {
let args = vec!["rona", "config", "which"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Config { subcommand } = cli.command else {
return Err("Wrong command parsed".into());
};
let ConfigSubcommand::Which {
path,
show_effective,
} = subcommand
else {
return Err("Wrong subcommand parsed".into());
};
assert!(path.is_none());
assert!(!show_effective);
Ok(())
}
#[test]
fn test_config_which_short() -> TestResult {
let args = vec!["rona", "config", "-w"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Config { subcommand } = cli.command else {
return Err("Wrong command parsed".into());
};
assert!(matches!(subcommand, ConfigSubcommand::Which { .. }));
Ok(())
}
#[test]
fn test_config_missing_subcommand() {
let args = vec!["rona", "config"];
assert!(Cli::try_parse_from(args).is_err());
}
#[test]
fn test_config_invalid_scope() {
let args = vec!["rona", "config", "create", "invalid"];
assert!(Cli::try_parse_from(args).is_err());
}
#[test]
fn test_template_selection_with_no_commit_number() -> TestResult {
use std::collections::HashMap;
use crate::template::{TemplateVariables, process_template};
let default_template = "{?commit_number}[{commit_number}] {/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: "Test User".to_string(),
email: "test@example.com".to_string(),
};
let result = process_template(default_template, &variables, &HashMap::new())?;
assert!(
!result.contains("[]"),
"Output should not contain empty brackets: {result}"
);
assert_eq!(result, "(docs on main) Update docs");
Ok(())
}
#[test]
fn test_template_selection_with_commit_number() -> TestResult {
use std::collections::HashMap;
use crate::template::{TemplateVariables, process_template};
let default_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: "Test User".to_string(),
email: "test@example.com".to_string(),
};
let result = process_template(default_template, &variables, &HashMap::new())?;
assert!(
result.starts_with("[42]"),
"Output should start with [42]: {result}"
);
assert_eq!(result, "[42] (feat on new-feature) Add feature");
Ok(())
}
#[test]
fn test_bug_using_wrong_template_with_no_commit_number() -> TestResult {
use std::collections::HashMap;
use crate::template::{TemplateVariables, process_template};
let wrong_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: "Test User".to_string(),
email: "test@example.com".to_string(),
};
let result = process_template(wrong_template, &variables, &HashMap::new())?;
assert_eq!(result, "[] (docs on main) Update docs");
assert!(result.contains("[]"), "This demonstrates the bug we fixed");
Ok(())
}
#[test]
fn test_fallback_format_with_no_commit_number() {
let no_commit_number = true;
let commit_type = "fix";
let branch_name = "bugfix";
let message = "Fix issue";
let formatted_message = if no_commit_number {
format!("({commit_type} on {branch_name}) {message}")
} else {
format!("[42] ({commit_type} on {branch_name}) {message}")
};
assert_eq!(formatted_message, "(fix on bugfix) Fix issue");
assert!(
!formatted_message.contains("[]"),
"Fallback should not produce empty brackets"
);
}
#[test]
fn test_fallback_format_with_commit_number() {
let no_commit_number = false;
let commit_number = 15u32;
let commit_type = "feat";
let branch_name = "feature";
let message = "Add feature";
let formatted_message = if no_commit_number {
format!("({commit_type} on {branch_name}) {message}")
} else {
format!("[{commit_number}] ({commit_type} on {branch_name}) {message}")
};
assert_eq!(formatted_message, "[15] (feat on feature) Add feature");
assert!(
!formatted_message.contains("[]"),
"Should not produce empty brackets"
);
}
#[test]
fn test_sync_basic() -> TestResult {
let args = vec!["rona", "sync"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "main");
assert!(!rebase);
assert!(new_branch.is_none());
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_branch() -> TestResult {
let args = vec!["rona", "sync", "--branch", "develop"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "develop");
assert!(!rebase);
assert!(new_branch.is_none());
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_branch_short_flag() -> TestResult {
let args = vec!["rona", "sync", "-b", "staging"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "staging");
assert!(!rebase);
assert!(new_branch.is_none());
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_rebase() -> TestResult {
let args = vec!["rona", "sync", "--rebase"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "main");
assert!(rebase);
assert!(new_branch.is_none());
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_rebase_short_flag() -> TestResult {
let args = vec!["rona", "sync", "-r"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "main");
assert!(rebase);
assert!(new_branch.is_none());
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_new_branch() -> TestResult {
let args = vec!["rona", "sync", "--new-branch", "feature/new-feature"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "main");
assert!(!rebase);
assert_eq!(new_branch, Some("feature/new-feature".to_string()));
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_new_branch_short_flag() -> TestResult {
let args = vec!["rona", "sync", "-n", "bugfix/issue-123"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "main");
assert!(!rebase);
assert_eq!(new_branch, Some("bugfix/issue-123".to_string()));
assert!(!dry_run);
Ok(())
}
#[test]
fn test_sync_with_dry_run() -> TestResult {
let args = vec!["rona", "sync", "--dry-run"];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "main");
assert!(!rebase);
assert!(new_branch.is_none());
assert!(dry_run);
Ok(())
}
#[test]
fn test_sync_all_options() -> TestResult {
let args = vec![
"rona",
"sync",
"--branch",
"develop",
"--rebase",
"--new-branch",
"feature/test",
"--dry-run",
];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "develop");
assert!(rebase);
assert_eq!(new_branch, Some("feature/test".to_string()));
assert!(dry_run);
Ok(())
}
#[test]
fn test_sync_short_flags_combination() -> TestResult {
let args = vec![
"rona",
"sync",
"-b",
"staging",
"-r",
"-n",
"hotfix/critical",
];
let cli = Cli::try_parse_from(args)?;
let CliCommand::Sync {
source_branch,
rebase,
new_branch,
dry_run,
} = cli.command
else {
return Err("Wrong command parsed".into());
};
assert_eq!(source_branch, "staging");
assert!(rebase);
assert_eq!(new_branch, Some("hotfix/critical".to_string()));
assert!(!dry_run);
Ok(())
}
}