use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use anyhow::Context;
use crate::model::changeset::ChangeType;
use crate::path::AbsolutePath;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForgeReference {
GitHub {
number: u64,
},
GitLab {
project: Option<String>,
number: u64,
},
}
impl ForgeReference {
fn format_token(&self) -> String {
match self {
Self::GitHub { number } => format!("#{number}"),
Self::GitLab {
project: Some(project),
number,
} => format!("{project}!{number}+"),
Self::GitLab {
project: None,
number,
} => format!("!{number}+"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitReference {
pub short_hash: String,
pub reference: Option<ForgeReference>,
}
impl CommitReference {
pub fn new(full_sha: &str, message: &str) -> Self {
Self {
short_hash: full_sha.chars().take(7).collect(),
reference: extract_reference(message),
}
}
pub fn has_reference(&self) -> bool {
self.reference.is_some()
}
pub fn format_suffix(&self) -> String {
match &self.reference {
Some(reference) => {
format!(" [{}] via {}", self.short_hash, reference.format_token())
}
None => format!(" [{}]", self.short_hash),
}
}
}
fn extract_reference(message: &str) -> Option<ForgeReference> {
let first_line = message.lines().next().unwrap_or("");
if let Some(rest) = first_line.strip_prefix("Merge pull request #") {
let num_str = rest.split_whitespace().next().unwrap_or("");
if let Ok(number) = num_str.parse::<u64>() {
return Some(ForgeReference::GitHub { number });
}
}
if let Some(reference) = extract_gitlab_see_merge_request(message) {
return Some(reference);
}
if let Some(number) = extract_parenthesised_number(first_line, "(#") {
return Some(ForgeReference::GitHub { number });
}
if let Some(number) = extract_parenthesised_number(first_line, "(!") {
return Some(ForgeReference::GitLab {
project: None,
number,
});
}
None
}
fn extract_parenthesised_number(text: &str, open: &str) -> Option<u64> {
let pos = text.rfind(open)?;
let rest = &text[pos + open.len()..];
let end = rest.find(')')?;
rest[..end].parse::<u64>().ok()
}
fn extract_gitlab_see_merge_request(message: &str) -> Option<ForgeReference> {
const MARKER: &str = "See merge request ";
let after = message
.lines()
.find_map(|line| line.trim_start().strip_prefix(MARKER))?;
let token = after.split_whitespace().next().unwrap_or("");
let bang = token.find('!')?;
let project_part = &token[..bang];
let number_part = &token[bang + 1..];
let number = number_part.parse::<u64>().ok()?;
let project = if project_part.is_empty() {
None
} else if is_valid_gitlab_project_path(project_part) {
Some(project_part.to_string())
} else {
return None;
};
Some(ForgeReference::GitLab { project, number })
}
fn is_valid_gitlab_project_path(path: &str) -> bool {
let mut chars = path.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphanumeric() || first == '_' || first == '.') {
return false;
}
path.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-' | '/'))
}
pub struct Changelog {
version: semver::Version,
date: String,
changes: Vec<(ChangeType, Option<String>, Option<CommitReference>)>,
dependency_entries: Vec<String>,
project_path: AbsolutePath,
}
impl Changelog {
pub fn new(
version: semver::Version,
date: String,
changes: Vec<(ChangeType, Option<String>, Option<CommitReference>)>,
project_path: AbsolutePath,
) -> Self {
Self {
version,
date,
changes,
dependency_entries: Vec::new(),
project_path,
}
}
pub fn with_dependency_entries(mut self, entries: Vec<String>) -> Self {
self.dependency_entries = entries;
self
}
pub fn format_sections(&self) -> String {
let mut sections: BTreeMap<ChangeType, Vec<(&str, Option<&CommitReference>)>> =
BTreeMap::new();
for (ct, msg, commit_ref) in &self.changes {
if let Some(text) = msg.as_deref() {
sections
.entry(*ct)
.or_default()
.push((text, commit_ref.as_ref()));
}
}
let mut output = String::new();
for ct in [ChangeType::Major, ChangeType::Minor, ChangeType::Patch] {
if let Some(messages) = sections.get(&ct) {
let heading = match ct {
ChangeType::Major => "Breaking Changes",
ChangeType::Minor => "Features",
ChangeType::Patch => "Bug Fixes",
};
output.push_str(&format_change_section(
heading,
messages,
!output.is_empty(),
));
}
}
if !self.dependency_entries.is_empty() {
let dep_messages: Vec<(&str, Option<&CommitReference>)> = self
.dependency_entries
.iter()
.map(|e| (e.as_str(), None))
.collect();
output.push_str(&format_change_section(
"Dependencies",
&dep_messages,
!output.is_empty(),
));
}
output
}
pub fn format_entry(&self) -> String {
let sections = self.format_sections();
if sections.is_empty() {
format!("## {} - {}\n", self.version, self.date)
} else {
format!("## {} - {}\n\n{}", self.version, self.date, sections)
}
}
pub async fn update(
&self,
dry_run: bool,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<()> {
let changelog_path = self.project_path.child("CHANGELOG.md");
let entry = self.format_entry();
let content = if fs.exists(&changelog_path).await? {
let existing = fs
.read_to_string(&changelog_path)
.await
.with_context(|| format!("Failed to read {}", changelog_path.display()))?;
let (preamble, rest) = split_at_first_h2(&existing);
format!("{preamble}{entry}\n{rest}")
} else {
format!("# Changelog\n\n{entry}\n")
};
if !dry_run {
fs.write(&changelog_path, content.as_bytes())
.await
.with_context(|| format!("Failed to write {}", changelog_path.display()))?;
}
Ok(())
}
}
pub async fn extract_version_body(
changelog_path: &Path,
version: &semver::Version,
fs: &dyn crate::filesystem::Filesystem,
) -> anyhow::Result<String> {
let abs_path = crate::path::AbsolutePath::new(changelog_path).with_context(|| {
format!(
"changelog path is not absolute: {}",
changelog_path.display()
)
})?;
let content = fs
.read_to_string(&abs_path)
.await
.with_context(|| format!("Failed to read {}", changelog_path.display()))?;
let version_str = version.to_string();
let mut in_section = false;
let mut body_lines: Vec<&str> = Vec::new();
for line in content.lines() {
if let Some(rest) = line.strip_prefix("## ") {
if in_section {
break;
}
if rest == version_str || rest.starts_with(&format!("{version_str} ")) {
in_section = true;
}
} else if in_section {
body_lines.push(line);
}
}
if !in_section {
return Ok(String::new());
}
let start = body_lines
.iter()
.position(|l| !l.is_empty())
.unwrap_or(body_lines.len());
let end = body_lines
.iter()
.rposition(|l| !l.is_empty())
.map_or(start, |i| i + 1);
Ok(body_lines[start..end].join("\n"))
}
fn format_change_section(
heading: &str,
messages: &[(&str, Option<&CommitReference>)],
needs_separator: bool,
) -> String {
let mut section = String::new();
if needs_separator {
section.push('\n');
}
let _ = writeln!(section, "### {heading}\n");
for (msg, commit_ref) in messages {
let suffix = commit_ref.map_or_else(String::new, CommitReference::format_suffix);
let text_with_suffix = if suffix.is_empty() {
(*msg).to_string()
} else {
let mut lines = msg.splitn(2, '\n');
let first = lines.next().unwrap_or("");
let rest = lines.next().unwrap_or("");
if rest.is_empty() {
format!("{first}{suffix}")
} else {
format!("{first}{suffix}\n{rest}")
}
};
let _ = writeln!(
section,
"- {}",
indent_continuation_lines(&text_with_suffix)
);
}
section
}
fn indent_continuation_lines(text: &str) -> String {
text.split('\n')
.enumerate()
.map(|(i, line)| {
if i == 0 || line.is_empty() {
line.to_string()
} else {
format!(" {line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn split_at_first_h2(content: &str) -> (&str, &str) {
if content.starts_with("## ") {
return ("", content);
}
if let Some(pos) = content.find("\n## ") {
(&content[..pos + 1], &content[pos + 1..])
} else {
(content, "")
}
}
#[cfg(test)]
mod tests;