git_cliff_core/
config.rs

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