1use crate::commit::Commit;
2use crate::config::{
3 Config,
4 GitConfig,
5};
6use crate::error::{
7 Error,
8 Result,
9};
10use crate::release::{
11 Release,
12 Releases,
13};
14#[cfg(feature = "bitbucket")]
15use crate::remote::bitbucket::BitbucketClient;
16#[cfg(feature = "gitea")]
17use crate::remote::gitea::GiteaClient;
18#[cfg(feature = "github")]
19use crate::remote::github::GitHubClient;
20#[cfg(feature = "gitlab")]
21use crate::remote::gitlab::GitLabClient;
22use crate::template::Template;
23use std::collections::HashMap;
24use std::io::{
25 Read,
26 Write,
27};
28use std::time::{
29 SystemTime,
30 UNIX_EPOCH,
31};
32
33#[derive(Debug)]
35pub struct Changelog<'a> {
36 pub releases: Vec<Release<'a>>,
38 header_template: Option<Template>,
39 body_template: Template,
40 footer_template: Option<Template>,
41 config: &'a Config,
42 additional_context: HashMap<String, serde_json::Value>,
43}
44
45impl<'a> Changelog<'a> {
46 pub fn new(
48 releases: Vec<Release<'a>>,
49 config: &'a Config,
50 range: Option<&str>,
51 ) -> Result<Self> {
52 let mut changelog = Changelog::build(releases, config)?;
53 changelog.add_remote_data(range)?;
54 changelog.process_commits()?;
55 changelog.process_releases();
56 Ok(changelog)
57 }
58
59 fn build(releases: Vec<Release<'a>>, config: &'a Config) -> Result<Self> {
61 let trim = config.changelog.trim;
62 Ok(Self {
63 releases,
64 header_template: match &config.changelog.header {
65 Some(header) => {
66 Some(Template::new("header", header.to_string(), trim)?)
67 }
68 None => None,
69 },
70 body_template: get_body_template(config, trim)?,
71 footer_template: match &config.changelog.footer {
72 Some(footer) => {
73 Some(Template::new("footer", footer.to_string(), trim)?)
74 }
75 None => None,
76 },
77 config,
78 additional_context: HashMap::new(),
79 })
80 }
81
82 pub fn from_context<R: Read>(input: &mut R, config: &'a Config) -> Result<Self> {
84 Changelog::build(serde_json::from_reader(input)?, config)
85 }
86
87 pub fn add_context(
95 &mut self,
96 key: impl Into<String>,
97 value: impl serde::Serialize,
98 ) -> Result<()> {
99 self.additional_context
100 .insert(key.into(), serde_json::to_value(value)?);
101 Ok(())
102 }
103
104 fn process_commit(
106 commit: &Commit<'a>,
107 git_config: &GitConfig,
108 ) -> Option<Commit<'a>> {
109 match commit.process(git_config) {
110 Ok(commit) => Some(commit),
111 Err(e) => {
112 trace!(
113 "{} - {} ({})",
114 commit.id.chars().take(7).collect::<String>(),
115 e,
116 commit.message.lines().next().unwrap_or_default().trim()
117 );
118 None
119 }
120 }
121 }
122
123 fn check_conventional_commits(commits: &Vec<Commit<'a>>) -> Result<()> {
126 debug!("Verifying that all commits are conventional.");
127 let mut unconventional_count = 0;
128
129 commits.iter().for_each(|commit| {
130 if commit.conv.is_none() {
131 error!(
132 "Commit {id} is not conventional:\n{message}",
133 id = &commit.id[..7],
134 message = commit
135 .message
136 .lines()
137 .map(|line| { format!(" | {}", line.trim()) })
138 .collect::<Vec<String>>()
139 .join("\n")
140 );
141 unconventional_count += 1;
142 }
143 });
144
145 if unconventional_count > 0 {
146 return Err(Error::UnconventionalCommitsError(unconventional_count));
147 }
148
149 Ok(())
150 }
151
152 fn process_commit_list(
153 commits: &mut Vec<Commit<'a>>,
154 git_config: &GitConfig,
155 ) -> Result<()> {
156 *commits = commits
157 .iter()
158 .filter_map(|commit| Self::process_commit(commit, git_config))
159 .flat_map(|commit| {
160 if git_config.split_commits {
161 commit
162 .message
163 .lines()
164 .filter_map(|line| {
165 let mut c = commit.clone();
166 c.message = line.to_string();
167 if c.message.is_empty() {
168 None
169 } else {
170 Self::process_commit(&c, git_config)
171 }
172 })
173 .collect()
174 } else {
175 vec![commit]
176 }
177 })
178 .collect::<Vec<Commit>>();
179
180 if git_config.require_conventional {
181 Self::check_conventional_commits(commits)?;
182 }
183
184 Ok(())
185 }
186
187 fn process_commits(&mut self) -> Result<()> {
190 debug!("Processing the commits...");
191 for release in self.releases.iter_mut() {
192 Self::process_commit_list(&mut release.commits, &self.config.git)?;
193 for submodule_commits in release.submodule_commits.values_mut() {
194 Self::process_commit_list(submodule_commits, &self.config.git)?;
195 }
196 }
197 Ok(())
198 }
199
200 fn process_releases(&mut self) {
202 debug!("Processing {} release(s)...", self.releases.len());
203 let skip_regex = self.config.git.skip_tags.as_ref();
204 let mut skipped_tags = Vec::new();
205 self.releases = self
206 .releases
207 .clone()
208 .into_iter()
209 .rev()
210 .filter(|release| {
211 if release.commits.is_empty() {
212 if let Some(version) = release.version.clone() {
213 trace!("Release doesn't have any commits: {}", version);
214 }
215 match &release.previous {
216 Some(prev_release) if prev_release.commits.is_empty() => {
217 self.config.changelog.render_always
218 }
219 _ => false,
220 }
221 } else if let Some(version) = &release.version {
222 !skip_regex.is_some_and(|r| {
223 let skip_tag = r.is_match(version);
224 if skip_tag {
225 skipped_tags.push(version.clone());
226 trace!("Skipping release: {}", version);
227 }
228 skip_tag
229 })
230 } else {
231 true
232 }
233 })
234 .collect();
235 for skipped_tag in &skipped_tags {
236 if let Some(release_index) = self.releases.iter().position(|release| {
237 release
238 .previous
239 .as_ref()
240 .and_then(|release| release.version.as_ref()) ==
241 Some(skipped_tag)
242 }) {
243 if let Some(previous_release) =
244 self.releases.get_mut(release_index + 1)
245 {
246 previous_release.previous = None;
247 self.releases[release_index].previous =
248 Some(Box::new(previous_release.clone()));
249 } else if release_index == self.releases.len() - 1 {
250 self.releases[release_index].previous = None;
251 }
252 }
253 }
254 }
255
256 #[cfg(feature = "github")]
270 fn get_github_metadata(
271 &self,
272 ref_name: Option<&str>,
273 ) -> Result<crate::remote::RemoteMetadata> {
274 use crate::remote::github;
275 if self.config.remote.github.is_custom ||
276 self.body_template
277 .contains_variable(github::TEMPLATE_VARIABLES) ||
278 self.footer_template
279 .as_ref()
280 .map(|v| v.contains_variable(github::TEMPLATE_VARIABLES))
281 .unwrap_or(false)
282 {
283 debug!("You are using an experimental feature! Please report bugs at <https://git-cliff.org/issues>");
284 let github_client =
285 GitHubClient::try_from(self.config.remote.github.clone())?;
286 info!(
287 "{} ({})",
288 github::START_FETCHING_MSG,
289 self.config.remote.github
290 );
291 let data = tokio::runtime::Builder::new_multi_thread()
292 .enable_all()
293 .build()?
294 .block_on(async {
295 let (commits, pull_requests) = tokio::try_join!(
296 github_client.get_commits(ref_name),
297 github_client.get_pull_requests(ref_name),
298 )?;
299 debug!("Number of GitHub commits: {}", commits.len());
300 debug!(
301 "Number of GitHub pull requests: {}",
302 pull_requests.len()
303 );
304 Ok((commits, pull_requests))
305 });
306 info!("{}", github::FINISHED_FETCHING_MSG);
307 data
308 } else {
309 Ok((vec![], vec![]))
310 }
311 }
312
313 #[cfg(feature = "gitlab")]
329 fn get_gitlab_metadata(
330 &self,
331 ref_name: Option<&str>,
332 ) -> Result<crate::remote::RemoteMetadata> {
333 use crate::remote::gitlab;
334 if self.config.remote.gitlab.is_custom ||
335 self.body_template
336 .contains_variable(gitlab::TEMPLATE_VARIABLES) ||
337 self.footer_template
338 .as_ref()
339 .map(|v| v.contains_variable(gitlab::TEMPLATE_VARIABLES))
340 .unwrap_or(false)
341 {
342 debug!("You are using an experimental feature! Please report bugs at <https://git-cliff.org/issues>");
343 let gitlab_client =
344 GitLabClient::try_from(self.config.remote.gitlab.clone())?;
345 info!(
346 "{} ({})",
347 gitlab::START_FETCHING_MSG,
348 self.config.remote.gitlab
349 );
350 let data = tokio::runtime::Builder::new_multi_thread()
351 .enable_all()
352 .build()?
353 .block_on(async {
354 let project_id =
356 match tokio::join!(gitlab_client.get_project(ref_name)) {
357 (Ok(project),) => project.id,
358 (Err(err),) => {
359 error!("Failed to lookup project! {}", err);
360 return Err(err);
361 }
362 };
363 let (commits, merge_requests) = tokio::try_join!(
364 gitlab_client.get_commits(project_id, ref_name),
366 gitlab_client.get_merge_requests(project_id, ref_name),
367 )?;
368 debug!("Number of GitLab commits: {}", commits.len());
369 debug!(
370 "Number of GitLab merge requests: {}",
371 merge_requests.len()
372 );
373 Ok((commits, merge_requests))
374 });
375 info!("{}", gitlab::FINISHED_FETCHING_MSG);
376 data
377 } else {
378 Ok((vec![], vec![]))
379 }
380 }
381
382 #[cfg(feature = "gitea")]
396 fn get_gitea_metadata(
397 &self,
398 ref_name: Option<&str>,
399 ) -> Result<crate::remote::RemoteMetadata> {
400 use crate::remote::gitea;
401 if self.config.remote.gitea.is_custom ||
402 self.body_template
403 .contains_variable(gitea::TEMPLATE_VARIABLES) ||
404 self.footer_template
405 .as_ref()
406 .map(|v| v.contains_variable(gitea::TEMPLATE_VARIABLES))
407 .unwrap_or(false)
408 {
409 debug!("You are using an experimental feature! Please report bugs at <https://git-cliff.org/issues>");
410 let gitea_client =
411 GiteaClient::try_from(self.config.remote.gitea.clone())?;
412 info!(
413 "{} ({})",
414 gitea::START_FETCHING_MSG,
415 self.config.remote.gitea
416 );
417 let data = tokio::runtime::Builder::new_multi_thread()
418 .enable_all()
419 .build()?
420 .block_on(async {
421 let (commits, pull_requests) = tokio::try_join!(
422 gitea_client.get_commits(ref_name),
423 gitea_client.get_pull_requests(ref_name),
424 )?;
425 debug!("Number of Gitea commits: {}", commits.len());
426 debug!("Number of Gitea pull requests: {}", pull_requests.len());
427 Ok((commits, pull_requests))
428 });
429 info!("{}", gitea::FINISHED_FETCHING_MSG);
430 data
431 } else {
432 Ok((vec![], vec![]))
433 }
434 }
435
436 #[cfg(feature = "bitbucket")]
452 fn get_bitbucket_metadata(
453 &self,
454 ref_name: Option<&str>,
455 ) -> Result<crate::remote::RemoteMetadata> {
456 use crate::remote::bitbucket;
457 if self.config.remote.bitbucket.is_custom ||
458 self.body_template
459 .contains_variable(bitbucket::TEMPLATE_VARIABLES) ||
460 self.footer_template
461 .as_ref()
462 .map(|v| v.contains_variable(bitbucket::TEMPLATE_VARIABLES))
463 .unwrap_or(false)
464 {
465 debug!("You are using an experimental feature! Please report bugs at <https://git-cliff.org/issues>");
466 let bitbucket_client =
467 BitbucketClient::try_from(self.config.remote.bitbucket.clone())?;
468 info!(
469 "{} ({})",
470 bitbucket::START_FETCHING_MSG,
471 self.config.remote.bitbucket
472 );
473 let data = tokio::runtime::Builder::new_multi_thread()
474 .enable_all()
475 .build()?
476 .block_on(async {
477 let (commits, pull_requests) = tokio::try_join!(
478 bitbucket_client.get_commits(ref_name),
479 bitbucket_client.get_pull_requests(ref_name)
480 )?;
481 debug!("Number of Bitbucket commits: {}", commits.len());
482 debug!(
483 "Number of Bitbucket pull requests: {}",
484 pull_requests.len()
485 );
486 Ok((commits, pull_requests))
487 });
488 info!("{}", bitbucket::FINISHED_FETCHING_MSG);
489 data
490 } else {
491 Ok((vec![], vec![]))
492 }
493 }
494
495 pub fn add_remote_context(&mut self) -> Result<()> {
497 self.additional_context.insert(
498 "remote".to_string(),
499 serde_json::to_value(self.config.remote.clone())?,
500 );
501 Ok(())
502 }
503
504 pub fn add_remote_data(&mut self, range: Option<&str>) -> Result<()> {
506 debug!("Adding remote data...");
507 self.add_remote_context()?;
508
509 let range_head = range.and_then(|r| r.split("..").last());
512 let ref_name = match range_head {
513 Some("HEAD") => None,
514 other => other,
515 };
516
517 #[cfg(feature = "github")]
518 let (github_commits, github_pull_requests) = if self.config.remote.github.is_set()
519 {
520 self.get_github_metadata(ref_name)
521 .expect("Could not get github metadata")
522 } else {
523 (vec![], vec![])
524 };
525 #[cfg(feature = "gitlab")]
526 let (gitlab_commits, gitlab_merge_request) = if self.config.remote.gitlab.is_set()
527 {
528 self.get_gitlab_metadata(ref_name)
529 .expect("Could not get gitlab metadata")
530 } else {
531 (vec![], vec![])
532 };
533 #[cfg(feature = "gitea")]
534 let (gitea_commits, gitea_merge_request) = if self.config.remote.gitea.is_set() {
535 self.get_gitea_metadata(ref_name)
536 .expect("Could not get gitea metadata")
537 } else {
538 (vec![], vec![])
539 };
540 #[cfg(feature = "bitbucket")]
541 let (bitbucket_commits, bitbucket_pull_request) =
542 if self.config.remote.bitbucket.is_set() {
543 self.get_bitbucket_metadata(ref_name)
544 .expect("Could not get bitbucket metadata")
545 } else {
546 (vec![], vec![])
547 };
548 #[cfg(feature = "remote")]
549 for release in &mut self.releases {
550 #[cfg(feature = "github")]
551 release.update_github_metadata(
552 github_commits.clone(),
553 github_pull_requests.clone(),
554 )?;
555 #[cfg(feature = "gitlab")]
556 release.update_gitlab_metadata(
557 gitlab_commits.clone(),
558 gitlab_merge_request.clone(),
559 )?;
560 #[cfg(feature = "gitea")]
561 release.update_gitea_metadata(
562 gitea_commits.clone(),
563 gitea_merge_request.clone(),
564 )?;
565 #[cfg(feature = "bitbucket")]
566 release.update_bitbucket_metadata(
567 bitbucket_commits.clone(),
568 bitbucket_pull_request.clone(),
569 )?;
570 }
571 Ok(())
572 }
573
574 pub fn bump_version(&mut self) -> Result<Option<String>> {
576 if let Some(ref mut last_release) = self.releases.iter_mut().next() {
577 if last_release.version.is_none() {
578 let next_version = last_release
579 .calculate_next_version_with_config(&self.config.bump)?;
580 debug!("Bumping the version to {next_version}");
581 last_release.version = Some(next_version.to_string());
582 last_release.timestamp = SystemTime::now()
583 .duration_since(UNIX_EPOCH)?
584 .as_secs()
585 .try_into()?;
586 return Ok(Some(next_version));
587 }
588 }
589 Ok(None)
590 }
591
592 pub fn generate<W: Write + ?Sized>(&self, out: &mut W) -> Result<()> {
594 debug!("Generating changelog...");
595 let postprocessors = self.config.changelog.postprocessors.clone();
596
597 if let Some(header_template) = &self.header_template {
598 let write_result = writeln!(
599 out,
600 "{}",
601 header_template.render(
602 &Releases {
603 releases: &self.releases,
604 },
605 Some(&self.additional_context),
606 &postprocessors,
607 )?
608 );
609 if let Err(e) = write_result {
610 if e.kind() != std::io::ErrorKind::BrokenPipe {
611 return Err(e.into());
612 }
613 }
614 }
615
616 for release in &self.releases {
617 let write_result = write!(
618 out,
619 "{}",
620 self.body_template.render(
621 &release,
622 Some(&self.additional_context),
623 &postprocessors
624 )?
625 );
626 if let Err(e) = write_result {
627 if e.kind() != std::io::ErrorKind::BrokenPipe {
628 return Err(e.into());
629 }
630 }
631 }
632
633 if let Some(footer_template) = &self.footer_template {
634 let write_result = writeln!(
635 out,
636 "{}",
637 footer_template.render(
638 &Releases {
639 releases: &self.releases,
640 },
641 Some(&self.additional_context),
642 &postprocessors,
643 )?
644 );
645 if let Err(e) = write_result {
646 if e.kind() != std::io::ErrorKind::BrokenPipe {
647 return Err(e.into());
648 }
649 }
650 }
651
652 Ok(())
653 }
654
655 pub fn prepend<W: Write + ?Sized>(
657 &self,
658 mut changelog: String,
659 out: &mut W,
660 ) -> Result<()> {
661 debug!("Generating changelog and prepending...");
662 if let Some(header) = &self.config.changelog.header {
663 changelog = changelog.replacen(header, "", 1);
664 }
665 self.generate(out)?;
666 write!(out, "{changelog}")?;
667 Ok(())
668 }
669
670 pub fn write_context<W: Write + ?Sized>(&self, out: &mut W) -> Result<()> {
672 let output = Releases {
673 releases: &self.releases,
674 }
675 .as_json()?;
676 writeln!(out, "{output}")?;
677 Ok(())
678 }
679}
680
681fn get_body_template(config: &Config, trim: bool) -> Result<Template> {
682 let template = Template::new("body", config.changelog.body.clone(), trim)?;
683 let deprecated_vars = [
684 "commit.github",
685 "commit.gitea",
686 "commit.gitlab",
687 "commit.bitbucket",
688 ];
689 if template.contains_variable(&deprecated_vars) {
690 warn!(
691 "Variables {deprecated_vars:?} are deprecated and will be removed in \
692 the future. Use `commit.remote` instead."
693 );
694 }
695 Ok(template)
696}
697
698#[cfg(test)]
699mod test {
700 use super::*;
701 use crate::config::{
702 Bump,
703 ChangelogConfig,
704 CommitParser,
705 Remote,
706 RemoteConfig,
707 TextProcessor,
708 };
709 use pretty_assertions::assert_eq;
710 use regex::Regex;
711 use std::str;
712
713 fn get_test_data() -> (Config, Vec<Release<'static>>) {
714 let config = Config {
715 changelog: ChangelogConfig {
716 header: Some(String::from("# Changelog")),
717 body: String::from(
718 r#"{% if version %}
719 ## Release [{{ version }}] - {{ timestamp | date(format="%Y-%m-%d") }} - ({{ repository }})
720 {% if commit_id %}({{ commit_id }}){% endif %}{% else %}
721 ## Unreleased{% endif %}
722 {% for group, commits in commits | group_by(attribute="group") %}
723 ### {{ group }}{% for group, commits in commits | group_by(attribute="scope") %}
724 #### {{ group }}{% for commit in commits %}
725 - {{ commit.message }}{% endfor %}
726 {% endfor %}{% endfor %}"#,
727 ),
728 footer: Some(String::from(
729 r#"-- total releases: {{ releases | length }} --"#,
730 )),
731 trim: true,
732 postprocessors: vec![TextProcessor {
733 pattern: Regex::new("boring")
734 .expect("failed to compile regex"),
735 replace: Some(String::from("exciting")),
736 replace_command: None,
737 }],
738 render_always: false,
739 output: None,
740 },
741 git: GitConfig {
742 conventional_commits: true,
743 require_conventional: false,
744 filter_unconventional: false,
745 split_commits: false,
746 commit_preprocessors: vec![TextProcessor {
747 pattern: Regex::new("<preprocess>")
748 .expect("failed to compile regex"),
749 replace: Some(String::from(
750 "this commit is preprocessed",
751 )),
752 replace_command: None,
753 }],
754 commit_parsers: vec![
755 CommitParser {
756 sha: Some(String::from("tea")),
757 message: None,
758 body: None,
759 footer: None,
760 group: Some(String::from("I love tea")),
761 default_scope: None,
762 scope: None,
763 skip: None,
764 field: None,
765 pattern: None,
766 },
767 CommitParser {
768 sha: Some(String::from("coffee")),
769 message: None,
770 body: None,
771 footer: None,
772 group: None,
773 default_scope: None,
774 scope: None,
775 skip: Some(true),
776 field: None,
777 pattern: None,
778 },
779 CommitParser {
780 sha: Some(String::from("coffee2")),
781 message: None,
782 body: None,
783 footer: None,
784 group: None,
785 default_scope: None,
786 scope: None,
787 skip: Some(true),
788 field: None,
789 pattern: None,
790 },
791 CommitParser {
792 sha: None,
793 message: Regex::new(r".*merge.*").ok(),
794 body: None,
795 footer: None,
796 group: None,
797 default_scope: None,
798 scope: None,
799 skip: Some(true),
800 field: None,
801 pattern: None,
802 },
803 CommitParser {
804 sha: None,
805 message: Regex::new("feat*").ok(),
806 body: None,
807 footer: None,
808 group: Some(String::from("New features")),
809 default_scope: Some(String::from("other")),
810 scope: None,
811 skip: None,
812 field: None,
813 pattern: None,
814 },
815 CommitParser {
816 sha: None,
817 message: Regex::new("^fix*").ok(),
818 body: None,
819 footer: None,
820 group: Some(String::from("Bug Fixes")),
821 default_scope: None,
822 scope: None,
823 skip: None,
824 field: None,
825 pattern: None,
826 },
827 CommitParser {
828 sha: None,
829 message: Regex::new("doc:").ok(),
830 body: None,
831 footer: None,
832 group: Some(String::from("Documentation")),
833 default_scope: None,
834 scope: Some(String::from("documentation")),
835 skip: None,
836 field: None,
837 pattern: None,
838 },
839 CommitParser {
840 sha: None,
841 message: Regex::new("docs:").ok(),
842 body: None,
843 footer: None,
844 group: Some(String::from("Documentation")),
845 default_scope: None,
846 scope: Some(String::from("documentation")),
847 skip: None,
848 field: None,
849 pattern: None,
850 },
851 CommitParser {
852 sha: None,
853 message: Regex::new(r"match\((.*)\):.*").ok(),
854 body: None,
855 footer: None,
856 group: Some(String::from("Matched ($1)")),
857 default_scope: None,
858 scope: None,
859 skip: None,
860 field: None,
861 pattern: None,
862 },
863 CommitParser {
864 sha: None,
865 message: None,
866 body: None,
867 footer: Regex::new("Footer:.*").ok(),
868 group: Some(String::from("Footer")),
869 default_scope: None,
870 scope: Some(String::from("footer")),
871 skip: None,
872 field: None,
873 pattern: None,
874 },
875 CommitParser {
876 sha: None,
877 message: Regex::new(".*").ok(),
878 body: None,
879 footer: None,
880 group: Some(String::from("Other")),
881 default_scope: Some(String::from("other")),
882 scope: None,
883 skip: None,
884 field: None,
885 pattern: None,
886 },
887 ],
888 protect_breaking_commits: false,
889 filter_commits: false,
890 tag_pattern: None,
891 skip_tags: Regex::new("v3.*").ok(),
892 ignore_tags: None,
893 count_tags: None,
894 use_branch_tags: false,
895 topo_order: false,
896 topo_order_commits: true,
897 sort_commits: String::from("oldest"),
898 link_parsers: [].to_vec(),
899 limit_commits: None,
900 recurse_submodules: None,
901 },
902 remote: RemoteConfig {
903 github: Remote {
904 owner: String::from("coolguy"),
905 repo: String::from("awesome"),
906 token: None,
907 is_custom: false,
908 api_url: None,
909 native_tls: None,
910 },
911 gitlab: Remote {
912 owner: String::from("coolguy"),
913 repo: String::from("awesome"),
914 token: None,
915 is_custom: false,
916 api_url: None,
917 native_tls: None,
918 },
919 gitea: Remote {
920 owner: String::from("coolguy"),
921 repo: String::from("awesome"),
922 token: None,
923 is_custom: false,
924 api_url: None,
925 native_tls: None,
926 },
927 bitbucket: Remote {
928 owner: String::from("coolguy"),
929 repo: String::from("awesome"),
930 token: None,
931 is_custom: false,
932 api_url: None,
933 native_tls: None,
934 },
935 },
936 bump: Bump::default(),
937 };
938 let test_release = Release {
939 version: Some(String::from("v1.0.0")),
940 message: None,
941 extra: None,
942 commits: vec![
943 Commit::new(
944 String::from("coffee"),
945 String::from("revert(app): skip this commit"),
946 ),
947 Commit::new(
948 String::from("tea"),
949 String::from("feat(app): damn right"),
950 ),
951 Commit::new(
952 String::from("0bc123"),
953 String::from("feat(app): add cool features"),
954 ),
955 Commit::new(
956 String::from("000000"),
957 String::from("support unconventional commits"),
958 ),
959 Commit::new(
960 String::from("0bc123"),
961 String::from("feat: support unscoped commits"),
962 ),
963 Commit::new(
964 String::from("0werty"),
965 String::from("style(ui): make good stuff"),
966 ),
967 Commit::new(
968 String::from("0w3rty"),
969 String::from("fix(ui): fix more stuff"),
970 ),
971 Commit::new(
972 String::from("qw3rty"),
973 String::from("doc: update docs"),
974 ),
975 Commit::new(
976 String::from("0bc123"),
977 String::from("docs: add some documentation"),
978 ),
979 Commit::new(
980 String::from("0jkl12"),
981 String::from("chore(app): do nothing"),
982 ),
983 Commit::new(
984 String::from("qwerty"),
985 String::from("chore: <preprocess>"),
986 ),
987 Commit::new(
988 String::from("qwertz"),
989 String::from("feat!: support breaking commits"),
990 ),
991 Commit::new(
992 String::from("qwert0"),
993 String::from("match(group): support regex-replace for groups"),
994 ),
995 Commit::new(
996 String::from("coffee"),
997 String::from("revert(app): skip this commit"),
998 ),
999 Commit::new(
1000 String::from("footer"),
1001 String::from("misc: use footer\n\nFooter: footer text"),
1002 ),
1003 ],
1004 commit_range: None,
1005 commit_id: Some(String::from("0bc123")),
1006 timestamp: 50000000,
1007 previous: None,
1008 repository: Some(String::from("/root/repo")),
1009 submodule_commits: HashMap::from([(
1010 String::from("submodule_one"),
1011 vec![
1012 Commit::new(
1013 String::from("sub0jkl12"),
1014 String::from("chore(app): submodule_one do nothing"),
1015 ),
1016 Commit::new(
1017 String::from("subqwerty"),
1018 String::from("chore: submodule_one <preprocess>"),
1019 ),
1020 Commit::new(
1021 String::from("subqwertz"),
1022 String::from(
1023 "feat!: submodule_one support breaking commits",
1024 ),
1025 ),
1026 Commit::new(
1027 String::from("subqwert0"),
1028 String::from(
1029 "match(group): submodule_one support regex-replace for \
1030 groups",
1031 ),
1032 ),
1033 ],
1034 )]),
1035 #[cfg(feature = "github")]
1036 github: crate::remote::RemoteReleaseMetadata {
1037 contributors: vec![],
1038 },
1039 #[cfg(feature = "gitlab")]
1040 gitlab: crate::remote::RemoteReleaseMetadata {
1041 contributors: vec![],
1042 },
1043 #[cfg(feature = "gitea")]
1044 gitea: crate::remote::RemoteReleaseMetadata {
1045 contributors: vec![],
1046 },
1047 #[cfg(feature = "bitbucket")]
1048 bitbucket: crate::remote::RemoteReleaseMetadata {
1049 contributors: vec![],
1050 },
1051 };
1052 let releases = vec![
1053 test_release.clone(),
1054 Release {
1055 version: Some(String::from("v3.0.0")),
1056 commits: vec![Commit::new(
1057 String::from("n0thin"),
1058 String::from("feat(xyz): skip commit"),
1059 )],
1060 ..Release::default()
1061 },
1062 Release {
1063 version: None,
1064 message: None,
1065 extra: None,
1066 commits: vec![
1067 Commit::new(
1068 String::from("abc123"),
1069 String::from("feat(app): add xyz"),
1070 ),
1071 Commit::new(
1072 String::from("abc124"),
1073 String::from("docs(app): document zyx"),
1074 ),
1075 Commit::new(String::from("def789"), String::from("merge #4")),
1076 Commit::new(
1077 String::from("dev063"),
1078 String::from("feat(app)!: merge #5"),
1079 ),
1080 Commit::new(
1081 String::from("qwerty"),
1082 String::from("fix(app): fix abc"),
1083 ),
1084 Commit::new(
1085 String::from("hjkl12"),
1086 String::from("chore(ui): do boring stuff"),
1087 ),
1088 Commit::new(
1089 String::from("coffee2"),
1090 String::from("revert(app): skip this commit"),
1091 ),
1092 ],
1093 commit_range: None,
1094 commit_id: None,
1095 timestamp: 1000,
1096 previous: Some(Box::new(test_release)),
1097 repository: Some(String::from("/root/repo")),
1098 submodule_commits: HashMap::from([
1099 (String::from("submodule_one"), vec![
1100 Commit::new(
1101 String::from("def349"),
1102 String::from("sub_one merge #4"),
1103 ),
1104 Commit::new(
1105 String::from("da8912"),
1106 String::from("sub_one merge #5"),
1107 ),
1108 ]),
1109 (String::from("submodule_two"), vec![Commit::new(
1110 String::from("ab76ef"),
1111 String::from("sub_two bump"),
1112 )]),
1113 ]),
1114 #[cfg(feature = "github")]
1115 github: crate::remote::RemoteReleaseMetadata {
1116 contributors: vec![],
1117 },
1118 #[cfg(feature = "gitlab")]
1119 gitlab: crate::remote::RemoteReleaseMetadata {
1120 contributors: vec![],
1121 },
1122 #[cfg(feature = "gitea")]
1123 gitea: crate::remote::RemoteReleaseMetadata {
1124 contributors: vec![],
1125 },
1126 #[cfg(feature = "bitbucket")]
1127 bitbucket: crate::remote::RemoteReleaseMetadata {
1128 contributors: vec![],
1129 },
1130 },
1131 ];
1132 (config, releases)
1133 }
1134
1135 #[test]
1136 fn changelog_generator() -> Result<()> {
1137 let (config, releases) = get_test_data();
1138 let mut changelog = Changelog::new(releases, &config, None)?;
1139 changelog.bump_version()?;
1140 changelog.releases[0].timestamp = 0;
1141 let mut out = Vec::new();
1142 changelog.generate(&mut out)?;
1143 assert_eq!(
1144 String::from(
1145 r#"# Changelog
1146
1147 ## Release [v1.1.0] - 1970-01-01 - (/root/repo)
1148
1149
1150 ### Bug Fixes
1151 #### app
1152 - fix abc
1153
1154 ### New features
1155 #### app
1156 - add xyz
1157
1158 ### Other
1159 #### app
1160 - document zyx
1161
1162 #### ui
1163 - do exciting stuff
1164
1165 ## Release [v1.0.0] - 1971-08-02 - (/root/repo)
1166 (0bc123)
1167
1168 ### Bug Fixes
1169 #### ui
1170 - fix more stuff
1171
1172 ### Documentation
1173 #### documentation
1174 - update docs
1175 - add some documentation
1176
1177 ### Footer
1178 #### footer
1179 - use footer
1180
1181 ### I love tea
1182 #### app
1183 - damn right
1184
1185 ### Matched (group)
1186 #### group
1187 - support regex-replace for groups
1188
1189 ### New features
1190 #### app
1191 - add cool features
1192
1193 #### other
1194 - support unscoped commits
1195 - support breaking commits
1196
1197 ### Other
1198 #### app
1199 - do nothing
1200
1201 #### other
1202 - support unconventional commits
1203 - this commit is preprocessed
1204
1205 #### ui
1206 - make good stuff
1207 -- total releases: 2 --
1208 "#
1209 )
1210 .replace(" ", ""),
1211 str::from_utf8(&out).unwrap_or_default()
1212 );
1213 Ok(())
1214 }
1215
1216 #[test]
1217 fn changelog_generator_split_commits() -> Result<()> {
1218 let (mut config, mut releases) = get_test_data();
1219 config.git.split_commits = true;
1220 config.git.filter_unconventional = false;
1221 config.git.protect_breaking_commits = true;
1222
1223 for parser in config
1224 .git
1225 .commit_parsers
1226 .iter_mut()
1227 .filter(|p| p.footer.is_some())
1228 {
1229 parser.skip = Some(true);
1230 }
1231
1232 releases[0].commits.push(Commit::new(
1233 String::from("0bc123"),
1234 String::from(
1235 "feat(app): add some more cool features
1236feat(app): even more features
1237feat(app): feature #3
1238",
1239 ),
1240 ));
1241 releases[0].commits.push(Commit::new(
1242 String::from("003934"),
1243 String::from(
1244 "feat: add awesome stuff
1245fix(backend): fix awesome stuff
1246style: make awesome stuff look better
1247",
1248 ),
1249 ));
1250 releases[2].commits.push(Commit::new(
1251 String::from("123abc"),
1252 String::from(
1253 "chore(deps): bump some deps
1254
1255chore(deps): bump some more deps
1256chore(deps): fix broken deps
1257",
1258 ),
1259 ));
1260 let changelog = Changelog::new(releases, &config, None)?;
1261 let mut out = Vec::new();
1262 changelog.generate(&mut out)?;
1263 assert_eq!(
1264 String::from(
1265 r#"# Changelog
1266
1267 ## Unreleased
1268
1269 ### Bug Fixes
1270 #### app
1271 - fix abc
1272
1273 ### New features
1274 #### app
1275 - add xyz
1276
1277 ### Other
1278 #### app
1279 - document zyx
1280
1281 #### deps
1282 - bump some deps
1283 - bump some more deps
1284 - fix broken deps
1285
1286 #### ui
1287 - do exciting stuff
1288
1289 ### feat
1290 #### app
1291 - merge #5
1292
1293 ## Release [v1.0.0] - 1971-08-02 - (/root/repo)
1294 (0bc123)
1295
1296 ### Bug Fixes
1297 #### backend
1298 - fix awesome stuff
1299
1300 #### ui
1301 - fix more stuff
1302
1303 ### Documentation
1304 #### documentation
1305 - update docs
1306 - add some documentation
1307
1308 ### I love tea
1309 #### app
1310 - damn right
1311
1312 ### Matched (group)
1313 #### group
1314 - support regex-replace for groups
1315
1316 ### New features
1317 #### app
1318 - add cool features
1319 - add some more cool features
1320 - even more features
1321 - feature #3
1322
1323 #### other
1324 - support unscoped commits
1325 - support breaking commits
1326 - add awesome stuff
1327
1328 ### Other
1329 #### app
1330 - do nothing
1331
1332 #### other
1333 - support unconventional commits
1334 - this commit is preprocessed
1335 - make awesome stuff look better
1336
1337 #### ui
1338 - make good stuff
1339 -- total releases: 2 --
1340 "#
1341 )
1342 .replace(" ", ""),
1343 str::from_utf8(&out).unwrap_or_default()
1344 );
1345 Ok(())
1346 }
1347
1348 #[test]
1349 fn changelog_adds_additional_context() -> Result<()> {
1350 let (mut config, releases) = get_test_data();
1351 config.changelog.body = r#"{% if version %}
1353 ## {{ custom_field }} [{{ version }}] - {{ timestamp | date(format="%Y-%m-%d") }}
1354 {% if commit_id %}({{ commit_id }}){% endif %}{% else %}
1355 ## Unreleased{% endif %}
1356 {% for group, commits in commits | group_by(attribute="group") %}
1357 ### {{ group }}{% for group, commits in commits | group_by(attribute="scope") %}
1358 #### {{ group }}{% for commit in commits %}
1359 - {{ commit.message }}{% endfor %}
1360 {% endfor %}{% endfor %}"#
1361 .to_string();
1362 let mut changelog = Changelog::new(releases, &config, None)?;
1363 changelog.add_context("custom_field", "Hello")?;
1364 let mut out = Vec::new();
1365 changelog.generate(&mut out)?;
1366 expect_test::expect![[r#"
1367 # Changelog
1368
1369 ## Unreleased
1370
1371 ### Bug Fixes
1372 #### app
1373 - fix abc
1374
1375 ### New features
1376 #### app
1377 - add xyz
1378
1379 ### Other
1380 #### app
1381 - document zyx
1382
1383 #### ui
1384 - do exciting stuff
1385
1386 ## Hello [v1.0.0] - 1971-08-02
1387 (0bc123)
1388
1389 ### Bug Fixes
1390 #### ui
1391 - fix more stuff
1392
1393 ### Documentation
1394 #### documentation
1395 - update docs
1396 - add some documentation
1397
1398 ### Footer
1399 #### footer
1400 - use footer
1401
1402 ### I love tea
1403 #### app
1404 - damn right
1405
1406 ### Matched (group)
1407 #### group
1408 - support regex-replace for groups
1409
1410 ### New features
1411 #### app
1412 - add cool features
1413
1414 #### other
1415 - support unscoped commits
1416 - support breaking commits
1417
1418 ### Other
1419 #### app
1420 - do nothing
1421
1422 #### other
1423 - support unconventional commits
1424 - this commit is preprocessed
1425
1426 #### ui
1427 - make good stuff
1428 -- total releases: 2 --
1429"#]]
1430 .assert_eq(str::from_utf8(&out).unwrap_or_default());
1431 Ok(())
1432 }
1433}