1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use crate::commit::{CommitType, ConventionalCommit};
6use crate::error::ReleaseError;
7
8#[derive(Debug, Clone, Serialize)]
10pub struct ChangelogEntry {
11 pub version: String,
12 pub date: String,
13 pub commits: Vec<ConventionalCommit>,
14 pub compare_url: Option<String>,
15 pub repo_url: Option<String>,
16}
17
18pub trait ChangelogFormatter: Send + Sync {
20 fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError>;
21}
22
23pub struct DefaultChangelogFormatter {
26 template: Option<String>,
27 types: Vec<CommitType>,
28 breaking_section: String,
29 misc_section: String,
30}
31
32impl DefaultChangelogFormatter {
33 pub fn new(
34 template: Option<String>,
35 types: Vec<CommitType>,
36 breaking_section: String,
37 misc_section: String,
38 ) -> Self {
39 Self {
40 template,
41 types,
42 breaking_section,
43 misc_section,
44 }
45 }
46}
47
48impl ChangelogFormatter for DefaultChangelogFormatter {
49 fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError> {
50 if let Some(ref template_str) = self.template {
51 return render_template(template_str, entries);
52 }
53
54 let mut output = String::new();
55
56 let mut seen_sections = Vec::new();
58 let mut section_map: BTreeMap<&str, &str> = BTreeMap::new();
59 for ct in &self.types {
60 if let Some(ref section) = ct.section {
61 if !seen_sections.contains(§ion.as_str()) {
62 seen_sections.push(section.as_str());
63 }
64 section_map.insert(&ct.name, section.as_str());
65 }
66 }
67
68 let known_types: BTreeSet<&str> = self.types.iter().map(|t| t.name.as_str()).collect();
70
71 for entry in entries {
72 output.push_str(&format!("## {} ({})\n", entry.version, entry.date));
73
74 let breaking: Vec<_> = entry.commits.iter().filter(|c| c.breaking).collect();
76 if !breaking.is_empty() {
77 output.push_str(&format!("\n### {}\n\n", self.breaking_section));
78 for commit in &breaking {
79 format_commit_line(&mut output, commit, entry.repo_url.as_deref());
80 }
81 }
82
83 for section_name in &seen_sections {
85 let commits_in_section: Vec<_> = entry
86 .commits
87 .iter()
88 .filter(|c| {
89 !c.breaking
90 && section_map
91 .get(c.r#type.as_str())
92 .is_some_and(|s| s == section_name)
93 })
94 .collect();
95
96 if !commits_in_section.is_empty() {
97 output.push_str(&format!("\n### {section_name}\n\n"));
98 for commit in &commits_in_section {
99 format_commit_line(&mut output, commit, entry.repo_url.as_deref());
100 }
101 }
102 }
103
104 let misc: Vec<_> = entry
106 .commits
107 .iter()
108 .filter(|c| {
109 !c.breaking
110 && !section_map.contains_key(c.r#type.as_str())
111 && known_types.contains(c.r#type.as_str())
112 })
113 .collect();
114 if !misc.is_empty() {
115 output.push_str(&format!("\n### {}\n\n", self.misc_section));
116 for commit in &misc {
117 format_commit_line(&mut output, commit, entry.repo_url.as_deref());
118 }
119 }
120
121 if let Some(url) = &entry.compare_url {
122 output.push_str(&format!("\n[Full Changelog]({url})\n"));
123 }
124
125 output.push('\n');
126 }
127
128 Ok(output.trim_end().to_string())
129 }
130}
131
132fn render_template(template_str: &str, entries: &[ChangelogEntry]) -> Result<String, ReleaseError> {
133 let mut env = minijinja::Environment::new();
134 env.add_template("changelog", template_str)
135 .map_err(|e| ReleaseError::Changelog(format!("invalid template: {e}")))?;
136 let tmpl = env
137 .get_template("changelog")
138 .map_err(|e| ReleaseError::Changelog(format!("template error: {e}")))?;
139 let output = tmpl
140 .render(minijinja::context! { entries => entries })
141 .map_err(|e| ReleaseError::Changelog(format!("template render error: {e}")))?;
142 Ok(output.trim_end().to_string())
143}
144
145fn format_commit_line(output: &mut String, commit: &ConventionalCommit, repo_url: Option<&str>) {
146 let short_sha = &commit.sha[..7.min(commit.sha.len())];
147 let sha_display = match repo_url {
148 Some(url) => format!("[{short_sha}]({url}/commit/{})", commit.sha),
149 None => short_sha.to_string(),
150 };
151 if let Some(scope) = &commit.scope {
152 output.push_str(&format!(
153 "- **{scope}**: {} ({sha_display})\n",
154 commit.description
155 ));
156 } else {
157 output.push_str(&format!("- {} ({sha_display})\n", commit.description));
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::commit::default_commit_types;
165
166 fn make_commit(
167 type_: &str,
168 desc: &str,
169 scope: Option<&str>,
170 breaking: bool,
171 ) -> ConventionalCommit {
172 ConventionalCommit {
173 sha: "abc1234def5678".into(),
174 r#type: type_.into(),
175 scope: scope.map(Into::into),
176 description: desc.into(),
177 body: None,
178 breaking,
179 }
180 }
181
182 fn entry(commits: Vec<ConventionalCommit>, compare_url: Option<&str>) -> ChangelogEntry {
183 ChangelogEntry {
184 version: "1.0.0".into(),
185 date: "2025-01-01".into(),
186 commits,
187 compare_url: compare_url.map(Into::into),
188 repo_url: None,
189 }
190 }
191
192 fn format(entries: &[ChangelogEntry]) -> String {
193 DefaultChangelogFormatter::new(
194 None,
195 default_commit_types(),
196 "Breaking Changes".into(),
197 "Miscellaneous".into(),
198 )
199 .format(entries)
200 .unwrap()
201 }
202
203 #[test]
204 fn format_features_only() {
205 let out = format(&[entry(
206 vec![make_commit("feat", "add button", None, false)],
207 None,
208 )]);
209 assert!(out.contains("## 1.0.0"));
210 assert!(out.contains("### Features"));
211 assert!(out.contains("add button"));
212 }
213
214 #[test]
215 fn format_fixes_only() {
216 let out = format(&[entry(
217 vec![make_commit("fix", "null check", None, false)],
218 None,
219 )]);
220 assert!(out.contains("### Bug Fixes"));
221 assert!(out.contains("null check"));
222 }
223
224 #[test]
225 fn format_breaking_changes() {
226 let out = format(&[entry(
227 vec![make_commit("feat", "new API", None, true)],
228 None,
229 )]);
230 assert!(out.contains("### Breaking Changes"));
231 }
232
233 #[test]
234 fn format_mixed_commits() {
235 let commits = vec![
236 make_commit("feat", "add button", None, false),
237 make_commit("fix", "null check", None, false),
238 make_commit("feat", "breaking thing", None, true),
239 ];
240 let out = format(&[entry(commits, None)]);
241 assert!(out.contains("### Features"));
242 assert!(out.contains("### Bug Fixes"));
243 assert!(out.contains("### Breaking Changes"));
244 }
245
246 #[test]
247 fn format_with_scope() {
248 let out = format(&[entry(
249 vec![make_commit("feat", "add flag", Some("cli"), false)],
250 None,
251 )]);
252 assert!(out.contains("**cli**:"));
253 }
254
255 #[test]
256 fn format_with_compare_url() {
257 let out = format(&[entry(
258 vec![make_commit("feat", "add button", None, false)],
259 Some("https://github.com/o/r/compare/v0.1.0...v1.0.0"),
260 )]);
261 assert!(out.contains("[Full Changelog]"));
262 }
263
264 #[test]
265 fn format_empty_entries() {
266 let out = format(&[entry(vec![], None)]);
267 assert!(!out.contains("### Features"));
268 assert!(!out.contains("### Bug Fixes"));
269 assert!(!out.contains("### Breaking Changes"));
270 }
271
272 #[test]
273 fn format_with_commit_links() {
274 let mut e = entry(vec![make_commit("feat", "add button", None, false)], None);
275 e.repo_url = Some("https://github.com/o/r".into());
276 let out = format(&[e]);
277 assert!(out.contains("[abc1234](https://github.com/o/r/commit/abc1234def5678)"));
278 }
279
280 #[test]
281 fn format_breaking_at_top() {
282 let commits = vec![
283 make_commit("feat", "add button", None, false),
284 make_commit("feat", "breaking thing", None, true),
285 ];
286 let out = format(&[entry(commits, None)]);
287 let breaking_pos = out.find("### Breaking Changes").unwrap();
288 let features_pos = out.find("### Features").unwrap();
289 assert!(
290 breaking_pos < features_pos,
291 "Breaking Changes should appear before Features"
292 );
293 }
294
295 #[test]
296 fn format_misc_catch_all() {
297 let commits = vec![
298 make_commit("feat", "add button", None, false),
299 make_commit("chore", "tidy up", None, false),
300 make_commit("ci", "fix pipeline", None, false),
301 ];
302 let out = format(&[entry(commits, None)]);
303 assert!(out.contains("### Miscellaneous"));
304 assert!(out.contains("tidy up"));
305 assert!(out.contains("fix pipeline"));
306 }
307
308 #[test]
309 fn format_breaking_excluded_from_type_sections() {
310 let commits = vec![
311 make_commit("feat", "normal feature", None, false),
312 make_commit("feat", "breaking feature", None, true),
313 ];
314 let out = format(&[entry(commits, None)]);
315 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 format_new_type_sections() {
328 let commits = vec![
329 make_commit("perf", "speed up query", None, false),
330 make_commit("docs", "update readme", None, false),
331 make_commit("refactor", "clean up code", None, false),
332 make_commit("revert", "undo change", None, false),
333 ];
334 let out = format(&[entry(commits, None)]);
335 assert!(out.contains("### Performance"));
336 assert!(out.contains("### Documentation"));
337 assert!(out.contains("### Refactoring"));
338 assert!(out.contains("### Reverts"));
339 }
340
341 #[test]
342 fn custom_template_renders() {
343 let template = r#"{% for entry in entries %}Release {{ entry.version }}
344{% for c in entry.commits %}- {{ c.description }}
345{% endfor %}{% endfor %}"#;
346 let formatter = DefaultChangelogFormatter::new(
347 Some(template.into()),
348 default_commit_types(),
349 "Breaking Changes".into(),
350 "Miscellaneous".into(),
351 );
352 let out = formatter
353 .format(&[entry(
354 vec![
355 make_commit("feat", "add button", None, false),
356 make_commit("fix", "null check", None, false),
357 ],
358 None,
359 )])
360 .unwrap();
361 assert!(out.contains("Release 1.0.0"));
362 assert!(out.contains("- add button"));
363 assert!(out.contains("- null check"));
364 assert!(!out.contains("### Features"));
366 }
367
368 #[test]
369 fn custom_template_access_all_fields() {
370 let template = r#"{% for entry in entries %}## {{ entry.version }} ({{ entry.date }})
371{% for c in entry.commits %}{{ c.type }}{% if c.scope %}({{ c.scope }}){% endif %}{% if c.breaking %}!{% endif %}: {{ c.description }} ({{ c.sha }})
372{% endfor %}{% endfor %}"#;
373 let formatter = DefaultChangelogFormatter::new(
374 Some(template.into()),
375 default_commit_types(),
376 "Breaking Changes".into(),
377 "Miscellaneous".into(),
378 );
379 let out = formatter
380 .format(&[entry(
381 vec![
382 make_commit("feat", "add flag", Some("cli"), false),
383 make_commit("fix", "crash", None, true),
384 ],
385 None,
386 )])
387 .unwrap();
388 assert!(out.contains("feat(cli): add flag"));
389 assert!(out.contains("fix!: crash"));
390 assert!(out.contains("(abc1234def5678)"));
391 }
392
393 #[test]
394 fn invalid_template_returns_error() {
395 let template = "{% invalid %}";
396 let formatter = DefaultChangelogFormatter::new(
397 Some(template.into()),
398 default_commit_types(),
399 "Breaking Changes".into(),
400 "Miscellaneous".into(),
401 );
402 let result = formatter.format(&[entry(vec![], None)]);
403 assert!(result.is_err());
404 let err = result.unwrap_err().to_string();
405 assert!(err.contains("template"));
406 }
407
408 #[test]
409 fn none_template_uses_default_format() {
410 let commits = vec![make_commit("feat", "add button", None, false)];
412 let out = format(&[entry(commits, None)]);
413 assert!(out.contains("## 1.0.0"));
414 assert!(out.contains("### Features"));
415 assert!(out.contains("- add button"));
416 }
417}