use crate::error::{RailError, RailResult};
use crate::release::version::BumpLevel;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
pub const DEFAULT_CHANGE_DIR: &str = ".changes";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeBump {
None,
Patch,
Minor,
Major,
}
impl ChangeBump {
pub const fn release_level(self) -> Option<BumpLevel> {
match self {
Self::None => None,
Self::Patch => Some(BumpLevel::Patch),
Self::Minor => Some(BumpLevel::Minor),
Self::Major => Some(BumpLevel::Major),
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Patch => "patch",
Self::Minor => "minor",
Self::Major => "major",
}
}
}
impl std::str::FromStr for ChangeBump {
type Err = RailError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"none" | "no-release" => Ok(Self::None),
"patch" => Ok(Self::Patch),
"minor" => Ok(Self::Minor),
"major" => Ok(Self::Major),
_ => Err(RailError::with_help(
format!("invalid change intent: {}", value),
"use 'none', 'patch', 'minor', or 'major'",
)),
}
}
}
impl std::fmt::Display for ChangeBump {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChangePresentation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default)]
pub format: ChangeFormat,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeFormat {
#[default]
List,
Markdown,
}
impl ChangePresentation {
pub(crate) fn render(&self, body: &str) -> String {
match self.format {
ChangeFormat::List => render_change_body(body),
ChangeFormat::Markdown => body.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ChangeIntent {
pub path: PathBuf,
pub crate_name: String,
pub bump: ChangeBump,
pub body: String,
pub presentation: ChangePresentation,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChangeFile {
pub path: PathBuf,
pub intents: Vec<ChangeIntent>,
pub body: String,
pub presentation: ChangePresentation,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrateChangeSummary {
pub crate_name: String,
pub bump: ChangeBump,
pub file_count: usize,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct PendingChangeSet {
pub files: Vec<ChangeFile>,
by_crate: FxHashMap<String, Vec<ChangeIntent>>,
}
pub(crate) struct ChangeFileConsumption {
pub delete: Vec<PathBuf>,
pub update: Vec<(PathBuf, String)>,
}
impl PendingChangeSet {
pub fn load(workspace_root: &Path, change_dir: &str, workspace_members: &[String]) -> RailResult<Self> {
validate_change_dir_value(change_dir)?;
let dir = workspace_root.join(change_dir);
if !dir.exists() {
return Ok(Self::default());
}
let member_set: FxHashSet<&str> = workspace_members.iter().map(String::as_str).collect();
let mut paths = Vec::new();
for entry in
fs::read_dir(&dir).map_err(|e| RailError::message(format!("failed to read {}: {}", dir.display(), e)))?
{
let entry = entry.map_err(|e| RailError::message(format!("failed to read {}: {}", dir.display(), e)))?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "md") {
paths.push(path);
}
}
paths.sort();
let mut files = Vec::with_capacity(paths.len());
let mut by_crate: FxHashMap<String, Vec<ChangeIntent>> = FxHashMap::default();
for path in paths {
let content = fs::read_to_string(&path)
.map_err(|e| RailError::message(format!("failed to read {}: {}", path.display(), e)))?;
let file = parse_change_file(&path, &content, &member_set)?;
for intent in &file.intents {
by_crate
.entry(intent.crate_name.clone())
.or_default()
.push(intent.clone());
}
files.push(file);
}
Ok(Self { files, by_crate })
}
pub fn for_crate(&self, crate_name: &str) -> &[ChangeIntent] {
self.by_crate.get(crate_name).map(Vec::as_slice).unwrap_or(&[])
}
pub fn covers(&self, crate_name: &str) -> bool {
self.by_crate.contains_key(crate_name)
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn crate_summaries(&self) -> Vec<CrateChangeSummary> {
let mut summaries: Vec<_> = self
.by_crate
.iter()
.filter_map(|(crate_name, intents)| {
intents
.iter()
.map(|intent| intent.bump)
.max()
.map(|bump| CrateChangeSummary {
crate_name: crate_name.clone(),
bump,
file_count: intents.len(),
})
})
.collect();
summaries.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
summaries
}
pub(crate) fn plan_consumption(
&self,
consumed: &[PathBuf],
planned: &std::collections::HashSet<&str>,
) -> RailResult<ChangeFileConsumption> {
let mut partial: Vec<String> = Vec::new();
let mut delete = Vec::new();
let mut update = Vec::new();
for file in &self.files {
if !consumed.contains(&file.path) {
continue;
}
let mut missing: Vec<&str> = file
.intents
.iter()
.filter(|intent| intent.bump.release_level().is_some())
.map(|intent| intent.crate_name.as_str())
.filter(|name| !planned.contains(name))
.collect();
if !missing.is_empty() {
missing.sort_unstable();
partial.push(format!("{} (missing: {})", file.path.display(), missing.join(", ")));
continue;
}
let retained: BTreeMap<_, _> = file
.intents
.iter()
.filter(|intent| !planned.contains(intent.crate_name.as_str()))
.map(|intent| (intent.crate_name.clone(), intent.bump))
.collect();
if retained.is_empty() {
delete.push(file.path.clone());
} else {
update.push((
file.path.clone(),
render_change_content_with_presentation(&retained, &file.body, &file.presentation),
));
}
}
if partial.is_empty() {
return Ok(ChangeFileConsumption { delete, update });
}
Err(RailError::with_help(
format!(
"release would consume change file(s) naming crates outside this plan:\n {}",
partial.join("\n ")
),
"release every release-worthy crate the change file names together, or split it into one file per release group",
))
}
}
pub fn write_change_file(
workspace_root: &Path,
change_dir: &str,
workspace_members: &[String],
intents: &BTreeMap<String, ChangeBump>,
body: &str,
name_override: Option<&str>,
) -> RailResult<ChangeFile> {
validate_change_dir_value(change_dir)?;
let member_set: FxHashSet<&str> = workspace_members.iter().map(String::as_str).collect();
validate_intents(intents, &member_set)?;
let body = body.trim();
if body.is_empty() {
return Err(RailError::message("change file body cannot be empty"));
}
let content = render_change_content(intents, body);
let slug = match name_override {
Some(name) => slugify_name(name)?,
None => slugify_message(body),
};
let hash = hash4(&content);
let dir = workspace_root.join(change_dir);
fs::create_dir_all(&dir).map_err(|e| RailError::message(format!("failed to create {}: {}", dir.display(), e)))?;
let path = dir.join(format!("{}-{}.md", slug, hash));
if path.exists() {
let existing = fs::read_to_string(&path)
.map_err(|e| RailError::message(format!("failed to read {}: {}", path.display(), e)))?;
if existing == content {
return Err(RailError::message(format!(
"change file already exists: {}",
path.display()
)));
}
return Err(RailError::with_help(
format!("change file name collision: {}", path.display()),
"choose a different --name so cargo-rail does not overwrite unrelated release intent",
));
}
fs::write(&path, &content).map_err(|e| RailError::message(format!("failed to write {}: {}", path.display(), e)))?;
parse_change_file(&path, &content, &member_set)
}
pub fn render_change_content(intents: &BTreeMap<String, ChangeBump>, body: &str) -> String {
render_change_content_with_presentation(intents, body, &ChangePresentation::default())
}
fn render_change_content_with_presentation(
intents: &BTreeMap<String, ChangeBump>,
body: &str,
presentation: &ChangePresentation,
) -> String {
let mut content = String::from("---\n");
for (crate_name, bump) in intents {
content.push_str(&format!("\"{}\" = \"{}\"\n", crate_name, bump.as_str()));
}
if *presentation != ChangePresentation::default() {
content.push_str("\n[\"$release\"]\n");
if let Some(category) = &presentation.category {
content.push_str(&format!("category = {}\n", toml_edit::Value::from(category.as_str())));
}
if presentation.format == ChangeFormat::Markdown {
content.push_str("format = \"markdown\"\n");
}
}
content.push_str("---\n\n");
content.push_str(body.trim());
content.push('\n');
content
}
pub(crate) fn parse_change_file(
path: &Path,
content: &str,
workspace_members: &FxHashSet<&str>,
) -> RailResult<ChangeFile> {
let Some(rest) = content.strip_prefix("---\n") else {
return Err(RailError::with_help(
format!("invalid change file {}: missing TOML frontmatter", path.display()),
"start the file with --- followed by crate bump entries",
));
};
let Some((frontmatter, body)) = rest.split_once("\n---\n") else {
return Err(RailError::with_help(
format!("invalid change file {}: unterminated TOML frontmatter", path.display()),
"close the frontmatter with --- on its own line",
));
};
let body = body.trim();
if body.is_empty() {
return Err(RailError::message(format!(
"invalid change file {}: body cannot be empty",
path.display()
)));
}
#[derive(Deserialize)]
#[serde(untagged)]
enum FrontmatterValue {
Bump(String),
Presentation(ChangePresentation),
}
let mut raw: BTreeMap<String, FrontmatterValue> = toml_edit::de::from_str(frontmatter)
.map_err(|e| RailError::message(format!("invalid change file {} frontmatter: {}", path.display(), e)))?;
let presentation = match raw.remove("$release") {
None => ChangePresentation::default(),
Some(FrontmatterValue::Presentation(value)) => value,
Some(_) => {
return Err(RailError::message(format!(
"invalid change file {}: $release must be a presentation table",
path.display()
)));
}
};
if let Some(category) = &presentation.category {
crate::release::changelog::validate_label(category, "change category")?;
}
crate::release::presentation::validate_entry(&presentation.render(body))
.map_err(|error| error.context(format!("invalid change file {}", path.display())))?;
if raw.is_empty() {
return Err(RailError::message(format!(
"invalid change file {}: frontmatter must name at least one crate",
path.display()
)));
}
let mut intents = Vec::with_capacity(raw.len());
for (crate_name, bump) in raw {
if !workspace_members.contains(crate_name.as_str()) {
return Err(RailError::with_help(
format!("invalid change file {}: unknown crate '{}'", path.display(), crate_name),
"use a workspace crate name in the frontmatter",
));
}
intents.push(ChangeIntent {
path: path.to_path_buf(),
crate_name: crate_name.clone(),
bump: match bump {
FrontmatterValue::Bump(bump) => bump.parse()?,
FrontmatterValue::Presentation(_) => {
return Err(RailError::message(format!(
"invalid change file {}: crate '{crate_name}' requires a bump string",
path.display()
)));
}
},
body: body.to_string(),
presentation: presentation.clone(),
});
}
intents.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
Ok(ChangeFile {
path: path.to_path_buf(),
intents,
body: body.to_string(),
presentation,
})
}
fn validate_intents(intents: &BTreeMap<String, ChangeBump>, workspace_members: &FxHashSet<&str>) -> RailResult<()> {
if intents.is_empty() {
return Err(RailError::with_help(
"change add requires at least one crate",
"usage: cargo rail change add <CRATE...> --bump <none|patch|minor|major> --message \"reviewed intent\"",
));
}
let mut unknown = Vec::new();
for crate_name in intents.keys() {
if !workspace_members.contains(crate_name.as_str()) {
unknown.push(crate_name.as_str());
}
}
if unknown.is_empty() {
return Ok(());
}
unknown.sort_unstable();
let mut available: Vec<_> = workspace_members.iter().copied().collect();
available.sort_unstable();
Err(RailError::with_help(
format!("unknown crate(s): {}", unknown.join(", ")),
format!("available: {}", available.join(", ")),
))
}
pub(crate) fn change_dir_validation_error(change_dir: &str) -> Option<&'static str> {
let path = Path::new(change_dir);
if change_dir.trim().is_empty() {
return Some("change_dir cannot be empty");
}
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Some("change_dir must be a workspace-relative path");
}
None
}
fn validate_change_dir_value(change_dir: &str) -> RailResult<()> {
let Some(reason) = change_dir_validation_error(change_dir) else {
return Ok(());
};
Err(RailError::message(format!("invalid release.change_dir: {}", reason)))
}
fn slugify_message(value: &str) -> String {
let words = ascii_words(value).take(4);
truncate_slug(words.collect::<Vec<_>>().join("-"))
}
fn slugify_name(value: &str) -> RailResult<String> {
let slug = truncate_slug(ascii_words(value).collect::<Vec<_>>().join("-"));
if slug == "change" && value.chars().all(|c| !c.is_ascii_alphanumeric()) {
return Err(RailError::with_help(
"change file --name must contain at least one ASCII letter or digit",
"use a short kebab-case name such as --name fix-wasm-parser",
));
}
Ok(slug)
}
fn ascii_words(value: &str) -> impl Iterator<Item = String> + '_ {
value
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.map(|part| part.to_ascii_lowercase())
}
fn truncate_slug(mut slug: String) -> String {
if slug.is_empty() {
return "change".to_string();
}
if slug.len() > 32 {
slug.truncate(32);
}
while slug.ends_with('-') {
slug.pop();
}
if slug.is_empty() { "change".to_string() } else { slug }
}
fn hash4(content: &str) -> String {
let hash = crate::utils::fnv1a64(content.as_bytes());
format!("{:04x}", hash & 0xffff)
}
pub fn render_change_body(body: &str) -> String {
let trimmed = body.trim();
if trimmed.starts_with('-') {
trimmed.to_string()
} else {
format!("- {}", trimmed.replace('\n', "\n "))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn members() -> Vec<String> {
vec!["rail-core".to_string(), "rail-cli".to_string()]
}
#[test]
fn parses_change_file_frontmatter() {
let path = PathBuf::from(".changes/add-auto.md");
let content = r#"---
"rail-core" = "minor"
"rail-cli" = "patch"
---
Added auto bump planning.
"#;
let members = members();
let member_set: FxHashSet<&str> = members.iter().map(String::as_str).collect();
let file = parse_change_file(&path, content, &member_set).unwrap();
assert_eq!(file.intents.len(), 2);
assert_eq!(file.intents[0].crate_name, "rail-cli");
assert_eq!(file.intents[0].bump, ChangeBump::Patch);
assert_eq!(file.intents[1].bump, ChangeBump::Minor);
assert_eq!(file.body, "Added auto bump planning.");
}
#[test]
fn parses_explicit_no_release_intent() {
let members = members();
let member_set: FxHashSet<&str> = members.iter().map(String::as_str).collect();
let file = parse_change_file(
Path::new(".changes/no-release.md"),
"---\n\"rail-core\" = \"none\"\n---\n\nInternal refactor with no shipped behavior change.\n",
&member_set,
)
.unwrap();
assert_eq!(file.intents[0].bump, ChangeBump::None);
assert_eq!(file.intents[0].bump.release_level(), None);
}
#[test]
fn rejects_unknown_crate() {
let path = PathBuf::from(".changes/bad.md");
let content = "---\nghost = \"patch\"\n---\n\nMessage\n";
let members = members();
let member_set: FxHashSet<&str> = members.iter().map(String::as_str).collect();
let err = parse_change_file(&path, content, &member_set).unwrap_err();
assert!(err.to_string().contains("unknown crate 'ghost'"));
}
#[test]
fn renders_body_as_entry() {
assert_eq!(render_change_body("Added a feature."), "- Added a feature.");
assert_eq!(render_change_body("- Already listed."), "- Already listed.");
}
#[test]
fn slug_uses_first_four_words_and_truncates() {
assert_eq!(
slugify_message("Added --bump auto for workspace releases"),
"added-bump-auto-for"
);
assert_eq!(
slugify_message("Supercalifragilisticexpialidocious parser cleanup"),
"supercalifragilisticexpialidocio"
);
assert_eq!(slugify_message("!!!"), "change");
}
#[test]
fn name_override_is_sanitized_and_truncated() {
assert_eq!(
slugify_name("Fixed ECDSA oracle compatibility with p256 0.14").unwrap(),
"fixed-ecdsa-oracle-compatibility"
);
slugify_name("../").unwrap_err();
}
#[test]
fn content_hash_is_stable() {
let mut intents = BTreeMap::new();
intents.insert("rail-core".to_string(), ChangeBump::Minor);
let content = render_change_content(&intents, "Added reviewed release intent.");
assert_eq!(hash4(&content), hash4(&content));
assert_ne!(hash4(&content), hash4(&content.replace("Added", "Changed")));
}
}