use crate::change_detection::changed_code_crate_names;
use crate::config::{
ChangelogConfig, ChangelogFilters, ChangelogRelativeTo, CommitPolicy, ReleaseConfig, ReleaseSource,
};
use crate::error::{RailError, RailResult};
use crate::release::attribution::{AttributedCommit, AttributedHistory, CommitAttributor};
use crate::release::change_files::{ChangeBump, ChangeIntent, ChangePresentation, PendingChangeSet};
use crate::release::changelog::{ChangelogSpec, CommitDiagnostic, CommitRef, detect_github_repo, resolve_links};
use crate::release::presentation::{self, PlannedPresentation};
use crate::release::process;
use crate::release::semver_checks::{self, ApiEvidence};
use crate::release::version::{BumpLevel, BumpRequest, commit_bump_level};
use crate::workspace::WorkspaceContext;
use cargo_metadata::PackageId;
use rustc_hash::{FxHashMap, FxHashSet};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub(crate) const RELEASE_REGISTRY: &str = "crates-io";
pub(crate) const RELEASE_PLAN_CONTRACT_VERSION: u32 = 9;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleasePlan {
pub plan_contract_version: u32,
pub snapshot_id: String,
#[serde(default)]
pub source: ReleaseSource,
pub canonical_crate_order: Vec<String>,
pub crates: Vec<CrateReleasePlan>,
pub summary: ReleaseSummary,
#[serde(serialize_with = "super::path_serde::serialize_vec")]
pub change_files_to_delete: Vec<PathBuf>,
pub change_files_to_update: Vec<PlannedChangeFileUpdate>,
pub auxiliary_lockfiles: Vec<PlannedAuxiliaryLockfile>,
pub artifacts: Vec<super::ArtifactRequirement>,
pub skipped: Vec<SkippedCrate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SkippedCrate {
pub name: String,
pub reason: String,
}
enum CratePlanOutcome {
Planned(Box<CrateReleasePlan>),
Skipped(SkippedCrate),
}
struct VersionGroupForce {
name: String,
level: BumpLevel,
}
#[derive(Debug, Clone, Default)]
pub struct ReleaseCheckInsights {
pub commit_diagnostics: Vec<(String, Vec<CommitDiagnostic>)>,
pub missing_change_files: Vec<String>,
pub shallow_repository: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CrateReleasePlan {
pub name: String,
pub current_version: Version,
pub new_version: Version,
#[serde(serialize_with = "super::path_serde::serialize")]
pub manifest_path: PathBuf,
#[serde(serialize_with = "super::path_serde::serialize")]
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 api_evidence: ApiEvidence,
pub generate_changelog: bool,
pub bump: String,
pub bump_reason: String,
pub changelog_body: String,
#[serde(deserialize_with = "required_presentation")]
pub presentation: Option<PlannedPresentation>,
#[serde(default)]
pub commits: Vec<PlannedCommit>,
#[serde(default)]
pub commit_diagnostics: Vec<CommitDiagnostic>,
#[serde(skip)]
presentation_policy: Option<ChangelogSpec>,
pub dependency_updates: Vec<DependencyUpdate>,
pub change_entries: Vec<PlannedChangeEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_group: Option<String>,
pub affected_dependents: Vec<String>,
#[serde(skip)]
auto_bump_level: Option<BumpLevel>,
#[serde(skip)]
version_group_entry: Option<String>,
}
fn required_presentation<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<PlannedPresentation>, D::Error> {
PlannedPresentation::deserialize(deserializer).map(Some)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlannedCommit {
pub sha: String,
pub subject: String,
pub body: Option<String>,
pub commit_type: Option<String>,
pub scope: Option<String>,
pub breaking: bool,
pub bump: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DependencyUpdate {
pub name: String,
pub version: Version,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlannedChangeEntry {
#[serde(serialize_with = "super::path_serde::serialize")]
pub path: PathBuf,
pub bump: ChangeBump,
pub body: String,
#[serde(default)]
pub presentation: ChangePresentation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlannedChangeFileUpdate {
#[serde(serialize_with = "super::path_serde::serialize")]
pub path: PathBuf,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlannedAuxiliaryLockfile {
#[serde(serialize_with = "super::path_serde::serialize")]
pub manifest_path: PathBuf,
#[serde(serialize_with = "super::path_serde::serialize")]
pub lockfile_path: PathBuf,
pub before_digest: String,
pub after_digest: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseSummary {
pub total_crates: usize,
pub crates_to_publish: usize,
pub crates_to_tag: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DependentPolicy {
RejectPartialClosure,
IncludeDependents,
}
#[derive(Default)]
struct PlanningInputs {
pending: PendingChangeSet,
histories: HashMap<HistoryKey, AttributedHistory>,
changed_code: HashMap<Option<String>, FxHashSet<String>>,
head: String,
}
#[derive(Debug)]
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_request: &BumpRequest,
dependent_policy: DependentPolicy,
) -> RailResult<ReleasePlan> {
if matches!(bump_request, BumpRequest::Auto)
&& self.release_config.source.uses_commits()
&& is_shallow_repository(self.ctx.workspace_root())?
{
return Err(RailError::with_help(
"automatic commit releases require complete history",
"fetch full history and tags before selecting an automatic commit bump",
));
}
let ordered_targets = self.resolve_targets(crate_names, dependent_policy)?;
self.validate_touched_version_groups(&ordered_targets)?;
let mut crate_plans = Vec::with_capacity(ordered_targets.len());
let mut version_map: FxHashMap<String, Version> = FxHashMap::default();
let mut inputs = self.planning_inputs()?;
let mut skipped = Vec::new();
for crate_name in &ordered_targets {
match self.plan_crate(crate_name, bump_request, &version_map, &mut inputs, None)? {
CratePlanOutcome::Planned(plan) => {
version_map.insert(crate_name.clone(), plan.new_version.clone());
crate_plans.push(*plan);
}
CratePlanOutcome::Skipped(skip) => skipped.push(skip),
}
}
if matches!(bump_request, BumpRequest::Auto) {
self.apply_version_groups(
&ordered_targets,
&mut crate_plans,
&mut skipped,
&mut version_map,
&mut inputs,
)?;
}
let planned_crates: HashSet<String> = crate_plans.iter().map(|plan| plan.name.clone()).collect();
for plan in &mut crate_plans {
let package_id = self.workspace_package_id(&plan.name)?;
plan.affected_dependents = self
.ctx
.graph()
.direct_dependents_by_id(package_id)?
.into_iter()
.filter(|dependent| planned_crates.contains(dependent))
.collect();
}
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();
let mut selected_change_files: Vec<PathBuf> = crate_plans
.iter()
.flat_map(|plan| plan.change_entries.iter().map(|entry| entry.path.clone()))
.collect();
selected_change_files.sort();
selected_change_files.dedup();
let planned_names: HashSet<&str> = crate_plans.iter().map(|plan| plan.name.as_str()).collect();
let consumption = inputs
.pending
.plan_consumption(&selected_change_files, &planned_names)?;
let change_files_to_update = consumption
.update
.into_iter()
.map(|(path, content)| PlannedChangeFileUpdate { path, content })
.collect();
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let github = detect_github_repo(self.ctx.workspace_root());
for plan in &mut crate_plans {
let overrides = self
.ctx
.config()
.and_then(|config| config.crates.get(&plan.name))
.and_then(|config| config.changelog.as_ref());
if plan.generate_changelog {
let spec = plan
.presentation_policy
.take()
.ok_or_else(|| RailError::message("release plan has no resolved presentation policy"))?;
let links = if self.release_config.source.uses_commits() {
resolve_links(&self.release_config.changelog, overrides, self.ctx.workspace_root())?
} else {
Default::default()
};
let commits = plan.commits.iter().map(PlannedCommit::as_ref).collect::<Vec<_>>();
let highlights = plan.version_group_entry.iter().cloned().collect::<Vec<_>>();
let dependencies = plan
.dependency_updates
.iter()
.map(|update| (update.name.clone(), update.version.clone()))
.collect::<Vec<_>>();
let mut rendered_highlights = highlights;
rendered_highlights.extend(
plan.change_entries
.iter()
.map(|entry| entry.presentation.render(&entry.body)),
);
plan.changelog_body = spec.render(&links, &rendered_highlights, &commits, &dependencies);
}
plan.presentation = Some(presentation::capture(
self.ctx.workspace_root(),
self.release_config,
plan,
&date,
github.as_ref(),
)?);
}
let auxiliary_lockfiles = crate::release::auxiliary::plan_lockfiles(
self.ctx,
&crate_plans,
&self.release_config.auxiliary_cargo_manifests,
)?;
Ok(ReleasePlan {
plan_contract_version: RELEASE_PLAN_CONTRACT_VERSION,
artifacts: super::artifacts::plan(self.release_config, &crate_plans)?,
snapshot_id: self.ctx.snapshot()?.id().to_string(),
source: self.release_config.source,
canonical_crate_order,
crates: crate_plans,
summary,
change_files_to_delete: consumption.delete,
change_files_to_update,
auxiliary_lockfiles,
skipped,
})
}
fn planning_inputs(&self) -> RailResult<PlanningInputs> {
let pending =
if self.release_config.source.uses_changes() || self.release_config.require_change_files.is_enabled() {
PendingChangeSet::load(
self.ctx.workspace_root(),
&self.release_config.change_dir,
self.ctx.graph().workspace_members(),
)?
} else {
PendingChangeSet::default()
};
Ok(PlanningInputs {
pending,
head: self.ctx.git()?.git().head_commit()?,
..Default::default()
})
}
pub fn release_check_insights(&self, crate_names: &[String]) -> RailResult<ReleaseCheckInsights> {
let mut inputs = self.planning_inputs()?;
let mut insights = ReleaseCheckInsights {
shallow_repository: is_shallow_repository(self.ctx.workspace_root())?,
..Default::default()
};
for name in crate_names {
let previous = self.find_previous_tag(name)?;
let overrides = self
.ctx
.config()
.and_then(|config| config.crates.get(name))
.and_then(|config| config.changelog.as_ref());
let has_code = if self.release_config.source.uses_commits() {
let filters = overrides
.and_then(|config| config.filters.as_ref())
.unwrap_or(&self.release_config.changelog.filters);
let history = history_for_range(
&mut inputs.histories,
&CommitAttributor::new(self.ctx),
previous,
&inputs.head,
filters,
)?;
if self.release_config.unconventional_commits != CommitPolicy::Allow
&& !overrides.is_some_and(|config| config.skip)
{
let diagnostics = ChangelogSpec::resolve(&self.release_config.changelog, overrides)?
.diagnose(&history.commit_refs_for_crate(name));
if !diagnostics.is_empty() {
insights.commit_diagnostics.push((name.clone(), diagnostics));
}
}
history.has_code_changes(name)
} else {
self.changed_code_crates(previous, &mut inputs.changed_code)?
.contains(name)
};
if self.release_config.requires_change_file(name) && !inputs.pending.covers(name) && has_code {
insights.missing_change_files.push(name.clone());
}
}
insights.missing_change_files.sort();
Ok(insights)
}
fn changed_code_crates<'b>(
&self,
base: Option<String>,
cache: &'b mut HashMap<Option<String>, FxHashSet<String>>,
) -> RailResult<&'b FxHashSet<String>> {
if !cache.contains_key(&base) {
let crates = changed_code_crate_names(self.ctx, base.as_deref())?;
cache.insert(base.clone(), crates);
}
cache
.get(&base)
.ok_or_else(|| RailError::message("internal error: missing cached release coverage"))
}
fn plan_crate(
&self,
crate_name: &str,
bump_request: &BumpRequest,
version_map: &FxHashMap<String, Version>,
inputs: &mut PlanningInputs,
version_group_force: Option<&VersionGroupForce>,
) -> RailResult<CratePlanOutcome> {
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 previous_tag = self.find_previous_tag(crate_name)?;
let crate_config = self.ctx.config().and_then(|c| c.crates.get(crate_name));
let changelog_config = crate_config.and_then(|c| c.changelog.as_ref());
let changelog_path = self.resolve_changelog_path(&manifest_path, changelog_config)?;
let generate_changelog = !changelog_config.is_some_and(|changelog_cfg| changelog_cfg.skip);
let spec = ChangelogSpec::resolve(&self.release_config.changelog, changelog_config)?;
let (commits, commit_diagnostics, has_code_changes) = if self.release_config.source.uses_commits() {
let filters = changelog_config
.and_then(|config| config.filters.as_ref())
.unwrap_or(&self.release_config.changelog.filters);
let history = history_for_range(
&mut inputs.histories,
&CommitAttributor::new(self.ctx),
previous_tag.clone(),
&inputs.head,
filters,
)?;
let commits = planned_commits(&history.for_crate(crate_name).collect::<Vec<_>>());
let diagnostics = if self.release_config.unconventional_commits == CommitPolicy::Allow {
Vec::new()
} else {
spec.diagnose(&history.commit_refs_for_crate(crate_name))
};
(commits, diagnostics, history.has_code_changes(crate_name))
} else {
(
Vec::new(),
Vec::new(),
self.changed_code_crates(previous_tag.clone(), &mut inputs.changed_code)?
.contains(crate_name),
)
};
if self.release_config.unconventional_commits == CommitPolicy::Deny && !commit_diagnostics.is_empty() {
return Err(RailError::with_help(
format!(
"commit diagnostics failed for {crate_name}: {}",
commit_diagnostics
.iter()
.map(CommitDiagnostic::describe)
.collect::<Vec<_>>()
.join("; ")
),
"rewrite the commit subjects or select release.unconventional_commits = 'warn' or 'allow'",
));
}
if self.release_config.requires_change_file(crate_name)
&& !inputs.pending.covers(crate_name)
&& has_code_changes
{
return Err(RailError::with_help(
format!(
"missing change files for {crate_name}: code changes require {} coverage",
self.release_config.change_dir
),
format!("add reviewed release intent under {}", self.release_config.change_dir),
));
}
let inferred_bump = commits
.iter()
.filter_map(|commit| commit.bump.as_deref().and_then(|bump| bump.parse::<BumpLevel>().ok()))
.max();
let dependency_updates = self.dependency_updates(package, version_map);
let change_entries = if self.release_config.source.uses_changes() {
planned_change_entries(inputs.pending.for_crate(crate_name))
} else {
Vec::new()
};
let change_bump = change_entries
.iter()
.filter_map(|entry| entry.bump.release_level())
.max();
let publish_from_cargo = crate::workspace::CargoState::package_allows_registry(package, RELEASE_REGISTRY);
let publish = publish_from_cargo
&& crate_config
.and_then(|c| c.release.as_ref())
.map(|r| r.publish)
.unwrap_or(true);
let explicit_signal = [change_bump, inferred_bump].into_iter().flatten().max();
let natural_level = explicit_signal.or_else(|| (!dependency_updates.is_empty()).then_some(BumpLevel::Patch));
let (bump_type, bump_reason, planned_auto_level) = match bump_request {
BumpRequest::Explicit(bump_type) => (bump_type.clone(), "explicit".to_string(), None),
BumpRequest::Auto => {
let (level, mut reason) = if let Some(force) = version_group_force {
(force.level, format!("version group {} -> {}", force.name, force.level))
} else if let Some(level) = natural_level {
(
level,
auto_bump_reason(level, change_bump, inferred_bump, !dependency_updates.is_empty()),
)
} else {
let since = previous_tag
.as_deref()
.map(|tag| format!("since {}", tag))
.unwrap_or_else(|| "with no previous tag: full history".to_string());
return Ok(CratePlanOutcome::Skipped(SkippedCrate {
name: crate_name.to_string(),
reason: format!(
"auto: no release-worthy changes {} ({})",
since,
no_release_signal_reason(self.release_config.source)
),
}));
};
if previous_tag.is_none() && version_group_force.is_none() {
reason = format!("{} (no previous tag: full history)", reason);
}
(
level.to_bump_type(¤t_version, self.release_config.pre_1_breaking_bump),
reason,
Some(level),
)
}
};
let new_version = bump_type.apply(¤t_version);
let tag_name = self.format_tag(crate_name, &new_version);
let version_group_entry = version_group_force.and_then(|force| {
natural_level
.is_none()
.then(|| format!("- synchronized version with version group {}.", force.name))
});
let bump = format!("{} -> {}", current_version, new_version);
let publish_intent = if publish {
"publish_to_crates_io".to_string()
} else {
"skip_publish".to_string()
};
let api_evidence = semver_checks::assess(
self.ctx,
crate_name,
publish,
previous_tag.as_deref(),
self.release_config.semver_check,
explicit_signal,
)?;
Ok(CratePlanOutcome::Planned(Box::new(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: inputs.head.clone(),
publish,
publish_intent,
api_evidence,
generate_changelog,
bump,
bump_reason,
changelog_body: String::new(),
presentation: None,
commits,
commit_diagnostics,
presentation_policy: Some(spec),
dependency_updates,
change_entries,
version_group: version_group_force.map(|force| force.name.clone()),
affected_dependents: Vec::new(),
auto_bump_level: planned_auto_level,
version_group_entry,
})))
}
fn resolve_changelog_path(
&self,
manifest_path: &std::path::Path,
changelog_config: Option<&ChangelogConfig>,
) -> RailResult<PathBuf> {
let changelog_relative_path = changelog_config
.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 relative_to = changelog_config
.and_then(|ch| ch.relative_to)
.unwrap_or(self.release_config.changelog.relative_to);
match relative_to {
ChangelogRelativeTo::Crate => Ok(manifest_path
.parent()
.ok_or_else(|| RailError::message("Invalid manifest path"))?
.join(&changelog_relative_path)),
ChangelogRelativeTo::Workspace => Ok(self.ctx.workspace_root().join(&changelog_relative_path)),
}
}
fn apply_version_groups(
&self,
ordered_targets: &[String],
crate_plans: &mut Vec<CrateReleasePlan>,
skipped: &mut Vec<SkippedCrate>,
version_map: &mut FxHashMap<String, Version>,
inputs: &mut PlanningInputs,
) -> RailResult<()> {
if self.release_config.version_groups.is_empty() {
return Ok(());
}
let mut group_levels: HashMap<String, BumpLevel> = HashMap::new();
for plan in crate_plans.iter() {
if let Some((group_name, _)) = self.version_group_for(&plan.name)
&& let Some(level) = plan.auto_bump_level
{
group_levels
.entry(group_name.to_string())
.and_modify(|current| *current = (*current).max(level))
.or_insert(level);
}
}
if group_levels.is_empty() {
return Ok(());
}
let forced: Vec<(String, VersionGroupForce)> = skipped
.iter()
.filter_map(|skip| {
let (group_name, _) = self.version_group_for(&skip.name)?;
let level = *group_levels.get(group_name)?;
Some((
skip.name.clone(),
VersionGroupForce {
name: group_name.to_string(),
level,
},
))
})
.collect();
let forced_names: HashSet<&str> = forced.iter().map(|(name, _)| name.as_str()).collect();
skipped.retain(|skip| !forced_names.contains(skip.name.as_str()));
for (crate_name, force) in forced {
match self.plan_crate(&crate_name, &BumpRequest::Auto, version_map, inputs, Some(&force))? {
CratePlanOutcome::Planned(plan) => {
version_map.insert(crate_name, plan.new_version.clone());
crate_plans.push(*plan);
}
CratePlanOutcome::Skipped(_) => {}
}
}
for plan in crate_plans.iter_mut() {
if plan.version_group.is_some() {
continue;
}
let Some((group_name, _)) = self.version_group_for(&plan.name) else {
continue;
};
let Some(level) = group_levels.get(group_name).copied() else {
continue;
};
let current_level = plan.auto_bump_level.unwrap_or(level);
if level > current_level {
let bump_type = level.to_bump_type(&plan.current_version, self.release_config.pre_1_breaking_bump);
plan.new_version = bump_type.apply(&plan.current_version);
plan.bump = format!("{} -> {}", plan.current_version, plan.new_version);
plan.tag_name = self.format_tag(&plan.name, &plan.new_version);
plan.auto_bump_level = Some(level);
}
plan.bump_reason = format!("{}; version group {} -> {}", plan.bump_reason, group_name, level);
plan.version_group = Some(group_name.to_string());
}
let order: HashMap<&str, usize> = ordered_targets
.iter()
.enumerate()
.map(|(idx, name)| (name.as_str(), idx))
.collect();
crate_plans.sort_by_key(|plan| order.get(plan.name.as_str()).copied().unwrap_or(usize::MAX));
version_map.clear();
for plan in crate_plans.iter() {
version_map.insert(plan.name.clone(), plan.new_version.clone());
}
self.refresh_dependency_updates(crate_plans, version_map)
}
fn refresh_dependency_updates(
&self,
crate_plans: &mut [CrateReleasePlan],
version_map: &FxHashMap<String, Version>,
) -> RailResult<()> {
for plan in crate_plans {
let package = self
.ctx
.cargo()
.get_package(&plan.name)
.ok_or_else(|| RailError::message(format!("Crate '{}' not found", plan.name)))?;
let dependency_updates = self.dependency_updates(package, version_map);
if dependency_updates == plan.dependency_updates {
continue;
}
plan.dependency_updates = dependency_updates;
}
Ok(())
}
fn dependency_updates(
&self,
package: &cargo_metadata::Package,
version_map: &FxHashMap<String, Version>,
) -> Vec<DependencyUpdate> {
let mut updates: Vec<_> = package
.dependencies
.iter()
.filter_map(|dep| {
version_map.get(dep.name.as_str()).map(|version| DependencyUpdate {
name: dep.name.clone(),
version: version.clone(),
})
})
.collect();
updates.sort_by(|a, b| a.name.cmp(&b.name));
updates.dedup_by(|a, b| a.name == b.name);
updates
}
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 {
const VERSION_PLACEHOLDER: &str = "\u{7b}version\u{7d}";
self.release_config
.tag_format
.replace("{prefix}", &self.release_config.tag_prefix)
.replace("{crate}", crate_name)
.replace(VERSION_PLACEHOLDER, &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)
}
pub(crate) fn resolve_targets(
&self,
crate_names: Option<Vec<String>>,
dependent_policy: DependentPolicy,
) -> RailResult<Vec<String>> {
let all_ordered = self.ctx.graph().publish_order()?;
let Some(targets) = crate_names else {
return Ok(all_ordered);
};
for crate_name in &targets {
self.workspace_package_id(crate_name)?;
}
let requested: HashSet<String> = targets.into_iter().collect();
if dependent_policy == DependentPolicy::RejectPartialClosure {
self.reject_partial_version_groups(&requested)?;
}
let requested_ids = self.workspace_package_ids(&requested)?;
let dependents = self.ctx.graph().transitive_dependents_of_ids(&requested_ids)?;
if !dependents.is_empty() && dependent_policy == DependentPolicy::RejectPartialClosure {
let mut missing: Vec<String> = dependents.into_iter().collect();
missing.sort();
return Err(RailError::with_help(
format!(
"partial release would leave dependent crate(s) out of sync: {}",
missing.join(", ")
),
"re-run with --include-dependents or release the full dependent closure",
));
}
if dependent_policy == DependentPolicy::IncludeDependents {
let mut selected = requested;
loop {
let before = selected.len();
self.expand_selected_version_groups(&mut selected);
let selected_ids = self.workspace_package_ids(&selected)?;
let dependents = self.ctx.graph().transitive_dependents_of_ids(&selected_ids)?;
selected.extend(dependents);
self.expand_selected_version_groups(&mut selected);
if selected.len() == before {
break;
}
}
return Ok(all_ordered.into_iter().filter(|name| selected.contains(name)).collect());
}
Ok(all_ordered
.into_iter()
.filter(|name| requested.contains(name))
.collect())
}
fn workspace_package_id(&self, crate_name: &str) -> RailResult<&PackageId> {
self.ctx
.graph()
.workspace_package_by_name(crate_name)
.map(|package| &package.id)
}
fn workspace_package_ids(&self, crate_names: &HashSet<String>) -> RailResult<HashSet<PackageId>> {
crate_names
.iter()
.map(|crate_name| self.workspace_package_id(crate_name).cloned())
.collect()
}
fn version_group_for(&self, crate_name: &str) -> Option<(&str, &[String])> {
self.release_config
.version_groups
.iter()
.find(|(_, members)| members.iter().any(|member| member == crate_name))
.map(|(name, members)| (name.as_str(), members.as_slice()))
}
fn reject_partial_version_groups(&self, selected: &HashSet<String>) -> RailResult<()> {
for (group_name, members) in &self.release_config.version_groups {
let selected_count = members.iter().filter(|member| selected.contains(*member)).count();
if selected_count == 0 || selected_count == members.len() {
continue;
}
let mut missing: Vec<_> = members
.iter()
.filter(|member| !selected.contains(*member))
.cloned()
.collect();
missing.sort();
return Err(RailError::with_help(
format!(
"partial release would leave version group '{}' out of sync: missing {}",
group_name,
missing.join(", ")
),
"re-run with --include-dependents or release every member of the version group",
));
}
Ok(())
}
fn expand_selected_version_groups(&self, selected: &mut HashSet<String>) {
let touched_groups: Vec<_> = self
.release_config
.version_groups
.iter()
.filter(|(_, members)| members.iter().any(|member| selected.contains(member)))
.map(|(_, members)| members.clone())
.collect();
for members in touched_groups {
selected.extend(members);
}
}
fn validate_touched_version_groups(&self, ordered_targets: &[String]) -> RailResult<()> {
let selected: HashSet<&str> = ordered_targets.iter().map(String::as_str).collect();
for (group_name, members) in &self.release_config.version_groups {
if !members.iter().any(|member| selected.contains(member.as_str())) {
continue;
}
let mut versions: Vec<_> = members
.iter()
.filter_map(|member| {
self.ctx
.cargo()
.get_package(member)
.map(|package| (member.as_str(), package.version.clone()))
})
.collect();
versions.sort_by(|a, b| a.0.cmp(b.0));
let Some((_, first_version)) = versions.first() else {
continue;
};
if versions.iter().all(|(_, version)| version == first_version) {
continue;
}
let detail = versions
.iter()
.map(|(name, version)| format!("{}={}", name, version))
.collect::<Vec<_>>()
.join(", ");
return Err(RailError::with_help(
format!(
"version group '{}' has mismatched current versions: {}",
group_name, detail
),
"align member versions before enabling lockstep releases",
));
}
Ok(())
}
}
pub(crate) fn is_shallow_repository(workspace_root: &Path) -> RailResult<bool> {
let output = process::run("git", &["rev-parse", "--is-shallow-repository"], Some(workspace_root))?;
if !output.status.success() {
return Err(RailError::message("failed to determine whether repository is shallow"));
}
Ok(String::from_utf8_lossy(&output.stdout).trim() == "true")
}
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
));
output.push_str(&format!(" Bump: {} ({})\n", crate_plan.bump, crate_plan.bump_reason));
output.push_str(&format!(
" API: {:?}: {}\n",
crate_plan.api_evidence.outcome, crate_plan.api_evidence.detail
));
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 {
"✗ (not authorized; pass --publish)".to_string()
} else if crate_plan.publish {
"✓".to_string()
} else {
"✗ (publish = false)".to_string()
};
output.push_str(&format!(" Publish: {}\n", publish_status));
if let Some(artifact) = self
.artifacts
.iter()
.find(|artifact| artifact.package == crate_plan.name)
{
output.push_str(&format!(" Asset producer: {}\n", artifact.workflow));
for asset in &artifact.files {
output.push_str(&format!(" {}", asset.name));
if let Some(target) = &asset.target {
output.push_str(&format!(" (target: {target})"));
}
if let Some(source) = &asset.source {
output.push_str(&format!(" (committed source: {source})"));
}
output.push('\n');
}
}
if !crate_plan.affected_dependents.is_empty() {
output.push_str(&format!(" Affects: {}\n", crate_plan.affected_dependents.join(", ")));
}
output.push('\n');
}
if !self.auxiliary_lockfiles.is_empty() {
output.push_str("Auxiliary Cargo lockfiles:\n");
for projection in &self.auxiliary_lockfiles {
output.push_str(&format!(
" {} — {}\n",
projection.lockfile_path.display(),
projection.manifest_path.display()
));
}
output.push('\n');
}
if !self.skipped.is_empty() {
output.push_str("Skipped:\n");
for skip in &self.skipped {
output.push_str(&format!(" {} — {}\n", skip.name, skip.reason));
}
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), {} skipped\n",
self.summary.total_crates,
effective_publish_count,
effective_tag_count,
self.skipped.len()
));
output
}
pub fn format_summary(&self) -> String {
self.format_summary_with_flags(false, false)
}
}
fn planned_change_entries(intents: &[ChangeIntent]) -> Vec<PlannedChangeEntry> {
intents
.iter()
.map(|intent| PlannedChangeEntry {
path: intent.path.clone(),
bump: intent.bump,
body: intent.body.clone(),
presentation: intent.presentation.clone(),
})
.collect()
}
fn auto_bump_reason(
level: BumpLevel,
change_bump: Option<BumpLevel>,
inferred_bump: Option<BumpLevel>,
has_dependency_updates: bool,
) -> String {
if change_bump.is_some() {
format!("auto: reviewed change files -> {}", level)
} else if inferred_bump.is_some() {
format!("auto: conventional commits -> {}", level)
} else if has_dependency_updates {
"auto: dependent closure -> patch".to_string()
} else {
format!("auto: {}", level)
}
}
fn no_release_signal_reason(source: ReleaseSource) -> &'static str {
match source {
ReleaseSource::Changes => "no reviewed release intent or dependency updates",
ReleaseSource::Commits => "no qualifying conventional commits or dependency updates",
ReleaseSource::Both => "no qualifying reviewed intent, conventional commits, or dependency updates",
}
}
fn history_for_range<'a>(
histories: &'a mut HashMap<HistoryKey, AttributedHistory>,
attributor: &CommitAttributor<'_>,
from: Option<String>,
to: &str,
filters: &ChangelogFilters,
) -> RailResult<&'a AttributedHistory> {
let key = HistoryKey::new(from, filters);
if !histories.contains_key(&key) {
let history = attributor.history_with_filters(key.from.as_deref(), to, Some(filters))?;
histories.insert(key.clone(), history);
}
histories
.get(&key)
.ok_or_else(|| RailError::message("missing captured release history"))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct HistoryKey {
from: Option<String>,
include_paths: Vec<String>,
exclude_paths: Vec<String>,
}
impl HistoryKey {
fn new(from: Option<String>, filters: &ChangelogFilters) -> Self {
let (include_paths, exclude_paths) = (filters.include_paths.clone(), filters.exclude_paths.clone());
Self {
from,
include_paths,
exclude_paths,
}
}
}
fn planned_commits(commits: &[&AttributedCommit]) -> Vec<PlannedCommit> {
commits
.iter()
.map(|commit| {
let parsed = crate::release::changelog::parse_subject(&commit.subject, commit.body.as_deref());
let bump = commit_bump_level(&parsed).map(|level| level.as_str().to_string());
PlannedCommit {
sha: commit.sha.clone(),
subject: commit.subject.clone(),
body: commit.body.clone(),
commit_type: parsed.commit_type.map(str::to_string),
scope: parsed.scope.map(str::to_string),
breaking: parsed.breaking,
bump,
}
})
.collect()
}
impl PlannedCommit {
fn as_ref(&self) -> CommitRef<'_> {
CommitRef {
sha: &self.sha,
subject: &self.subject,
body: self.body.as_deref(),
}
}
}