use crate::config::{ChangelogRelativeTo, ReleaseConfig};
use crate::error::{RailError, RailResult};
use crate::release::version::BumpType;
use crate::workspace::WorkspaceContext;
use rustc_hash::FxHashMap;
use semver::Version;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize)]
pub struct ReleasePlan {
pub plan_contract_version: u32,
pub canonical_crate_order: Vec<String>,
pub crates: Vec<CrateReleasePlan>,
pub summary: ReleaseSummary,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrateReleasePlan {
pub name: String,
pub current_version: Version,
pub new_version: Version,
pub manifest_path: PathBuf,
pub changelog_path: PathBuf,
pub tag_name: String,
pub previous_tag: Option<String>,
pub changelog_range_start: Option<String>,
pub changelog_range_end: String,
pub publish: bool,
pub publish_intent: String,
pub generate_changelog: bool,
pub bump: String,
pub affected_dependents: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReleaseSummary {
pub total_crates: usize,
pub crates_to_publish: usize,
pub crates_to_tag: usize,
}
pub struct ReleasePlanner<'a> {
ctx: &'a WorkspaceContext,
release_config: &'a ReleaseConfig,
}
impl<'a> ReleasePlanner<'a> {
pub fn new(ctx: &'a WorkspaceContext, release_config: &'a ReleaseConfig) -> Self {
Self { ctx, release_config }
}
pub fn plan(&self, crate_names: Option<Vec<String>>, bump_type: &BumpType) -> RailResult<ReleasePlan> {
let target_crates = if let Some(names) = crate_names {
names
} else {
self.ctx.graph.workspace_members().to_vec()
};
let all_ordered = self.ctx.graph.publish_order()?;
let ordered_targets: Vec<String> = all_ordered
.into_iter()
.filter(|name| target_crates.contains(name))
.collect();
let mut crate_plans = Vec::with_capacity(ordered_targets.len());
let mut version_map: FxHashMap<String, Version> = FxHashMap::default();
for crate_name in &ordered_targets {
let plan = self.plan_crate(crate_name, bump_type, &version_map)?;
version_map.insert(crate_name.clone(), plan.new_version.clone());
crate_plans.push(plan);
}
let crates_to_publish = crate_plans.iter().filter(|p| p.publish).count();
let summary = ReleaseSummary {
total_crates: crate_plans.len(),
crates_to_publish,
crates_to_tag: crate_plans.len(),
};
let canonical_crate_order = crate_plans.iter().map(|plan| plan.name.clone()).collect();
Ok(ReleasePlan {
plan_contract_version: 1,
canonical_crate_order,
crates: crate_plans,
summary,
})
}
fn plan_crate(
&self,
crate_name: &str,
bump_type: &BumpType,
_version_map: &FxHashMap<String, Version>,
) -> RailResult<CrateReleasePlan> {
let package = self
.ctx
.cargo
.get_package(crate_name)
.ok_or_else(|| RailError::message(format!("Crate '{}' not found", crate_name)))?;
let manifest_path = package.manifest_path.clone().into_std_path_buf();
let current_version = package.version.clone();
let new_version = bump_type.apply(¤t_version);
let tag_name = self.format_tag(crate_name, &new_version);
let previous_tag = self.find_previous_tag(crate_name)?;
let crate_config = self.ctx.config.as_ref().and_then(|c| c.crates.get(crate_name));
let changelog_relative_path = crate_config
.and_then(|c| c.changelog.as_ref())
.and_then(|ch| ch.path.as_ref())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| self.release_config.changelog_path.clone());
let changelog_path = match self.release_config.changelog_relative_to {
ChangelogRelativeTo::Crate => {
manifest_path
.parent()
.ok_or_else(|| RailError::message("Invalid manifest path"))?
.join(&changelog_relative_path)
}
ChangelogRelativeTo::Workspace => {
self.ctx.workspace_root().join(&changelog_relative_path)
}
};
let generate_changelog = if let Some(changelog_cfg) = crate_config.and_then(|c| c.changelog.as_ref()) {
!changelog_cfg.skip
} else {
!self.release_config.skip_changelog_for.iter().any(|c| c == crate_name)
};
let publish_from_cargo = crate::workspace::CargoState::is_package_publishable(package);
let publish = crate_config
.and_then(|c| c.release.as_ref())
.map(|r| r.publish)
.unwrap_or(publish_from_cargo);
let affected_dependents = self.ctx.graph.transitive_dependents(crate_name)?;
let bump = format!("{} -> {}", current_version, new_version);
let publish_intent = if publish {
"publish_to_crates_io".to_string()
} else {
"skip_publish".to_string()
};
Ok(CrateReleasePlan {
name: crate_name.to_string(),
current_version,
new_version,
manifest_path,
changelog_path,
tag_name,
previous_tag: previous_tag.clone(),
changelog_range_start: previous_tag,
changelog_range_end: "HEAD".to_string(),
publish,
publish_intent,
generate_changelog,
bump,
affected_dependents,
})
}
fn format_tag(&self, crate_name: &str, version: &Version) -> String {
let workspace_members = self.ctx.graph.workspace_members();
let is_single_crate = workspace_members.len() == 1;
if is_single_crate {
format!("{}{}", self.release_config.tag_prefix, version)
} else {
self
.release_config
.tag_format
.replace("{prefix}", &self.release_config.tag_prefix)
.replace("{crate}", crate_name)
.replace("{version}", &version.to_string())
}
}
fn find_previous_tag(&self, crate_name: &str) -> RailResult<Option<String>> {
let workspace_members = self.ctx.graph.workspace_members();
let is_single_crate = workspace_members.len() == 1;
let pattern = if is_single_crate {
format!("{}*", self.release_config.tag_prefix)
} else {
self
.release_config
.tag_format
.replace("{crate}", crate_name)
.replace("{version}", "*")
};
self.ctx.git.git().find_latest_tag(&pattern)
}
}
impl ReleasePlan {
pub fn format_summary_with_flags(&self, skip_publish: bool, skip_tag: bool) -> String {
let mut output = String::new();
output.push_str("📦 Release Plan\n\n");
for (i, crate_plan) in self.crates.iter().enumerate() {
output.push_str(&format!("{}. {}\n", i + 1, crate_plan.name));
output.push_str(&format!(
" Version: {} → {}\n",
crate_plan.current_version, crate_plan.new_version
));
let tag_status = if skip_tag {
"✗ (--skip-tag)"
} else {
&crate_plan.tag_name
};
output.push_str(&format!(" Tag: {}\n", tag_status));
let publish_status = if skip_publish {
"✗ (--skip-publish)".to_string()
} else if crate_plan.publish {
"✓".to_string()
} else {
"✗ (publish = false)".to_string()
};
output.push_str(&format!(" Publish: {}\n", publish_status));
if !crate_plan.affected_dependents.is_empty() {
output.push_str(&format!(" Affects: {}\n", crate_plan.affected_dependents.join(", ")));
}
output.push('\n');
}
let effective_publish_count = if skip_publish {
0
} else {
self.summary.crates_to_publish
};
let effective_tag_count = if skip_tag { 0 } else { self.summary.crates_to_tag };
output.push_str(&format!(
"Summary: {} crate(s), {} to publish, {} tag(s)\n",
self.summary.total_crates, effective_publish_count, effective_tag_count
));
output
}
pub fn format_summary(&self) -> String {
self.format_summary_with_flags(false, false)
}
}