use crate::command;
use crate::error::Result;
use regex::{
Regex,
RegexBuilder,
};
use secrecy::SecretString;
use serde::{
Deserialize,
Serialize,
};
use std::fmt;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
const DEFAULT_INITIAL_TAG: &str = "0.1.0";
#[derive(Debug)]
struct ManifestInfo {
path: PathBuf,
regex: Regex,
}
lazy_static::lazy_static! {
static ref MANIFEST_INFO: Vec<ManifestInfo> = vec![
ManifestInfo {
path: PathBuf::from("Cargo.toml"),
regex: RegexBuilder::new(
r"^\[(?:workspace|package)\.metadata\.git\-cliff\.",
)
.multi_line(true)
.build()
.expect("failed to build regex"),
},
ManifestInfo {
path: PathBuf::from("pyproject.toml"),
regex: RegexBuilder::new(r"^\[(?:tool)\.git\-cliff\.")
.multi_line(true)
.build()
.expect("failed to build regex"),
},
];
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub changelog: ChangelogConfig,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub remote: RemoteConfig,
#[serde(default)]
pub bump: Bump,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ChangelogConfig {
pub header: Option<String>,
pub body: Option<String>,
pub footer: Option<String>,
pub trim: Option<bool>,
pub render_always: Option<bool>,
pub postprocessors: Option<Vec<TextProcessor>>,
pub output: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GitConfig {
pub conventional_commits: Option<bool>,
pub filter_unconventional: Option<bool>,
pub split_commits: Option<bool>,
pub commit_preprocessors: Option<Vec<TextProcessor>>,
pub commit_parsers: Option<Vec<CommitParser>>,
pub protect_breaking_commits: Option<bool>,
pub link_parsers: Option<Vec<LinkParser>>,
pub filter_commits: Option<bool>,
#[serde(with = "serde_regex", default)]
pub tag_pattern: Option<Regex>,
#[serde(with = "serde_regex", default)]
pub skip_tags: Option<Regex>,
#[serde(with = "serde_regex", default)]
pub ignore_tags: Option<Regex>,
#[serde(with = "serde_regex", default)]
pub count_tags: Option<Regex>,
pub use_branch_tags: Option<bool>,
pub topo_order: Option<bool>,
pub sort_commits: Option<String>,
pub limit_commits: Option<usize>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct RemoteConfig {
#[serde(default)]
pub github: Remote,
#[serde(default)]
pub gitlab: Remote,
#[serde(default)]
pub gitea: Remote,
#[serde(default)]
pub bitbucket: Remote,
}
impl RemoteConfig {
pub fn is_any_set(&self) -> bool {
#[cfg(feature = "github")]
if self.github.is_set() {
return true;
}
#[cfg(feature = "gitlab")]
if self.gitlab.is_set() {
return true;
}
#[cfg(feature = "gitea")]
if self.gitea.is_set() {
return true;
}
#[cfg(feature = "bitbucket")]
if self.bitbucket.is_set() {
return true;
}
false
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Remote {
pub owner: String,
pub repo: String,
#[serde(skip_serializing)]
pub token: Option<SecretString>,
#[serde(skip_deserializing, default = "default_true")]
pub is_custom: bool,
}
fn default_true() -> bool {
true
}
impl fmt::Display for Remote {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.owner, self.repo)
}
}
impl PartialEq for Remote {
fn eq(&self, other: &Self) -> bool {
self.to_string() == other.to_string()
}
}
impl Remote {
pub fn new<S: Into<String>>(owner: S, repo: S) -> Self {
Self {
owner: owner.into(),
repo: repo.into(),
token: None,
is_custom: false,
}
}
pub fn is_set(&self) -> bool {
!self.owner.is_empty() && !self.repo.is_empty()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
pub enum BumpType {
Major,
Minor,
Patch,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Bump {
pub features_always_bump_minor: Option<bool>,
pub breaking_always_bump_major: Option<bool>,
pub initial_tag: Option<String>,
pub custom_major_increment_regex: Option<String>,
pub custom_minor_increment_regex: Option<String>,
pub bump_type: Option<BumpType>,
}
impl Bump {
pub fn get_initial_tag(&self) -> String {
if let Some(tag) = self.initial_tag.clone() {
warn!(
"No releases found, using initial tag '{tag}' as the next version."
);
tag
} else {
warn!(
"No releases found, using {DEFAULT_INITIAL_TAG} as the next \
version."
);
DEFAULT_INITIAL_TAG.into()
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CommitParser {
pub sha: Option<String>,
#[serde(with = "serde_regex", default)]
pub message: Option<Regex>,
#[serde(with = "serde_regex", default)]
pub body: Option<Regex>,
#[serde(with = "serde_regex", default)]
pub footer: Option<Regex>,
pub group: Option<String>,
pub default_scope: Option<String>,
pub scope: Option<String>,
pub skip: Option<bool>,
pub field: Option<String>,
#[serde(with = "serde_regex", default)]
pub pattern: Option<Regex>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextProcessor {
#[serde(with = "serde_regex")]
pub pattern: Regex,
pub replace: Option<String>,
pub replace_command: Option<String>,
}
impl TextProcessor {
pub fn replace(
&self,
rendered: &mut String,
command_envs: Vec<(&str, &str)>,
) -> Result<()> {
if let Some(text) = &self.replace {
*rendered = self.pattern.replace_all(rendered, text).to_string();
} else if let Some(command) = &self.replace_command {
if self.pattern.is_match(rendered) {
*rendered =
command::run(command, Some(rendered.to_string()), command_envs)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkParser {
#[serde(with = "serde_regex")]
pub pattern: Regex,
pub href: String,
pub text: Option<String>,
}
impl Config {
pub fn read_from_manifest() -> Result<Option<String>> {
for info in &(*MANIFEST_INFO) {
if info.path.exists() {
let contents = fs::read_to_string(&info.path)?;
if info.regex.is_match(&contents) {
return Ok(Some(
info.regex.replace_all(&contents, "[").to_string(),
));
}
}
}
Ok(None)
}
pub fn parse_from_str(contents: &str) -> Result<Config> {
Ok(config::Config::builder()
.add_source(config::File::from_str(contents, config::FileFormat::Toml))
.add_source(
config::Environment::with_prefix("GIT_CLIFF").separator("__"),
)
.build()?
.try_deserialize()?)
}
pub fn parse(path: &Path) -> Result<Config> {
if MANIFEST_INFO
.iter()
.any(|v| path.file_name() == v.path.file_name())
{
if let Some(contents) = Self::read_from_manifest()? {
return Self::parse_from_str(&contents);
}
}
Ok(config::Config::builder()
.add_source(config::File::from(path))
.add_source(
config::Environment::with_prefix("GIT_CLIFF").separator("__"),
)
.build()?
.try_deserialize()?)
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use std::env;
#[test]
fn parse_config() -> Result<()> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("parent directory not found")
.to_path_buf()
.join("config")
.join(crate::DEFAULT_CONFIG);
const FOOTER_VALUE: &str = "test";
const TAG_PATTERN_VALUE: &str = ".*[0-9].*";
const IGNORE_TAGS_VALUE: &str = "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+";
env::set_var("GIT_CLIFF__CHANGELOG__FOOTER", FOOTER_VALUE);
env::set_var("GIT_CLIFF__GIT__TAG_PATTERN", TAG_PATTERN_VALUE);
env::set_var("GIT_CLIFF__GIT__IGNORE_TAGS", IGNORE_TAGS_VALUE);
let config = Config::parse(&path)?;
assert_eq!(Some(String::from(FOOTER_VALUE)), config.changelog.footer);
assert_eq!(
Some(String::from(TAG_PATTERN_VALUE)),
config
.git
.tag_pattern
.map(|tag_pattern| tag_pattern.to_string())
);
assert_eq!(
Some(String::from(IGNORE_TAGS_VALUE)),
config
.git
.ignore_tags
.map(|ignore_tags| ignore_tags.to_string())
);
Ok(())
}
#[test]
fn remote_config() {
let remote1 = Remote::new("abc", "xyz1");
let remote2 = Remote::new("abc", "xyz2");
assert!(!remote1.eq(&remote2));
assert_eq!("abc/xyz1", remote1.to_string());
assert!(remote1.is_set());
assert!(!Remote::new("", "test").is_set());
assert!(!Remote::new("test", "").is_set());
assert!(!Remote::new("", "").is_set());
}
}