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
18const DEFAULT_INITIAL_TAG: &str = "0.1.0";
20
21#[derive(Debug)]
23struct ManifestInfo {
24 path: PathBuf,
26 regex: Regex,
28}
29
30static 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#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Config {
53 #[serde(default)]
55 pub changelog: ChangelogConfig,
56 #[serde(default)]
58 pub git: GitConfig,
59 #[serde(default)]
61 pub remote: RemoteConfig,
62 #[serde(default)]
64 pub bump: Bump,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ChangelogConfig {
70 pub header: Option<String>,
72 #[serde(default = "default_header_marker")]
74 pub header_marker: String,
75 pub body: String,
77 pub footer: Option<String>,
79 pub trim: bool,
81 pub render_always: bool,
83 #[serde(default)]
89 pub format: bool,
90 pub postprocessors: Vec<TextProcessor>,
92 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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
118#[allow(clippy::struct_excessive_bools)]
119pub struct GitConfig {
120 pub processing_order: Option<Vec<ProcessingStep>>,
125 pub conventional_commits: bool,
127 pub require_conventional: bool,
130 pub filter_unconventional: bool,
133 pub split_commits: bool,
135
136 pub commit_preprocessors: Vec<TextProcessor>,
139 pub commit_parsers: Vec<CommitParser>,
142 pub protect_breaking_commits: bool,
145 pub link_parsers: Vec<LinkParser>,
148 pub filter_commits: bool,
150 pub fail_on_unmatched_commit: bool,
152 #[serde(with = "serde_regex", default)]
154 pub tag_pattern: Option<Regex>,
155 #[serde(with = "serde_regex", default)]
157 pub skip_tags: Option<Regex>,
158 #[serde(with = "serde_regex", default)]
160 pub ignore_tags: Option<Regex>,
161 #[serde(with = "serde_regex", default)]
163 pub count_tags: Option<Regex>,
164 pub limit_tags: Option<usize>,
166 pub use_branch_tags: bool,
168 pub topo_order: bool,
170 pub topo_order_commits: bool,
172 pub sort_commits: String,
174 pub limit_commits: Option<usize>,
176 pub recurse_submodules: Option<bool>,
178 #[serde(with = "serde_pattern", default)]
180 pub include_paths: Vec<Pattern>,
181 #[serde(with = "serde_pattern", default)]
183 pub exclude_paths: Vec<Pattern>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum ProcessingStep {
190 CommitPreprocessors,
193 SplitCommits,
195 ConventionalCommits,
197 CommitParsers,
200 LinkParsers,
203}
204
205mod 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#[derive(Default, Debug, Clone, Serialize, Deserialize)]
237pub struct RemoteConfig {
238 #[serde(default)]
240 pub offline: bool,
241 #[serde(default)]
243 pub github: Remote,
244 #[serde(default)]
246 pub gitlab: Remote,
247 #[serde(default)]
249 pub gitea: Remote,
250 #[serde(default)]
252 pub bitbucket: Remote,
253 #[serde(default)]
255 pub azure_devops: Remote,
256}
257
258impl RemoteConfig {
259 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct Remote {
313 pub owner: String,
315 pub repo: String,
317 #[serde(skip_serializing)]
319 pub token: Option<SecretString>,
320 #[serde(skip_deserializing, default = "default_true")]
322 pub is_custom: bool,
323 pub api_url: Option<String>,
325 #[serde(default = "default_http_timeout", with = "humantime_serde")]
327 pub http_timeout: Duration,
328 pub native_tls: Option<bool>,
330}
331
332fn default_true() -> bool {
334 true
335}
336
337fn default_http_timeout() -> Duration {
338 Duration::from_secs(30)
339}
340
341impl 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 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 #[must_use]
381 pub fn is_set(&self) -> bool {
382 !self.owner.is_empty() && !self.repo.is_empty()
383 }
384}
385
386#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
388#[serde(rename_all = "lowercase")]
389pub enum BumpType {
390 Major,
392 Minor,
394 Patch,
396}
397
398#[derive(Debug, Default, Clone, Serialize, Deserialize)]
400pub struct Bump {
401 pub features_always_bump_minor: Option<bool>,
409
410 pub breaking_always_bump_major: Option<bool>,
419
420 pub initial_tag: Option<String>,
424
425 pub custom_major_increment_regex: Option<String>,
433
434 pub custom_minor_increment_regex: Option<String>,
442
443 pub no_increment_regex: Option<String>,
451
452 pub bump_type: Option<BumpType>,
454}
455
456impl Bump {
457 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
474pub struct CommitParser {
475 pub sha: Option<String>,
477 #[serde(with = "serde_regex", default)]
479 pub message: Option<Regex>,
480 #[serde(with = "serde_regex", default)]
482 pub body: Option<Regex>,
483 #[serde(with = "serde_regex", default)]
485 pub footer: Option<Regex>,
486 pub group: Option<String>,
488 pub default_scope: Option<String>,
490 pub scope: Option<String>,
492 pub skip: Option<bool>,
494 pub r#continue: Option<bool>,
497 pub field: Option<String>,
499 #[serde(with = "serde_regex", default)]
501 pub pattern: Option<Regex>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct TextProcessor {
507 #[serde(with = "serde_regex")]
509 pub pattern: Regex,
510 pub replace: Option<String>,
512 pub replace_command: Option<String>,
514}
515
516impl TextProcessor {
517 pub fn replace(&self, rendered: &mut String, command_envs: Vec<(&str, &str)>) -> Result<()> {
519 if let Some(text) = &self.replace {
520 *rendered = self.pattern.replace_all(rendered, text).to_string();
521 } else if let Some(command) = &self.replace_command &&
522 self.pattern.is_match(rendered)
523 {
524 *rendered = command::run(command, Some(rendered.clone()), command_envs)?;
525 }
526 Ok(())
527 }
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct LinkParser {
533 #[serde(with = "serde_regex")]
535 pub pattern: Regex,
536 pub href: String,
538 pub text: Option<String>,
540}
541
542impl Config {
543 pub fn read_from_manifest() -> Result<Option<String>> {
546 for info in &(*MANIFEST_INFO) {
547 if info.path.exists() {
548 let contents = fs::read_to_string(&info.path)?;
549 if info.regex.is_match(&contents) {
550 return Ok(Some(info.regex.replace_all(&contents, "[").to_string()));
551 }
552 }
553 }
554 Ok(None)
555 }
556
557 pub fn load(path: &Path) -> Result<Config> {
559 if MANIFEST_INFO
560 .iter()
561 .any(|v| path.file_name() == v.path.file_name()) &&
562 let Some(contents) = Self::read_from_manifest()?
563 {
564 return contents.parse();
565 }
566
567 let default_config_str = EmbeddedConfig::get_config()?;
570 Ok(config::Config::builder()
571 .add_source(config::File::from_str(
572 &default_config_str,
573 config::FileFormat::Toml,
574 ))
575 .add_source(config::File::from(path))
576 .add_source(config::Environment::with_prefix("GIT_CLIFF").separator("__"))
577 .build()?
578 .try_deserialize()?)
579 }
580
581 #[must_use]
585 pub fn retrieve_user_config_path() -> Option<PathBuf> {
586 let strategy = choose_base_strategy()
588 .expect("cannot determine current OS's default strategy (layout)");
589 for supported_path in [
590 strategy.config_dir().join("git-cliff").join(DEFAULT_CONFIG),
591 #[cfg(target_os = "macos")]
593 strategy
594 .home_dir()
595 .to_path_buf()
596 .join("Library/Application Support/git-cliff")
597 .join(DEFAULT_CONFIG),
598 ]
599 .iter()
600 {
601 if supported_path.exists() {
602 #[allow(clippy::unnecessary_debug_formatting)]
603 {
604 tracing::debug!("Using configuration file from: {supported_path:?}");
605 }
606 return Some(supported_path.clone());
607 }
608 }
609 None
610 }
611
612 pub fn retrieve_project_config_path(dir: &Path) -> Option<PathBuf> {
614 CONFIG_FILES.iter().find_map(|file| {
615 let path = dir.join(file);
616 if path.is_file() { Some(path) } else { None }
617 })
618 }
619
620 pub fn uses_commit_statistics(&self) -> Result<bool> {
623 if self
624 .git
625 .commit_parsers
626 .iter()
627 .filter_map(|parser| parser.field.as_deref())
628 .any(|field| field.starts_with("statistics."))
629 {
630 return Ok(true);
631 }
632
633 let trim = self.changelog.trim;
634 let body_template = Template::new("body", self.changelog.body.clone(), trim)?;
635 if body_template.contains_variable(statistics::TEMPLATE_VARIABLES) {
636 return Ok(true);
637 }
638 if let Some(header) = &self.changelog.header {
639 let header_template = Template::new("header", header.clone(), trim)?;
640 if header_template.contains_variable(statistics::TEMPLATE_VARIABLES) {
641 return Ok(true);
642 }
643 }
644 if let Some(footer) = &self.changelog.footer {
645 let footer_template = Template::new("footer", footer.clone(), trim)?;
646 if footer_template.contains_variable(statistics::TEMPLATE_VARIABLES) {
647 return Ok(true);
648 }
649 }
650 Ok(false)
651 }
652}
653
654impl FromStr for Config {
655 type Err = error::Error;
656
657 fn from_str(contents: &str) -> Result<Self> {
659 let default_config_str = EmbeddedConfig::get_config()?;
663
664 Ok(config::Config::builder()
665 .add_source(config::File::from_str(
666 &default_config_str,
667 config::FileFormat::Toml,
668 ))
669 .add_source(config::File::from_str(contents, config::FileFormat::Toml))
670 .add_source(config::Environment::with_prefix("GIT_CLIFF").separator("__"))
671 .build()?
672 .try_deserialize()?)
673 }
674}
675
676#[cfg(test)]
677mod test {
678 use std::{env, fs};
679
680 use pretty_assertions::assert_eq;
681 use temp_dir::TempDir;
682
683 use super::*;
684
685 #[test]
686 fn load() -> Result<()> {
687 const FOOTER_VALUE: &str = "test";
688 const TAG_PATTERN_VALUE: &str = ".*[0-9].*";
689 const IGNORE_TAGS_VALUE: &str = "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+";
690
691 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
692 .parent()
693 .expect("parent directory not found")
694 .to_path_buf()
695 .join("config")
696 .join(crate::DEFAULT_CONFIG);
697
698 unsafe {
699 env::set_var("GIT_CLIFF__CHANGELOG__FOOTER", FOOTER_VALUE);
700 env::set_var("GIT_CLIFF__GIT__TAG_PATTERN", TAG_PATTERN_VALUE);
701 env::set_var("GIT_CLIFF__GIT__IGNORE_TAGS", IGNORE_TAGS_VALUE);
702 };
703
704 let config = Config::load(&path)?;
705
706 assert_eq!(Some(String::from(FOOTER_VALUE)), config.changelog.footer);
707 assert_eq!(
708 Some(String::from(TAG_PATTERN_VALUE)),
709 config
710 .git
711 .tag_pattern
712 .map(|tag_pattern| tag_pattern.to_string())
713 );
714 assert_eq!(
715 Some(String::from(IGNORE_TAGS_VALUE)),
716 config
717 .git
718 .ignore_tags
719 .map(|ignore_tags| ignore_tags.to_string())
720 );
721 Ok(())
722 }
723
724 #[test]
725 fn remote_config() {
726 let remote1 = Remote::new("abc", "xyz1");
727 let remote2 = Remote::new("abc", "xyz2");
728 assert!(!remote1.eq(&remote2));
729 assert_eq!("abc/xyz1", remote1.to_string());
730 assert!(remote1.is_set());
731 assert!(!Remote::new("", "test").is_set());
732 assert!(!Remote::new("test", "").is_set());
733 assert!(!Remote::new("", "").is_set());
734 assert_eq!(Duration::from_secs(30), remote1.http_timeout);
735 }
736
737 #[test]
738 fn parse_changelog_header_marker() -> Result<()> {
739 let config: Config = r#"
740 [changelog]
741 header_marker = "<!-- custom header boundary -->"
742 "#
743 .parse()?;
744
745 assert_eq!(
746 "<!-- custom header boundary -->",
747 config.changelog.header_marker
748 );
749 Ok(())
750 }
751
752 #[test]
753 fn default_remote_http_timeout() -> Result<()> {
754 assert_eq!(default_http_timeout(), Remote::default().http_timeout);
755
756 let config = Config::from_str(
759 r#"
760 [changelog]
761 body = "test"
762 "#,
763 )?;
764 assert_eq!(default_http_timeout(), config.remote.github.http_timeout);
765 assert_eq!(default_http_timeout(), config.remote.gitlab.http_timeout);
766 Ok(())
767 }
768
769 #[test]
770 fn parse_remote_http_timeout() -> Result<()> {
771 let config = Config::from_str(
772 r#"
773 [remote.github]
774 owner = "orhun"
775 repo = "git-cliff"
776 http_timeout = "60s"
777 "#,
778 )?;
779
780 assert_eq!(Duration::from_secs(60), config.remote.github.http_timeout);
781 Ok(())
782 }
783
784 #[test]
785 fn find_project_config_file() -> Result<()> {
786 let dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
787
788 assert_eq!(Config::retrieve_project_config_path(dir.path()), None);
792
793 fs::create_dir(dir.path().join(".config"))?;
794 fs::write(dir.path().join(".config/cliff.toml"), "")?;
795 assert_eq!(
796 Config::retrieve_project_config_path(dir.path()),
797 Some(dir.path().join(".config/cliff.toml")),
798 );
799
800 fs::write(dir.path().join("cliff.toml"), "")?;
801 assert_eq!(
802 Config::retrieve_project_config_path(dir.path()),
803 Some(dir.path().join("cliff.toml")),
804 );
805
806 Ok(())
807 }
808
809 #[test]
810 fn detects_commit_statistics_usage_in_templates() -> Result<()> {
811 let mut config = EmbeddedConfig::parse()?;
812 assert!(!config.uses_commit_statistics()?);
813
814 config.changelog.body = String::from(
815 "{% for commit in commits %}{{ commit.statistics.files_changed }}{% endfor %}",
816 );
817 assert!(config.uses_commit_statistics()?);
818
819 config.changelog.body = String::from("{{ version }}");
820 config.changelog.footer = Some(String::from("{{ commit.statistics.additions }}"));
821 assert!(config.uses_commit_statistics()?);
822
823 Ok(())
824 }
825}