pub mod parse;
mod render;
pub use parse::{ParsedSubject, parse_subject, suggest_type};
pub use render::{EntryTemplate, LinkTemplates};
use crate::config::{ChangelogConfig, ChangelogShape, GroupSpec};
use crate::error::{ConfigError, RailError, RailResult};
use rustc_hash::{FxHashMap, FxHashSet};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::Path;
pub(crate) fn validate_label(value: &str, field: &str) -> RailResult<()> {
let valid = !value.trim().is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ' '));
if valid {
Ok(())
} else {
Err(RailError::message(format!(
"{field} must contain only letters, numbers, spaces, '-' or '_'"
)))
}
}
const BREAKING_TYPE: &str = "breaking";
const DEPS_TYPE: &str = "deps";
const BUILTIN_TYPES: &[(&str, &str, &str)] = &[
("breaking", "BREAKING CHANGES", "⚠️"),
("feat", "Features", "✨"),
("fix", "Bug Fixes", "🐛"),
("perf", "Performance", "⚡"),
("docs", "Documentation", "📝"),
("chore", "Chores", "🔧"),
("refactor", "Refactoring", "♻️"),
("style", "Styling", "💄"),
("test", "Testing", "✅"),
("build", "Build", "🏗️"),
("ci", "CI", "👷"),
("deps", "Dependencies", "📦"),
("other", "Other Changes", "📦"),
];
#[derive(Debug, Clone)]
struct GroupDef {
title: String,
emoji: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct CommitRef<'a> {
pub sha: &'a str,
pub subject: &'a str,
pub body: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommitDiagnostic {
pub sha: String,
pub subject: String,
pub issue: CommitIssue,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommitIssue {
NotConventional,
UnknownType {
found: String,
suggestion: Option<String>,
},
}
impl CommitDiagnostic {
pub fn describe(&self) -> String {
match &self.issue {
CommitIssue::NotConventional => {
format!("{}: not a conventional commit: \"{}\"", self.sha, self.subject)
}
CommitIssue::UnknownType { found, suggestion } => match suggestion {
Some(known) => format!(
"{}: unknown commit type '{}' (did you mean '{}'?): \"{}\"",
self.sha, found, known, self.subject
),
None => format!("{}: unknown commit type '{}': \"{}\"", self.sha, found, self.subject),
},
}
}
}
#[derive(Debug, Clone)]
pub struct ChangelogSpec {
groups: Vec<GroupDef>,
type_to_group: FxHashMap<Box<str>, usize>,
fallback: Option<usize>,
template: EntryTemplate,
emoji: bool,
skip_types: FxHashSet<Box<str>>,
skip_scopes: FxHashSet<Box<str>>,
known_types: Vec<Box<str>>,
}
impl ChangelogSpec {
pub fn resolve(shape: &ChangelogShape, overrides: Option<&ChangelogConfig>) -> RailResult<Self> {
let entry_format = overrides
.and_then(|o| o.entry_format.as_deref())
.unwrap_or(&shape.entry_format);
let emoji = overrides.and_then(|o| o.emoji).unwrap_or(shape.emoji);
let group_order = overrides
.and_then(|o| o.group_order.as_deref())
.unwrap_or(&shape.group_order);
let fallback = overrides.and_then(|o| o.fallback.as_deref()).unwrap_or(&shape.fallback);
let filters = overrides.and_then(|o| o.filters.as_ref()).unwrap_or(&shape.filters);
let mut definitions: Vec<GroupDef> = Vec::with_capacity(BUILTIN_TYPES.len() + shape.groups.len());
let mut type_to_def: FxHashMap<Box<str>, usize> = FxHashMap::default();
for (commit_type, title, emoji_str) in BUILTIN_TYPES {
type_to_def.insert(Box::from(*commit_type), definitions.len());
definitions.push(GroupDef {
title: (*title).to_string(),
emoji: Some((*emoji_str).to_string()),
});
}
let crate_groups = overrides.map(|o| o.groups.as_slice()).unwrap_or_default();
for spec in shape.groups.iter().chain(crate_groups) {
Self::add_custom_group(spec, &mut definitions, &mut type_to_def)?;
}
let mut groups: Vec<GroupDef> = Vec::with_capacity(group_order.len());
let mut def_to_group: FxHashMap<usize, usize> = FxHashMap::default();
for type_key in group_order {
let Some(&def_idx) = type_to_def.get(type_key.as_str()) else {
return Err(RailError::Config(ConfigError::InvalidField {
field: "release.changelog.group_order".to_string(),
reason: format!(
"unknown commit type '{}'; define it with [[release.changelog.groups]] (types = [\"{}\"])",
type_key, type_key
),
}));
};
def_to_group.entry(def_idx).or_insert_with(|| {
groups.push(definitions[def_idx].clone());
groups.len() - 1
});
}
let mut type_to_group: FxHashMap<Box<str>, usize> = FxHashMap::default();
for (type_key, def_idx) in &type_to_def {
if let Some(&group_idx) = def_to_group.get(def_idx) {
type_to_group.insert(type_key.clone(), group_idx);
}
}
let fallback = match fallback {
"skip" => None,
key => Some(*type_to_group.get(key).ok_or_else(|| {
RailError::Config(ConfigError::InvalidField {
field: "release.changelog.fallback".to_string(),
reason: format!("fallback '{}' must be \"skip\" or a type listed in group_order", key),
})
})?),
};
let template = EntryTemplate::parse(entry_format).map_err(|reason| {
RailError::Config(ConfigError::InvalidField {
field: "release.changelog.entry_format".to_string(),
reason,
})
})?;
let mut known_types: Vec<Box<str>> = type_to_def.keys().cloned().collect();
known_types.sort_unstable();
Ok(Self {
groups,
type_to_group,
fallback,
template,
emoji,
skip_types: to_boxed_set(&filters.skip_types),
skip_scopes: to_boxed_set(&filters.skip_scopes),
known_types,
})
}
fn add_custom_group(
spec: &GroupSpec,
definitions: &mut Vec<GroupDef>,
type_to_def: &mut FxHashMap<Box<str>, usize>,
) -> RailResult<()> {
if spec.types.is_empty() {
return Err(RailError::Config(ConfigError::InvalidField {
field: "release.changelog.groups".to_string(),
reason: format!("group '{}' must declare at least one commit type", spec.title),
}));
}
let def_idx = definitions.len();
definitions.push(GroupDef {
title: spec.title.clone(),
emoji: spec.emoji.clone(),
});
for commit_type in &spec.types {
type_to_def.insert(Box::from(commit_type.as_str()), def_idx);
}
Ok(())
}
fn classify(&self, parsed: &ParsedSubject<'_>) -> Option<usize> {
if let Some(scope) = parsed.scope
&& self.skip_scopes.contains(scope)
&& !parsed.breaking
{
return None;
}
if parsed.breaking {
return self.type_to_group.get(BREAKING_TYPE).copied().or(self.fallback);
}
let Some(commit_type) = parsed.commit_type else {
return self.fallback;
};
if self.skip_types.contains(commit_type) {
return None;
}
self.type_to_group.get(commit_type).copied().or(self.fallback)
}
pub fn render(
&self,
links: &LinkTemplates,
highlights: &[String],
commits: &[CommitRef<'_>],
dependency_updates: &[(String, Version)],
) -> String {
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); self.groups.len()];
for (idx, commit) in commits.iter().enumerate() {
let parsed = parse_subject(commit.subject, commit.body);
if let Some(group) = self.classify(&parsed) {
buckets[group].push(idx);
}
}
let deps_group = (!dependency_updates.is_empty())
.then(|| self.type_to_group.get(DEPS_TYPE).copied().or(self.fallback))
.flatten();
let mut out = String::with_capacity(commits.len() * 96 + 64);
for highlight in highlights {
let trimmed = highlight.trim();
if !trimmed.is_empty() {
out.push_str(trimmed);
out.push_str("\n\n");
}
}
for (group_idx, group) in self.groups.iter().enumerate() {
let bucket = &buckets[group_idx];
let synthesized = deps_group == Some(group_idx);
if bucket.is_empty() && !synthesized {
continue;
}
self.write_section_header(&mut out, group);
for &commit_idx in bucket {
let commit = &commits[commit_idx];
let parsed = parse_subject(commit.subject, commit.body);
self.template.render(&mut out, &parsed, commit.sha, commit.body, links);
out.push('\n');
}
if synthesized {
for (name, version) in dependency_updates {
out.push_str("- updated ");
out.push_str(name);
out.push_str(" to ");
out.push_str(&version.to_string());
out.push('\n');
}
}
out.push('\n');
}
out
}
fn write_section_header(&self, out: &mut String, group: &GroupDef) {
match (&group.emoji, self.emoji) {
(Some(emoji), true) => {
out.push_str("### ");
out.push_str(emoji);
out.push(' ');
out.push_str(&group.title);
out.push_str("\n\n");
}
_ => {
out.push_str("### ");
out.push_str(&group.title);
out.push_str("\n\n");
}
}
}
pub fn diagnose(&self, commits: &[CommitRef<'_>]) -> Vec<CommitDiagnostic> {
let mut diagnostics = Vec::new();
for commit in commits {
let parsed = parse_subject(commit.subject, commit.body);
let issue = match parsed.commit_type {
None => Some(CommitIssue::NotConventional),
Some(commit_type) if !self.known_types.iter().any(|t| &**t == commit_type) => {
Some(CommitIssue::UnknownType {
found: commit_type.to_string(),
suggestion: suggest_type(commit_type, self.known_types.iter().map(|t| &**t))
.map(str::to_string),
})
}
Some(_) => None,
};
if let Some(issue) = issue {
diagnostics.push(CommitDiagnostic {
sha: commit.sha.get(..7).unwrap_or(commit.sha).to_string(),
subject: commit.subject.to_string(),
issue,
});
}
}
diagnostics
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "machine-readable changelog projection for release consumers")
)]
pub fn entries_json(&self, commits: &[CommitRef<'_>]) -> Vec<serde_json::Value> {
commits
.iter()
.filter_map(|commit| {
let parsed = parse_subject(commit.subject, commit.body);
self.classify(&parsed).map(|group| {
serde_json::json!({
"sha": commit.sha,
"type": parsed.commit_type.unwrap_or("other"),
"scope": parsed.scope,
"breaking": parsed.breaking,
"description": parsed.description,
"section": self.groups[group].title,
})
})
})
.collect()
}
}
pub fn resolve_links(
shape: &ChangelogShape,
overrides: Option<&ChangelogConfig>,
workspace_root: &Path,
) -> RailResult<LinkTemplates> {
let commit_url = overrides
.and_then(|o| o.commit_url.as_deref())
.or(shape.commit_url.as_deref());
let pr_url = overrides.and_then(|o| o.pr_url.as_deref()).or(shape.pr_url.as_deref());
if commit_url.is_some() || pr_url.is_some() {
return LinkTemplates::new(commit_url, pr_url).map_err(|reason| {
RailError::Config(ConfigError::InvalidField {
field: "release.changelog.commit_url".to_string(),
reason,
})
});
}
Ok(match detect_github_repo(workspace_root) {
Some((org, repo)) => LinkTemplates::for_github(&org, &repo),
None => LinkTemplates::default(),
})
}
pub fn detect_github_repo(workspace_root: &Path) -> Option<(String, String)> {
let repository = crate::release::remote::fetch_repository(workspace_root).ok()?;
if repository.host() != Some("github.com") {
return None;
}
let (owner, name) = repository.github_owner_repo()?;
Some((owner.to_string(), name.to_string()))
}
fn to_boxed_set(values: &[String]) -> FxHashSet<Box<str>> {
values.iter().map(|v| Box::from(v.as_str())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ChangelogShape;
fn default_spec() -> ChangelogSpec {
ChangelogSpec::resolve(&ChangelogShape::default(), None).unwrap()
}
fn commit<'a>(sha: &'a str, subject: &'a str) -> CommitRef<'a> {
CommitRef {
sha,
subject,
body: None,
}
}
#[test]
fn default_render_groups_breaking_features_fixes_and_other_changes() {
let spec = default_spec();
let links = LinkTemplates::for_github("org", "repo");
let commits = [
commit("aaaaaaa1111111", "feat: add planner"),
commit("bbbbbbb2222222", "fix(core): null deref"),
commit("ccccccc3333333", "feat!: drop old API"),
commit("ddddddd4444444", "Update README"),
];
let body = spec.render(&links, &[], &commits, &[]);
let breaking_pos = body.find("### ⚠️ BREAKING CHANGES").unwrap();
let features_pos = body.find("### ✨ Features").unwrap();
let fixes_pos = body.find("### 🐛 Bug Fixes").unwrap();
let other_pos = body.find("### 📦 Other Changes").unwrap();
assert!(breaking_pos < features_pos);
assert!(features_pos < fixes_pos);
assert!(fixes_pos < other_pos);
assert!(
body.contains(
"- [**breaking**] drop old API ([ccccccc](https://github.com/org/repo/commit/ccccccc3333333))"
)
);
assert!(body.contains("- **core**: null deref"));
assert!(body.contains("- Update README"));
}
#[test]
fn custom_group_and_order() {
let mut shape = ChangelogShape::default();
shape.groups.push(GroupSpec {
types: vec!["sec".to_string(), "security".to_string()],
title: "Security".to_string(),
emoji: Some("🔒".to_string()),
});
shape.group_order = vec!["sec".to_string(), "feat".to_string(), "other".to_string()];
let spec = ChangelogSpec::resolve(&shape, None).unwrap();
let commits = [
commit("aaaaaaa1111111", "feat: add planner"),
commit("bbbbbbb2222222", "security: rotate keys"),
commit("ccccccc3333333", "sec(auth): patch leak"),
];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
let sec_pos = body.find("### 🔒 Security").unwrap();
let feat_pos = body.find("### ✨ Features").unwrap();
assert!(sec_pos < feat_pos);
assert_eq!(body.matches("### 🔒 Security").count(), 1);
assert!(body.contains("- rotate keys"));
assert!(body.contains("- **auth**: patch leak"));
}
#[test]
fn unordered_types_fall_through_to_fallback() {
let shape = ChangelogShape {
group_order: vec!["feat".to_string(), "other".to_string()],
..ChangelogShape::default()
};
let spec = ChangelogSpec::resolve(&shape, None).unwrap();
let commits = [commit("aaaaaaa1111111", "chore: tidy")];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
assert!(body.contains("### 📦 Other Changes"));
assert!(body.contains("- tidy"));
}
#[test]
fn fallback_skip_drops_unlisted_types() {
let shape = ChangelogShape {
group_order: vec!["feat".to_string()],
fallback: "skip".to_string(),
..ChangelogShape::default()
};
let spec = ChangelogSpec::resolve(&shape, None).unwrap();
let commits = [
commit("aaaaaaa1111111", "chore: tidy"),
commit("bbbbbbb2222222", "Update README"),
commit("ccccccc3333333", "feat: keep me"),
];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
assert!(!body.contains("tidy"));
assert!(!body.contains("README"));
assert!(body.contains("- keep me"));
}
#[test]
fn skip_types_never_drop_breaking_commits() {
let mut shape = ChangelogShape::default();
shape.filters.skip_types = vec!["feat".to_string()];
let spec = ChangelogSpec::resolve(&shape, None).unwrap();
let commits = [
commit("aaaaaaa1111111", "feat: dropped"),
commit("bbbbbbb2222222", "feat!: kept, breaking"),
];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
assert!(!body.contains("dropped"));
assert!(body.contains("[**breaking**] kept, breaking"));
}
#[test]
fn skip_scopes_filter_commits() {
let mut shape = ChangelogShape::default();
shape.filters.skip_scopes = vec!["internal".to_string()];
let spec = ChangelogSpec::resolve(&shape, None).unwrap();
let commits = [
commit("aaaaaaa1111111", "feat(internal): hidden"),
commit("bbbbbbb2222222", "feat(api): visible"),
];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
assert!(!body.contains("hidden"));
assert!(body.contains("**api**: visible"));
}
#[test]
fn emoji_off_renders_plain_headers() {
let shape = ChangelogShape {
emoji: false,
..ChangelogShape::default()
};
let spec = ChangelogSpec::resolve(&shape, None).unwrap();
let commits = [commit("aaaaaaa1111111", "feat: add planner")];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
assert!(body.contains("### Features\n"));
assert!(!body.contains("✨"));
}
#[test]
fn crate_overrides_win_over_workspace_shape() {
let shape = ChangelogShape::default();
let overrides = ChangelogConfig {
emoji: Some(false),
group_order: Some(vec!["fix".to_string(), "other".to_string()]),
..ChangelogConfig::default()
};
let spec = ChangelogSpec::resolve(&shape, Some(&overrides)).unwrap();
let commits = [
commit("aaaaaaa1111111", "feat: goes to other"),
commit("bbbbbbb2222222", "fix: stays first"),
];
let body = spec.render(&LinkTemplates::default(), &[], &commits, &[]);
let fixes_pos = body.find("### Bug Fixes").unwrap();
let other_pos = body.find("### Other Changes").unwrap();
assert!(fixes_pos < other_pos);
assert!(body.contains("- goes to other"));
}
#[test]
fn synthesized_dependency_entries() {
let spec = default_spec();
let updates = vec![("rail-core".to_string(), Version::new(0, 5, 0))];
let body = spec.render(&LinkTemplates::default(), &[], &[], &updates);
assert!(body.contains("### 📦 Dependencies"));
assert!(body.contains("- updated rail-core to 0.5.0"));
}
#[test]
fn highlights_render_before_sections() {
let spec = default_spec();
let highlights = vec!["Added `--bump auto` for per-crate version inference.".to_string()];
let commits = [commit("aaaaaaa1111111", "feat: add planner")];
let body = spec.render(&LinkTemplates::default(), &highlights, &commits, &[]);
let highlight_pos = body.find("Added `--bump auto`").unwrap();
let section_pos = body.find("### ✨ Features").unwrap();
assert!(highlight_pos < section_pos);
}
#[test]
fn unknown_group_order_type_is_config_error() {
let shape = ChangelogShape {
group_order: vec!["sec".to_string()],
..ChangelogShape::default()
};
let err = ChangelogSpec::resolve(&shape, None).unwrap_err();
assert!(err.to_string().contains("sec"));
}
#[test]
fn bad_fallback_is_config_error() {
let shape = ChangelogShape {
fallback: "nope".to_string(),
..ChangelogShape::default()
};
let err = ChangelogSpec::resolve(&shape, None).unwrap_err();
assert!(err.to_string().contains("fallback"));
}
#[test]
fn diagnose_flags_typos_and_unconventional() {
let spec = default_spec();
let commits = [
commit("aaaaaaa1111111", "hore(ci): avoid dry-run hooks"),
commit("bbbbbbb2222222", "Update README"),
commit("ccccccc3333333", "feat: fine"),
];
let diagnostics = spec.diagnose(&commits);
assert_eq!(diagnostics.len(), 2);
assert_eq!(
diagnostics[0].issue,
CommitIssue::UnknownType {
found: "hore".to_string(),
suggestion: Some("chore".to_string()),
}
);
assert_eq!(diagnostics[1].issue, CommitIssue::NotConventional);
assert!(diagnostics[0].describe().contains("did you mean 'chore'"));
}
#[test]
fn entries_json_is_structured() {
let spec = default_spec();
let commits = [commit("aaaaaaa1111111", "feat(api): add endpoint")];
let entries = spec.entries_json(&commits);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["type"], "feat");
assert_eq!(entries[0]["scope"], "api");
assert_eq!(entries[0]["section"], "Features");
}
#[test]
fn detects_only_one_unambiguous_github_fetch_repository() {
let directory = tempfile::tempdir().unwrap();
crate::git::init_repo(directory.path(), "main").unwrap();
let git = crate::git::SystemGit::open(directory.path()).unwrap();
assert_eq!(detect_github_repo(directory.path()), None);
for url in [
"git@github.com:org/repo.git",
"https://github.com/org/repo",
"ssh://git@github.com/org/repo",
"https://github.com/org/repo.git/",
] {
git.run_git(&["config", "--local", "remote.origin.url", url]).unwrap();
assert_eq!(
detect_github_repo(directory.path()),
Some(("org".to_string(), "repo".to_string())),
"{url}"
);
}
for url in [
"git@gitlab.com:org/repo.git",
"https://github.com/org/repo/extra",
"https://github.com/org/repo.git?ref=main",
"https://github.com/org/repo#readme",
"https://github.com//repo",
"https://github.com/org/",
] {
git.run_git(&["config", "--local", "remote.origin.url", url]).unwrap();
assert_eq!(detect_github_repo(directory.path()), None, "{url}");
}
git.run_git(&["config", "--local", "remote.origin.url", "https://github.com/org/repo"])
.unwrap();
git.run_git(&[
"config",
"--local",
"--add",
"remote.origin.url",
"https://github.com/other/repo",
])
.unwrap();
assert_eq!(detect_github_repo(directory.path()), None, "multiple fetch URLs");
}
#[test]
fn crate_link_overrides_win() {
let shape = ChangelogShape::default();
let overrides = ChangelogConfig {
commit_url: Some("https://git.example/{sha}".to_string()),
pr_url: Some("https://git.example/pr/{pr}".to_string()),
..ChangelogConfig::default()
};
let links = resolve_links(&shape, Some(&overrides), Path::new(".")).unwrap();
let mut out = String::new();
links.write_sha_link(&mut out, "abcdef1234");
assert_eq!(out, "[abcdef1](https://git.example/abcdef1234)");
}
}