1use serde::Serialize;
2
3use crate::commit::ConventionalCommit;
4use crate::config::ChangelogGroup;
5use crate::error::ReleaseError;
6
7#[derive(Debug, Clone, Serialize)]
9pub struct ChangelogEntry {
10 pub version: String,
11 pub date: String,
12 pub commits: Vec<ConventionalCommit>,
13 pub compare_url: Option<String>,
14 pub repo_url: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize)]
19pub struct RenderedGroup {
20 pub name: String,
21 pub commits: Vec<ConventionalCommit>,
22}
23
24pub trait ChangelogFormatter: Send + Sync {
26 fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError>;
27}
28
29pub struct DefaultChangelogFormatter {
32 template: Option<String>,
33 groups: Vec<ChangelogGroup>,
34}
35
36impl DefaultChangelogFormatter {
37 pub fn new(template: Option<String>, groups: Vec<ChangelogGroup>) -> Self {
38 Self { template, groups }
39 }
40
41 fn resolve_template(&self) -> Option<String> {
44 let tmpl = self.template.as_ref()?;
45 if std::path::Path::new(tmpl).exists() {
46 std::fs::read_to_string(tmpl).ok()
47 } else {
48 Some(tmpl.clone())
49 }
50 }
51
52 fn build_groups(&self, commits: &[ConventionalCommit]) -> Vec<RenderedGroup> {
54 self.groups
55 .iter()
56 .map(|group| {
57 let group_commits: Vec<_> = commits
58 .iter()
59 .filter(|c| {
60 if group.content.contains(&"breaking".to_string()) {
61 c.breaking
62 } else {
63 !c.breaking && group.content.contains(&c.r#type)
64 }
65 })
66 .cloned()
67 .collect();
68 RenderedGroup {
69 name: group.name.clone(),
70 commits: group_commits,
71 }
72 })
73 .collect()
74 }
75}
76
77impl ChangelogFormatter for DefaultChangelogFormatter {
78 fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError> {
79 if let Some(template_str) = self.resolve_template() {
80 #[derive(Serialize)]
82 struct TemplateEntry {
83 version: String,
84 date: String,
85 groups: Vec<RenderedGroup>,
86 compare_url: Option<String>,
87 repo_url: Option<String>,
88 }
89
90 let template_entries: Vec<_> = entries
91 .iter()
92 .map(|e| TemplateEntry {
93 version: e.version.clone(),
94 date: e.date.clone(),
95 groups: self.build_groups(&e.commits),
96 compare_url: e.compare_url.clone(),
97 repo_url: e.repo_url.clone(),
98 })
99 .collect();
100
101 let mut env = minijinja::Environment::new();
102 env.add_template("changelog", &template_str)
103 .map_err(|e| ReleaseError::Changelog(format!("invalid template: {e}")))?;
104 let tmpl = env
105 .get_template("changelog")
106 .map_err(|e| ReleaseError::Changelog(format!("template error: {e}")))?;
107 let output = tmpl
108 .render(minijinja::context! { entries => template_entries })
109 .map_err(|e| ReleaseError::Changelog(format!("template render error: {e}")))?;
110 return Ok(output.trim_end().to_string());
111 }
112
113 let mut output = String::new();
115
116 for entry in entries {
117 output.push_str(&format!("## {} ({})\n", entry.version, entry.date));
118
119 let groups = self.build_groups(&entry.commits);
120 for group in &groups {
121 if group.commits.is_empty() {
122 continue;
123 }
124 let title = group
126 .name
127 .split('-')
128 .map(|w| {
129 let mut c = w.chars();
130 match c.next() {
131 None => String::new(),
132 Some(f) => f.to_uppercase().to_string() + c.as_str(),
133 }
134 })
135 .collect::<Vec<_>>()
136 .join(" ");
137
138 output.push_str(&format!("\n### {title}\n\n"));
139 for commit in &group.commits {
140 format_commit_line(&mut output, commit, entry.repo_url.as_deref());
141 }
142 }
143
144 if let Some(url) = &entry.compare_url {
145 output.push_str(&format!("\n[Full Changelog]({url})\n"));
146 }
147
148 output.push('\n');
149 }
150
151 Ok(output.trim_end().to_string())
152 }
153}
154
155fn format_commit_line(output: &mut String, commit: &ConventionalCommit, repo_url: Option<&str>) {
156 let short_sha = &commit.sha[..7.min(commit.sha.len())];
157 let sha_display = match repo_url {
158 Some(url) => format!("[{short_sha}]({url}/commit/{})", commit.sha),
159 None => short_sha.to_string(),
160 };
161 if let Some(scope) = &commit.scope {
162 output.push_str(&format!(
163 "- **{scope}**: {} ({sha_display})\n",
164 commit.description
165 ));
166 } else {
167 output.push_str(&format!("- {} ({sha_display})\n", commit.description));
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::config::default_changelog_groups;
175
176 fn make_commit(
177 type_: &str,
178 desc: &str,
179 scope: Option<&str>,
180 breaking: bool,
181 ) -> ConventionalCommit {
182 ConventionalCommit {
183 sha: "abc1234def5678".into(),
184 r#type: type_.into(),
185 scope: scope.map(Into::into),
186 description: desc.into(),
187 body: None,
188 breaking,
189 }
190 }
191
192 fn entry(commits: Vec<ConventionalCommit>, compare_url: Option<&str>) -> ChangelogEntry {
193 ChangelogEntry {
194 version: "1.0.0".into(),
195 date: "2025-01-01".into(),
196 commits,
197 compare_url: compare_url.map(Into::into),
198 repo_url: None,
199 }
200 }
201
202 fn format(entries: &[ChangelogEntry]) -> String {
203 DefaultChangelogFormatter::new(None, default_changelog_groups())
204 .format(entries)
205 .unwrap()
206 }
207
208 #[test]
209 fn format_features_only() {
210 let out = format(&[entry(
211 vec![make_commit("feat", "add button", None, false)],
212 None,
213 )]);
214 assert!(out.contains("## 1.0.0"));
215 assert!(out.contains("### Features"));
216 assert!(out.contains("add button"));
217 }
218
219 #[test]
220 fn format_fixes_only() {
221 let out = format(&[entry(
222 vec![make_commit("fix", "null check", None, false)],
223 None,
224 )]);
225 assert!(out.contains("### Bug Fixes"));
226 assert!(out.contains("null check"));
227 }
228
229 #[test]
230 fn format_breaking_changes() {
231 let out = format(&[entry(
232 vec![make_commit("feat", "new API", None, true)],
233 None,
234 )]);
235 assert!(out.contains("### Breaking"));
236 }
237
238 #[test]
239 fn format_mixed_commits() {
240 let commits = vec![
241 make_commit("feat", "add button", None, false),
242 make_commit("fix", "null check", None, false),
243 make_commit("feat", "breaking thing", None, true),
244 ];
245 let out = format(&[entry(commits, None)]);
246 assert!(out.contains("### Features"));
247 assert!(out.contains("### Bug Fixes"));
248 assert!(out.contains("### Breaking"));
249 }
250
251 #[test]
252 fn format_with_scope() {
253 let out = format(&[entry(
254 vec![make_commit("feat", "add flag", Some("cli"), false)],
255 None,
256 )]);
257 assert!(out.contains("**cli**:"));
258 }
259
260 #[test]
261 fn format_with_compare_url() {
262 let out = format(&[entry(
263 vec![make_commit("feat", "add button", None, false)],
264 Some("https://github.com/o/r/compare/v0.1.0...v1.0.0"),
265 )]);
266 assert!(out.contains("[Full Changelog]"));
267 }
268
269 #[test]
270 fn format_empty_entries() {
271 let out = format(&[entry(vec![], None)]);
272 assert!(!out.contains("### Features"));
273 assert!(!out.contains("### Bug Fixes"));
274 }
275
276 #[test]
277 fn format_with_commit_links() {
278 let mut e = entry(vec![make_commit("feat", "add button", None, false)], None);
279 e.repo_url = Some("https://github.com/o/r".into());
280 let out = format(&[e]);
281 assert!(out.contains("[abc1234](https://github.com/o/r/commit/abc1234def5678)"));
282 }
283
284 #[test]
285 fn format_breaking_at_top() {
286 let commits = vec![
287 make_commit("feat", "add button", None, false),
288 make_commit("feat", "breaking thing", None, true),
289 ];
290 let out = format(&[entry(commits, None)]);
291 let breaking_pos = out.find("### Breaking").unwrap();
292 let features_pos = out.find("### Features").unwrap();
293 assert!(breaking_pos < features_pos);
294 }
295
296 #[test]
297 fn format_misc_catch_all() {
298 let commits = vec![
299 make_commit("feat", "add button", None, false),
300 make_commit("chore", "tidy up", None, false),
301 make_commit("ci", "fix pipeline", None, false),
302 ];
303 let out = format(&[entry(commits, None)]);
304 assert!(out.contains("### Misc"));
305 assert!(out.contains("tidy up"));
306 assert!(out.contains("fix pipeline"));
307 }
308
309 #[test]
310 fn format_breaking_excluded_from_type_sections() {
311 let commits = vec![
312 make_commit("feat", "normal feature", None, false),
313 make_commit("feat", "breaking feature", None, true),
314 ];
315 let out = format(&[entry(commits, None)]);
316 let features_section_start = out.find("### Features").unwrap();
317 let features_section_end = out[features_section_start..]
318 .find("\n### ")
319 .map(|p| features_section_start + p)
320 .unwrap_or(out.len());
321 let features_section = &out[features_section_start..features_section_end];
322 assert!(features_section.contains("normal feature"));
323 assert!(!features_section.contains("breaking feature"));
324 }
325
326 #[test]
327 fn custom_template_renders() {
328 let template = r#"{% for entry in entries %}Release {{ entry.version }}
329{% for group in entry.groups %}{% if group.commits %}{{ group.name }}:
330{% for c in group.commits %}- {{ c.description }}
331{% endfor %}{% endif %}{% endfor %}{% endfor %}"#;
332 let formatter =
333 DefaultChangelogFormatter::new(Some(template.into()), default_changelog_groups());
334 let out = formatter
335 .format(&[entry(
336 vec![
337 make_commit("feat", "add button", None, false),
338 make_commit("fix", "null check", None, false),
339 ],
340 None,
341 )])
342 .unwrap();
343 assert!(out.contains("Release 1.0.0"));
344 assert!(out.contains("- add button"));
345 assert!(out.contains("- null check"));
346 assert!(!out.contains("### Features"));
347 }
348
349 #[test]
350 fn invalid_template_returns_error() {
351 let template = "{% invalid %}";
352 let formatter =
353 DefaultChangelogFormatter::new(Some(template.into()), default_changelog_groups());
354 let result = formatter.format(&[entry(vec![], None)]);
355 assert!(result.is_err());
356 }
357}