git_cliff_core/
changelog.rs

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