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
16const DEFAULT_INITIAL_TAG: &str = "0.1.0";
18
19#[derive(Debug)]
21struct ManifestInfo {
22 path: PathBuf,
24 regex: Regex,
26}
27
28static 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#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Config {
51 #[serde(default)]
53 pub changelog: ChangelogConfig,
54 #[serde(default)]
56 pub git: GitConfig,
57 #[serde(default)]
59 pub remote: RemoteConfig,
60 #[serde(default)]
62 pub bump: Bump,
63}
64
65#[derive(Debug, Default, Clone, Serialize, Deserialize)]
67pub struct ChangelogConfig {
68 pub header: Option<String>,
70 pub body: String,
72 pub footer: Option<String>,
74 pub trim: bool,
76 pub render_always: bool,
78 pub postprocessors: Vec<TextProcessor>,
80 pub output: Option<PathBuf>,
82}
83
84#[derive(Debug, Default, Clone, Serialize, Deserialize)]
86#[allow(clippy::struct_excessive_bools)]
87pub struct GitConfig {
88 pub processing_order: Option<Vec<ProcessingStep>>,
93 pub conventional_commits: bool,
95 pub require_conventional: bool,
98 pub filter_unconventional: bool,
101 pub split_commits: bool,
103
104 pub commit_preprocessors: Vec<TextProcessor>,
107 pub commit_parsers: Vec<CommitParser>,
110 pub protect_breaking_commits: bool,
113 pub link_parsers: Vec<LinkParser>,
116 pub filter_commits: bool,
118 pub fail_on_unmatched_commit: bool,
120 #[serde(with = "serde_regex", default)]
122 pub tag_pattern: Option<Regex>,
123 #[serde(with = "serde_regex", default)]
125 pub skip_tags: Option<Regex>,
126 #[serde(with = "serde_regex", default)]
128 pub ignore_tags: Option<Regex>,
129 #[serde(with = "serde_regex", default)]
131 pub count_tags: Option<Regex>,
132 pub use_branch_tags: bool,
134 pub topo_order: bool,
136 pub topo_order_commits: bool,
138 pub sort_commits: String,
140 pub limit_commits: Option<usize>,
142 pub recurse_submodules: Option<bool>,
144 #[serde(with = "serde_pattern", default)]
146 pub include_paths: Vec<Pattern>,
147 #[serde(with = "serde_pattern", default)]
149 pub exclude_paths: Vec<Pattern>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum ProcessingStep {
156 CommitPreprocessors,
159 SplitCommits,
161 ConventionalCommits,
163 CommitParsers,
166 LinkParsers,
169}
170
171mod 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#[derive(Default, Debug, Clone, Serialize, Deserialize)]
203pub struct RemoteConfig {
204 #[serde(default)]
206 pub offline: bool,
207 #[serde(default)]
209 pub github: Remote,
210 #[serde(default)]
212 pub gitlab: Remote,
213 #[serde(default)]
215 pub gitea: Remote,
216 #[serde(default)]
218 pub bitbucket: Remote,
219 #[serde(default)]
221 pub azure_devops: Remote,
222}
223
224impl RemoteConfig {
225 #[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 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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
278pub struct Remote {
279 pub owner: String,
281 pub repo: String,
283 #[serde(skip_serializing)]
285 pub token: Option<SecretString>,
286 #[serde(skip_deserializing, default = "default_true")]
288 pub is_custom: bool,
289 pub api_url: Option<String>,
291 pub native_tls: Option<bool>,
293}
294
295fn 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 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 #[must_use]
327 pub fn is_set(&self) -> bool {
328 !self.owner.is_empty() && !self.repo.is_empty()
329 }
330}
331
332#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
334#[serde(rename_all = "lowercase")]
335pub enum BumpType {
336 Major,
338 Minor,
340 Patch,
342}
343
344#[derive(Debug, Default, Clone, Serialize, Deserialize)]
346pub struct Bump {
347 pub features_always_bump_minor: Option<bool>,
355
356 pub breaking_always_bump_major: Option<bool>,
365
366 pub initial_tag: Option<String>,
370
371 pub custom_major_increment_regex: Option<String>,
379
380 pub custom_minor_increment_regex: Option<String>,
388
389 pub bump_type: Option<BumpType>,
391}
392
393impl Bump {
394 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
411pub struct CommitParser {
412 pub sha: Option<String>,
414 #[serde(with = "serde_regex", default)]
416 pub message: Option<Regex>,
417 #[serde(with = "serde_regex", default)]
419 pub body: Option<Regex>,
420 #[serde(with = "serde_regex", default)]
422 pub footer: Option<Regex>,
423 pub group: Option<String>,
425 pub default_scope: Option<String>,
427 pub scope: Option<String>,
429 pub skip: Option<bool>,
431 pub field: Option<String>,
433 #[serde(with = "serde_regex", default)]
435 pub pattern: Option<Regex>,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct TextProcessor {
441 #[serde(with = "serde_regex")]
443 pub pattern: Regex,
444 pub replace: Option<String>,
446 pub replace_command: Option<String>,
448}
449
450impl TextProcessor {
451 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#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct LinkParser {
467 #[serde(with = "serde_regex")]
469 pub pattern: Regex,
470 pub href: String,
472 pub text: Option<String>,
474}
475
476impl Config {
477 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 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 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 #[must_use]
520 pub fn retrieve_user_config_path() -> Option<PathBuf> {
521 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 #[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 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 fn from_str(contents: &str) -> Result<Self> {
561 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 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}