Skip to content

Commit 12a33c1

Browse files
committed
cargo-rail: added the changelog updates/improvements.
1 parent 599bd3d commit 12a33c1

File tree

6 files changed

+313
-36
lines changed

6 files changed

+313
-36
lines changed

.gitignore

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,16 @@ AGENTS.md
1414

1515
# Cursor
1616
.cursor
17-
18-
# Zed
1917
.zed
2018

2119
# Credentials (should never be committed)
2220
*.pem
2321
*.key
2422

23+
# Documentation
24+
docs/*
25+
AUDIT_SUMMARY.md
26+
2527
# Cargo-Rail (Testing)
2628
*rail.toml
27-
.config/rail.toml.example
28-
29-
# Notes
30-
TODO.md
31-
32-
# Documentation
33-
docs/*
29+
.config/rail.toml.example

src/commands/init.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,9 @@ fn serialize_config_with_comments(config: &RailConfig) -> RailResult<String> {
616616
output.push_str("# publish_delay - Seconds between crate publishes\n");
617617
output.push_str("# create_github_release - Auto-create GitHub releases via gh CLI\n");
618618
output.push_str("# sign_tags - Sign git tags with GPG/SSH\n");
619-
output.push_str("# changelog_path - Default changelog filename\n\n");
619+
output.push_str("# changelog_path - Default changelog filename\n");
620+
output.push_str("# skip_changelog_for - Crates to skip changelog generation for\n");
621+
output.push_str("# require_changelog_entries - Error if no entries are found\n\n");
620622

621623
output.push_str("[release]\n");
622624
output.push_str(&format!(
@@ -647,6 +649,27 @@ fn serialize_config_with_comments(config: &RailConfig) -> RailResult<String> {
647649
"changelog_path = \"{}\" # Default changelog file\n",
648650
config.release.changelog_path
649651
));
652+
output.push_str(&format!(
653+
"skip_changelog_for = {} # e.g., [\"internal-tooling\"]\n",
654+
if config.release.skip_changelog_for.is_empty() {
655+
"[]".to_string()
656+
} else {
657+
format!(
658+
"[{}]",
659+
config
660+
.release
661+
.skip_changelog_for
662+
.iter()
663+
.map(|s| format!("\"{}\"", s))
664+
.collect::<Vec<_>>()
665+
.join(", ")
666+
)
667+
}
668+
));
669+
output.push_str(&format!(
670+
"require_changelog_entries = {} # Fail if no commits for a release\n",
671+
config.release.require_changelog_entries
672+
));
650673

651674
output.push('\n');
652675

src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,14 @@ pub struct ReleaseConfig {
357357
/// Default changelog path for all crates (default: "CHANGELOG.md")
358358
#[serde(default = "default_changelog_path")]
359359
pub changelog_path: String,
360+
361+
/// Crates that should not generate changelog entries
362+
#[serde(default)]
363+
pub skip_changelog_for: Vec<String>,
364+
365+
/// If true, error when there are no changelog entries for a crate
366+
#[serde(default)]
367+
pub require_changelog_entries: bool,
360368
}
361369

362370
impl Default for ReleaseConfig {
@@ -369,6 +377,8 @@ impl Default for ReleaseConfig {
369377
create_github_release: false,
370378
sign_tags: false,
371379
changelog_path: default_changelog_path(),
380+
skip_changelog_for: Vec::new(),
381+
require_changelog_entries: false,
372382
}
373383
}
374384
}

src/release/changelog.rs

Lines changed: 209 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,43 @@ impl CommitType {
9494
}
9595
}
9696

97+
/// Parse a GitHub remote URL into (org, repo)
98+
fn parse_github_remote(url: &str) -> Option<(String, String)> {
99+
let trimmed = url.trim().trim_end_matches(".git").trim_end_matches('/');
100+
101+
let repo_part = if let Some(ssh) = trimmed.strip_prefix("[email protected]:") {
102+
ssh
103+
} else if let Some(ssh) = trimmed.strip_prefix("ssh://[email protected]/") {
104+
ssh
105+
} else if let Some(https) = trimmed.strip_prefix("https://github.com/") {
106+
https
107+
} else {
108+
return None;
109+
};
110+
111+
let mut parts = repo_part.split('/');
112+
let org = parts.next()?;
113+
let repo = parts.next()?;
114+
115+
Some((org.to_string(), repo.to_string()))
116+
}
117+
118+
/// Detect GitHub repository from the git remote
119+
pub fn detect_github_repo(workspace_root: &Path) -> Option<(String, String)> {
120+
let output = Command::new("git")
121+
.current_dir(workspace_root)
122+
.args(["config", "--get", "remote.origin.url"])
123+
.output()
124+
.ok()?;
125+
126+
if !output.status.success() {
127+
return None;
128+
}
129+
130+
let url = String::from_utf8_lossy(&output.stdout);
131+
parse_github_remote(&url)
132+
}
133+
97134
/// Parse commit type from string
98135
fn parse_commit_type(input: &mut &str) -> PResult<CommitType> {
99136
alt((
@@ -126,6 +163,30 @@ fn parse_description<'a>(input: &mut &'a str) -> PResult<&'a str> {
126163
preceded((':', space0), till_line_ending).parse_next(input)
127164
}
128165

166+
/// Extract PR references (#123) from text
167+
fn extract_pr_numbers(text: &str) -> Vec<u32> {
168+
let mut prs = Vec::new();
169+
170+
for word in text.split_whitespace() {
171+
if let Some(num_str) = word.strip_prefix("(#").and_then(|s| s.strip_suffix(')'))
172+
&& let Ok(num) = num_str.parse::<u32>() {
173+
prs.push(num);
174+
continue;
175+
}
176+
177+
if let Some(num_str) = word.strip_prefix('#') {
178+
let numeric = num_str.trim_end_matches(|c: char| !c.is_ascii_digit());
179+
if let Ok(num) = numeric.parse::<u32>() {
180+
prs.push(num);
181+
}
182+
}
183+
}
184+
185+
prs.sort_unstable();
186+
prs.dedup();
187+
prs
188+
}
189+
129190
/// Parse a conventional commit message
130191
///
131192
/// Format: type(scope)!: description
@@ -179,12 +240,83 @@ pub fn parse_conventional_commit<'a>(sha: &'a str, subject: &'a str, body: Optio
179240
/// Changelog generator
180241
pub struct ChangelogGenerator {
181242
workspace_root: std::path::PathBuf,
243+
github_repo: Option<(String, String)>,
182244
}
183245

184246
impl ChangelogGenerator {
185247
pub fn new(workspace_root: &Path) -> Self {
186248
Self {
187249
workspace_root: workspace_root.to_path_buf(),
250+
github_repo: detect_github_repo(workspace_root),
251+
}
252+
}
253+
254+
pub fn github_repo(&self) -> Option<&(String, String)> {
255+
self.github_repo.as_ref()
256+
}
257+
258+
fn short_sha<'a>(&self, sha: &'a str) -> &'a str {
259+
sha.get(..7).unwrap_or(sha)
260+
}
261+
262+
fn format_sha(&self, sha: &str) -> String {
263+
if let Some((org, repo)) = &self.github_repo {
264+
return format!(
265+
"[{}](https://github.com/{}/{}/commit/{})",
266+
self.short_sha(sha),
267+
org,
268+
repo,
269+
sha
270+
);
271+
}
272+
273+
self.short_sha(sha).to_string()
274+
}
275+
276+
fn format_pr_links(&self, commit: &ConventionalCommit) -> Option<String> {
277+
let mut text = commit.description.to_string();
278+
if let Some(body) = commit.body {
279+
text.push(' ');
280+
text.push_str(body);
281+
}
282+
283+
let prs = extract_pr_numbers(&text);
284+
if prs.is_empty() {
285+
return None;
286+
}
287+
288+
if let Some((org, repo)) = &self.github_repo {
289+
let links = prs
290+
.iter()
291+
.map(|n| format!("[#{}](https://github.com/{}/{}/pull/{})", n, org, repo, n))
292+
.collect::<Vec<_>>()
293+
.join(" ");
294+
Some(links)
295+
} else {
296+
let refs = prs.iter().map(|n| format!("#{}", n)).collect::<Vec<_>>().join(" ");
297+
Some(refs)
298+
}
299+
}
300+
301+
fn format_description(&self, commit: &ConventionalCommit) -> String {
302+
let mut desc = commit.description.trim().to_string();
303+
304+
if commit.breaking {
305+
desc = format!("[**breaking**] {}", desc);
306+
}
307+
308+
desc
309+
}
310+
311+
fn format_entry(&self, commit: &ConventionalCommit) -> String {
312+
let scope_prefix = commit.scope.map(|s| format!("**{}**: ", s)).unwrap_or_default();
313+
let sha = self.format_sha(commit.sha);
314+
let desc = self.format_description(commit);
315+
316+
if let Some(prs) = self.format_pr_links(commit) {
317+
format!("- {}{} {} ({})\n", scope_prefix, desc, prs, sha)
318+
} else {
319+
format!("- {}{} ({})\n", scope_prefix, desc, sha)
188320
}
189321
}
190322

@@ -233,7 +365,7 @@ impl ChangelogGenerator {
233365
CommitType::Breaking.section_title()
234366
));
235367
for commit in breaking {
236-
changelog.push_str(&format!("- {} ({})\n", commit.description.trim(), &commit.sha[..7]));
368+
changelog.push_str(&self.format_entry(commit));
237369
}
238370
changelog.push('\n');
239371
}
@@ -246,13 +378,7 @@ impl ChangelogGenerator {
246378
CommitType::Feature.section_title()
247379
));
248380
for commit in features {
249-
let scope_prefix = commit.scope.map(|s| format!("**{}**: ", s)).unwrap_or_default();
250-
changelog.push_str(&format!(
251-
"- {}{} ({})\n",
252-
scope_prefix,
253-
commit.description.trim(),
254-
&commit.sha[..7]
255-
));
381+
changelog.push_str(&self.format_entry(commit));
256382
}
257383
changelog.push('\n');
258384
}
@@ -265,13 +391,7 @@ impl ChangelogGenerator {
265391
CommitType::Fix.section_title()
266392
));
267393
for commit in fixes {
268-
let scope_prefix = commit.scope.map(|s| format!("**{}**: ", s)).unwrap_or_default();
269-
changelog.push_str(&format!(
270-
"- {}{} ({})\n",
271-
scope_prefix,
272-
commit.description.trim(),
273-
&commit.sha[..7]
274-
));
394+
changelog.push_str(&self.format_entry(commit));
275395
}
276396
changelog.push('\n');
277397
}
@@ -288,13 +408,7 @@ impl ChangelogGenerator {
288408
commit_type.section_title()
289409
));
290410
for commit in commits {
291-
let scope_prefix = commit.scope.map(|s| format!("**{}**: ", s)).unwrap_or_default();
292-
changelog.push_str(&format!(
293-
"- {}{} ({})\n",
294-
scope_prefix,
295-
commit.description.trim(),
296-
&commit.sha[..7]
297-
));
411+
changelog.push_str(&self.format_entry(commit));
298412
}
299413
changelog.push('\n');
300414
}
@@ -420,4 +534,77 @@ mod tests {
420534
assert_eq!(commit.commit_type, CommitType::Other);
421535
assert_eq!(commit.description, "Update README");
422536
}
537+
538+
#[test]
539+
fn parse_github_remote_supports_common_patterns() {
540+
assert_eq!(
541+
parse_github_remote("[email protected]:org/repo.git"),
542+
Some(("org".to_string(), "repo".to_string()))
543+
);
544+
545+
assert_eq!(
546+
parse_github_remote("https://github.com/org/repo"),
547+
Some(("org".to_string(), "repo".to_string()))
548+
);
549+
550+
assert_eq!(
551+
parse_github_remote("ssh://[email protected]/org/repo"),
552+
Some(("org".to_string(), "repo".to_string()))
553+
);
554+
}
555+
556+
#[test]
557+
fn parse_github_remote_non_github_returns_none() {
558+
assert_eq!(parse_github_remote("[email protected]:org/repo.git"), None);
559+
}
560+
561+
#[test]
562+
fn extract_pr_numbers_supports_common_patterns() {
563+
let prs = extract_pr_numbers("feat(auth): add login (#12) closes #34 and refs #34");
564+
assert_eq!(prs, vec![12, 34]);
565+
}
566+
567+
#[test]
568+
fn format_entry_includes_pr_and_links_when_available() {
569+
let commit = ConventionalCommit {
570+
commit_type: CommitType::Feature,
571+
scope: Some("api"),
572+
breaking: false,
573+
description: "redesign REST endpoints (#123)",
574+
body: Some("closes #456"),
575+
sha: "abcdef1234567890",
576+
};
577+
578+
let generator = ChangelogGenerator {
579+
workspace_root: std::path::PathBuf::new(),
580+
github_repo: Some(("org".to_string(), "repo".to_string())),
581+
};
582+
583+
let line = generator.format_entry(&commit);
584+
585+
assert!(line.contains("[#123](https://github.com/org/repo/pull/123)"));
586+
assert!(line.contains("[#456](https://github.com/org/repo/pull/456)"));
587+
assert!(line.contains("**api**: redesign REST endpoints (#123)"));
588+
assert!(line.contains("[abcdef1](https://github.com/org/repo/commit/abcdef1234567890)"));
589+
}
590+
591+
#[test]
592+
fn format_entry_marks_breaking_inline() {
593+
let commit = ConventionalCommit {
594+
commit_type: CommitType::Breaking,
595+
scope: None,
596+
breaking: true,
597+
description: "change API",
598+
body: None,
599+
sha: "1234567",
600+
};
601+
602+
let generator = ChangelogGenerator {
603+
workspace_root: std::path::PathBuf::new(),
604+
github_repo: None,
605+
};
606+
607+
let line = generator.format_entry(&commit);
608+
assert!(line.contains("[**breaking**] change API"));
609+
}
423610
}

0 commit comments

Comments
 (0)