1use crate::command;
2use crate::embed::EmbeddedConfig;
3use crate::error::Result;
4use regex::{
5 Regex,
6 RegexBuilder,
7};
8use secrecy::SecretString;
9use serde::{
10 Deserialize,
11 Serialize,
12};
13use std::fmt;
14use std::fs;
15use std::path::Path;
16use std::path::PathBuf;
17
18const DEFAULT_INITIAL_TAG: &str = "0.1.0";
20
21#[derive(Debug)]
23struct ManifestInfo {
24 path: PathBuf,
26 regex: Regex,
28}
29
30lazy_static::lazy_static! {
31 static ref MANIFEST_INFO: Vec<ManifestInfo> = vec![
33 ManifestInfo {
34 path: PathBuf::from("Cargo.toml"),
35 regex: RegexBuilder::new(
36 r"^\[(?:workspace|package)\.metadata\.git\-cliff\.",
37 )
38 .multi_line(true)
39 .build()
40 .expect("failed to build regex"),
41 },
42 ManifestInfo {
43 path: PathBuf::from("pyproject.toml"),
44 regex: RegexBuilder::new(r"^\[(?:tool)\.git\-cliff\.")
45 .multi_line(true)
46 .build()
47 .expect("failed to build regex"),
48 },
49 ];
50
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Config {
56 #[serde(default)]
58 pub changelog: ChangelogConfig,
59 #[serde(default)]
61 pub git: GitConfig,
62 #[serde(default)]
64 pub remote: RemoteConfig,
65 #[serde(default)]
67 pub bump: Bump,
68}
69
70#[derive(Debug, Default, Clone, Serialize, Deserialize)]
72pub struct ChangelogConfig {
73 pub header: Option<String>,
75 pub body: String,
77 pub footer: Option<String>,
79 pub trim: bool,
81 pub render_always: bool,
83 pub postprocessors: Vec<TextProcessor>,
85 pub output: Option<PathBuf>,
87}
88
89#[derive(Debug, Default, Clone, Serialize, Deserialize)]
91pub struct GitConfig {
92 pub conventional_commits: bool,
94 pub require_conventional: bool,
97 pub filter_unconventional: bool,
100 pub split_commits: bool,
102
103 pub commit_preprocessors: Vec<TextProcessor>,
106 pub commit_parsers: Vec<CommitParser>,
109 pub protect_breaking_commits: bool,
112 pub link_parsers: Vec<LinkParser>,
115 pub filter_commits: bool,
117 #[serde(with = "serde_regex", default)]
119 pub tag_pattern: Option<Regex>,
120 #[serde(with = "serde_regex", default)]
122 pub skip_tags: Option<Regex>,
123 #[serde(with = "serde_regex", default)]
125 pub ignore_tags: Option<Regex>,
126 #[serde(with = "serde_regex", default)]
128 pub count_tags: Option<Regex>,
129 pub use_branch_tags: bool,
131 pub topo_order: bool,
133 pub topo_order_commits: bool,
135 pub sort_commits: String,
137 pub limit_commits: Option<usize>,
139 pub recurse_submodules: Option<bool>,
141}
142
143#[derive(Default, Debug, Clone, Serialize, Deserialize)]
145pub struct RemoteConfig {
146 #[serde(default)]
148 pub github: Remote,
149 #[serde(default)]
151 pub gitlab: Remote,
152 #[serde(default)]
154 pub gitea: Remote,
155 #[serde(default)]
157 pub bitbucket: Remote,
158}
159
160impl RemoteConfig {
161 pub fn is_any_set(&self) -> bool {
163 #[cfg(feature = "github")]
164 if self.github.is_set() {
165 return true;
166 }
167 #[cfg(feature = "gitlab")]
168 if self.gitlab.is_set() {
169 return true;
170 }
171 #[cfg(feature = "gitea")]
172 if self.gitea.is_set() {
173 return true;
174 }
175 #[cfg(feature = "bitbucket")]
176 if self.bitbucket.is_set() {
177 return true;
178 }
179 false
180 }
181
182 pub fn enable_native_tls(&mut self) {
184 #[cfg(feature = "github")]
185 {
186 self.github.native_tls = Some(true);
187 }
188 #[cfg(feature = "gitlab")]
189 {
190 self.gitlab.native_tls = Some(true);
191 }
192 #[cfg(feature = "gitea")]
193 {
194 self.gitea.native_tls = Some(true);
195 }
196 #[cfg(feature = "bitbucket")]
197 {
198 self.bitbucket.native_tls = Some(true);
199 }
200 }
201}
202
203#[derive(Debug, Default, Clone, Serialize, Deserialize)]
205pub struct Remote {
206 pub owner: String,
208 pub repo: String,
210 #[serde(skip_serializing)]
212 pub token: Option<SecretString>,
213 #[serde(skip_deserializing, default = "default_true")]
215 pub is_custom: bool,
216 pub api_url: Option<String>,
218 pub native_tls: Option<bool>,
220}
221
222fn default_true() -> bool {
224 true
225}
226
227impl fmt::Display for Remote {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 write!(f, "{}/{}", self.owner, self.repo)
230 }
231}
232
233impl PartialEq for Remote {
234 fn eq(&self, other: &Self) -> bool {
235 self.to_string() == other.to_string()
236 }
237}
238
239impl Remote {
240 pub fn new<S: Into<String>>(owner: S, repo: S) -> Self {
242 Self {
243 owner: owner.into(),
244 repo: repo.into(),
245 token: None,
246 is_custom: false,
247 api_url: None,
248 native_tls: None,
249 }
250 }
251
252 pub fn is_set(&self) -> bool {
254 !self.owner.is_empty() && !self.repo.is_empty()
255 }
256}
257
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
260#[serde(rename_all = "lowercase")]
261pub enum BumpType {
262 Major,
264 Minor,
266 Patch,
268}
269
270#[derive(Debug, Default, Clone, Serialize, Deserialize)]
272pub struct Bump {
273 pub features_always_bump_minor: Option<bool>,
281
282 pub breaking_always_bump_major: Option<bool>,
291
292 pub initial_tag: Option<String>,
296
297 pub custom_major_increment_regex: Option<String>,
305
306 pub custom_minor_increment_regex: Option<String>,
314
315 pub bump_type: Option<BumpType>,
317}
318
319impl Bump {
320 pub fn get_initial_tag(&self) -> String {
324 if let Some(tag) = self.initial_tag.clone() {
325 warn!(
326 "No releases found, using initial tag '{tag}' as the next version."
327 );
328 tag
329 } else {
330 warn!(
331 "No releases found, using {DEFAULT_INITIAL_TAG} as the next \
332 version."
333 );
334 DEFAULT_INITIAL_TAG.into()
335 }
336 }
337}
338
339#[derive(Debug, Default, Clone, Serialize, Deserialize)]
341pub struct CommitParser {
342 pub sha: Option<String>,
344 #[serde(with = "serde_regex", default)]
346 pub message: Option<Regex>,
347 #[serde(with = "serde_regex", default)]
349 pub body: Option<Regex>,
350 #[serde(with = "serde_regex", default)]
352 pub footer: Option<Regex>,
353 pub group: Option<String>,
355 pub default_scope: Option<String>,
357 pub scope: Option<String>,
359 pub skip: Option<bool>,
361 pub field: Option<String>,
363 #[serde(with = "serde_regex", default)]
365 pub pattern: Option<Regex>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct TextProcessor {
371 #[serde(with = "serde_regex")]
373 pub pattern: Regex,
374 pub replace: Option<String>,
376 pub replace_command: Option<String>,
378}
379
380impl TextProcessor {
381 pub fn replace(
383 &self,
384 rendered: &mut String,
385 command_envs: Vec<(&str, &str)>,
386 ) -> Result<()> {
387 if let Some(text) = &self.replace {
388 *rendered = self.pattern.replace_all(rendered, text).to_string();
389 } else if let Some(command) = &self.replace_command {
390 if self.pattern.is_match(rendered) {
391 *rendered =
392 command::run(command, Some(rendered.to_string()), command_envs)?;
393 }
394 }
395 Ok(())
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct LinkParser {
402 #[serde(with = "serde_regex")]
404 pub pattern: Regex,
405 pub href: String,
407 pub text: Option<String>,
409}
410
411impl Config {
412 pub fn read_from_manifest() -> Result<Option<String>> {
415 for info in &(*MANIFEST_INFO) {
416 if info.path.exists() {
417 let contents = fs::read_to_string(&info.path)?;
418 if info.regex.is_match(&contents) {
419 return Ok(Some(
420 info.regex.replace_all(&contents, "[").to_string(),
421 ));
422 }
423 }
424 }
425 Ok(None)
426 }
427
428 pub fn parse_from_str(contents: &str) -> Result<Config> {
430 let default_config_str = EmbeddedConfig::get_config()?;
433
434 Ok(config::Config::builder()
435 .add_source(config::File::from_str(
436 &default_config_str,
437 config::FileFormat::Toml,
438 ))
439 .add_source(config::File::from_str(contents, config::FileFormat::Toml))
440 .add_source(
441 config::Environment::with_prefix("GIT_CLIFF").separator("__"),
442 )
443 .build()?
444 .try_deserialize()?)
445 }
446
447 pub fn parse(path: &Path) -> Result<Config> {
449 if MANIFEST_INFO
450 .iter()
451 .any(|v| path.file_name() == v.path.file_name())
452 {
453 if let Some(contents) = Self::read_from_manifest()? {
454 return Self::parse_from_str(&contents);
455 }
456 }
457
458 let default_config_str = EmbeddedConfig::get_config()?;
461 Ok(config::Config::builder()
462 .add_source(config::File::from_str(
463 &default_config_str,
464 config::FileFormat::Toml,
465 ))
466 .add_source(config::File::from(path))
467 .add_source(
468 config::Environment::with_prefix("GIT_CLIFF").separator("__"),
469 )
470 .build()?
471 .try_deserialize()?)
472 }
473}
474
475#[cfg(test)]
476mod test {
477 use super::*;
478 use pretty_assertions::assert_eq;
479 use std::env;
480 #[test]
481 fn parse_config() -> Result<()> {
482 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
483 .parent()
484 .expect("parent directory not found")
485 .to_path_buf()
486 .join("config")
487 .join(crate::DEFAULT_CONFIG);
488
489 const FOOTER_VALUE: &str = "test";
490 const TAG_PATTERN_VALUE: &str = ".*[0-9].*";
491 const IGNORE_TAGS_VALUE: &str = "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+";
492
493 unsafe {
494 env::set_var("GIT_CLIFF__CHANGELOG__FOOTER", FOOTER_VALUE);
495 env::set_var("GIT_CLIFF__GIT__TAG_PATTERN", TAG_PATTERN_VALUE);
496 env::set_var("GIT_CLIFF__GIT__IGNORE_TAGS", IGNORE_TAGS_VALUE);
497 };
498
499 let config = Config::parse(&path)?;
500
501 assert_eq!(Some(String::from(FOOTER_VALUE)), config.changelog.footer);
502 assert_eq!(
503 Some(String::from(TAG_PATTERN_VALUE)),
504 config
505 .git
506 .tag_pattern
507 .map(|tag_pattern| tag_pattern.to_string())
508 );
509 assert_eq!(
510 Some(String::from(IGNORE_TAGS_VALUE)),
511 config
512 .git
513 .ignore_tags
514 .map(|ignore_tags| ignore_tags.to_string())
515 );
516 Ok(())
517 }
518
519 #[test]
520 fn remote_config() {
521 let remote1 = Remote::new("abc", "xyz1");
522 let remote2 = Remote::new("abc", "xyz2");
523 assert!(!remote1.eq(&remote2));
524 assert_eq!("abc/xyz1", remote1.to_string());
525 assert!(remote1.is_set());
526 assert!(!Remote::new("", "test").is_set());
527 assert!(!Remote::new("test", "").is_set());
528 assert!(!Remote::new("", "").is_set());
529 }
530}