Skip to main content

git_cliff_core/
config.rs

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