1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3use std::{fmt, fs};
4
5use glob::Pattern;
6use regex::{Regex, RegexBuilder};
7use secrecy::SecretString;
8use serde::{Deserialize, Serialize};
9
10use crate::embed::EmbeddedConfig;
11use crate::error::Result;
12use crate::{command, error};
13
14const DEFAULT_INITIAL_TAG: &str = "0.1.0";
16
17#[derive(Debug)]
19struct ManifestInfo {
20 path: PathBuf,
22 regex: Regex,
24}
25
26lazy_static::lazy_static! {
27 static ref MANIFEST_INFO: Vec<ManifestInfo> = vec![
29 ManifestInfo {
30 path: PathBuf::from("Cargo.toml"),
31 regex: RegexBuilder::new(
32 r"^\[(?:workspace|package)\.metadata\.git\-cliff\.",
33 )
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
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Config {
52 #[serde(default)]
54 pub changelog: ChangelogConfig,
55 #[serde(default)]
57 pub git: GitConfig,
58 #[serde(default)]
60 pub remote: RemoteConfig,
61 #[serde(default)]
63 pub bump: Bump,
64}
65
66#[derive(Debug, Default, Clone, Serialize, Deserialize)]
68pub struct ChangelogConfig {
69 pub header: Option<String>,
71 pub body: String,
73 pub footer: Option<String>,
75 pub trim: bool,
77 pub render_always: bool,
79 pub postprocessors: Vec<TextProcessor>,
81 pub output: Option<PathBuf>,
83}
84
85#[derive(Debug, Default, Clone, Serialize, Deserialize)]
87pub struct GitConfig {
88 pub conventional_commits: bool,
90 pub require_conventional: bool,
93 pub filter_unconventional: bool,
96 pub split_commits: bool,
98
99 pub commit_preprocessors: Vec<TextProcessor>,
102 pub commit_parsers: Vec<CommitParser>,
105 pub protect_breaking_commits: bool,
108 pub link_parsers: Vec<LinkParser>,
111 pub filter_commits: bool,
113 #[serde(with = "serde_regex", default)]
115 pub tag_pattern: Option<Regex>,
116 #[serde(with = "serde_regex", default)]
118 pub skip_tags: Option<Regex>,
119 #[serde(with = "serde_regex", default)]
121 pub ignore_tags: Option<Regex>,
122 #[serde(with = "serde_regex", default)]
124 pub count_tags: Option<Regex>,
125 pub use_branch_tags: bool,
127 pub topo_order: bool,
129 pub topo_order_commits: bool,
131 pub sort_commits: String,
133 pub limit_commits: Option<usize>,
135 pub recurse_submodules: Option<bool>,
137 #[serde(with = "serde_pattern", default)]
139 pub include_paths: Vec<Pattern>,
140 #[serde(with = "serde_pattern", default)]
142 pub exclude_paths: Vec<Pattern>,
143}
144
145mod serde_pattern {
147 use glob::Pattern;
148 use serde::Deserialize;
149 use serde::de::Error;
150 use serde::ser::SerializeSeq;
151
152 pub fn serialize<S>(patterns: &[Pattern], serializer: S) -> Result<S::Ok, S::Error>
153 where
154 S: serde::Serializer,
155 {
156 let mut seq = serializer.serialize_seq(Some(patterns.len()))?;
157 for pattern in patterns {
158 seq.serialize_element(pattern.as_str())?;
159 }
160 seq.end()
161 }
162
163 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Pattern>, D::Error>
164 where
165 D: serde::Deserializer<'de>,
166 {
167 let patterns = Vec::<String>::deserialize(deserializer)?;
168 patterns
169 .into_iter()
170 .map(|pattern| pattern.parse().map_err(D::Error::custom))
171 .collect()
172 }
173}
174
175#[derive(Default, Debug, Clone, Serialize, Deserialize)]
177pub struct RemoteConfig {
178 #[serde(default)]
180 pub github: Remote,
181 #[serde(default)]
183 pub gitlab: Remote,
184 #[serde(default)]
186 pub gitea: Remote,
187 #[serde(default)]
189 pub bitbucket: Remote,
190}
191
192impl RemoteConfig {
193 pub fn is_any_set(&self) -> bool {
195 #[cfg(feature = "github")]
196 if self.github.is_set() {
197 return true;
198 }
199 #[cfg(feature = "gitlab")]
200 if self.gitlab.is_set() {
201 return true;
202 }
203 #[cfg(feature = "gitea")]
204 if self.gitea.is_set() {
205 return true;
206 }
207 #[cfg(feature = "bitbucket")]
208 if self.bitbucket.is_set() {
209 return true;
210 }
211 false
212 }
213
214 pub fn enable_native_tls(&mut self) {
216 #[cfg(feature = "github")]
217 {
218 self.github.native_tls = Some(true);
219 }
220 #[cfg(feature = "gitlab")]
221 {
222 self.gitlab.native_tls = Some(true);
223 }
224 #[cfg(feature = "gitea")]
225 {
226 self.gitea.native_tls = Some(true);
227 }
228 #[cfg(feature = "bitbucket")]
229 {
230 self.bitbucket.native_tls = Some(true);
231 }
232 }
233}
234
235#[derive(Debug, Default, Clone, Serialize, Deserialize)]
237pub struct Remote {
238 pub owner: String,
240 pub repo: String,
242 #[serde(skip_serializing)]
244 pub token: Option<SecretString>,
245 #[serde(skip_deserializing, default = "default_true")]
247 pub is_custom: bool,
248 pub api_url: Option<String>,
250 pub native_tls: Option<bool>,
252}
253
254fn default_true() -> bool {
256 true
257}
258
259impl fmt::Display for Remote {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 write!(f, "{}/{}", self.owner, self.repo)
262 }
263}
264
265impl PartialEq for Remote {
266 fn eq(&self, other: &Self) -> bool {
267 self.to_string() == other.to_string()
268 }
269}
270
271impl Remote {
272 pub fn new<S: Into<String>>(owner: S, repo: S) -> Self {
274 Self {
275 owner: owner.into(),
276 repo: repo.into(),
277 token: None,
278 is_custom: false,
279 api_url: None,
280 native_tls: None,
281 }
282 }
283
284 pub fn is_set(&self) -> bool {
286 !self.owner.is_empty() && !self.repo.is_empty()
287 }
288}
289
290#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
292#[serde(rename_all = "lowercase")]
293pub enum BumpType {
294 Major,
296 Minor,
298 Patch,
300}
301
302#[derive(Debug, Default, Clone, Serialize, Deserialize)]
304pub struct Bump {
305 pub features_always_bump_minor: Option<bool>,
313
314 pub breaking_always_bump_major: Option<bool>,
323
324 pub initial_tag: Option<String>,
328
329 pub custom_major_increment_regex: Option<String>,
337
338 pub custom_minor_increment_regex: Option<String>,
346
347 pub bump_type: Option<BumpType>,
349}
350
351impl Bump {
352 pub fn get_initial_tag(&self) -> String {
356 if let Some(tag) = self.initial_tag.clone() {
357 warn!("No releases found, using initial tag '{tag}' as the next version.");
358 tag
359 } else {
360 warn!("No releases found, using {DEFAULT_INITIAL_TAG} as the next version.");
361 DEFAULT_INITIAL_TAG.into()
362 }
363 }
364}
365
366#[derive(Debug, Default, Clone, Serialize, Deserialize)]
368pub struct CommitParser {
369 pub sha: Option<String>,
371 #[serde(with = "serde_regex", default)]
373 pub message: Option<Regex>,
374 #[serde(with = "serde_regex", default)]
376 pub body: Option<Regex>,
377 #[serde(with = "serde_regex", default)]
379 pub footer: Option<Regex>,
380 pub group: Option<String>,
382 pub default_scope: Option<String>,
384 pub scope: Option<String>,
386 pub skip: Option<bool>,
388 pub field: Option<String>,
390 #[serde(with = "serde_regex", default)]
392 pub pattern: Option<Regex>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct TextProcessor {
398 #[serde(with = "serde_regex")]
400 pub pattern: Regex,
401 pub replace: Option<String>,
403 pub replace_command: Option<String>,
405}
406
407impl TextProcessor {
408 pub fn replace(&self, rendered: &mut String, command_envs: Vec<(&str, &str)>) -> Result<()> {
410 if let Some(text) = &self.replace {
411 *rendered = self.pattern.replace_all(rendered, text).to_string();
412 } else if let Some(command) = &self.replace_command {
413 if self.pattern.is_match(rendered) {
414 *rendered = command::run(command, Some(rendered.to_string()), command_envs)?;
415 }
416 }
417 Ok(())
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct LinkParser {
424 #[serde(with = "serde_regex")]
426 pub pattern: Regex,
427 pub href: String,
429 pub text: Option<String>,
431}
432
433impl Config {
434 pub fn read_from_manifest() -> Result<Option<String>> {
437 for info in &(*MANIFEST_INFO) {
438 if info.path.exists() {
439 let contents = fs::read_to_string(&info.path)?;
440 if info.regex.is_match(&contents) {
441 return Ok(Some(info.regex.replace_all(&contents, "[").to_string()));
442 }
443 }
444 }
445 Ok(None)
446 }
447
448 pub fn load(path: &Path) -> Result<Config> {
450 if MANIFEST_INFO
451 .iter()
452 .any(|v| path.file_name() == v.path.file_name())
453 {
454 if let Some(contents) = Self::read_from_manifest()? {
455 return contents.parse();
456 }
457 }
458
459 let default_config_str = EmbeddedConfig::get_config()?;
462 Ok(config::Config::builder()
463 .add_source(config::File::from_str(
464 &default_config_str,
465 config::FileFormat::Toml,
466 ))
467 .add_source(config::File::from(path))
468 .add_source(config::Environment::with_prefix("GIT_CLIFF").separator("__"))
469 .build()?
470 .try_deserialize()?)
471 }
472}
473
474impl FromStr for Config {
475 type Err = error::Error;
476
477 fn from_str(contents: &str) -> Result<Self> {
479 let default_config_str = EmbeddedConfig::get_config()?;
483
484 Ok(config::Config::builder()
485 .add_source(config::File::from_str(
486 &default_config_str,
487 config::FileFormat::Toml,
488 ))
489 .add_source(config::File::from_str(contents, config::FileFormat::Toml))
490 .add_source(config::Environment::with_prefix("GIT_CLIFF").separator("__"))
491 .build()?
492 .try_deserialize()?)
493 }
494}
495
496#[cfg(test)]
497mod test {
498 use std::env;
499
500 use pretty_assertions::assert_eq;
501
502 use super::*;
503 #[test]
504 fn load() -> Result<()> {
505 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
506 .parent()
507 .expect("parent directory not found")
508 .to_path_buf()
509 .join("config")
510 .join(crate::DEFAULT_CONFIG);
511
512 const FOOTER_VALUE: &str = "test";
513 const TAG_PATTERN_VALUE: &str = ".*[0-9].*";
514 const IGNORE_TAGS_VALUE: &str = "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+";
515
516 unsafe {
517 env::set_var("GIT_CLIFF__CHANGELOG__FOOTER", FOOTER_VALUE);
518 env::set_var("GIT_CLIFF__GIT__TAG_PATTERN", TAG_PATTERN_VALUE);
519 env::set_var("GIT_CLIFF__GIT__IGNORE_TAGS", IGNORE_TAGS_VALUE);
520 };
521
522 let config = Config::load(&path)?;
523
524 assert_eq!(Some(String::from(FOOTER_VALUE)), config.changelog.footer);
525 assert_eq!(
526 Some(String::from(TAG_PATTERN_VALUE)),
527 config
528 .git
529 .tag_pattern
530 .map(|tag_pattern| tag_pattern.to_string())
531 );
532 assert_eq!(
533 Some(String::from(IGNORE_TAGS_VALUE)),
534 config
535 .git
536 .ignore_tags
537 .map(|ignore_tags| ignore_tags.to_string())
538 );
539 Ok(())
540 }
541
542 #[test]
543 fn remote_config() {
544 let remote1 = Remote::new("abc", "xyz1");
545 let remote2 = Remote::new("abc", "xyz2");
546 assert!(!remote1.eq(&remote2));
547 assert_eq!("abc/xyz1", remote1.to_string());
548 assert!(remote1.is_set());
549 assert!(!Remote::new("", "test").is_set());
550 assert!(!Remote::new("test", "").is_set());
551 assert!(!Remote::new("", "").is_set());
552 }
553}