use clap::{Args, Parser, Subcommand, ValueEnum};
use git_url_parse::{GitUrl, types::provider::GenericProvider};
use merge::Merge;
use releasaurus_core::{
config::{
changelog::RewordedCommit,
prerelease::PrereleaseStrategy,
resolved::{CommitModifiers, GlobalOverrides, PackageOverrides},
},
forge::{
azure_devops::{AzureDevops, url_parse::azure_git_url_to_repo_url},
config::{RepoUrl, Scheme, TokenVar, resolve_token},
forgejo::Forgejo,
gitea::Gitea,
github::Github,
gitlab::Gitlab,
local::{LocalRepo, Remote},
traits::Forge,
},
resolver::resolvers::commit_modifiers::validate_sha,
result::{ReleasaurusError, Result},
};
use secrecy::SecretString;
use serde::Deserialize;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
pub mod get;
#[derive(Parser, Debug)]
#[command(version, about)]
pub struct Cli {
#[command(flatten)]
pub forge_args: ForgeArgs,
#[arg(long, default_value_t = false, global = true)]
pub debug: bool,
#[arg(long, global = true)]
pub base_branch: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Clone, Args)]
pub struct ForgeArgs {
#[arg(short, long, value_enum, global = true, env = "RELEASAURUS_FORGE")]
pub forge: Option<ForgeType>,
#[arg(short, long, global = true, env = "RELEASAURUS_REPO")]
pub repo: Option<String>,
#[arg(long, global = true, env = "RELEASAURUS_LOCAL_PATH")]
pub local_path: Option<PathBuf>,
#[arg(short, long, global = true)]
pub token: Option<SecretString>,
}
impl ForgeArgs {
pub async fn forge(&self) -> Result<Box<dyn Forge>> {
let inferred_forge = self
.forge
.or_else(|| self.repo.as_deref().and_then(infer_forge_from_url));
if let Some(forge_type) = inferred_forge.as_ref()
&& let Some(git_url) = self.repo.as_ref()
{
let forge: Box<dyn Forge> = match forge_type {
ForgeType::Github => {
let repo = git_url_to_repo_url(git_url)?;
let github =
Github::new(repo.clone(), self.token.clone()).await?;
if let Some(local_path) = self.local_path.as_ref() {
self.resolve_hybrid_forge(
Arc::new(github),
local_path,
&repo,
TokenVar::Github,
)?
} else {
Box::new(github)
}
}
ForgeType::Gitlab => {
let repo = git_url_to_repo_url(git_url)?;
let gitlab =
Gitlab::new(repo.clone(), self.token.clone()).await?;
if let Some(local_path) = self.local_path.as_ref() {
self.resolve_hybrid_forge(
Arc::new(gitlab),
local_path,
&repo,
TokenVar::Gitlab,
)?
} else {
Box::new(gitlab)
}
}
ForgeType::Gitea => {
let repo = git_url_to_repo_url(git_url)?;
let gitea =
Gitea::new(repo.clone(), self.token.clone(), None)
.await?;
if let Some(local_path) = self.local_path.as_ref() {
self.resolve_hybrid_forge(
Arc::new(gitea),
local_path,
&repo,
TokenVar::Gitea,
)?
} else {
Box::new(gitea)
}
}
ForgeType::Forgejo => {
let repo = git_url_to_repo_url(git_url)?;
let forgejo =
Forgejo::new(repo.clone(), self.token.clone()).await?;
if let Some(local_path) = self.local_path.as_ref() {
self.resolve_hybrid_forge(
Arc::new(forgejo),
local_path,
&repo,
TokenVar::Forgejo,
)?
} else {
Box::new(forgejo)
}
}
ForgeType::AzureDevops => {
let repo = azure_git_url_to_repo_url(git_url)?;
let azure =
AzureDevops::new(repo.clone(), self.token.clone())
.await?;
if let Some(local_path) = self.local_path.as_ref() {
self.resolve_hybrid_forge(
Arc::new(azure),
local_path,
&repo,
TokenVar::AzureDevops,
)?
} else {
Box::new(azure)
}
}
ForgeType::Local => {
Box::new(LocalRepo::new(Path::new(git_url), None)?)
}
};
Ok(forge)
} else if self.repo.is_none() {
Err(ReleasaurusError::InvalidArgs(
"--repo is required (or set RELEASAURUS_REPO)".into(),
))
} else {
Err(ReleasaurusError::InvalidArgs(
"could not infer --forge from repo URL; \
pass --forge explicitly (or set RELEASAURUS_FORGE)."
.into(),
))
}
}
fn resolve_hybrid_forge(
&self,
forge: Arc<dyn Forge>,
local_path: &Path,
repo: &RepoUrl,
token_var: TokenVar,
) -> Result<Box<dyn Forge>> {
let token =
resolve_token(self.token.clone(), repo.token.as_ref(), token_var)?;
Ok(Box::new(LocalRepo::new(
local_path,
Some(Remote {
forge,
token,
url: repo.clone(),
}),
)?))
}
}
fn infer_forge_from_url(url: &str) -> Option<ForgeType> {
let host = GitUrl::parse(url).ok()?.host()?.to_lowercase();
match host.as_str() {
"github.com" => Some(ForgeType::Github),
"gitlab.com" => Some(ForgeType::Gitlab),
"gitea.com" => Some(ForgeType::Gitea),
"codeberg.org" => Some(ForgeType::Forgejo),
"dev.azure.com" => Some(ForgeType::AzureDevops),
_ => None,
}
}
fn git_url_to_repo_url(url: &str) -> Result<RepoUrl> {
let git_url = GitUrl::parse(url).map_err(|e| {
ReleasaurusError::InvalidArgs(format!(
"failed to parse repo url as git url: {}",
e
))
})?;
let url_scheme = git_url.scheme().ok_or(ReleasaurusError::InvalidArgs(
"failed to parse scheme from repo url".into(),
))?;
let scheme = match url_scheme {
"https" => Ok(Scheme::Https),
"http" => Ok(Scheme::Http),
_ => Err(ReleasaurusError::InvalidArgs(
"only https and http schemes are supported for repo urls".into(),
)),
}?;
let provider: GenericProvider = git_url.provider_info().map_err(|e| {
ReleasaurusError::InvalidArgs(format!(
"failed to parse provider info from repo url: {}",
e
))
})?;
let host = git_url.host().ok_or(ReleasaurusError::InvalidArgs(
"failed to parse host from repo url".into(),
))?;
let owner = provider.owner();
let name = provider.repo();
let path = git_url.path();
let port = git_url.port();
let token = git_url.password().map(SecretString::from);
Ok(RepoUrl {
host: host.to_string(),
owner: owner.to_string(),
name: name.to_string(),
path: path.to_string(),
port,
scheme,
token,
})
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum ForgeType {
Github,
Gitlab,
Gitea,
Forgejo,
AzureDevops,
Local,
}
#[derive(Debug, Clone)]
pub struct PackagePathOverride {
pub package_name: String,
pub path: String,
pub value: String,
}
#[derive(Debug, Clone, Merge, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliPackageOverrides {
#[serde(rename = "tag_prefix")]
#[merge(strategy = merge::option::overwrite_none)]
pub tag_prefix: Option<String>,
#[serde(rename = "prerelease.suffix")]
#[merge(strategy = merge::option::overwrite_none)]
pub prerelease_suffix: Option<String>,
#[serde(rename = "prerelease.strategy")]
#[merge(strategy = merge::option::overwrite_none)]
pub prerelease_strategy: Option<PrereleaseStrategy>,
}
impl From<CliPackageOverrides> for PackageOverrides {
fn from(value: CliPackageOverrides) -> Self {
Self {
prerelease_strategy: value.prerelease_strategy,
prerelease_suffix: value.prerelease_suffix,
tag_prefix: value.tag_prefix,
}
}
}
#[derive(Debug, Clone, Args)]
pub struct SharedCommandOverrides {
#[arg(
long = "set-package",
value_parser = parse_package_override,
value_name = "KEY=VALUE"
)]
package_overrides: Vec<PackagePathOverride>,
#[arg(long)]
tag_prefix: Option<String>,
#[arg(long)]
prerelease_suffix: Option<String>,
#[arg(long, value_parser = parse_prerelease_strategy)]
prerelease_strategy: Option<PrereleaseStrategy>,
}
#[derive(Debug, Clone, Default, Args)]
pub struct CliCommitModifiers {
#[arg(
long = "skip-sha",
value_parser = validate_sha,
value_name = "SKIP_SHA"
)]
pub skip_shas: Vec<String>,
#[arg(long, value_parser = parse_reworded_commit, value_name = "KEY=VALUE")]
pub reword: Vec<RewordedCommit>,
}
impl From<CommitModifiers> for CliCommitModifiers {
fn from(value: CommitModifiers) -> Self {
Self {
reword: value.reword,
skip_shas: value.skip_shas,
}
}
}
impl From<CliCommitModifiers> for CommitModifiers {
fn from(value: CliCommitModifiers) -> Self {
Self {
reword: value.reword,
skip_shas: value.skip_shas,
}
}
}
fn parse_prerelease_strategy(s: &str) -> Result<PrereleaseStrategy> {
s.parse::<PrereleaseStrategy>().map_err(|_| {
ReleasaurusError::invalid_config(format!(
"Invalid prerelease strategy: '{}'. \
Valid values: versioned, static",
s
))
})
}
fn parse_package_override(s: &str) -> Result<PackagePathOverride> {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(ReleasaurusError::invalid_config(format!(
"Invalid format: '{}'. Expected package_name.path=value",
s
)));
}
let key = parts[0];
let value = parts[1];
let key_parts: Vec<&str> = key.split('.').collect();
if key_parts.len() < 2 {
return Err(ReleasaurusError::invalid_config(format!(
"Invalid key: '{}'. Expected package_name.path",
key
)));
}
Ok(PackagePathOverride {
package_name: key_parts[0].to_string(),
path: key_parts[1..].join("."),
value: value.to_string(),
})
}
fn parse_reworded_commit(s: &str) -> Result<RewordedCommit> {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(ReleasaurusError::invalid_config(format!(
"Invalid --reword format: '{}'. Expected \
'commit_sha=new_message'. Example: \
--reword 'abc123de=fix: corrected message'",
s
)));
}
let sha = parts[0];
let message = parts[1];
let validated_sha = validate_sha(sha)?;
Ok(RewordedCommit {
sha: validated_sha,
message: message.into(),
})
}
#[derive(Subcommand, Debug, Clone)]
pub enum GetCommand {
NextRelease {
#[arg(short, long)]
out_file: Option<String>,
#[arg(short, long)]
package: Option<String>,
#[command(flatten)]
commit_modifiers: CliCommitModifiers,
#[command(flatten)]
overrides: SharedCommandOverrides,
},
CurrentRelease {
#[arg(short, long)]
out_file: Option<String>,
#[arg(short, long)]
package: Option<String>,
},
Release {
#[arg(short, long)]
out_file: Option<String>,
#[arg(long, required = true)]
tag: String,
},
#[command(visible_alias = "notes")]
RecompiledNotes {
#[arg(long)]
file: String,
#[arg(short, long)]
out_file: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum Command {
ReleasePR {
#[command(flatten)]
commit_modifiers: CliCommitModifiers,
#[command(flatten)]
overrides: SharedCommandOverrides,
#[arg(short, long)]
package: Option<String>,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
Release {
#[arg(short, long)]
package: Option<String>,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
#[command(visible_alias = "show")]
Get {
#[command(subcommand)]
command: GetCommand,
},
StartNext {
#[command(flatten)]
overrides: SharedCommandOverrides,
#[arg(long, value_delimiter(','))]
packages: Option<Vec<String>>,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
impl Cli {
pub fn get_commit_modifiers(&self) -> CliCommitModifiers {
match &self.command {
Command::ReleasePR {
commit_modifiers, ..
} => commit_modifiers.to_owned(),
Command::Get {
command:
GetCommand::NextRelease {
commit_modifiers, ..
},
} => commit_modifiers.to_owned(),
_ => CliCommitModifiers::default(),
}
}
pub fn get_package_overrides(
&self,
) -> Result<HashMap<String, CliPackageOverrides>> {
let mut map: HashMap<String, CliPackageOverrides> = HashMap::new();
let mut map_overrides =
|overrides: &SharedCommandOverrides| -> Result<()> {
for path_override in overrides.package_overrides.clone() {
let value = serde_json::json!({
path_override.path.clone(): path_override.value
});
let mut overrides: CliPackageOverrides =
serde_json::from_value(value)?;
if let Some(existing) =
map.get(&path_override.package_name).cloned()
{
overrides.merge(existing);
}
map.insert(path_override.package_name.clone(), overrides);
}
Ok(())
};
match &self.command {
Command::ReleasePR { overrides, .. } => {
map_overrides(overrides)?;
}
Command::StartNext { overrides, .. } => {
map_overrides(overrides)?;
}
Command::Get {
command: GetCommand::NextRelease { overrides, .. },
} => {
map_overrides(overrides)?;
}
_ => {}
};
Ok(map)
}
pub fn get_global_overrides(&self) -> GlobalOverrides {
let mut global_overrides = GlobalOverrides {
base_branch: self.base_branch.clone(),
..GlobalOverrides::default()
};
let cmd_overrides = match &self.command {
Command::ReleasePR { overrides, .. } => Some(overrides),
Command::StartNext { overrides, .. } => Some(overrides),
Command::Get {
command: GetCommand::NextRelease { overrides, .. },
} => Some(overrides),
_ => None,
};
if let Some(overrides) = cmd_overrides {
global_overrides.tag_prefix = overrides.tag_prefix.clone();
global_overrides.prerelease_suffix =
overrides.prerelease_suffix.clone();
global_overrides.prerelease_strategy =
overrides.prerelease_strategy;
}
global_overrides
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn forge_args_errors_if_missing_forge_type_and_url_unrecognized() {
let repo = "https://git.self-hosted.example/owner/repo";
let token = SecretString::from("token");
let forge_args = ForgeArgs {
forge: None,
repo: Some(repo.to_string()),
token: Some(token),
local_path: None,
};
let result = forge_args.forge().await;
match result {
Ok(_) => {
unreachable!("missing forge type should have resulted in error")
}
Err(err) => {
assert!(matches!(err, ReleasaurusError::InvalidArgs(_)))
}
}
}
#[test]
fn infer_forge_recognizes_cloud_hosts() {
assert_eq!(
infer_forge_from_url("https://github.com/o/r"),
Some(ForgeType::Github),
);
assert_eq!(
infer_forge_from_url("https://gitlab.com/o/r"),
Some(ForgeType::Gitlab),
);
assert_eq!(
infer_forge_from_url("https://gitea.com/o/r"),
Some(ForgeType::Gitea),
);
assert_eq!(
infer_forge_from_url("https://codeberg.org/o/r"),
Some(ForgeType::Forgejo),
);
assert_eq!(
infer_forge_from_url("https://dev.azure.com/org/proj/_git/r"),
Some(ForgeType::AzureDevops),
);
}
#[test]
fn infer_forge_returns_none_for_self_hosted() {
assert_eq!(infer_forge_from_url("https://git.example.com/o/r"), None,);
assert_eq!(
infer_forge_from_url("https://gitlab.self-hosted.io/o/r"),
None,
);
}
#[tokio::test]
async fn forge_args_errors_if_missing_repo() {
let token = SecretString::from("github_token");
let forge_args = ForgeArgs {
forge: Some(ForgeType::Github),
repo: None,
token: Some(token),
local_path: None,
};
let result = forge_args.forge().await;
match result {
Ok(_) => {
unreachable!("missing repo should have resulted in error")
}
Err(err) => {
assert!(matches!(err, ReleasaurusError::InvalidArgs(_)))
}
}
}
#[tokio::test]
async fn forge_args_local_forge_accepts_local_path_as_repo() {
use std::process::Command;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let path = dir.path();
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.unwrap();
std::fs::write(path.join("README"), "init").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(path)
.output()
.unwrap();
let forge_args = ForgeArgs {
forge: Some(ForgeType::Local),
repo: Some(path.to_string_lossy().to_string()),
token: None,
local_path: None,
};
forge_args.forge().await.unwrap();
}
#[test]
fn validate_sha_accepts_valid_short_sha() {
validate_sha("abc123d").unwrap();
}
#[test]
fn validate_sha_accepts_valid_full_sha() {
validate_sha("abc123def456789012345678901234567890abcd").unwrap();
}
#[test]
fn validate_sha_rejects_too_short() {
let result = validate_sha("abc123");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ReleasaurusError::InvalidConfig(_)
));
}
#[test]
fn validate_sha_rejects_too_long() {
let result = validate_sha("abc123def456789012345678901234567890abcdef");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ReleasaurusError::InvalidConfig(_)
));
}
#[test]
fn validate_sha_rejects_non_hex_characters() {
let result = validate_sha("abc123g");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ReleasaurusError::InvalidConfig(_)
));
}
#[test]
fn validate_sha_accepts_uppercase_hex() {
validate_sha("ABC123D").unwrap();
}
#[test]
fn validate_sha_trims_whitespace() {
validate_sha(" abc123d ").unwrap();
}
#[test]
fn parse_reworded_commit_succeeds_with_valid_sha() {
let reworded =
parse_reworded_commit("abc123d=fix: new message").unwrap();
assert_eq!(reworded.sha, "abc123d");
assert_eq!(reworded.message, "fix: new message");
}
#[test]
fn parse_reworded_commit_fails_with_invalid_sha() {
let result = parse_reworded_commit("abc=fix: new message");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ReleasaurusError::InvalidConfig(_)
));
}
#[test]
fn parse_reworded_commit_fails_with_missing_equals() {
let result = parse_reworded_commit("abc123dfix: new message");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ReleasaurusError::InvalidConfig(_)
));
}
#[test]
fn parse_reworded_commit_handles_multiline_message() {
let reworded =
parse_reworded_commit("abc123d=fix: new message\n\nMore content")
.unwrap();
assert_eq!(reworded.message, "fix: new message\n\nMore content");
}
#[test]
fn parse_reworded_commit_trims_sha_whitespace() {
let reworded =
parse_reworded_commit(" abc123d =fix: new message").unwrap();
assert_eq!(reworded.sha, "abc123d");
}
}