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