Skip to main content

git_cliff_core/
config.rs

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