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    /// All commits in this release, flat. Used when rendering a single-package
13    /// repo or when `package_sections` is empty (flat layout).
14    pub commits: Vec<ConventionalCommit>,
15    pub compare_url: Option<String>,
16    pub repo_url: Option<String>,
17    /// Optional per-package sections (path + its commits). When `len() > 1`,
18    /// the default formatter renders per-package subsections under the
19    /// version header instead of a flat list. Ignored for single-package
20    /// repos or when only one package had commits.
21    #[serde(default)]
22    pub package_sections: Vec<PackageSection>,
23}
24
25/// One package's commits under a release entry. Used for per-package
26/// sectioning in monorepos.
27#[derive(Debug, Clone, Serialize)]
28pub struct PackageSection {
29    /// Package path (matches `Config.packages[].path`).
30    pub path: String,
31    pub commits: Vec<ConventionalCommit>,
32}
33
34/// A rendered group of commits for template context.
35#[derive(Debug, Clone, Serialize)]
36pub struct RenderedGroup {
37    pub name: String,
38    pub commits: Vec<ConventionalCommit>,
39}
40
41/// Formats changelog entries into a string representation.
42pub trait ChangelogFormatter: Send + Sync {
43    fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError>;
44}
45
46/// Default formatter using changelog groups.
47/// When a custom template is provided, renders using minijinja.
48pub 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    /// Resolve template: if it's a file path that exists, read it.
59    /// Otherwise treat as inline template string.
60    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    /// Build rendered groups from commits using configured groups.
70    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            // Build groups for each entry and pass to template.
98            #[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        // Built-in default format using groups.
131        let mut output = String::new();
132
133        for entry in entries {
134            output.push_str(&format!("## {} ({})\n", entry.version, entry.date));
135
136            // Decide: per-package sections vs. flat.
137            //
138            // Use per-package sections when more than one package has commits
139            // in this release. Single-package repos (or releases where only
140            // one package changed) render flat for readability.
141            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(&section.commits),
154                        entry.repo_url.as_deref(),
155                        /*heading_level=*/ 4,
156                    );
157                }
158            } else {
159                render_groups(
160                    &mut output,
161                    &self.build_groups(&entry.commits),
162                    entry.repo_url.as_deref(),
163                    /*heading_level=*/ 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}