git_cliff_core/
changelog.rs

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/// Changelog generator.
20#[derive(Debug)]
21pub struct Changelog<'a> {
22    /// Releases that the changelog will contain.
23    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    /// Constructs a new instance.
33    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    /// Builds a changelog from releases and config.
46    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    /// Constructs an instance from a serialized context object.
65    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    /// Adds a key value pair to the template context.
70    ///
71    /// These values will be used when generating the changelog.
72    ///
73    /// # Errors
74    ///
75    /// This operation fails if the deserialization fails.
76    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    /// Processes a single commit and returns/logs the result.
87    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    /// Checks the commits and returns an error if any unconventional commits
103    /// are found.
104    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    /// Processes the commits and omits the ones that doesn't match the
165    /// criteria set by configuration file.
166    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    /// Processes the releases and filters them out based on the configuration.
178    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    /// Returns the GitHub metadata needed for the changelog.
230    ///
231    /// This function creates a multithread async runtime for handling the
232    /// requests. The following are fetched from the GitHub REST API:
233    ///
234    /// - Commits
235    /// - Pull requests
236    ///
237    /// Each of these are paginated requests so they are being run in parallel
238    /// for speedup.
239    ///
240    /// If no GitHub related variable is used in the template then this function
241    /// returns empty vectors.
242    #[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    /// Returns the GitLab metadata needed for the changelog.
282    ///
283    /// This function creates a multithread async runtime for handling the
284    ///
285    /// requests. The following are fetched from the GitLab REST API:
286    ///
287    /// - Commits
288    /// - Merge requests
289    ///
290    /// Each of these are paginated requests so they are being run in parallel
291    /// for speedup.
292    ///
293    ///
294    /// If no GitLab related variable is used in the template then this function
295    /// returns empty vectors.
296    #[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                    // Map repo/owner to gitlab id
321                    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                        // Send id to these functions
330                        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    /// Returns the Gitea metadata needed for the changelog.
345    ///
346    /// This function creates a multithread async runtime for handling the
347    /// requests. The following are fetched from the GitHub REST API:
348    ///
349    /// - Commits
350    /// - Pull requests
351    ///
352    /// Each of these are paginated requests so they are being run in parallel
353    /// for speedup.
354    ///
355    /// If no Gitea related variable is used in the template then this function
356    /// returns empty vectors.
357    #[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    /// Returns the Bitbucket metadata needed for the changelog.
397    ///
398    /// This function creates a multithread async runtime for handling the
399    ///
400    /// requests. The following are fetched from the bitbucket REST API:
401    ///
402    /// - Commits
403    /// - Pull requests
404    ///
405    /// Each of these are paginated requests so they are being run in parallel
406    /// for speedup.
407    ///
408    ///
409    /// If no bitbucket related variable is used in the template then this
410    /// function returns empty vectors.
411    #[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    /// Adds information about the remote to the template context.
454    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    /// Adds remote data (e.g. GitHub commits) to the releases.
463    #[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        // Determine the ref at which to fetch remote commits, based on the commit
469        // range
470        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    /// Increments the version for the unreleased changes based on semver.
522    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    /// Generates the changelog and writes it to the given output.
542    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    /// Generates a changelog and prepends it to the given changelog.
605    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    /// Prints the changelog context to the given output.
616    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        // add `{{ custom_field }}` to the template
1501        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}