use clap::{Args, ValueEnum};
use homeboy::log_status;
use serde::Serialize;
use homeboy::component;
use homeboy::deploy::{self, DeployConfig};
use homeboy::release::{self, ReleasePlan, ReleaseRun};
use super::{CmdResult, ProjectsSummary};
#[derive(Clone, ValueEnum)]
pub enum BumpType {
Patch,
Minor,
Major,
}
impl BumpType {
pub fn as_str(&self) -> &'static str {
match self {
BumpType::Patch => "patch",
BumpType::Minor => "minor",
BumpType::Major => "major",
}
}
}
#[derive(Args)]
pub struct ReleaseArgs {
#[arg(value_name = "COMPONENT")]
component_id: String,
#[arg(
value_name = "BUMP_TYPE",
ignore_case = true,
required_unless_present = "recover"
)]
bump_type: Option<BumpType>,
#[arg(long)]
dry_run: bool,
#[arg(long, hide = true)]
json: bool,
#[arg(long)]
deploy: bool,
#[arg(long, conflicts_with = "bump_type")]
recover: bool,
}
#[derive(Serialize)]
pub struct DeploymentResult {
pub projects: Vec<ProjectDeployResult>,
pub summary: ProjectsSummary,
}
#[derive(Serialize)]
pub struct ProjectDeployResult {
pub project_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub component_result: Option<homeboy::deploy::ComponentDeployResult>,
}
#[derive(Serialize)]
#[serde(tag = "command", rename = "release")]
pub struct ReleaseOutput {
pub result: ReleaseResult,
}
#[derive(Serialize)]
pub struct ReleaseResult {
pub component_id: String,
pub bump_type: String,
pub dry_run: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan: Option<ReleasePlan>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run: Option<ReleaseRun>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment: Option<DeploymentResult>,
}
pub fn run(args: ReleaseArgs, _global: &crate::commands::GlobalArgs) -> CmdResult<ReleaseOutput> {
if args.recover {
return run_recover(&args.component_id);
}
let bump_type = args.bump_type.ok_or_else(|| {
homeboy::Error::validation_missing_argument(vec!["bump_type".to_string()])
})?;
let options = release::ReleaseOptions {
bump_type: bump_type.as_str().to_string(),
dry_run: args.dry_run,
path_override: None,
};
if args.dry_run {
let plan = release::plan(&args.component_id, &options)?;
let deployment = if args.deploy {
Some(plan_deployment(&args.component_id))
} else {
None
};
Ok((
ReleaseOutput {
result: ReleaseResult {
component_id: args.component_id,
bump_type: options.bump_type,
dry_run: true,
plan: Some(plan),
run: None,
deployment,
},
},
0,
))
} else {
let run_result = release::run(&args.component_id, &options)?;
display_release_summary(&run_result);
let (deployment, deploy_exit_code) = if args.deploy {
execute_deployment(&args.component_id)
} else {
(None, 0)
};
Ok((
ReleaseOutput {
result: ReleaseResult {
component_id: args.component_id,
bump_type: options.bump_type,
dry_run: false,
plan: None,
run: Some(run_result),
deployment,
},
},
deploy_exit_code,
))
}
}
pub fn display_release_summary(run: &ReleaseRun) {
if let Some(ref summary) = run.result.summary {
if !summary.success_summary.is_empty() {
eprintln!();
for line in &summary.success_summary {
log_status!("release", "{}", line);
}
}
}
}
fn plan_deployment(component_id: &str) -> DeploymentResult {
let projects = component::projects_using(component_id).unwrap_or_default();
if projects.is_empty() {
log_status!(
"release",
"Warning: No projects use component '{}'. Nothing to deploy.",
component_id
);
}
let project_results: Vec<ProjectDeployResult> = projects
.iter()
.map(|project_id| ProjectDeployResult {
project_id: project_id.clone(),
status: "planned".to_string(),
error: None,
component_result: None,
})
.collect();
let total = project_results.len() as u32;
DeploymentResult {
projects: project_results,
summary: ProjectsSummary {
total_projects: total,
succeeded: 0,
failed: 0,
},
}
}
fn execute_deployment(component_id: &str) -> (Option<DeploymentResult>, i32) {
let projects = component::projects_using(component_id).unwrap_or_default();
if projects.is_empty() {
log_status!(
"release",
"Warning: No projects use component '{}'. Nothing to deploy.",
component_id
);
return (
Some(DeploymentResult {
projects: vec![],
summary: ProjectsSummary {
total_projects: 0,
succeeded: 0,
failed: 0,
},
}),
0,
);
}
log_status!(
"release",
"Deploying '{}' to {} project(s)...",
component_id,
projects.len()
);
let mut project_results = Vec::new();
let mut succeeded: u32 = 0;
let mut failed: u32 = 0;
for project_id in &projects {
log_status!("release", "Deploying to project '{}'...", project_id);
let config = DeployConfig {
component_ids: vec![component_id.to_string()],
all: false,
outdated: false,
dry_run: false,
check: false,
force: false,
skip_build: true,
keep_deps: false, };
match deploy::run(project_id, &config) {
Ok(result) => {
let component_result = result.results.into_iter().next();
let deploy_failed = result.summary.failed > 0;
if deploy_failed {
let error_msg = component_result
.as_ref()
.and_then(|r| r.error.clone())
.unwrap_or_else(|| "Deployment failed".to_string());
project_results.push(ProjectDeployResult {
project_id: project_id.clone(),
status: "failed".to_string(),
error: Some(error_msg),
component_result,
});
failed += 1;
} else {
project_results.push(ProjectDeployResult {
project_id: project_id.clone(),
status: "deployed".to_string(),
error: None,
component_result,
});
succeeded += 1;
}
}
Err(e) => {
project_results.push(ProjectDeployResult {
project_id: project_id.clone(),
status: "failed".to_string(),
error: Some(e.to_string()),
component_result: None,
});
failed += 1;
}
}
}
let total = project_results.len() as u32;
let exit_code = if failed > 0 { 1 } else { 0 };
(
Some(DeploymentResult {
projects: project_results,
summary: ProjectsSummary {
total_projects: total,
succeeded,
failed,
},
}),
exit_code,
)
}
fn run_recover(component_id: &str) -> CmdResult<ReleaseOutput> {
let component = component::load(component_id)?;
let version_info = homeboy::version::read_version(Some(component_id))?;
let current_version = &version_info.version;
let tag_name = format!("v{}", current_version);
let tag_exists_local =
homeboy::git::tag_exists_locally(&component.local_path, &tag_name).unwrap_or(false);
let tag_exists_remote =
homeboy::git::tag_exists_on_remote(&component.local_path, &tag_name).unwrap_or(false);
let uncommitted = homeboy::git::get_uncommitted_changes(&component.local_path)?;
let mut actions = Vec::new();
if uncommitted.has_changes {
log_status!("recover", "Committing uncommitted changes...");
let msg = format!("release: v{}", current_version);
let commit_result = homeboy::git::commit(
Some(component_id),
Some(msg.as_str()),
homeboy::git::CommitOptions {
staged_only: false,
files: None,
exclude: None,
amend: false,
},
)?;
if !commit_result.success {
return Err(homeboy::Error::git_command_failed(format!(
"Failed to commit: {}",
commit_result.stderr
)));
}
actions.push("committed version files".to_string());
}
if !tag_exists_local {
log_status!("recover", "Creating tag {}...", tag_name);
let tag_result = homeboy::git::tag(
Some(component_id),
Some(&tag_name),
Some(&format!("Release {}", tag_name)),
)?;
if !tag_result.success {
return Err(homeboy::Error::git_command_failed(format!(
"Failed to create tag: {}",
tag_result.stderr
)));
}
actions.push(format!("created tag {}", tag_name));
}
if !tag_exists_remote {
log_status!("recover", "Pushing to remote...");
let push_result = homeboy::git::push(Some(component_id), true)?;
if !push_result.success {
return Err(homeboy::Error::git_command_failed(format!(
"Failed to push: {}",
push_result.stderr
)));
}
actions.push("pushed commits and tags".to_string());
}
if actions.is_empty() {
log_status!(
"recover",
"Release v{} appears complete — nothing to recover.",
current_version
);
} else {
log_status!(
"recover",
"Recovery complete for v{}: {}",
current_version,
actions.join(", ")
);
}
Ok((
ReleaseOutput {
result: ReleaseResult {
component_id: component_id.to_string(),
bump_type: "recover".to_string(),
dry_run: false,
plan: None,
run: None,
deployment: None,
},
},
0,
))
}