1use anyhow::Context;
2use chrono::{NaiveDate, TimeZone, Utc};
3use git_cliff_core::{
4 changelog::Changelog as GitCliffChangelog,
5 commit::Commit,
6 config::{Bump, ChangelogConfig, CommitParser, Config, GitConfig, RemoteConfig, TextProcessor},
7 contributor::RemoteContributor,
8 release::Release,
9};
10use regex::Regex;
11use serde::Serialize;
12use tracing::warn;
13
14use crate::changelog_parser;
15
16pub const CHANGELOG_HEADER: &str = r"# Changelog
17
18All notable changes to this project will be documented in this file.
19
20The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
21and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
22
23## [Unreleased]
24";
25
26pub const CHANGELOG_FILENAME: &str = "CHANGELOG.md";
27pub const RELEASE_LINK: &str = "release_link";
28pub const REMOTE: &str = "remote";
29
30#[derive(Debug)]
31pub struct Changelog<'a> {
32 release: Release<'a>,
33 config: Option<Config>,
34 release_link: Option<String>,
35 package: String,
36 remote: Option<Remote>,
37 pr_link: Option<String>,
38}
39
40#[derive(Debug, Serialize, Clone)]
41pub struct Remote {
42 pub owner: String,
44 pub repo: String,
46 pub link: String,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
51 pub contributors: Vec<RemoteContributor>,
52}
53
54impl Changelog<'_> {
55 pub fn generate(self) -> anyhow::Result<String> {
57 let config = self.changelog_config(None);
58 let changelog = self.get_changelog(&config)?;
59 let mut out = Vec::new();
60 changelog
61 .generate(&mut out)
62 .context("cannot generate changelog")?;
63 String::from_utf8(out).context("cannot convert bytes to string")
64 }
65
66 pub fn prepend(self, old_changelog: impl Into<String>) -> anyhow::Result<String> {
68 let old_changelog: String = old_changelog.into();
69 if is_version_unchanged(&self.release) {
70 return Ok(old_changelog);
72 }
73 let old_header = changelog_parser::parse_header(&old_changelog);
74 let config = self.changelog_config(old_header.clone());
75 let changelog = self.get_changelog(&config)?;
76
77 if let Some(header) = old_header {
80 return compose_changelog(&old_changelog, &changelog, &header);
81 }
82
83 let mut out = Vec::new();
85 changelog
86 .prepend(old_changelog, &mut out)
87 .context("cannot update changelog")?;
88 String::from_utf8(out).context("cannot convert bytes to string")
89 }
90
91 fn get_changelog<'a>(
92 &'a self,
93 config: &'a Config,
94 ) -> Result<GitCliffChangelog<'a>, anyhow::Error> {
95 let mut changelog = GitCliffChangelog::new(vec![self.release.clone()], config, None)
96 .context("error while building changelog")?;
97 add_package_context(&mut changelog, &self.package)?;
98 add_release_link_context(&mut changelog, self.release_link.as_deref())?;
99 add_remote_context(&mut changelog, self.remote.as_ref())?;
100 Ok(changelog)
101 }
102
103 fn changelog_config(&self, header: Option<String>) -> Config {
104 let user_config = self.config.clone().unwrap_or(default_git_cliff_config());
105 Config {
106 changelog: apply_defaults_to_changelog_config(user_config.changelog, header),
107 git: apply_defaults_to_git_config(user_config.git, self.pr_link.as_deref()),
108 remote: user_config.remote,
109 bump: Bump::default(),
110 }
111 }
112}
113
114fn compose_changelog(
115 old_changelog: &str,
116 changelog: &GitCliffChangelog<'_>,
117 header: &str,
118) -> Result<String, anyhow::Error> {
119 let generated = {
120 let mut new_out = Vec::new();
121 changelog
122 .generate(&mut new_out)
123 .context("cannot generate updated changelog")?;
124 String::from_utf8(new_out).context("cannot convert bytes to string")?
125 };
126 let generated_header = crate::changelog_parser::parse_header(&generated);
128 let header_to_strip = if let Some(gen_h) = generated_header {
129 gen_h
130 } else {
131 header.to_string()
132 };
133 let generated_body = generated
135 .strip_prefix(&header_to_strip)
136 .unwrap_or(generated.as_str());
137 let old_body = old_changelog.strip_prefix(header).unwrap_or(old_changelog);
138 Ok(format!("{header}{generated_body}{old_body}"))
139}
140
141fn apply_defaults_to_git_config(git_config: GitConfig, pr_link: Option<&str>) -> GitConfig {
143 let default_git_config = default_git_config(pr_link);
144
145 GitConfig {
146 conventional_commits: git_config.conventional_commits,
147 require_conventional: git_config.require_conventional,
148 filter_unconventional: git_config.filter_unconventional,
149 split_commits: git_config.split_commits,
150 commit_preprocessors: if git_config.commit_preprocessors.is_empty() {
151 default_git_config.commit_preprocessors
152 } else {
153 git_config.commit_preprocessors
154 },
155 commit_parsers: if git_config.commit_parsers.is_empty() {
156 default_git_config.commit_parsers
157 } else {
158 git_config.commit_parsers
159 },
160 protect_breaking_commits: git_config.protect_breaking_commits,
161 filter_commits: git_config.filter_commits,
162 tag_pattern: git_config.tag_pattern,
163 skip_tags: git_config.skip_tags,
164 ignore_tags: git_config.ignore_tags,
165 count_tags: git_config.count_tags,
166 use_branch_tags: git_config.use_branch_tags,
167 topo_order: git_config.topo_order,
168 topo_order_commits: git_config.topo_order_commits,
169 sort_commits: if git_config.sort_commits.is_empty() {
170 default_git_config.sort_commits
171 } else {
172 git_config.sort_commits
173 },
174 limit_commits: git_config.limit_commits,
175 recurse_submodules: git_config.recurse_submodules,
176 link_parsers: if git_config.link_parsers.is_empty() {
177 default_git_config.link_parsers
178 } else {
179 git_config.link_parsers
180 },
181 exclude_paths: git_config.exclude_paths,
182 include_paths: git_config.include_paths,
183 }
184}
185
186fn add_package_context(
187 changelog: &mut GitCliffChangelog,
188 package: &str,
189) -> Result<(), anyhow::Error> {
190 changelog
191 .add_context("package", package)
192 .with_context(|| format!("failed to add `{package}` to the `package` changelog context"))?;
193 Ok(())
194}
195
196fn add_release_link_context(
197 changelog: &mut GitCliffChangelog,
198 release_link: Option<&str>,
199) -> Result<(), anyhow::Error> {
200 if let Some(release_link) = release_link {
201 changelog
202 .add_context(RELEASE_LINK, release_link)
203 .with_context(|| {
204 format!(
205 "failed to add `{release_link:?}` to the `{RELEASE_LINK}` changelog context"
206 )
207 })?;
208 }
209 Ok(())
210}
211
212fn add_remote_context(
213 changelog: &mut GitCliffChangelog,
214 remote: Option<&Remote>,
215) -> Result<(), anyhow::Error> {
216 if let Some(remote) = remote {
217 add_context(changelog, REMOTE, remote)?;
218 }
219 Ok(())
220}
221
222fn add_context(
223 changelog: &mut GitCliffChangelog,
224 key: &str,
225 value: impl serde::Serialize,
226) -> Result<(), anyhow::Error> {
227 let value_str = serde_json::to_string(&value).context("failed to serialize value")?;
228 changelog
229 .add_context(key, value)
230 .with_context(|| format!("failed to add `{value_str}` to the `{key}` changelog context"))
231}
232
233fn apply_defaults_to_changelog_config(
235 changelog: ChangelogConfig,
236 header: Option<String>,
237) -> ChangelogConfig {
238 let default_changelog_config = default_changelog_config(header);
239
240 ChangelogConfig {
241 header: changelog.header.or(default_changelog_config.header),
242 body: if changelog.body.is_empty() {
243 default_changelog_config.body
244 } else {
245 changelog.body
246 },
247 footer: changelog.footer.or(default_changelog_config.footer),
248 trim: changelog.trim,
249 render_always: changelog.render_always,
250 postprocessors: if changelog.postprocessors.is_empty() {
251 default_changelog_config.postprocessors
252 } else {
253 changelog.postprocessors
254 },
255 output: changelog.output.or(default_changelog_config.output),
256 }
257}
258
259fn is_version_unchanged(release: &Release) -> bool {
260 let previous_version = release.previous.as_ref().and_then(|r| r.version.as_deref());
261 let new_version = release.version.as_deref();
262 previous_version == new_version
263}
264
265fn default_git_cliff_config() -> Config {
266 Config {
267 changelog: default_changelog_config(None),
268 git: default_git_config(None),
269 remote: RemoteConfig::default(),
270 bump: Bump::default(),
271 }
272}
273
274#[derive(Debug, Clone)]
275pub struct ChangelogBuilder<'a> {
276 commits: Vec<Commit<'a>>,
277 version: String,
278 previous_version: Option<String>,
279 config: Option<Config>,
280 remote: Option<Remote>,
281 release_date: Option<NaiveDate>,
282 release_link: Option<String>,
283 package: String,
284 pr_link: Option<String>,
285}
286
287impl<'a> ChangelogBuilder<'a> {
288 pub fn new(
289 commits: Vec<Commit<'a>>,
290 version: impl Into<String>,
291 package: impl Into<String>,
292 ) -> Self {
293 Self {
294 commits,
295 version: version.into(),
296 previous_version: None,
297 config: None,
298 release_date: None,
299 remote: None,
300 release_link: None,
301 package: package.into(),
302 pr_link: None,
303 }
304 }
305
306 pub fn with_previous_version(self, previous_version: impl Into<String>) -> Self {
307 Self {
308 previous_version: Some(previous_version.into()),
309 ..self
310 }
311 }
312
313 pub fn with_pr_link(self, pr_link: impl Into<String>) -> Self {
314 Self {
315 pr_link: Some(pr_link.into()),
316 ..self
317 }
318 }
319
320 pub fn with_release_date(self, release_date: NaiveDate) -> Self {
321 Self {
322 release_date: Some(release_date),
323 ..self
324 }
325 }
326
327 pub fn with_release_link(self, release_link: impl Into<String>) -> Self {
328 Self {
329 release_link: Some(release_link.into()),
330 ..self
331 }
332 }
333
334 pub fn with_config(self, config: Config) -> Self {
335 Self {
336 config: Some(config),
337 ..self
338 }
339 }
340
341 pub fn with_remote(self, remote: Remote) -> Self {
342 Self {
343 remote: Some(remote),
344 ..self
345 }
346 }
347
348 pub fn config(&self) -> Option<&Config> {
349 self.config.as_ref()
350 }
351
352 pub fn build(&self) -> Changelog<'a> {
353 let git_config = self
354 .config
355 .clone()
356 .map(|c| c.git)
357 .unwrap_or_else(|| default_git_config(self.pr_link.as_deref()));
358 let release_date = self.release_timestamp();
359 let mut commits: Vec<_> = self
360 .commits
361 .iter()
362 .filter_map(|c| c.process(&git_config).ok())
363 .collect();
364
365 match git_config.sort_commits.to_lowercase().as_str() {
366 "oldest" => {
367 commits.reverse();
368 }
369 "newest" => {
370 }
372 other => {
373 warn!(
374 "Invalid setting for sort_commits: '{other}'. Valid values are 'newest' and 'oldest'."
375 );
376 }
377 }
378
379 let previous = self.previous_version.as_ref().map(|ver| Release {
380 version: Some(ver.clone()),
381 commits: vec![],
382 commit_id: None,
383 timestamp: Some(0),
384 previous: None,
385 message: None,
386 repository: None,
387 ..Default::default()
388 });
389
390 Changelog {
391 release: Release {
392 version: Some(self.version.clone()),
393 commits,
394 commit_id: None,
395 timestamp: Some(release_date),
396 previous: previous.map(Box::new),
397 message: None,
398 repository: None,
399 ..Default::default()
400 },
401 remote: self.remote.clone(),
402 release_link: self.release_link.clone(),
403 config: self.config.clone(),
404 package: self.package.clone(),
405 pr_link: self.pr_link.clone(),
406 }
407 }
408
409 fn release_timestamp(&self) -> i64 {
412 self.release_date
413 .and_then(|date| date.and_hms_opt(0, 0, 0))
414 .map(|d| Utc.from_utc_datetime(&d))
415 .unwrap_or_else(Utc::now)
416 .timestamp()
417 }
418}
419
420pub fn default_git_config(pr_link: Option<&str>) -> GitConfig {
421 GitConfig {
422 conventional_commits: true,
423 filter_unconventional: false,
424 commit_parsers: kac_commit_parsers(),
425 filter_commits: false,
426 tag_pattern: None,
427 skip_tags: None,
428 split_commits: false,
429 protect_breaking_commits: false,
430 topo_order: false,
431 ignore_tags: None,
432 limit_commits: None,
433 sort_commits: "newest".to_string(),
434 commit_preprocessors: pr_link
435 .map(|pr_link| {
436 vec![TextProcessor {
439 pattern: Regex::new(r"\(#([0-9]+)\)").expect("invalid regex"),
440 replace: Some(format!("([#${{1}}]({pr_link}/${{1}}))")),
441 replace_command: None,
442 }]
443 })
444 .unwrap_or_default(),
445 link_parsers: vec![],
446 ..Default::default()
447 }
448}
449
450fn commit_parser(regex: &str, group: &str) -> CommitParser {
451 CommitParser {
452 message: Regex::new(regex).ok(),
453 body: None,
454 group: Some(group.to_string()),
455 default_scope: None,
456 scope: None,
457 skip: None,
458 field: None,
459 pattern: None,
460 sha: None,
461 footer: None,
462 }
463}
464
465fn kac_commit_parsers() -> Vec<CommitParser> {
467 vec![
468 commit_parser("^feat", "added"),
469 commit_parser("^changed", "changed"),
470 commit_parser("^deprecated", "deprecated"),
471 commit_parser("^removed", "removed"),
472 commit_parser("^fix", "fixed"),
473 commit_parser("^security", "security"),
474 commit_parser(".*", "other"),
475 ]
476}
477
478pub fn default_changelog_config(header: Option<String>) -> ChangelogConfig {
479 ChangelogConfig {
480 header: Some(header.unwrap_or(String::from(CHANGELOG_HEADER))),
481 body: default_changelog_body_config().to_string(),
482 footer: None,
483 postprocessors: vec![],
484 trim: true,
485 ..ChangelogConfig::default()
486 }
487}
488
489fn default_changelog_body_config() -> &'static str {
490 r#"
491## [{{ version }}]{%- if release_link -%}({{ release_link }}){% endif %} - {{ timestamp | date(format="%Y-%m-%d") }}
492{% for group, commits in commits | group_by(attribute="group") %}
493### {{ group | upper_first }}
494
495{% for commit in commits %}
496{%- if commit.scope -%}
497- *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %}
498{% else -%}
499- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}
500{% endif -%}
501{% endfor -%}
502{% endfor %}"#
503}
504
505#[cfg(test)]
506mod tests {
507 use crate::NO_COMMIT_ID;
508
509 use super::*;
510
511 #[test]
512 fn changelog_entries_are_generated() {
513 let commits = vec![
514 Commit::new(NO_COMMIT_ID.to_string(), "fix: myfix".to_string()),
515 Commit::new(NO_COMMIT_ID.to_string(), "simple update".to_string()),
516 ];
517 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
518 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
519 .build();
520
521 expect_test::expect,
527 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
528
529 ## [Unreleased]
530
531 ## [1.1.1] - 2015-05-15
532
533 ### Fixed
534
535 - myfix
536
537 ### Other
538
539 - simple update
540 "]]
541 .assert_eq(&changelog.generate().unwrap());
542 }
543
544 #[test]
545 fn changelog_entry_with_link_is_generated() {
546 let commits = vec![Commit::new(
547 NO_COMMIT_ID.to_string(),
548 "fix: myfix".to_string(),
549 )];
550 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
551 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
552 .with_release_link("https://github.com/release-plz/release-plz/compare/release-plz-v0.2.24...release-plz-v0.2.25")
553 .build();
554
555 expect_test::expect,
561 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
562
563 ## [Unreleased]
564
565 ## [1.1.1](https://github.com/release-plz/release-plz/compare/release-plz-v0.2.24...release-plz-v0.2.25) - 2015-05-15
566
567 ### Fixed
568
569 - myfix
570 "]]
571 .assert_eq(&changelog.generate().unwrap());
572 }
573
574 #[test]
575 fn generated_changelog_is_updated_correctly() {
576 let commits = vec![
577 Commit::new(NO_COMMIT_ID.to_string(), "fix: myfix".to_string()),
578 Commit::new(NO_COMMIT_ID.to_string(), "simple update".to_string()),
579 ];
580 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
581 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
582 .build();
583
584 let generated_changelog = changelog.generate().unwrap();
585
586 let commits = vec![
587 Commit::new(NO_COMMIT_ID.to_string(), "fix: myfix2".to_string()),
588 Commit::new(NO_COMMIT_ID.to_string(), "complex update".to_string()),
589 ];
590 let changelog = ChangelogBuilder::new(commits, "1.1.2", "my_pkg")
591 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
592 .build();
593
594 expect_test::expect,
600 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
601
602 ## [Unreleased]
603
604 ## [1.1.2] - 2015-05-15
605
606 ### Fixed
607
608 - myfix2
609
610 ### Other
611
612 - complex update
613
614 ## [1.1.1] - 2015-05-15
615
616 ### Fixed
617
618 - myfix
619
620 ### Other
621
622 - simple update
623 "]]
624 .assert_eq(&changelog.prepend(generated_changelog).unwrap());
625 }
626
627 #[test]
628 fn changelog_is_updated() {
629 let commits = vec![
630 Commit::new(NO_COMMIT_ID.to_string(), "fix: myfix".to_string()),
631 Commit::new(NO_COMMIT_ID.to_string(), "simple update".to_string()),
632 ];
633 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
634 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
635 .build();
636 let old_body = r"## [1.1.0] - 1970-01-01
637
638### fix bugs
639
640- my awesomefix
641
642### other
643
644- complex update
645";
646 let old = format!("{CHANGELOG_HEADER}\n{old_body}");
647 let new = changelog.prepend(old).unwrap();
648 expect_test::expect,
654 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
655
656 ## [Unreleased]
657
658 ## [1.1.1] - 2015-05-15
659
660 ### Fixed
661
662 - myfix
663
664 ### Other
665
666 - simple update
667
668 ## [1.1.0] - 1970-01-01
669
670 ### fix bugs
671
672 - my awesomefix
673
674 ### other
675
676 - complex update
677 "]]
678 .assert_eq(&new);
679 }
680
681 #[test]
682 fn changelog_without_header_is_updated() {
683 let commits = vec![
684 Commit::new(NO_COMMIT_ID.to_string(), "fix: myfix".to_string()),
685 Commit::new(NO_COMMIT_ID.to_string(), "simple update".to_string()),
686 ];
687 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
688 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
689 .build();
690 let old = r"
691## [1.1.0] - 1970-01-01
692
693### fix bugs
694
695- my awesomefix
696
697### other
698
699- complex update
700";
701 let new = changelog.prepend(old);
702 expect_test::expect,
708 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
709
710 ## [Unreleased]
711
712 ## [1.1.1] - 2015-05-15
713
714 ### Fixed
715
716 - myfix
717
718 ### Other
719
720 - simple update
721
722 ## [1.1.0] - 1970-01-01
723
724 ### fix bugs
725
726 - my awesomefix
727
728 ### other
729
730 - complex update
731 "]]
732 .assert_eq(&new.unwrap());
733 }
734
735 #[test]
736 fn changelog_has_commit_id() {
737 let commits = vec![
738 Commit::new("1111111".to_string(), "fix: myfix".to_string()),
739 Commit::new(
740 NO_COMMIT_ID.to_string(),
741 "chore: something else".to_string(),
742 ),
743 ];
744 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
745 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
746 .with_config(Config {
747 changelog: ChangelogConfig {
748 header: Some("# Changelog".to_string()),
749 body: r"{%- for commit in commits %}
750 {{ commit.message }} - {{ commit.id }}
751 {% endfor -%}"
752 .to_string(),
753 ..default_changelog_config(None)
754 },
755 git: default_git_config(None),
756 remote: RemoteConfig::default(),
757 bump: Bump::default(),
758 })
759 .build();
760
761 expect_test::expect![[r"
762 # Changelog
763
764 myfix - 1111111
765
766 something else - 0000000
767 "]]
768 .assert_eq(&changelog.generate().unwrap());
769 }
770
771 #[test]
772 fn changelog_sort_newest() {
773 let commits = vec![
774 Commit::new("1111111".to_string(), "fix: myfix".to_string()),
775 Commit::new("0000000".to_string(), "fix: another fix".to_string()),
776 ];
777 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
778 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
779 .with_config(Config {
780 changelog: default_changelog_config(None),
781 git: GitConfig {
782 sort_commits: "oldest".to_string(),
783 ..default_git_config(None)
784 },
785 remote: RemoteConfig::default(),
786 bump: Bump::default(),
787 })
788 .build();
789
790 expect_test::expect,
796 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
797
798 ## [Unreleased]
799
800 ## [1.1.1] - 2015-05-15
801
802 ### Fixed
803
804 - another fix
805 - myfix
806 "]]
807 .assert_eq(&changelog.generate().unwrap());
808 }
809}
810
811#[test]
812fn empty_changelog_is_updated() {
813 let commits = vec![
814 Commit::new(crate::NO_COMMIT_ID.to_string(), "fix: myfix".to_string()),
815 Commit::new(crate::NO_COMMIT_ID.to_string(), "simple update".to_string()),
816 ];
817 let changelog = ChangelogBuilder::new(commits, "1.1.1", "my_pkg")
818 .with_release_date(NaiveDate::from_ymd_opt(2015, 5, 15).unwrap())
819 .build();
820 let new = changelog.prepend(CHANGELOG_HEADER);
821 expect_test::expect,
827 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
828
829 ## [Unreleased]
830
831 ## [1.1.1] - 2015-05-15
832
833 ### Fixed
834
835 - myfix
836
837 ### Other
838
839 - simple update
840 "]]
841 .assert_eq(&new.unwrap());
842}