Skip to main content

sr_core/
changelog.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use crate::commit::{CommitType, ConventionalCommit};
6use crate::error::ReleaseError;
7
8/// A single changelog entry representing a release.
9#[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
18/// Formats changelog entries into a string representation.
19pub trait ChangelogFormatter: Send + Sync {
20    fn format(&self, entries: &[ChangelogEntry]) -> Result<String, ReleaseError>;
21}
22
23/// Default formatter that produces simple markdown output.
24/// When a custom template is provided, renders using minijinja.
25pub 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        // Build ordered list of unique sections, preserving definition order.
57        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(&section.as_str()) {
62                    seen_sections.push(section.as_str());
63                }
64                section_map.insert(&ct.name, section.as_str());
65            }
66        }
67
68        // Set of type names that have an explicit mapping (section or no-section).
69        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            // 1. Breaking changes section (at the top).
75            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            // 2. Type sections (Features, Bug Fixes, Performance, Documentation, etc.)
84            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            // 3. Miscellaneous catch-all (commits with no section mapping, excluding breaking).
105            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        // The breaking commit should be in Breaking Changes, not in Features
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 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        // Should NOT contain default markdown headings
365        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        // Verify that None template produces the same output as before
411        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}