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;
#[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 postprocessors: Option<Vec<TextProcessor>>,
}
#[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>,
pub topo_order: Option<bool>,
pub sort_commits: Option<String>,
pub limit_commits: Option<usize>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct RemoteConfig {
pub github: Remote,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Remote {
pub owner: String,
pub repo: String,
#[serde(skip_serializing)]
pub token: Option<SecretString>,
}
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,
}
}
pub fn is_set(&self) -> bool {
!self.owner.is_empty() && !self.repo.is_empty()
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Bump {
pub features_always_bump_minor: Option<bool>,
pub breaking_always_bump_major: Option<bool>,
}
#[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>,
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).iter() {
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());
}
}