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