git_cliff_core/
config.rs

1use crate::command;
2use crate::embed::EmbeddedConfig;
3use crate::error::Result;
4use regex::{
5	Regex,
6	RegexBuilder,
7};
8use secrecy::SecretString;
9use serde::{
10	Deserialize,
11	Serialize,
12};
13use std::fmt;
14use std::fs;
15use std::path::Path;
16use std::path::PathBuf;
17
18/// Default initial tag.
19const DEFAULT_INITIAL_TAG: &str = "0.1.0";
20
21/// Manifest file information and regex for matching contents.
22#[derive(Debug)]
23struct ManifestInfo {
24	/// Path of the manifest.
25	path:  PathBuf,
26	/// Regular expression for matching metadata in the manifest.
27	regex: Regex,
28}
29
30lazy_static::lazy_static! {
31	/// Array containing manifest information for Rust and Python projects.
32	static ref MANIFEST_INFO: Vec<ManifestInfo> = vec![
33		ManifestInfo {
34			path: PathBuf::from("Cargo.toml"),
35			regex: RegexBuilder::new(
36				r"^\[(?:workspace|package)\.metadata\.git\-cliff\.",
37			)
38			.multi_line(true)
39			.build()
40			.expect("failed to build regex"),
41		},
42		ManifestInfo {
43			path: PathBuf::from("pyproject.toml"),
44			regex: RegexBuilder::new(r"^\[(?:tool)\.git\-cliff\.")
45				.multi_line(true)
46				.build()
47				.expect("failed to build regex"),
48		},
49	];
50
51}
52
53/// Configuration values.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Config {
56	/// Configuration values about changelog generation.
57	#[serde(default)]
58	pub changelog: ChangelogConfig,
59	/// Configuration values about git.
60	#[serde(default)]
61	pub git:       GitConfig,
62	/// Configuration values about remote.
63	#[serde(default)]
64	pub remote:    RemoteConfig,
65	/// Configuration values about bump version.
66	#[serde(default)]
67	pub bump:      Bump,
68}
69
70/// Changelog configuration.
71#[derive(Debug, Default, Clone, Serialize, Deserialize)]
72pub struct ChangelogConfig {
73	/// Changelog header.
74	pub header:         Option<String>,
75	/// Changelog body, template.
76	pub body:           String,
77	/// Changelog footer.
78	pub footer:         Option<String>,
79	/// Trim the template.
80	pub trim:           bool,
81	/// Always render the body template.
82	pub render_always:  bool,
83	/// Changelog postprocessors.
84	pub postprocessors: Vec<TextProcessor>,
85	/// Output file path.
86	pub output:         Option<PathBuf>,
87}
88
89/// Git configuration
90#[derive(Debug, Default, Clone, Serialize, Deserialize)]
91pub struct GitConfig {
92	/// Parse commits according to the conventional commits specification.
93	pub conventional_commits:  bool,
94	/// Require all commits to be conventional.
95	/// Takes precedence over filter_unconventional.
96	pub require_conventional:  bool,
97	/// Exclude commits that do not match the conventional commits specification
98	/// from the changelog.
99	pub filter_unconventional: bool,
100	/// Split commits on newlines, treating each line as an individual commit.
101	pub split_commits:         bool,
102
103	/// An array of regex based parsers to modify commit messages prior to
104	/// further processing.
105	pub commit_preprocessors:     Vec<TextProcessor>,
106	/// An array of regex based parsers for extracting data from the commit
107	/// message.
108	pub commit_parsers:           Vec<CommitParser>,
109	/// Prevent commits having the `BREAKING CHANGE:` footer from being excluded
110	/// by commit parsers.
111	pub protect_breaking_commits: bool,
112	/// An array of regex based parsers to extract links from the commit message
113	/// and add them to the commit's context.
114	pub link_parsers:             Vec<LinkParser>,
115	/// Exclude commits that are not matched by any commit parser.
116	pub filter_commits:           bool,
117	/// Regex to select git tags that represent releases.
118	#[serde(with = "serde_regex", default)]
119	pub tag_pattern:              Option<Regex>,
120	/// Regex to select git tags that do not represent proper releases.
121	#[serde(with = "serde_regex", default)]
122	pub skip_tags:                Option<Regex>,
123	/// Regex to exclude git tags after applying the tag_pattern.
124	#[serde(with = "serde_regex", default)]
125	pub ignore_tags:              Option<Regex>,
126	/// Regex to count matched tags.
127	#[serde(with = "serde_regex", default)]
128	pub count_tags:               Option<Regex>,
129	/// Include only the tags that belong to the current branch.
130	pub use_branch_tags:          bool,
131	/// Order releases topologically instead of chronologically.
132	pub topo_order:               bool,
133	/// Order commits chronologically instead of topologically.
134	pub topo_order_commits:       bool,
135	/// How to order commits in each group/release within the changelog.
136	pub sort_commits:             String,
137	/// Limit the total number of commits included in the changelog.
138	pub limit_commits:            Option<usize>,
139	/// Read submodule commits.
140	pub recurse_submodules:       Option<bool>,
141}
142
143/// Remote configuration.
144#[derive(Default, Debug, Clone, Serialize, Deserialize)]
145pub struct RemoteConfig {
146	/// GitHub remote.
147	#[serde(default)]
148	pub github:    Remote,
149	/// GitLab remote.
150	#[serde(default)]
151	pub gitlab:    Remote,
152	/// Gitea remote.
153	#[serde(default)]
154	pub gitea:     Remote,
155	/// Bitbucket remote.
156	#[serde(default)]
157	pub bitbucket: Remote,
158}
159
160impl RemoteConfig {
161	/// Returns `true` if any remote is set.
162	pub fn is_any_set(&self) -> bool {
163		#[cfg(feature = "github")]
164		if self.github.is_set() {
165			return true;
166		}
167		#[cfg(feature = "gitlab")]
168		if self.gitlab.is_set() {
169			return true;
170		}
171		#[cfg(feature = "gitea")]
172		if self.gitea.is_set() {
173			return true;
174		}
175		#[cfg(feature = "bitbucket")]
176		if self.bitbucket.is_set() {
177			return true;
178		}
179		false
180	}
181
182	/// Enables the native TLS for all remotes.
183	pub fn enable_native_tls(&mut self) {
184		#[cfg(feature = "github")]
185		{
186			self.github.native_tls = Some(true);
187		}
188		#[cfg(feature = "gitlab")]
189		{
190			self.gitlab.native_tls = Some(true);
191		}
192		#[cfg(feature = "gitea")]
193		{
194			self.gitea.native_tls = Some(true);
195		}
196		#[cfg(feature = "bitbucket")]
197		{
198			self.bitbucket.native_tls = Some(true);
199		}
200	}
201}
202
203/// A single remote.
204#[derive(Debug, Default, Clone, Serialize, Deserialize)]
205pub struct Remote {
206	/// Owner of the remote.
207	pub owner:      String,
208	/// Repository name.
209	pub repo:       String,
210	/// Access token.
211	#[serde(skip_serializing)]
212	pub token:      Option<SecretString>,
213	/// Whether if the remote is set manually.
214	#[serde(skip_deserializing, default = "default_true")]
215	pub is_custom:  bool,
216	/// Remote API URL.
217	pub api_url:    Option<String>,
218	/// Whether to use native TLS.
219	pub native_tls: Option<bool>,
220}
221
222/// Returns `true` for serde's `default` attribute.
223fn default_true() -> bool {
224	true
225}
226
227impl fmt::Display for Remote {
228	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229		write!(f, "{}/{}", self.owner, self.repo)
230	}
231}
232
233impl PartialEq for Remote {
234	fn eq(&self, other: &Self) -> bool {
235		self.to_string() == other.to_string()
236	}
237}
238
239impl Remote {
240	/// Constructs a new instance.
241	pub fn new<S: Into<String>>(owner: S, repo: S) -> Self {
242		Self {
243			owner:      owner.into(),
244			repo:       repo.into(),
245			token:      None,
246			is_custom:  false,
247			api_url:    None,
248			native_tls: None,
249		}
250	}
251
252	/// Returns `true` if the remote has an owner and repo.
253	pub fn is_set(&self) -> bool {
254		!self.owner.is_empty() && !self.repo.is_empty()
255	}
256}
257
258/// Version bump type.
259#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
260#[serde(rename_all = "lowercase")]
261pub enum BumpType {
262	/// Bump major version.
263	Major,
264	/// Bump minor version.
265	Minor,
266	/// Bump patch version.
267	Patch,
268}
269
270/// Bump version configuration.
271#[derive(Debug, Default, Clone, Serialize, Deserialize)]
272pub struct Bump {
273	/// Configures automatic minor version increments for feature changes.
274	///
275	/// When `true`, a feature will always trigger a minor version update.
276	/// When `false`, a feature will trigger:
277	///
278	/// - A patch version update if the major version is 0.
279	/// - A minor version update otherwise.
280	pub features_always_bump_minor: Option<bool>,
281
282	/// Configures 0 -> 1 major version increments for breaking changes.
283	///
284	/// When `true`, a breaking change commit will always trigger a major
285	/// version update (including the transition from version 0 to 1)
286	/// When `false`, a breaking change commit will trigger:
287	///
288	/// - A minor version update if the major version is 0.
289	/// - A major version update otherwise.
290	pub breaking_always_bump_major: Option<bool>,
291
292	/// Configures the initial version of the project.
293	///
294	/// When set, the version will be set to this value if no tags are found.
295	pub initial_tag: Option<String>,
296
297	/// Configure a custom regex pattern for major version increments.
298	///
299	/// This will check only the type of the commit against the given pattern.
300	///
301	/// ### Note
302	///
303	/// `commit type` according to the spec is only `[a-zA-Z]+`
304	pub custom_major_increment_regex: Option<String>,
305
306	/// Configure a custom regex pattern for minor version increments.
307	///
308	/// This will check only the type of the commit against the given pattern.
309	///
310	/// ### Note
311	///
312	/// `commit type` according to the spec is only `[a-zA-Z]+`
313	pub custom_minor_increment_regex: Option<String>,
314
315	/// Force to always bump in major, minor or patch.
316	pub bump_type: Option<BumpType>,
317}
318
319impl Bump {
320	/// Returns the initial tag.
321	///
322	/// This function also logs the returned value.
323	pub fn get_initial_tag(&self) -> String {
324		if let Some(tag) = self.initial_tag.clone() {
325			warn!(
326				"No releases found, using initial tag '{tag}' as the next version."
327			);
328			tag
329		} else {
330			warn!(
331				"No releases found, using {DEFAULT_INITIAL_TAG} as the next \
332				 version."
333			);
334			DEFAULT_INITIAL_TAG.into()
335		}
336	}
337}
338
339/// Parser for grouping commits.
340#[derive(Debug, Default, Clone, Serialize, Deserialize)]
341pub struct CommitParser {
342	/// SHA1 of the commit.
343	pub sha:           Option<String>,
344	/// Regex for matching the commit message.
345	#[serde(with = "serde_regex", default)]
346	pub message:       Option<Regex>,
347	/// Regex for matching the commit body.
348	#[serde(with = "serde_regex", default)]
349	pub body:          Option<Regex>,
350	/// Regex for matching the commit footer.
351	#[serde(with = "serde_regex", default)]
352	pub footer:        Option<Regex>,
353	/// Group of the commit.
354	pub group:         Option<String>,
355	/// Default scope of the commit.
356	pub default_scope: Option<String>,
357	/// Commit scope for overriding the default scope.
358	pub scope:         Option<String>,
359	/// Whether to skip this commit group.
360	pub skip:          Option<bool>,
361	/// Field name of the commit to match the regex against.
362	pub field:         Option<String>,
363	/// Regex for matching the field value.
364	#[serde(with = "serde_regex", default)]
365	pub pattern:       Option<Regex>,
366}
367
368/// `TextProcessor`, e.g. for modifying commit messages.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct TextProcessor {
371	/// Regex for matching a text to replace.
372	#[serde(with = "serde_regex")]
373	pub pattern:         Regex,
374	/// Replacement text.
375	pub replace:         Option<String>,
376	/// Command that will be run for replacing the commit message.
377	pub replace_command: Option<String>,
378}
379
380impl TextProcessor {
381	/// Replaces the text with using the given pattern or the command output.
382	pub fn replace(
383		&self,
384		rendered: &mut String,
385		command_envs: Vec<(&str, &str)>,
386	) -> Result<()> {
387		if let Some(text) = &self.replace {
388			*rendered = self.pattern.replace_all(rendered, text).to_string();
389		} else if let Some(command) = &self.replace_command {
390			if self.pattern.is_match(rendered) {
391				*rendered =
392					command::run(command, Some(rendered.to_string()), command_envs)?;
393			}
394		}
395		Ok(())
396	}
397}
398
399/// Parser for extracting links in commits.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct LinkParser {
402	/// Regex for finding links in the commit message.
403	#[serde(with = "serde_regex")]
404	pub pattern: Regex,
405	/// The string used to generate the link URL.
406	pub href:    String,
407	/// The string used to generate the link text.
408	pub text:    Option<String>,
409}
410
411impl Config {
412	/// Reads the config file contents from project manifest (e.g. Cargo.toml,
413	/// pyproject.toml)
414	pub fn read_from_manifest() -> Result<Option<String>> {
415		for info in &(*MANIFEST_INFO) {
416			if info.path.exists() {
417				let contents = fs::read_to_string(&info.path)?;
418				if info.regex.is_match(&contents) {
419					return Ok(Some(
420						info.regex.replace_all(&contents, "[").to_string(),
421					));
422				}
423			}
424		}
425		Ok(None)
426	}
427
428	/// Parses the config file from string and returns the values.
429	pub fn parse_from_str(contents: &str) -> Result<Config> {
430		// Adding sources one after another overwrites the previous values.
431		// Thus adding the default config initializes the config with default values.
432		let default_config_str = EmbeddedConfig::get_config()?;
433
434		Ok(config::Config::builder()
435			.add_source(config::File::from_str(
436				&default_config_str,
437				config::FileFormat::Toml,
438			))
439			.add_source(config::File::from_str(contents, config::FileFormat::Toml))
440			.add_source(
441				config::Environment::with_prefix("GIT_CLIFF").separator("__"),
442			)
443			.build()?
444			.try_deserialize()?)
445	}
446
447	/// Parses the config file and returns the values.
448	pub fn parse(path: &Path) -> Result<Config> {
449		if MANIFEST_INFO
450			.iter()
451			.any(|v| path.file_name() == v.path.file_name())
452		{
453			if let Some(contents) = Self::read_from_manifest()? {
454				return Self::parse_from_str(&contents);
455			}
456		}
457
458		// Adding sources one after another overwrites the previous values.
459		// Thus adding the default config initializes the config with default values.
460		let default_config_str = EmbeddedConfig::get_config()?;
461		Ok(config::Config::builder()
462			.add_source(config::File::from_str(
463				&default_config_str,
464				config::FileFormat::Toml,
465			))
466			.add_source(config::File::from(path))
467			.add_source(
468				config::Environment::with_prefix("GIT_CLIFF").separator("__"),
469			)
470			.build()?
471			.try_deserialize()?)
472	}
473}
474
475#[cfg(test)]
476mod test {
477	use super::*;
478	use pretty_assertions::assert_eq;
479	use std::env;
480	#[test]
481	fn parse_config() -> Result<()> {
482		let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
483			.parent()
484			.expect("parent directory not found")
485			.to_path_buf()
486			.join("config")
487			.join(crate::DEFAULT_CONFIG);
488
489		const FOOTER_VALUE: &str = "test";
490		const TAG_PATTERN_VALUE: &str = ".*[0-9].*";
491		const IGNORE_TAGS_VALUE: &str = "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+";
492
493		unsafe {
494			env::set_var("GIT_CLIFF__CHANGELOG__FOOTER", FOOTER_VALUE);
495			env::set_var("GIT_CLIFF__GIT__TAG_PATTERN", TAG_PATTERN_VALUE);
496			env::set_var("GIT_CLIFF__GIT__IGNORE_TAGS", IGNORE_TAGS_VALUE);
497		};
498
499		let config = Config::parse(&path)?;
500
501		assert_eq!(Some(String::from(FOOTER_VALUE)), config.changelog.footer);
502		assert_eq!(
503			Some(String::from(TAG_PATTERN_VALUE)),
504			config
505				.git
506				.tag_pattern
507				.map(|tag_pattern| tag_pattern.to_string())
508		);
509		assert_eq!(
510			Some(String::from(IGNORE_TAGS_VALUE)),
511			config
512				.git
513				.ignore_tags
514				.map(|ignore_tags| ignore_tags.to_string())
515		);
516		Ok(())
517	}
518
519	#[test]
520	fn remote_config() {
521		let remote1 = Remote::new("abc", "xyz1");
522		let remote2 = Remote::new("abc", "xyz2");
523		assert!(!remote1.eq(&remote2));
524		assert_eq!("abc/xyz1", remote1.to_string());
525		assert!(remote1.is_set());
526		assert!(!Remote::new("", "test").is_set());
527		assert!(!Remote::new("test", "").is_set());
528		assert!(!Remote::new("", "").is_set());
529	}
530}