use clap::{Args, Parser, Subcommand, ValueEnum};
use git_url_parse::GitUrl;
use merge::Merge;
use releasaurus_core::{
config::{changelog::RewordedCommit, prerelease::PrereleaseStrategy},
error::{ReleasaurusError, Result},
forge::{
config::{RepoUrl, Scheme, TokenVar, resolve_token},
gitea::Gitea,
github::Github,
gitlab::Gitlab,
local::{LocalRepo, Remote},
traits::Forge,
},
orchestrator::config::{
CommitModifiers, GlobalOverrides, PackageOverrides, validate_sha,
},
};
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)]
pub forge: Option<ForgeType>,
#[arg(short, long, global = true)]
pub repo: Option<GitUrl>,
#[arg(long, global = true)]
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>> {
if let Some(forge_type) = self.forge.as_ref()
&& let Some(git_url) = self.repo.as_ref()
{
let repo = git_url_to_repo_url(git_url)?;
let forge: Box<dyn Forge> = match forge_type {
ForgeType::Github => {
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 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 gitea =
Gitea::new(repo.clone(), self.token.clone()).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::Local => {
Box::new(LocalRepo::new(Path::new(&git_url.path), None)?)
}
};
Ok(forge)
} else {
Err(ReleasaurusError::InvalidArgs(
"both --forge and --repo are required".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 git_url_to_repo_url(url: &GitUrl) -> Result<RepoUrl> {
let scheme = match url.scheme {
git_url_parse::Scheme::Http => Scheme::Http,
git_url_parse::Scheme::Https => Scheme::Https,
other => {
return Err(ReleasaurusError::GitUrlError(format!(
"unsupported URL scheme \"{other}\": \
only http and https are supported"
)));
}
};
Ok(RepoUrl {
host: url.host.clone().unwrap_or_default(),
owner: url.owner.clone().unwrap_or_default(),
name: url.name.clone(),
path: url.path.clone(),
port: url.port,
scheme,
token: url.token.clone().map(SecretString::from),
})
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum ForgeType {
Github,
Gitlab,
Gitea,
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() {
let repo = GitUrl::parse("https://github.com/github_owner/github_repo")
.unwrap();
let token = SecretString::from("github_token");
let forge_args = ForgeArgs {
forge: None,
repo: Some(repo),
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(_)))
}
}
}
#[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(_)))
}
}
}
#[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");
}
}