diff --git a/Cargo.toml b/Cargo.toml index 883166a..7c5e610 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,10 @@ include = ["/src", "LICENSE"] [dependencies] structopt = "0.3.26" inquire = "0.7.5" -git2 = { version = "0.20.0", features = ["vendored-openssl"] } -thiserror = "2.0.12" -anyhow = "1.0.98" -sentry = "0.37.0" +git2 = { version = "0.20.2", features = ["vendored-openssl"] } +thiserror = "2.0.16" +anyhow = "1.0.99" +sentry = "0.42.0" chrono = { version = "0.4.41", features = ["serde"] } semver = "1.0.24" regex = "1.11.1" @@ -28,18 +28,20 @@ self_update = { version = "0.42.0", features = [ "archive-zip", ] } colored = "3.0.0" -indicatif = "0.17.9" +indicatif = "0.18.0" serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -toml = "0.8.22" +serde_json = "1.0.143" +toml = "0.9.5" dirs = "6.0.0" -reqwest = { version = "0.12.15", features = ["json", "blocking"] } -tokio = { version = "1.44.2", features = ["full"] } +reqwest = { version = "0.12.23", features = ["json", "blocking"] } +tokio = { version = "1.47.1", features = ["full"] } once_cell = "1.21.3" -uuid = {version = "1.16.0", features = ["v4"]} +uuid = {version = "1.18.0", features = ["v4"]} [dev-dependencies] assert_cmd = "2.0.17" predicates = "3.1.3" -tempfile = "3.15.0" +tempfile = "3.21.0" mockall = "0.13.1" +once_cell = "1.21.3" +serial_test = "3.2.0" diff --git a/README.md b/README.md index f2fcd5d..36da89e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,70 @@ committy -s "change the api version" committy -s "change the api version" amend ``` +## ⚙️ CLI Reference & Advanced Usage + +### Output format + +- Use `--output json|text` on commands that support it. +- `lint --output json` prints `{ ok, count, issues }`. +- `tag --output json` (with `--dry-run`) prints `{ ok, new_tag }`. + +### Verbosity + +- `-v` or `--verbose`: increase log verbosity. Repeat for more details (`-vv`). +- `-q` or `--quiet`: only error logs. +- Defaults to `info` when neither is provided. + +### Non-interactive mode + +- `--non-interactive` disables prompts (ideal for CI). +- Also enabled when `COMMITTY_NONINTERACTIVE=1` or `CI=1`. + +### Fetch control for tags + +- `--fetch` / `--no-fetch` controls whether tags are fetched from remote before calculation. +- Default: fetch is enabled unless `--no-fetch` is provided. +- Example (no remote access): + +```bash +committy --non-interactive tag --no-fetch --dry-run --not-publish --output json +``` + +### Stable lint exit codes (for CI) + +- `0` = OK, no issues +- `3` = Lint issues found +- `1` = Error + +Example: + +```bash +committy --non-interactive lint --repo-path . --output json || { + code=$?; if [ $code -eq 3 ]; then echo "Lint issues"; else exit $code; fi +} +``` + +### Configurable version bump rules + +Committy determines semantic version bumps using regex patterns loaded from `config.toml`. + +- Default location: `~/.config/committy/config.toml` +- Override directory with `COMMITTY_CONFIG_DIR` (the file must be named `config.toml`). +- Keys: + - `major_regex` + - `minor_regex` + - `patch_regex` + +Example `config.toml` (use single quotes for literal regex): + +```toml +minor_regex = '(?im)^fix(?:\s*\([^)]*\))?:' +patch_regex = '(?im)^docs(?:\s*\([^)]*\))?:' +major_regex = '(?im)^(breaking change:|feat(?:\s*\([^)]*\))?!:)' +``` + +This example treats `fix:` commits as a minor bump (instead of patch) and moves `docs:` to patch. + ## 📝 Commit Types Committy supports the following commit types: diff --git a/build.rs b/build.rs index 09e462f..c527736 100644 --- a/build.rs +++ b/build.rs @@ -11,16 +11,13 @@ fn main() { let dest_path = std::path::Path::new(&out_dir).join("sentry_dsn.rs"); fs::write( &dest_path, - format!(r#"pub const SENTRY_DSN: &str = "{}";"#, sentry_dsn), + format!(r#"pub const SENTRY_DSN: &str = "{sentry_dsn}";"#), ) .unwrap(); let dest_path = std::path::Path::new(&out_dir).join("posthog_api_key.rs"); fs::write( &dest_path, - format!( - r#"pub const POSTHOG_API_KEY: &str = "{}";"#, - posthog_api_key - ), + format!(r#"pub const POSTHOG_API_KEY: &str = "{posthog_api_key}";"#), ) .unwrap(); } diff --git a/src/cli/commands/branch.rs b/src/cli/commands/branch.rs index 0fc1ace..9f16683 100644 --- a/src/cli/commands/branch.rs +++ b/src/cli/commands/branch.rs @@ -28,7 +28,7 @@ impl Command for BranchCommand { if let Some(name) = &self.name { git::create_branch(name, self.force)?; - println!("Branch {} created successfully!", name); + println!("Branch {name} created successfully!"); } else { if non_interactive { return Err(CliError::InputError( @@ -41,9 +41,9 @@ impl Command for BranchCommand { let subject = input::input_subject()?; let branch_name = if ticket.is_empty() { - format!("{}-{}", branch_type, subject) + format!("{branch_type}-{subject}") } else { - format!("{}-{}-{}", branch_type, ticket, subject) + format!("{branch_type}-{ticket}-{subject}") }; let validate = if !self.validate { @@ -56,9 +56,9 @@ impl Command for BranchCommand { return Ok(()); } git::create_branch(&branch_name, self.force)?; - println!("Branch {} created successfully!", branch_name); + println!("Branch {branch_name} created successfully!"); git::checkout_branch(&branch_name)?; - println!("Switched to branch {}", branch_name); + println!("Switched to branch {branch_name}"); if let Err(e) = tokio::runtime::Runtime::new() .unwrap() @@ -73,7 +73,7 @@ impl Command for BranchCommand { ]), )) { - debug!("Telemetry error: {:?}", e); + debug!("Telemetry error: {e:?}"); } } diff --git a/src/cli/commands/commit.rs b/src/cli/commands/commit.rs index 11f0a39..2ae62cd 100644 --- a/src/cli/commands/commit.rs +++ b/src/cli/commands/commit.rs @@ -54,14 +54,8 @@ impl Command for CommitCommand { let commit_type = if let Some(commit_type) = &self.commit_type { if let Some(suggested) = suggest_commit_type(commit_type) { if suggested != commit_type { - info!( - "Auto-correcting commit type from '{}' to '{}'", - commit_type, suggested - ); - debug!( - "Auto-corrected commit type from '{}' to '{}'", - commit_type, suggested - ); + info!("Auto-correcting commit type from '{commit_type}' to '{suggested}'"); + debug!("Auto-corrected commit type from '{commit_type}' to '{suggested}'"); } suggested.to_string() } else { @@ -94,7 +88,7 @@ impl Command for CommitCommand { // In non-interactive mode, apply corrections automatically let corrected = auto_correct_scope(scope); if corrected != *scope { - info!("Auto-correcting scope from '{}' to '{}'", scope, corrected); + info!("Auto-correcting scope from '{scope}' to '{corrected}'"); } corrected } @@ -129,7 +123,7 @@ impl Command for CommitCommand { &long_message, ); - debug!("Formatted commit message: {}", full_message); + debug!("Formatted commit message: {full_message}"); git::commit_changes(&full_message, self.amend)?; // fire off telemetry without making this function async if let Err(e) = @@ -158,7 +152,7 @@ impl Command for CommitCommand { ]), )) { - debug!("Telemetry error: {:?}", e); + debug!("Telemetry error: {e:?}"); } info!("Changes committed successfully! 🎉"); Ok(()) diff --git a/src/cli/commands/lint.rs b/src/cli/commands/lint.rs index c2657ba..ee1ec2a 100644 --- a/src/cli/commands/lint.rs +++ b/src/cli/commands/lint.rs @@ -1,6 +1,7 @@ use crate::cli::Command; use crate::error::CliError; use crate::linter::CommitLinter; +use serde::Serialize; use structopt::StructOpt; #[derive(Debug, StructOpt)] @@ -8,6 +9,10 @@ pub struct LintCommand { /// Path to the git repository (defaults to current directory) #[structopt(long, default_value = ".")] repo_path: String, + + /// Output format: text or json + #[structopt(long, default_value = "text", possible_values = &["text", "json"])] + output: String, } impl Command for LintCommand { @@ -17,20 +22,37 @@ impl Command for LintCommand { match linter.check_commits_since_last_tag() { Ok(issues) => { - if issues.is_empty() { + if self.output == "json" { + #[derive(Serialize)] + struct LintOutput<'a> { + ok: bool, + count: usize, + issues: &'a [crate::linter::CommitIssue], + } + let payload = LintOutput { + ok: issues.is_empty(), + count: issues.len(), + issues: &issues, + }; + println!("{}", serde_json::to_string(&payload).unwrap()); + } else if issues.is_empty() { println!( "✅ All commits since the last tag follow the conventional commit format!" ); } else { println!("❌ Found {} commit(s) with issues:", issues.len()); - for issue in issues { + for issue in &issues { println!("\nCommit: {}", issue.commit_id); println!("Message: {}", issue.message); println!("Issue: {}", issue.issue); } - std::process::exit(1); } - Ok(()) + + if issues.is_empty() { + Ok(()) + } else { + Err(CliError::LintIssues(issues.len())) + } } Err(e) => Err(CliError::Generic(e.to_string())), } diff --git a/src/cli/commands/tag.rs b/src/cli/commands/tag.rs index f4ebd79..4c32d9d 100644 --- a/src/cli/commands/tag.rs +++ b/src/cli/commands/tag.rs @@ -27,6 +27,10 @@ pub struct TagCommand { #[structopt(flatten)] tag_options: git::TagGeneratorOptions, + + /// Output format: text or json + #[structopt(long, default_value = "text", possible_values = &["text", "json"])] + output: String, } impl Command for TagCommand { @@ -39,14 +43,34 @@ impl Command for TagCommand { let version_manager = git::TagGenerator::new(self.tag_options.clone(), self.bump_config_files); version_manager.create_and_push_tag(&version_manager.open_repository()?, name)?; - println!("Tag {} created successfully!", name); - } else { - if non_interactive { - return Err(CliError::InputError( - "Tag name is required in non-interactive mode".to_string(), - )); + if self.output == "json" { + let payload = serde_json::json!({ + "ok": true, + "new_tag": name, + }); + println!("{}", serde_json::to_string(&payload).unwrap()); + } else { + println!("Tag {name} created successfully!"); } + } else if non_interactive { + // In non-interactive mode, auto-calculate and act based on options + let mut version_manager = + git::TagGenerator::new(self.tag_options.clone(), self.bump_config_files); + version_manager.run()?; + // Print the calculated tag so callers/tests can consume it + if self.output == "json" { + let payload = serde_json::json!({ + "ok": true, + "old_tag": version_manager.current_tag, + "new_tag": version_manager.new_tag, + "pre_release": version_manager.is_pre_release, + }); + println!("{}", serde_json::to_string(&payload).unwrap()); + } else { + println!("{}", version_manager.new_tag); + } + } else { let validate = if !self.validate { input::ask_want_create_new_tag()? } else { @@ -59,6 +83,15 @@ impl Command for TagCommand { let mut version_manager = git::TagGenerator::new(self.tag_options.clone(), self.bump_config_files); version_manager.run()?; + if self.output == "json" { + let payload = serde_json::json!({ + "ok": true, + "old_tag": version_manager.current_tag, + "new_tag": version_manager.new_tag, + "pre_release": version_manager.is_pre_release, + }); + println!("{}", serde_json::to_string(&payload).unwrap()); + } if let Err(e) = tokio::runtime::Runtime::new() .unwrap() @@ -75,7 +108,7 @@ impl Command for TagCommand { ]), )) { - debug!("Telemetry error: {:?}", e); + debug!("Telemetry error: {e:?}"); } } diff --git a/src/config.rs b/src/config.rs index 041afde..a000d73 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,10 @@ pub struct Config { pub metrics_enabled: bool, pub last_metrics_reminder: DateTime, pub user_id: String, + // Regex patterns for semantic version bump detection + pub major_regex: String, + pub minor_regex: String, + pub patch_regex: String, } impl Default for Config { @@ -42,6 +46,9 @@ impl Default for Config { last_metrics_reminder: DateTime::parse_from_rfc3339("2006-01-01T00:00:00+01:00") .unwrap(), user_id: "".to_string(), + major_regex: MAJOR_REGEX.to_string(), + minor_regex: MINOR_REGEX.to_string(), + patch_regex: PATCH_REGEX.to_string(), } } } @@ -49,7 +56,7 @@ impl Default for Config { impl Config { pub fn load() -> Result { let config_path = Self::get_config_path()?; - debug!("Loading configuration from {:?}", config_path); + debug!("Loading configuration from {config_path:?}"); if !config_path.exists() { debug!("No configuration file found, using defaults"); @@ -57,7 +64,7 @@ impl Config { } let config_str = fs::read_to_string(&config_path)?; - debug!("Read configuration content: {}", config_str); + debug!("Read configuration content: {config_str}"); // Load config with possible missing fields (serde default fills them) let mut config: Self = toml::from_str(&config_str)?; // If any field is still default (i.e., was missing in the file), re-save @@ -77,26 +84,31 @@ impl Config { pub fn save(&self) -> Result<()> { let config_path = Self::get_config_path()?; debug!("Saving configuration"); - debug!("Configuration path: {:?}", config_path); - debug!("Saving configuration to {:?}", config_path); + debug!("Configuration path: {config_path:?}"); + debug!("Saving configuration to {config_path:?}"); if let Some(parent) = config_path.parent() { - debug!("Creating config directory: {:?}", parent); + debug!("Creating config directory: {parent:?}"); fs::create_dir_all(parent)?; } let config_str = toml::to_string(self)?; - debug!("Writing configuration content: {}", config_str); + debug!("Writing configuration content: {config_str}"); fs::write(config_path, config_str)?; debug!("Configuration saved successfully"); Ok(()) } fn get_config_path() -> Result { + if let Ok(dir) = std::env::var("COMMITTY_CONFIG_DIR") { + let path = PathBuf::from(dir).join("config.toml"); + debug!("Config path resolved to: {path:?} via COMMITTY_CONFIG_DIR"); + return Ok(path); + } let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; let path = home.join(".config").join("committy").join("config.toml"); - debug!("Config path resolved to: {:?}", path); + debug!("Config path resolved to: {path:?}"); Ok(path) } } @@ -104,6 +116,7 @@ impl Config { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; use std::env; use std::fs; use tempfile::Builder; @@ -116,6 +129,9 @@ mod tests { // let temp_dir = TempDir::new(Uuid::new_v4().to_string()).unwrap(); env::set_var("HOME", temp_dir.path()); + // Point COMMITTY_CONFIG_DIR to a test-specific location to avoid global HOME races + let cfg_dir = temp_dir.path().join(".config").join("committy"); + env::set_var("COMMITTY_CONFIG_DIR", &cfg_dir); let config = Config { last_update_check: DateTime::parse_from_rfc3339("2025-01-08T17:39:49+01:00").unwrap(), @@ -123,6 +139,9 @@ mod tests { last_metrics_reminder: DateTime::parse_from_rfc3339("2025-01-08T17:39:49+01:00") .unwrap(), user_id: Uuid::new_v4().to_string(), + major_regex: MAJOR_REGEX.to_string(), + minor_regex: MINOR_REGEX.to_string(), + patch_regex: PATCH_REGEX.to_string(), }; (temp_dir, config) @@ -143,6 +162,7 @@ mod tests { } #[test] + #[serial] fn test_save_and_load_config() { let (_temp_dir, config) = setup_test_env(); @@ -158,6 +178,7 @@ mod tests { } #[test] + #[serial] fn test_load_nonexistent_config() { let (temp_dir, _) = setup_test_env(); let config_dir = temp_dir.path().join(".config").join("committy"); @@ -182,6 +203,7 @@ mod tests { } #[test] + #[serial] fn test_config_directory_creation() { let (temp_dir, config) = setup_test_env(); let config_dir = temp_dir.path().join(".config").join("committy"); @@ -208,6 +230,7 @@ mod tests { } #[test] + #[serial] fn test_update_config_values() { let (_temp_dir, mut config) = setup_test_env(); diff --git a/src/error.rs b/src/error.rs index fba0c8f..a4e63ca 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,9 @@ pub enum CliError { #[error("RegexError error: {0}")] RegexError(String), + + #[error("Found {0} commit(s) with lint issues")] + LintIssues(usize), } impl From for CliError { diff --git a/src/git/commit.rs b/src/git/commit.rs index 127801e..e369a03 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -51,25 +51,15 @@ pub fn format_commit_message( short_message: &str, long_message: &str, ) -> String { + let bang = if breaking_change { "!" } else { "" }; let mut full_message = if scope.is_empty() { - format!( - "{}{}: {}", - commit_type, - if breaking_change { "!" } else { "" }, - short_message - ) + format!("{commit_type}{bang}: {short_message}") } else { - format!( - "{}({}){}: {}", - commit_type, - scope, - if breaking_change { "!" } else { "" }, - short_message - ) + format!("{commit_type}({scope}){bang}: {short_message}") }; if !long_message.is_empty() { - full_message = format!("{}\n\n{}", full_message, long_message); + full_message = format!("{full_message}\n\n{long_message}"); } full_message diff --git a/src/git/repository.rs b/src/git/repository.rs index b25869e..68e8eeb 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -4,7 +4,7 @@ use std::env; pub fn discover_repository() -> Result { let current_dir = env::current_dir()?; - log::debug!("Starting repository discovery from: {:?}", current_dir); + log::debug!("Starting repository discovery from: {current_dir:?}"); match Repository::discover(¤t_dir) { Ok(repo) => { @@ -23,17 +23,13 @@ pub fn discover_repository() -> Result { match Repository::open(&repo_path) { Ok(new_repo) => Ok(new_repo), Err(e) => { - log::error!("Failed to open repository at {:?}: {}", repo_path, e); + log::error!("Failed to open repository at {repo_path:?}: {e}"); Err(CliError::GitError(e)) } } } Err(e) => { - log::error!( - "Failed to discover repository from {:?}: {}", - current_dir, - e - ); + log::error!("Failed to discover repository from {current_dir:?}: {e}"); Err(CliError::GitError(git2::Error::from_str( "Could not find Git repository in current directory or any parent directories", ))) diff --git a/src/git/tag.rs b/src/git/tag.rs index 615ced0..98db325 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -57,6 +57,15 @@ pub struct TagGeneratorOptions { #[structopt(long, help = "Do not publish the new tag")] not_publish: bool, + + #[structopt(long, help = "Fetch tags from remote before calculation")] + fetch: bool, + + #[structopt( + long = "no-fetch", + help = "Do not fetch tags from remote before calculation" + )] + no_fetch: bool, } pub struct TagGenerator { @@ -72,6 +81,7 @@ pub struct TagGenerator { force_without_change: bool, tag_message: String, not_publish: bool, + fetch: bool, bump_config_files: bool, pub current_tag: String, pub new_tag: String, @@ -97,6 +107,12 @@ impl TagGenerator { force_without_change: options.force_without_change, tag_message: options.tag_message.unwrap_or_default(), not_publish: options.not_publish, + // default to fetching unless --no-fetch is explicitly passed; --fetch enforces true + fetch: if options.fetch { + true + } else { + !options.no_fetch + }, bump_config_files: allow_bump_config_files, current_tag: String::new(), new_tag: String::new(), @@ -104,6 +120,10 @@ impl TagGenerator { } } + fn should_fetch(&self) -> bool { + self.fetch + } + pub fn run(&mut self) -> Result<(), CliError> { info!("🚀 Starting tag generation process"); let repo = self.open_repository()?; @@ -114,28 +134,29 @@ impl TagGenerator { self.prerelease }; - info!("📊 Current branch: {}", current_branch); + info!("📊 Current branch: {current_branch}"); info!( "🏷️ Pre-release mode: {}", if pre_release { "Yes" } else { "No" } ); - debug!("Current branch: {}", current_branch); - debug!("Is pre-release: {}", pre_release); + debug!("Current branch: {current_branch}"); + debug!("Is pre-release: {pre_release}"); self.current_tag = current_branch.clone(); self.is_pre_release = pre_release; - info!("🔄 Fetching tags from remote"); - self.fetch_tags(&repo)?; + if self.should_fetch() { + info!("🔄 Fetching tags from remote"); + self.fetch_tags(&repo)?; + } else { + debug!("Skipping remote tag fetch (fetch flag not set)"); + } let (tag, pre_tag) = self.get_latest_tags(&repo)?; let tag_commit = self.get_commit_for_tag(&repo, &tag)?; let current_commit = self.get_current_commit(&repo)?; - info!( - "📌 Latest tag: {}, Latest pre-release tag: {}", - tag, pre_tag - ); + info!("📌 Latest tag: {tag}, Latest pre-release tag: {pre_tag}"); if self.should_skip_tagging(tag_commit, current_commit) { info!("⏭️ No new commits since previous tag. Skipping..."); @@ -205,7 +226,7 @@ impl TagGenerator { remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut fetch_options), None) .map_err(|e| { - error!("Failed to fetch tags from remote: {}", e); + error!("Failed to fetch tags from remote: {e}"); match e.code() { git2::ErrorCode::Auth => { error!("Authentication error. Please ensure your credentials are set up correctly."); @@ -255,14 +276,14 @@ impl TagGenerator { .cloned() .unwrap_or_else(|| self.initial_version.clone()); - debug!("Latest regular tag: {}", tag); - debug!("Latest pre-release tag: {}", pre_tag); + debug!("Latest regular tag: {tag}"); + debug!("Latest pre-release tag: {pre_tag}"); Ok((tag, pre_tag)) } fn compare_versions(&self, a: &str, b: &str) -> std::cmp::Ordering { - debug!("Comparing versions: {} and {}", a, b); + debug!("Comparing versions: {a} and {b}"); if a.contains("none") || b.contains("none") { return a.cmp(b); } @@ -302,14 +323,21 @@ impl TagGenerator { pre_release: bool, ) -> Result { debug!( - "Calculating new tag. Current tag: {}, Pre-release tag: {}, Is pre-release: {}", - tag, pre_tag, pre_release + "Calculating new tag. Current tag: {tag}, Pre-release tag: {pre_tag}, Is pre-release: {pre_release}" ); use semver::Version as SemverVersion; - let (base_tag, base_is_pre) = if pre_release { + let (base_tag, _base_is_pre) = if pre_release { // Parse both tags - let reg_ver = SemverVersion::parse(tag.trim_start_matches('v')).unwrap_or_else(|_| SemverVersion::new(0,0,0)); - let pre_ver = SemverVersion::parse(pre_tag.trim_start_matches('v').split('-').next().unwrap_or("")).unwrap_or_else(|_| SemverVersion::new(0,0,0)); + let reg_ver = SemverVersion::parse(tag.trim_start_matches('v')) + .unwrap_or_else(|_| SemverVersion::new(0, 0, 0)); + let pre_ver = SemverVersion::parse( + pre_tag + .trim_start_matches('v') + .split('-') + .next() + .unwrap_or(""), + ) + .unwrap_or_else(|_| SemverVersion::new(0, 0, 0)); // If pre_tag is a higher version, use it as base if pre_ver > reg_ver { (pre_tag, true) @@ -322,7 +350,14 @@ impl TagGenerator { let log = self.get_commit_log(repo, base_tag)?; let bump: &str = self.determine_bump(&log)?; - let mut new_version = SemverVersion::parse(base_tag.trim_start_matches('v').split('-').next().unwrap_or("")).map_err(|e| CliError::SemVerError(e.to_string()))?; + let mut new_version = SemverVersion::parse( + base_tag + .trim_start_matches('v') + .split('-') + .next() + .unwrap_or(""), + ) + .map_err(|e| CliError::SemVerError(e.to_string()))?; self.apply_bump(&mut new_version, bump); let new_tag = if pre_release { @@ -332,7 +367,7 @@ impl TagGenerator { }; Ok(if !self.not_with_v { - format!("v{}", new_tag) + format!("v{new_tag}") } else { new_tag }) @@ -340,12 +375,13 @@ impl TagGenerator { fn determine_bump(&self, log: &str) -> Result<&str, CliError> { debug!("Determining bump from commit log"); + let cfg = config::Config::load().unwrap_or_default(); let major_pattern = - Regex::new(config::MAJOR_REGEX).map_err(|e| CliError::RegexError(e.to_string()))?; + Regex::new(&cfg.major_regex).map_err(|e| CliError::RegexError(e.to_string()))?; let minor_pattern = - Regex::new(config::MINOR_REGEX).map_err(|e| CliError::RegexError(e.to_string()))?; + Regex::new(&cfg.minor_regex).map_err(|e| CliError::RegexError(e.to_string()))?; let patch_pattern = - Regex::new(config::PATCH_REGEX).map_err(|e| CliError::RegexError(e.to_string()))?; + Regex::new(&cfg.patch_regex).map_err(|e| CliError::RegexError(e.to_string()))?; if major_pattern.is_match(log) { Ok("major") @@ -361,7 +397,7 @@ impl TagGenerator { } fn apply_bump(&self, version: &mut Version, bump: &str) { - debug!("Applying bump: {} to version: {}", bump, version); + debug!("Applying bump: {bump} to version: {version}"); match bump { "major" => { version.major += 1; @@ -375,7 +411,7 @@ impl TagGenerator { "patch" => version.patch += 1, _ => {} } - debug!("New version after bump: {}", version); + debug!("New version after bump: {version}"); } fn update_versions(&self, new_version: &str) -> Result, CliError> { @@ -412,11 +448,10 @@ impl TagGenerator { fn calculate_pre_release_tag(&self, new_version: &Version, pre_tag: &str) -> String { debug!( - "Calculating pre-release tag. New version: {}, Previous pre-tag: {}", - new_version, pre_tag + "Calculating pre-release tag. New version: {new_version}, Previous pre-tag: {pre_tag}" ); - debug!("{}", &new_version.to_string()); - debug!("{}", pre_tag); + debug!("{new_version}"); + debug!("{pre_tag}"); let version_string = new_version.to_string(); let pre_tag_without_v = pre_tag.trim_start_matches('v'); @@ -435,7 +470,7 @@ impl TagGenerator { } fn get_commit_log(&self, repo: &Repository, tag: &str) -> Result { - debug!("Getting commit log since tag: {}", tag); + debug!("Getting commit log since tag: {tag}"); let tag_commit = self.get_commit_for_tag(repo, tag)?; let head_commit = self.get_current_commit(repo)?; @@ -452,7 +487,8 @@ impl TagGenerator { .collect::>() .join("\n"); - debug!("Commit log length: {} characters", log.len()); + let len = log.len(); + debug!("Commit log length: {len} characters"); Ok(log) } @@ -479,7 +515,7 @@ impl TagGenerator { let tree = repo.find_tree(tree_id)?; let parent_commit = repo.head()?.peel_to_commit()?; let version_without_v = new_version.trim_start_matches('v'); - let message = format!("chore: bump version to {}", version_without_v); + let message = format!("chore: bump version to {version_without_v}"); repo.commit( Some("HEAD"), @@ -512,21 +548,17 @@ impl TagGenerator { push_options.remote_callbacks(callbacks); let current_branch = self.get_current_branch(repo)?; - let refspec = format!("refs/heads/{}", current_branch); + let refspec = format!("refs/heads/{current_branch}"); match remote.push(&[&refspec], Some(&mut push_options)) { Ok(_) => { - debug!( - "Successfully pushed commit to remote branch {}", - current_branch - ); + debug!("Successfully pushed commit to remote branch {current_branch}"); info!( - "✅ Pushed version bump commit to remote branch {}", - current_branch + "✅ Pushed version bump commit to remote branch {current_branch}" ); } Err(e) => { - error!("Failed to push commit to remote: {}", e); + error!("Failed to push commit to remote: {e}"); if e.code() == git2::ErrorCode::Auth { error!( "Authentication error. Please ensure your SSH key is set up correctly." @@ -548,7 +580,7 @@ impl TagGenerator { } pub fn create_and_push_tag(&self, repo: &Repository, new_tag: &str) -> Result<(), CliError> { - debug!("Creating and pushing new tag: {}", new_tag); + debug!("Creating and pushing new tag: {new_tag}"); let head = repo.head()?.peel_to_commit()?; let signature = repo.signature()?; @@ -581,11 +613,11 @@ impl TagGenerator { let mut push_options = PushOptions::new(); push_options.remote_callbacks(callbacks); - let refspec = format!("refs/tags/{}", new_tag); + let refspec = format!("refs/tags/{new_tag}"); match remote.push(&[&refspec], Some(&mut push_options)) { - Ok(_) => debug!("Successfully pushed tag {} to remote", new_tag), + Ok(_) => debug!("Successfully pushed tag {new_tag} to remote"), Err(e) => { - error!("Failed to push tag {} to remote: {}", new_tag, e); + error!("Failed to push tag {new_tag} to remote: {e}"); if e.code() == git2::ErrorCode::Auth { error!( "Authentication error. Please ensure your SSH key is set up correctly." diff --git a/src/input/prompts.rs b/src/input/prompts.rs index 068015f..0d8c790 100644 --- a/src/input/prompts.rs +++ b/src/input/prompts.rs @@ -9,7 +9,21 @@ use crate::error::CliError; use inquire::{Confirm, Select, Text}; use log::info; +fn non_interactive_env() -> bool { + std::env::var("COMMITTY_NONINTERACTIVE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + || std::env::var("CI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + pub fn select_commit_type() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot prompt for commit type".to_string(), + )); + } let commit_type = Select::new("Select the type of commit:", COMMIT_TYPES.to_vec()) .with_help_message("Use arrow keys to navigate, Enter to select") .prompt() @@ -19,6 +33,11 @@ pub fn select_commit_type() -> Result { } pub fn select_branch_type() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot prompt for branch type".to_string(), + )); + } let branch_type = Select::new("Select the type of branch:", BRANCH_TYPES.to_vec()) .with_help_message("Use arrow keys to navigate, Enter to select") .prompt() @@ -28,6 +47,11 @@ pub fn select_branch_type() -> Result { } pub fn confirm_breaking_change() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot confirm breaking change".to_string(), + )); + } Confirm::new("Is this a breaking change?") .with_default(false) .prompt() @@ -35,9 +59,13 @@ pub fn confirm_breaking_change() -> Result { } pub fn ask_want_create_new_branch(branch_name: &str) -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot confirm branch creation".to_string(), + )); + } Confirm::new(&format!( - "Are you sure you want to create a new branch {}?", - branch_name + "Are you sure you want to create a new branch {branch_name}?" )) .with_default(false) .prompt() @@ -45,23 +73,28 @@ pub fn ask_want_create_new_branch(branch_name: &str) -> Result { } pub fn input_ticket() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot input ticket".to_string(), + )); + } let validator = move |input: &str| { let len = input.len(); if len > MAX_TICKET_NAME_LENGTH { return Ok(inquire::validator::Validation::Invalid( - inquire::validator::ErrorMessage::Custom(format!( - "Ticket identifier must be at most {} characters ({} over)", - MAX_TICKET_NAME_LENGTH, - len - MAX_TICKET_NAME_LENGTH - )), + inquire::validator::ErrorMessage::Custom({ + let over = len - MAX_TICKET_NAME_LENGTH; + format!( + "Ticket identifier must be at most {MAX_TICKET_NAME_LENGTH} characters ({over} over)" + ) + }), )); } Ok(inquire::validator::Validation::Valid) }; let ticket = Text::new("Enter the ticket identifier (optional):") .with_help_message(&format!( - "Press Enter to skip, max {} characters", - MAX_TICKET_NAME_LENGTH + "Press Enter to skip, max {MAX_TICKET_NAME_LENGTH} characters" )) .with_validator(validator) .prompt() @@ -75,6 +108,11 @@ pub fn input_ticket() -> Result { } pub fn input_subject() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot input subject".to_string(), + )); + } let subject = Text::new("Enter the subject") .prompt() .map_err(|e| CliError::InputError(e.to_string()))?; @@ -91,16 +129,21 @@ pub fn validate_scope_input(scope: &str) -> Result { let corrected = auto_correct_scope(scope); if corrected != scope { - info!("Suggested correction: '{}' -> '{}'", scope, corrected); + info!("Suggested correction: '{scope}' -> '{corrected}'"); + if non_interactive_env() { + // In non-interactive environments, apply the correction automatically + info!("Applied correction (non-interactive): '{corrected}'"); + return Ok(corrected); + } if Confirm::new("Do you want to apply this correction?") .with_default(true) .prompt() .map_err(|e| CliError::InputError(e.to_string()))? { - info!("Applied correction: '{}'", corrected); + info!("Applied correction: '{corrected}'"); Ok(corrected) } else { - info!("Keeping original: '{}'", scope); + info!("Keeping original: '{scope}'"); Ok(scope.to_string()) } } else { @@ -109,23 +152,28 @@ pub fn validate_scope_input(scope: &str) -> Result { } pub fn input_scope() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot input scope".to_string(), + )); + } let validator = |input: &str| { let len = input.len(); if len > MAX_SCOPE_NAME_LENGTH { return Ok(inquire::validator::Validation::Invalid( - inquire::validator::ErrorMessage::Custom(format!( - "Scope must be at most {} characters ({} over)", - MAX_SCOPE_NAME_LENGTH, - len - MAX_SCOPE_NAME_LENGTH - )), + inquire::validator::ErrorMessage::Custom({ + let over = len - MAX_SCOPE_NAME_LENGTH; + format!( + "Scope must be at most {MAX_SCOPE_NAME_LENGTH} characters ({over} over)" + ) + }), )); } Ok(inquire::validator::Validation::Valid) }; let scope = Text::new("Enter the scope of the commit (optional):") .with_help_message(&format!( - "Press Enter to skip, max {} characters", - MAX_SCOPE_NAME_LENGTH + "Press Enter to skip, max {MAX_SCOPE_NAME_LENGTH} characters" )) .with_validator(validator) .prompt() @@ -139,33 +187,38 @@ pub fn input_scope() -> Result { } pub fn input_short_message() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot input short message".to_string(), + )); + } loop { let validator = |input: &str| { let len = input.len(); let remaining = MAX_SHORT_DESCRIPTION_LENGTH.saturating_sub(len); if len < 5 { return Ok(inquire::validator::Validation::Invalid( - inquire::validator::ErrorMessage::Custom(format!( - "Description must be at least 5 characters ({} more needed)", - 5 - len - )), + inquire::validator::ErrorMessage::Custom({ + let needed = 5 - len; + format!("Description must be at least 5 characters ({needed} more needed)") + }), )); } if len > MAX_SHORT_DESCRIPTION_LENGTH { return Ok(inquire::validator::Validation::Invalid( - inquire::validator::ErrorMessage::Custom(format!( - "Description must be at most {} characters ({} over)", - MAX_SHORT_DESCRIPTION_LENGTH, - len - MAX_SHORT_DESCRIPTION_LENGTH - )), + inquire::validator::ErrorMessage::Custom({ + let over = len - MAX_SHORT_DESCRIPTION_LENGTH; + format!( + "Description must be at most {MAX_SHORT_DESCRIPTION_LENGTH} characters ({over} over)" + ) + }), )); } match validate_short_message(input) { Ok(_) => Ok(inquire::validator::Validation::Valid), Err(msg) => Ok(inquire::validator::Validation::Invalid( inquire::validator::ErrorMessage::Custom(format!( - "{} ({} chars remaining)", - msg, remaining + "{msg} ({remaining} chars remaining)" )), )), } @@ -173,8 +226,7 @@ pub fn input_short_message() -> Result { let msg = Text::new("Enter a short description:") .with_help_message(&format!( - "Min 5, Max {} characters", - MAX_SHORT_DESCRIPTION_LENGTH + "Min 5, Max {MAX_SHORT_DESCRIPTION_LENGTH} characters" )) .with_validator(validator) .prompt(); @@ -199,8 +251,7 @@ pub fn input_short_message() -> Result { Err(_) => { // Any other error, re-prompt println!( - "Please enter a valid short description (min 5, max {} chars).", - MAX_SHORT_DESCRIPTION_LENGTH + "Please enter a valid short description (min 5, max {MAX_SHORT_DESCRIPTION_LENGTH} chars)." ); continue; } @@ -209,6 +260,11 @@ pub fn input_short_message() -> Result { } pub fn input_long_message() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot input long message".to_string(), + )); + } let msg = Text::new("Enter a detailed description (optional):") .with_help_message("Press Enter twice to finish") .prompt() @@ -217,6 +273,11 @@ pub fn input_long_message() -> Result { } pub fn ask_want_create_new_tag() -> Result { + if non_interactive_env() { + return Err(CliError::InputError( + "Non-interactive environment: cannot confirm tag creation".to_string(), + )); + } Confirm::new("Are you sure you want to create a new tag?") .with_default(false) .prompt() @@ -229,6 +290,8 @@ mod tests { #[test] fn test_select_commit_type() { + // Force non-interactive environment so prompt returns an error + std::env::set_var("COMMITTY_NONINTERACTIVE", "1"); // Since we can't easily test interactive selection in unit tests, // we'll just verify that the function exists and returns an error // when run in a non-interactive environment diff --git a/src/input/validation.rs b/src/input/validation.rs index a1acce8..1342039 100644 --- a/src/input/validation.rs +++ b/src/input/validation.rs @@ -6,8 +6,7 @@ pub fn validate_short_message(input: &str) -> Result<(), String> { Ok(()) } else { Err(format!( - "The message must be {} characters or less", - MAX_SHORT_DESCRIPTION_LENGTH + "The message must be {MAX_SHORT_DESCRIPTION_LENGTH} characters or less" )) } } @@ -26,8 +25,7 @@ pub fn validate_section(text: &str) -> Result { Ok(corrected) } else { Err(format!( - "Section must be empty or valid. Suggested correction: {}", - corrected + "Section must be empty or valid. Suggested correction: {corrected}" )) } } @@ -101,8 +99,7 @@ pub fn validate_scope(input: &str) -> Result<(), String> { } else { let corrected = auto_correct_scope(input); Err(format!( - "Scope must contain only alphanumeric characters and hyphens.\nSuggested correction: {}", - corrected + "Scope must contain only alphanumeric characters and hyphens.\nSuggested correction: {corrected}" )) } } diff --git a/src/linter/mod.rs b/src/linter/mod.rs index 3e76e3f..b93e8e3 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -1,12 +1,13 @@ use anyhow::Result; use git2::{ObjectType, Repository, Tag}; use regex::Regex; +use serde::Serialize; pub struct CommitLinter { repo: Repository, } -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct CommitIssue { pub commit_id: String, pub message: String, @@ -50,10 +51,8 @@ impl CommitLinter { let breaking_change = r"(?:!)?"; // Optional breaking change indicator let separator = r"\: "; let description = r".+"; - let full_pattern = format!( - "^{}{}{}{}{}$", - type_pattern, scope_pattern, breaking_change, separator, description - ); + let full_pattern = + format!("^{type_pattern}{scope_pattern}{breaking_change}{separator}{description}$"); let commit_regex = Regex::new(&full_pattern).unwrap(); // Check each commit @@ -72,10 +71,8 @@ impl CommitLinter { .iter() .any(|t| first_line.starts_with(t)) { - format!( - "Commit type must be one of: {}", - crate::config::COMMIT_TYPES.join(", ") - ) + let types = crate::config::COMMIT_TYPES.join(", "); + format!("Commit type must be one of: {types}") } else if first_line.contains("(") && !first_line.contains(")") { "Unclosed scope parenthesis".to_string() } else if first_line.contains(")") && !first_line.contains("(") { @@ -96,24 +93,24 @@ impl CommitLinter { // Check minimum length if first_line.len() < 10 { + let len = first_line.len(); issues.push(CommitIssue { commit_id: commit_id.to_string(), message: message.to_string(), issue: format!( - "Commit message is too short (got {} characters, minimum is 10)", - first_line.len() + "Commit message is too short (got {len} characters, minimum is 10)" ), }); } // Check maximum length of first line if first_line.len() > 72 { + let len = first_line.len(); issues.push(CommitIssue { commit_id: commit_id.to_string(), message: message.to_string(), issue: format!( - "First line of commit message is too long (got {} characters, maximum is 72)", - first_line.len() + "First line of commit message is too long (got {len} characters, maximum is 72)" ), }); } @@ -199,11 +196,7 @@ mod tests { let linter = CommitLinter::new(temp_dir.path().to_str().unwrap()).unwrap(); let issues = linter.check_commits_since_last_tag().unwrap(); - assert!( - issues.is_empty(), - "Expected no issues but got: {:?}", - issues - ); + assert!(issues.is_empty(), "Expected no issues but got: {issues:?}"); } #[test] diff --git a/src/main.rs b/src/main.rs index e61a54a..1713ea9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod version; use anyhow::Result; use env_logger::{Builder, Env}; +use log::LevelFilter; use sentry::ClientInitGuard; use structopt::StructOpt; @@ -51,29 +52,66 @@ struct Opt { #[structopt(long = "metrics-toggle", help = "Toggle metrics collection on/off")] metrics_toggle: bool, + + #[structopt( + short = "v", + long = "verbose", + parse(from_occurrences), + help = "Increase verbosity (-v, -vv)" + )] + verbose: u8, + + #[structopt(short = "q", long = "quiet", help = "Reduce verbosity (errors only)")] + quiet: bool, } fn main() { - Builder::from_env(Env::default().default_filter_or("info")).init(); - // Load configuration let mut config = Config::load().unwrap_or_else(|_| { let default_config = Config::default(); if let Err(e) = default_config.save() { - eprintln!("Failed to save default configuration: {}", e); + eprintln!("Failed to save default configuration: {e}"); } default_config }); if let Err(e) = run(&mut config) { - eprintln!("{}", e); - std::process::exit(1); + // Map specific errors to exit codes + if let Some(CliError::LintIssues(_)) = e.downcast_ref::() { + eprintln!("{e}"); + std::process::exit(3); + } else { + eprintln!("{e}"); + std::process::exit(1); + } } } fn run(config: &mut Config) -> Result<()> { let opt = Opt::from_args(); + // Initialize logger based on verbosity flags + let mut builder = Builder::from_env(Env::default().default_filter_or("info")); + let level = if opt.quiet { + LevelFilter::Error + } else { + match opt.verbose { + 0 => LevelFilter::Info, + 1 => LevelFilter::Debug, + _ => LevelFilter::Trace, + } + }; + builder.filter_level(level).init(); + + // Unified non-interactive mode for CI/tests and CLI flag + let env_non_interactive = std::env::var("COMMITTY_NONINTERACTIVE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + || std::env::var("CI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let non_interactive = opt.non_interactive || env_non_interactive; + if opt.metrics_toggle { config.metrics_enabled = !config.metrics_enabled; logger::info(&format!( @@ -119,7 +157,7 @@ fn run(config: &mut Config) -> Result<()> { let mut updater = Updater::new(env!("CARGO_PKG_VERSION"))?; updater .with_prerelease(opt.pre_release) - .with_non_interactive(opt.non_interactive); + .with_non_interactive(non_interactive); if let Ok(Some(release)) = updater.check_update() { logger::info(&format!("New version {} is available!", release.version)); @@ -136,7 +174,7 @@ fn run(config: &mut Config) -> Result<()> { } // Check for updates when running any command - if !opt.non_interactive + if !non_interactive && !opt.check_update && !opt.update && current_time - config.last_update_check >= one_day @@ -166,10 +204,10 @@ fn run(config: &mut Config) -> Result<()> { } let result = match opt.cmd { - Some(cmd) => cmd.execute(opt.non_interactive), + Some(cmd) => cmd.execute(non_interactive), None => { let cmd = CommitCommand::default(); - cmd.execute(opt.non_interactive) + cmd.execute(non_interactive) } }; diff --git a/src/telemetry/posthog.rs b/src/telemetry/posthog.rs index 000a900..ff4a9ab 100644 --- a/src/telemetry/posthog.rs +++ b/src/telemetry/posthog.rs @@ -38,6 +38,18 @@ pub async fn publish_event( event: &str, properties: HashMap<&str, Value>, ) -> Result<(), TelemetryError> { + // Skip telemetry entirely in CI or non-interactive environments + let env_non_interactive = std::env::var("COMMITTY_NONINTERACTIVE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + || std::env::var("CI") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if env_non_interactive { + debug!("Telemetry disabled in non-interactive/CI environment"); + return Ok(()); + } + if POSTHOG_API_KEY == "undefined" { debug!("POSTHOG_API_KEY is not set"); return Ok(()); @@ -71,7 +83,7 @@ pub async fn publish_event( for attempt in 1..=3 { match HTTP_CLIENT.post(url).json(&payload).send().await { Ok(resp) if resp.status().is_success() => { - debug!("Event sent: {}", event); + debug!("Event sent: {event}"); return Ok(()); } Ok(resp) if resp.status().is_client_error() || resp.status().is_server_error() => { @@ -80,7 +92,7 @@ pub async fn publish_event( } } Err(e) => { - error!("Attempt {} error: {}", attempt, e); + error!("Attempt {attempt} error: {e}"); if attempt == 3 { return Err(TelemetryError::Http(e)); } diff --git a/src/update.rs b/src/update.rs index acbbdac..d656661 100644 --- a/src/update.rs +++ b/src/update.rs @@ -116,18 +116,18 @@ impl Updater { pub fn update_to_version(&self, version_tag: &str) -> Result<()> { let version_tag = if !version_tag.starts_with('v') { - format!("v{}", version_tag) + format!("v{version_tag}") } else { version_tag.to_string() }; - info!("Starting update process for version {}...", version_tag); + info!("Starting update process for version {version_tag}..."); let status = self_update::backends::github::Update::configure() .repo_owner(GITHUB_REPO_OWNER) .repo_name(GITHUB_REPO_NAME) .bin_name("committy") .bin_path_in_archive("./committy") .target_version_tag(&version_tag) - .target(&format!("committy-{}.tar.gz", ASSET_SUFFIX)) + .target(&format!("committy-{ASSET_SUFFIX}.tar.gz")) .show_download_progress(true) .current_version(&self.current_version.to_string()) .no_confirm(true) // Disable built-in confirmation since we handle it ourselves @@ -151,7 +151,7 @@ impl Updater { ]), )) { - debug!("Telemetry error: {:?}", e); + debug!("Telemetry error: {e:?}"); } Ok(()) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fe53c6e..06619e7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,4 +1,7 @@ +use once_cell::sync::Lazy; +use std::env; use std::sync::Once; +use tempfile::TempDir; #[allow(dead_code)] static INIT: Once = Once::new(); @@ -9,5 +12,16 @@ pub fn setup_test_env() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("off")) .is_test(true) .init(); + + // Force non-interactive behavior across all tests and skip update prompts + env::set_var("COMMITTY_NONINTERACTIVE", "1"); + env::set_var("CI", "1"); + // Silence noisy network-related logs + env::set_var("RUST_LOG", "off"); + + // Create and use a persistent temporary HOME for the whole test process + static TEST_HOME: Lazy = + Lazy::new(|| tempfile::tempdir().expect("Failed to create temp HOME for tests")); + env::set_var("HOME", TEST_HOME.path()); }); } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index dac3a74..f0d44c9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -44,6 +44,280 @@ fn setup() -> tempfile::TempDir { dir } +#[test] +fn test_tag_prerelease_on_feature_branch_json() { + let temp_dir = setup(); + + // Commit staged file so index is clean + let _ = StdCommand::new("git") + .args(["commit", "-m", "feat: initial"]) // commit test.txt + .current_dir(&temp_dir) + .output() + .expect("Failed to create initial commit"); + + // Create and switch to a non-release branch to trigger pre-release mode automatically + let _ = StdCommand::new("git") + .args(["checkout", "-b", "feature/test-prerelease"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create and switch branch"); + + // Another commit to have content after the base tag + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: add feature work"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create feature commit"); + + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--output") + .arg("json") + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(v["ok"], serde_json::json!(true)); + assert_eq!(v["pre_release"], serde_json::json!(true)); + // default suffix is beta + assert_eq!(v["new_tag"], serde_json::json!("v0.1.0-beta.0")); + + cleanup(temp_dir); +} + +#[test] +fn test_tag_prerelease_custom_suffix_rc_on_release_branch_json() { + let temp_dir = setup(); + + // Commit staged file so index is clean (stay on default branch: master/main) + let _ = StdCommand::new("git") + .args(["commit", "-m", "feat: initial"]) // commit test.txt + .current_dir(&temp_dir) + .output() + .expect("Failed to create initial commit"); + + // Force pre-release on release branch with custom suffix + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--prerelease") + .arg("--prerelease-suffix") + .arg("rc") + .arg("--output") + .arg("json") + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(v["ok"], serde_json::json!(true)); + assert_eq!(v["pre_release"], serde_json::json!(true)); + assert_eq!(v["new_tag"], serde_json::json!("v0.1.0-rc.0")); + + cleanup(temp_dir); +} + +#[test] +fn test_tag_prerelease_increments_when_same_base_version() { + let temp_dir = setup(); + + // Commit staged file so index is clean + let _ = StdCommand::new("git") + .args(["commit", "-m", "feat: initial"]) // commit test.txt + .current_dir(&temp_dir) + .output() + .expect("Failed to create initial commit"); + + // Switch to prerelease branch + let _ = StdCommand::new("git") + .args(["checkout", "-b", "feature/pre-seq"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create and switch branch"); + + // First run: create actual pre-release tag locally (no push) + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--not-publish") + // not dry-run so tag is created locally + .arg("--output") + .arg("json") + .assert() + .success(); + + // Add a commit that signals no bump (contains #none) and does NOT match bump regexes + let _ = StdCommand::new("git") + .args([ + "commit", + "--allow-empty", + "-m", + "noop: keep prerelease base #none", + ]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create #none commit"); + + // Second run: should increment beta.0 -> beta.1 on same base version + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--output") + .arg("json") + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(v["ok"], serde_json::json!(true)); + assert_eq!(v["pre_release"], serde_json::json!(true)); + assert_eq!(v["new_tag"], serde_json::json!("v0.1.0-beta.1")); + + cleanup(temp_dir); +} + +#[test] +fn test_verbosity_quiet_suppresses_info_logs() { + let temp_dir = setup(); + + // ensure at least one commit exists + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + // With -q, only errors should be logged; dry run should produce none + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("-q") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .assert() + .success() + .stderr(predicate::str::is_empty()); + + cleanup(temp_dir); +} + +#[test] +fn test_verbosity_v_shows_debug_logs() { + let temp_dir = setup(); + + // ensure at least one commit exists + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + // With -v and --no-fetch, expect debug about skipping fetch due to flag + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("-v") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .assert() + .success() + .stderr(predicate::str::contains( + "Skipping remote tag fetch (fetch flag not set)", + )); + + cleanup(temp_dir); +} + +#[test] +fn test_fetch_flag_no_fetch_skips_fetch_path() { + let temp_dir = setup(); + + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("-v") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .assert() + .success() + .stderr(predicate::str::contains( + "Skipping remote tag fetch (fetch flag not set)", + )); + + cleanup(temp_dir); +} + +#[test] +fn test_fetch_flag_fetch_attempts_fetch_path() { + let temp_dir = setup(); + + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + // With --fetch, we should log that we're fetching tags; since repo has no origin, + // subsequent message may indicate skipping due to not found, but the "Fetching tags" info should appear + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("-v") + .arg("tag") + .arg("--fetch") + .arg("--dry-run") + .arg("--not-publish") + .assert() + .success() + .stderr(predicate::str::contains("Fetching tags from remote")); + + cleanup(temp_dir); +} + fn cleanup(temp_dir: tempfile::TempDir) { // Clean up the test repository let _ = StdCommand::new("rm") @@ -90,6 +364,149 @@ fn test_commit_command_with_valid_input() { cleanup(temp_dir); } +#[test] +fn test_lint_json_exit_code_and_payload() { + let temp_dir = setup(); + + // Make an invalid commit message to trigger lint issue + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "invalid message"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("lint") + .arg("--repo-path") + .arg(".") + .arg("--output") + .arg("json") + .assert() + .code(3); // stable lint exit code + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(v["ok"], serde_json::json!(false)); + assert_eq!(v["count"], serde_json::json!(1)); + + cleanup(temp_dir); +} + +#[test] +fn test_tag_json_dry_run_output_non_interactive() { + let temp_dir = setup(); + + // Create an initial commit so repo isn't empty and nothing is staged + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--output") + .arg("json") + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(v["ok"], serde_json::json!(true)); + assert_eq!(v["new_tag"], serde_json::json!("v0.1.0")); + + cleanup(temp_dir); +} + +#[test] +fn test_tag_respects_config_regex_override_fix_as_minor() { + let temp_dir = setup(); + + // Create an initial fix commit + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "fix: bug"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + // Point COMMITTY_CONFIG_DIR to isolated dir and write config overriding minor_regex to match fix + let cfg_dir = temp_dir.path().join(".config-override"); + std::fs::create_dir_all(&cfg_dir).unwrap(); + let cfg_path = cfg_dir.join("config.toml"); + let config_toml = r#" +minor_regex = '(?im)^fix(?:\s*\([^)]*\))?:' +patch_regex = '(?im)^docs(?:\s*\([^)]*\))?:' # ensure 'fix' doesn't match patch +major_regex = '(?im)^(breaking change:|feat(?:\s*\([^)]*\))?!:)' +"#; + std::fs::write(&cfg_path, config_toml).unwrap(); + + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("COMMITTY_CONFIG_DIR", &cfg_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--output") + .arg("json") + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + // With override, fix should result in minor bump from 0.0.0 -> 0.1.0 + assert_eq!(v["new_tag"], serde_json::json!("v0.1.0")); + + cleanup(temp_dir); +} + +#[test] +fn test_tag_fix_default_is_patch() { + let temp_dir = setup(); + + // Create an initial fix commit + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "fix: bug"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + // Default config should treat fix as patch -> v0.0.1 + let assert = Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--output") + .arg("json") + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap(); + assert_eq!(v["new_tag"], serde_json::json!("v0.0.1")); + + cleanup(temp_dir); +} + #[test] fn test_commit_command_with_auto_correction() { let temp_dir = setup(); @@ -172,3 +589,116 @@ fn test_commit_with_breaking_change() { cleanup(temp_dir); } + +#[test] +fn test_lint_text_output_success() { + let temp_dir = setup(); + + // Create a valid commit to ensure no lint issues + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("lint") + .arg("--repo-path") + .arg(".") + .arg("--output") + .arg("text") + .assert() + .success() + .stdout(predicate::str::contains( + "✅ All commits since the last tag follow the conventional commit format!", + )); + + cleanup(temp_dir); +} + +#[test] +fn test_lint_text_output_failure() { + let temp_dir = setup(); + + // Create an invalid commit message to trigger lint issues + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "invalid message"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("lint") + .arg("--repo-path") + .arg(".") + .arg("--output") + .arg("text") + .assert() + .code(3) + .stdout(predicate::str::contains( + "❌ Found 1 commit(s) with issues:", + )); + + cleanup(temp_dir); +} + +#[test] +fn test_tag_text_output_non_interactive() { + let temp_dir = setup(); + + // Ensure at least one commit so bump detection works + let _ = StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "feat: initial"]) + .current_dir(&temp_dir) + .output() + .expect("Failed to create commit"); + + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + .arg("tag") + .arg("--no-fetch") + .arg("--dry-run") + .arg("--not-publish") + .arg("--output") + .arg("text") + .assert() + .success() + .stdout(predicate::str::is_match(r"^v0\.1\.0\s*$").unwrap()); + + cleanup(temp_dir); +} + +#[test] +fn test_exit_code_1_on_no_staged_changes() { + let temp_dir = setup(); + + // Unstage any staged files to trigger NoStagedChanges + let _ = StdCommand::new("git") + .args(["reset"]) // unstage all + .current_dir(&temp_dir) + .output() + .expect("Failed to reset index"); + + Command::cargo_bin("committy") + .unwrap() + .current_dir(&temp_dir) + .env("RUST_LOG", "off") + .arg("--non-interactive") + // No subcommand + .assert() + .code(1) + .stderr(predicate::str::contains("No staged changes found")); + + cleanup(temp_dir); +} diff --git a/tests/repository_tests.rs b/tests/repository_tests.rs index 3f7c632..99e503d 100644 --- a/tests/repository_tests.rs +++ b/tests/repository_tests.rs @@ -1,6 +1,7 @@ use committy::error::CliError; use committy::git::has_staged_changes; use git2::Repository; +use serial_test::serial; use std::env; use std::fs; use tempfile::TempDir; @@ -39,6 +40,7 @@ fn setup_test_repo() -> (TempDir, Repository) { } #[test] +#[serial] fn test_repository_discovery_from_subdirectory() -> Result<(), CliError> { let (temp_dir, repo) = setup_test_repo(); @@ -72,6 +74,7 @@ fn test_repository_discovery_from_subdirectory() -> Result<(), CliError> { } #[test] +#[serial] fn test_repository_not_found() { // Create a temporary directory that is not a git repository let temp_dir = TempDir::new().unwrap(); @@ -96,6 +99,7 @@ fn test_repository_not_found() { } #[test] +#[serial] fn test_staged_deleted_file() -> Result<(), CliError> { let (temp_dir, repo) = setup_test_repo(); @@ -143,6 +147,7 @@ fn test_staged_deleted_file() -> Result<(), CliError> { } #[test] +#[serial] fn test_no_staged_changes() -> Result<(), CliError> { let (temp_dir, repo) = setup_test_repo(); @@ -189,6 +194,7 @@ fn test_no_staged_changes() -> Result<(), CliError> { } #[test] +#[serial] fn test_unstaged_changes_only() -> Result<(), CliError> { let (temp_dir, _repo) = setup_test_repo(); @@ -214,6 +220,7 @@ fn test_unstaged_changes_only() -> Result<(), CliError> { } #[test] +#[serial] fn test_repository_discovery_without_staged_changes() -> Result<(), CliError> { let (temp_dir, _repo) = setup_test_repo(); @@ -238,6 +245,7 @@ fn test_repository_discovery_without_staged_changes() -> Result<(), CliError> { } #[test] +#[serial] fn test_commit_from_subdirectory() -> Result<(), CliError> { let (temp_dir, repo) = setup_test_repo(); @@ -280,8 +288,7 @@ fn test_commit_from_subdirectory() -> Result<(), CliError> { let head_message = head_commit.message().unwrap_or(""); assert_eq!( head_message, commit_message, - "Expected commit message '{}' but got '{}'", - commit_message, head_message + "Expected commit message '{commit_message}' but got '{head_message}'" ); // Get the parent commit to verify the history diff --git a/tests/tag_tests.rs b/tests/tag_tests.rs index e150d9f..c21c395 100644 --- a/tests/tag_tests.rs +++ b/tests/tag_tests.rs @@ -81,19 +81,42 @@ fn test_pre_release_continues_from_highest_version() { // Tag v8.3.2 (regular) repo.tag( "v8.3.2", - &repo.head().unwrap().peel_to_commit().unwrap().as_object(), + repo.head().unwrap().peel_to_commit().unwrap().as_object(), &signature, "Regular release", false, - ).unwrap(); + ) + .unwrap(); // Tag v10.0.0-beta.1 (pre-release) repo.tag( "v10.0.0-beta.1", - &repo.head().unwrap().peel_to_commit().unwrap().as_object(), + repo.head().unwrap().peel_to_commit().unwrap().as_object(), &signature, "Pre-release", false, - ).unwrap(); + ) + .unwrap(); + + // Create a new commit after the tags so the tag command has something to process + { + let file_path = dir.path().join("chore.txt"); + fs::write(&file_path, "chore change").unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("chore.txt")).unwrap(); + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "misc: new commit after tags #none", + &tree, + &[&parent], + ) + .unwrap(); + } // Run the tag command in pre-release mode (should produce v10.0.0-beta.2) let mut cmd = Command::cargo_bin("committy").unwrap(); diff --git a/tests/version_tests.rs b/tests/version_tests.rs index e4c8d1a..dff4eb6 100644 --- a/tests/version_tests.rs +++ b/tests/version_tests.rs @@ -192,7 +192,7 @@ version = "1.0.0""#, for (name, content) in files { let file_path = temp_dir.path().join(name); - fs::write(&file_path, content).unwrap_or_else(|_| panic!("Failed to write {}", name)); + fs::write(&file_path, content).unwrap_or_else(|_| panic!("Failed to write {name}")); } // Register and update all common files @@ -433,7 +433,7 @@ version = "1.0.0" .peel_to_commit() .expect("Failed to get HEAD commit"); let commit_message = head_commit.message().unwrap_or(""); - println!("Actual commit message: {}", commit_message); + println!("Actual commit message: {commit_message}"); assert!( commit_message.contains("chore: bump version to 1.1.0"), "Commit message incorrect"