Skip to main content

sr_core/
changelog.rs

1use serde::Serialize;
2
3use crate::commit::ConventionalCommit;
4use crate::config::ChangelogGroup;
5use crate::error::ReleaseError;
6
7/// A single changelog entry representing a release.
8#[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/// A rendered group of commits for template context.
18#[derive(Debug, Clone, Serialize)]
19pub struct RenderedGroup {
20    pub name: String,
21    pub commits: Vec<ConventionalCommit>,
22}
23
24/// Formats changelog entries into a string representation.
25pub trait ChangelogFormatter: Send + Sync {
26    fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError>;
27}
28
29/// Default formatter using changelog groups.
30/// When a custom template is provided, renders using minijinja.
31pub 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    /// Resolve template: if it's a file path that exists, read it.
42    /// Otherwise treat as inline template string.
43    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    /// Build rendered groups from commits using configured groups.
53    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            // Build groups for each entry and pass to template.
81            #[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        // Built-in default format using groups.
114        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                // Convert group name to title case for heading.
125                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}