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 field: Option<String>,
496 #[serde(with = "serde_regex", default)]
498 pub pattern: Option<Regex>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct TextProcessor {
504 #[serde(with = "serde_regex")]
506 pub pattern: Regex,
507 pub replace: Option<String>,
509 pub replace_command: Option<String>,
511}
512
513impl TextProcessor {
514 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#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct LinkParser {
530 #[serde(with = "serde_regex")]
532 pub pattern: Regex,
533 pub href: String,
535 pub text: Option<String>,
537}
538
539impl Config {
540 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 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 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 #[must_use]
583 pub fn retrieve_user_config_path() -> Option<PathBuf> {
584 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 #[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 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 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 fn from_str(contents: &str) -> Result<Self> {
657 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 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 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}