mod git;
mod version;
use calver::{Cli, Commands};
use clap::Parser;
use std::process;
use git::find_last_tag_for_date;
use version::Version;
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
let mut version = Version::new(format, separator, Some(patch));
if let Some(repo_path) = git_path {
if !git::is_valid_repository(&repo_path) {
eprintln!("Error: '{}' is not a valid Git repository", repo_path);
process::exit(1);
}
match find_last_tag_for_date(&repo_path, &version) {
Ok(Some(latest_tag)) => {
match version.set_patch_from_last(&latest_tag) {
Ok(()) => {
println!("{}", version.generate());
}
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
}
Ok(None) => {
println!("{}", version.generate());
}
Err(e) => {
eprintln!("Error reading Git repository: {}", e);
process::exit(1);
}
}
}
else if let Some(last_version) = last {
match version.set_patch_from_last(&last_version) {
Ok(()) => {
println!("{}", version.generate());
}
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
} else {
println!("{}", version.generate());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_cli_parsing_bump_command() {
let args = vec!["calver", "bump"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, None);
assert_eq!(git_path, None);
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_format() {
let args = vec!["calver", "bump", "--format", "%Y.%m"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("%Y.%m".to_string()));
assert_eq!(separator, None);
assert_eq!(git_path, None);
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_separator() {
let args = vec!["calver", "bump", "--separator", "_"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, Some("_".to_string()));
assert_eq!(git_path, None);
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_patch() {
let args = vec!["calver", "bump", "--patch", "5"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, None);
assert_eq!(git_path, None);
assert_eq!(patch, 5);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_all_options() {
let args = vec![
"calver",
"bump",
"--format",
"%Y.%W",
"--separator",
".",
"--patch",
"10",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("%Y.%W".to_string()));
assert_eq!(separator, Some(".".to_string()));
assert_eq!(git_path, None);
assert_eq!(patch, 10);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_short_flags() {
let args = vec!["calver", "bump", "-f", "%Y-%m", "-s", "_", "-p", "3"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("%Y-%m".to_string()));
assert_eq!(separator, Some("_".to_string()));
assert_eq!(git_path, None);
assert_eq!(patch, 3);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_invalid_patch_value() {
let args = vec!["calver", "bump", "--patch", "invalid"];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
}
#[test]
fn test_cli_negative_patch_value() {
let args = vec!["calver", "bump", "--patch", "-1"];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
}
#[test]
fn test_cli_patch_overflow() {
let args = vec!["calver", "bump", "--patch", "65536"]; let result = Cli::try_parse_from(args);
assert!(result.is_err());
}
#[test]
fn test_cli_help_contains_description() {
use clap::CommandFactory;
let mut cmd = Cli::command();
let help = cmd.render_help();
let help_str = help.to_string();
assert!(help_str.contains("Calendar Versioning"));
assert!(help_str.contains("bump"));
}
#[test]
fn test_cli_version_flag() {
let args = vec!["calver", "--version"];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
}
#[test]
fn test_cli_no_subcommand() {
let args = vec!["calver"];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
}
#[test]
fn test_cli_invalid_subcommand() {
let args = vec!["calver", "invalid"];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
}
#[test]
fn test_cli_bump_with_empty_format() {
temp_env::with_vars(
[
("CALVER_FORMAT", None::<&str>),
("CALVER_SEPARATOR", None::<&str>),
("CALVER_PATCH", None::<&str>),
("CALVER_LAST", None::<&str>),
("CALVER_GIT_PATH", None::<&str>),
],
|| {
let args = vec!["calver", "bump", "--format", ""];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("".to_string()));
assert_eq!(separator, None);
assert_eq!(git_path, None);
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
},
);
}
#[test]
fn test_cli_bump_with_empty_separator() {
temp_env::with_vars(
[
("CALVER_FORMAT", None::<&str>),
("CALVER_SEPARATOR", None::<&str>),
("CALVER_PATCH", None::<&str>),
("CALVER_LAST", None::<&str>),
("CALVER_GIT_PATH", None::<&str>),
],
|| {
let args = vec!["calver", "bump", "--separator", ""];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, Some("".to_string()));
assert_eq!(git_path, None);
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
},
);
}
#[test]
fn test_cli_bump_with_max_patch() {
temp_env::with_vars(
[
("CALVER_FORMAT", None::<&str>),
("CALVER_SEPARATOR", None::<&str>),
("CALVER_PATCH", None::<&str>),
("CALVER_LAST", None::<&str>),
("CALVER_GIT_PATH", None::<&str>),
],
|| {
let args = vec!["calver", "bump", "--patch", "65535"]; let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, None);
assert_eq!(git_path, None);
assert_eq!(patch, 65535);
assert_eq!(last, None);
}
}
},
);
}
#[test]
fn test_cli_parsing_with_complex_format() {
let args = vec!["calver", "bump", "--format", "%Y.%m.%d.%H.%M"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("%Y.%m.%d.%H.%M".to_string()));
assert_eq!(separator, None);
assert_eq!(git_path, None);
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_environment_variables() {
temp_env::with_vars(
[
("CALVER_FORMAT", Some("%Y.%W")),
("CALVER_SEPARATOR", Some("_")),
("CALVER_PATCH", Some("7")),
],
|| {
let args = vec!["calver", "bump"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("%Y.%W".to_string()));
assert_eq!(separator, Some("_".to_string()));
assert_eq!(git_path, None);
assert_eq!(patch, 7);
assert_eq!(last, None);
}
}
},
);
}
#[test]
fn test_cli_args_override_env() {
temp_env::with_vars([("CALVER_FORMAT", Some("%Y.%W"))], || {
let args = vec!["calver", "bump", "--format", "%Y.%m.%d"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump { format, .. } => {
assert_eq!(format, Some("%Y.%m.%d".to_string()));
}
}
});
}
#[test]
fn test_cli_parsing_bump_with_git_path() {
let args = vec!["calver", "bump", "--git-path", "/path/to/repo"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, None);
assert_eq!(git_path, Some("/path/to/repo".to_string()));
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_git_path_short_flag() {
let args = vec!["calver", "bump", "-g", "."];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, None);
assert_eq!(separator, None);
assert_eq!(git_path, Some(".".to_string()));
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_parsing_bump_with_git_path_and_format() {
let args = vec![
"calver",
"bump",
"--git-path",
"/repo",
"--format",
"%Y%m%d",
"--separator",
"_",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump {
format,
separator,
git_path,
patch,
last,
} => {
assert_eq!(format, Some("%Y%m%d".to_string()));
assert_eq!(separator, Some("_".to_string()));
assert_eq!(git_path, Some("/repo".to_string()));
assert_eq!(patch, 0);
assert_eq!(last, None);
}
}
}
#[test]
fn test_cli_git_path_conflicts_with_patch() {
let args = vec!["calver", "bump", "--git-path", ".", "--patch", "5"];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn test_cli_git_path_conflicts_with_last() {
let args = vec![
"calver",
"bump",
"--git-path",
".",
"--last",
"2024.03.15-3",
];
let result = Cli::try_parse_from(args);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("cannot be used with"));
}
#[test]
fn test_cli_git_path_with_empty_string() {
let args = vec!["calver", "bump", "--git-path", ""];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump { git_path, .. } => {
assert_eq!(git_path, Some("".to_string()));
}
}
}
#[test]
fn test_cli_git_path_with_relative_path() {
let args = vec!["calver", "bump", "--git-path", "../other-repo"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump { git_path, .. } => {
assert_eq!(git_path, Some("../other-repo".to_string()));
}
}
}
#[test]
fn test_environment_variable_git_path() {
temp_env::with_vars([("CALVER_GIT_PATH", Some("/env/repo"))], || {
let args = vec!["calver", "bump"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump { git_path, .. } => {
assert_eq!(git_path, Some("/env/repo".to_string()));
}
}
});
}
#[test]
fn test_cli_args_override_env_git_path() {
temp_env::with_vars([("CALVER_GIT_PATH", Some("/env/repo"))], || {
let args = vec!["calver", "bump", "--git-path", "/cli/repo"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Commands::Bump { git_path, .. } => {
assert_eq!(git_path, Some("/cli/repo".to_string()));
}
}
});
}
}
#[cfg(test)]
mod integration_tests {
use std::process::Command;
fn run_calver_command(args: &[&str]) -> (String, String, i32) {
let output = Command::new("target/debug/calver")
.args(args)
.output()
.expect("Failed to execute calver command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
(stdout, stderr, exit_code)
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_integration_basic_bump() {
let (stdout, stderr, exit_code) = run_calver_command(&["bump"]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}-0", chrono::Utc::now().format("%Y.%m.%d"));
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_integration_with_custom_format() {
let (stdout, stderr, exit_code) = run_calver_command(&[
"bump",
"--format",
"%Y.%m",
"--separator",
"_",
"--patch",
"5",
]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}_5", chrono::Utc::now().format("%Y.%m"));
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_last_parameter_success() {
let now = chrono::Utc::now().format("%Y.%m.%d");
let last = format!("{}-3", now);
let (stdout, stderr, exit_code) = run_calver_command(&["bump", "--last", &last]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}-4", now);
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_last_parameter_not_today() {
let (stdout, stderr, exit_code) = run_calver_command(&["bump", "--last", "2025.09.15-3"]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}-0", chrono::Utc::now().format("%Y.%m.%d"));
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_last_parameter_overflow_error() {
let now = chrono::Utc::now().format("%Y.%m.%d");
let last = format!("{}-{}", now, u16::MAX);
let (stdout, stderr, exit_code) = run_calver_command(&["bump", "--last", &last]);
assert_eq!(exit_code, 1);
assert!(stdout.is_empty());
assert!(stderr.contains("Error:"));
assert!(stderr.contains("exceeds the maximum allowed value"));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_patch_and_last_parameter() {
let (stdout, stderr, exit_code) =
run_calver_command(&["bump", "--patch", "5", "--last", "2025.09.15-3"]);
assert_eq!(exit_code, 2);
assert!(stdout.is_empty());
assert!(stderr.contains("error:"));
assert!(stderr.contains("cannot be used with"));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_invalid_repository() {
let (stdout, stderr, exit_code) =
run_calver_command(&["bump", "--git-path", "/non/existent/repo"]);
assert_eq!(exit_code, 1);
assert!(stdout.is_empty());
assert!(stderr.contains("Error:"));
assert!(stderr.contains("not a valid Git repository"));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_valid_repo_no_tags() {
use git2::Repository;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let (stdout, stderr, exit_code) =
run_calver_command(&["bump", "--git-path", temp_dir.path().to_str().unwrap()]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}-0", chrono::Utc::now().format("%Y.%m.%d"));
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_with_matching_tags() {
use git2::Repository;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let commit_obj = repo.find_commit(commit).unwrap();
let today = chrono::Utc::now().format("%Y.%m.%d").to_string();
repo.tag_lightweight(&format!("{}-0", today), commit_obj.as_object(), false)
.unwrap();
repo.tag_lightweight(&format!("{}-1", today), commit_obj.as_object(), false)
.unwrap();
repo.tag_lightweight(&format!("{}-2", today), commit_obj.as_object(), false)
.unwrap();
let (stdout, stderr, exit_code) =
run_calver_command(&["bump", "--git-path", temp_dir.path().to_str().unwrap()]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}-3", today);
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_custom_format() {
use git2::Repository;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let commit_obj = repo.find_commit(commit).unwrap();
let today = chrono::Utc::now().format("%Y%m%d").to_string();
repo.tag_lightweight(&format!("{}_v0", today), commit_obj.as_object(), false)
.unwrap();
repo.tag_lightweight(&format!("{}_v1", today), commit_obj.as_object(), false)
.unwrap();
let (stdout, stderr, exit_code) = run_calver_command(&[
"bump",
"--git-path",
temp_dir.path().to_str().unwrap(),
"--format",
"%Y%m%d",
"--separator",
"_v",
]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}_v2", today);
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_mixed_date_tags() {
use git2::Repository;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let commit_obj = repo.find_commit(commit).unwrap();
let today = chrono::Utc::now().format("%Y.%m.%d").to_string();
repo.tag_lightweight("2024.01.01-10", commit_obj.as_object(), false)
.unwrap();
repo.tag_lightweight(&format!("{}-0", today), commit_obj.as_object(), false)
.unwrap();
repo.tag_lightweight("2024.12.31-5", commit_obj.as_object(), false)
.unwrap();
let (stdout, stderr, exit_code) =
run_calver_command(&["bump", "--git-path", temp_dir.path().to_str().unwrap()]);
assert_eq!(exit_code, 0);
assert!(stderr.is_empty());
let expected_output = format!("{}-1", today);
assert!(stdout.contains(&expected_output));
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_current_directory() {
let (stdout, stderr, exit_code) = run_calver_command(&["bump", "--git-path", "."]);
if exit_code == 0 {
assert!(stderr.is_empty());
assert!(!stdout.is_empty());
} else {
assert!(stderr.contains("Error:"));
}
}
#[test]
#[cfg_attr(tarpaulin, ignore)]
fn test_main_with_git_path_tag_overflow() {
use git2::Repository;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let repo = Repository::init(temp_dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
let sig = repo.signature().unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let commit_obj = repo.find_commit(commit).unwrap();
let today = chrono::Utc::now().format("%Y.%m.%d").to_string();
repo.tag_lightweight(
&format!("{}-{}", today, u16::MAX),
commit_obj.as_object(),
false,
)
.unwrap();
let (stdout, stderr, exit_code) =
run_calver_command(&["bump", "--git-path", temp_dir.path().to_str().unwrap()]);
assert_eq!(exit_code, 1);
assert!(stdout.is_empty());
assert!(stderr.contains("Error:"));
assert!(stderr.contains("exceeds the maximum allowed value"));
}
}