use std::{
fmt,
path::{Path, PathBuf},
};
use semver::Version;
use serde::{Deserialize, Deserializer, Serialize};
use url::Url;
use crate::{error::Error, git::GitHelper};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) enum Increment {
Major,
Minor,
Patch,
None,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Type {
pub(crate) r#type: String,
pub(crate) increment: Increment,
#[serde(default)]
pub(crate) section: String,
#[serde(default)]
pub(crate) hidden: bool,
}
impl fmt::Display for Type {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.r#type)
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Config {
#[serde(default = "default_header")]
pub(crate) header: String,
#[serde(default = "default_types")]
#[serde(deserialize_with = "deserialize_type")]
pub(crate) types: Vec<Type>,
#[serde(default)]
pub(crate) pre_major: bool,
#[serde(default = "default_commit_url_format")]
pub(crate) commit_url_format: String,
#[serde(default = "default_compare_url_format")]
pub(crate) compare_url_format: String,
#[serde(default = "default_issue_url_format")]
pub(crate) issue_url_format: String,
#[serde(default = "default_user_url_format")]
pub(crate) user_url_format: String,
#[serde(default = "default_release_commit_message_format")]
pub(crate) release_commit_message_format: String,
#[serde(default = "default_issue_prefixes")]
pub(crate) issue_prefixes: Vec<String>,
pub(crate) host: Option<String>,
pub(crate) owner: Option<String>,
pub(crate) repository: Option<String>,
pub(crate) template: Option<PathBuf>,
pub(crate) commit_template: Option<PathBuf>,
#[serde(default = "default_scope_regex")]
pub(crate) scope_regex: String,
#[serde(default = "default_line_length")]
pub(crate) line_length: usize,
#[serde(default)]
pub(crate) wrap_disabled: bool,
#[serde(default = "default_true")]
pub(crate) link_compare: bool,
#[serde(default = "default_true")]
pub(crate) link_references: bool,
#[serde(default)]
pub(crate) merges: bool,
#[serde(default)]
pub first_parent: bool,
#[serde(default = "default_strip_regex")]
pub(crate) strip_regex: String,
#[serde(default)]
pub(crate) description: DescriptionConfig,
#[serde(default = "default_initial_bump_version")]
pub(crate) initial_bump_version: Version,
#[serde(default)]
pub(crate) ignore_message_pattern: Vec<String>,
}
fn default_initial_bump_version() -> Version {
Version::new(0, 1, 0)
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Default)]
pub(crate) struct DescriptionConfig {
pub(crate) length: DescriptionLengthConfig,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct DescriptionLengthConfig {
#[serde(default = "default_some_10")]
pub(crate) min: Option<usize>,
pub(crate) max: Option<usize>,
}
impl Default for DescriptionLengthConfig {
fn default() -> Self {
Self {
min: Some(10),
max: None,
}
}
}
fn deserialize_type<'de, D>(deserializer: D) -> Result<Vec<Type>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct PartialType {
r#type: String,
increment: Option<Increment>,
section: String,
#[serde(default)]
hidden: bool,
}
let vec: Result<Vec<PartialType>, D::Error> = Deserialize::deserialize(deserializer);
vec.map(|vec| {
vec.into_iter()
.map(
|PartialType {
r#type,
increment,
section,
hidden,
}| Type {
r#type: r#type.clone(),
increment: increment.unwrap_or(match r#type.as_str() {
"feat" => Increment::Minor,
"fix" => Increment::Patch,
_ => Increment::None,
}),
section,
hidden,
},
)
.collect()
})
}
const fn default_true() -> bool {
true
}
const fn default_some_10() -> Option<usize> {
Some(10)
}
impl Default for Config {
fn default() -> Self {
Self {
header: default_header(),
types: default_types(),
pre_major: false,
commit_url_format: default_commit_url_format(),
compare_url_format: default_compare_url_format(),
issue_url_format: default_issue_url_format(),
user_url_format: default_user_url_format(),
release_commit_message_format: default_release_commit_message_format(),
issue_prefixes: default_issue_prefixes(),
line_length: default_line_length(),
host: None,
owner: None,
repository: None,
template: None,
commit_template: None,
scope_regex: "^[[:alnum:]]+(?:[-_/][[:alnum:]]+)*$".to_string(),
link_compare: true,
link_references: true,
merges: false,
first_parent: false,
wrap_disabled: false,
strip_regex: "".to_string(),
description: Default::default(),
initial_bump_version: Version::new(0, 1, 0),
ignore_message_pattern: vec![],
}
}
}
fn default_header() -> String {
"# Changelog\n".into()
}
fn default_types() -> Vec<Type> {
vec![
Type {
r#type: "feat".into(),
increment: Increment::Minor,
section: "Features".into(),
hidden: false,
},
Type {
r#type: "fix".into(),
increment: Increment::Patch,
section: "Fixes".into(),
hidden: false,
},
Type {
r#type: "build".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
Type {
r#type: "chore".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
Type {
r#type: "ci".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
Type {
r#type: "docs".into(),
increment: Increment::None,
section: "Documentation".into(),
hidden: true,
},
Type {
r#type: "style".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
Type {
r#type: "refactor".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
Type {
r#type: "perf".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
Type {
r#type: "test".into(),
increment: Increment::None,
section: "Other".into(),
hidden: true,
},
]
}
fn default_commit_url_format() -> String {
"{{@root.host}}/{{@root.owner}}/{{@root.repository}}/commit/{{hash}}".into()
}
fn default_compare_url_format() -> String {
"{{@root.host}}/{{@root.owner}}/{{@root.repository}}/compare/{{previousTag}}...{{currentTag}}"
.into()
}
fn default_issue_url_format() -> String {
"{{@root.host}}/{{@root.owner}}/{{@root.repository}}/issues/{{issue}}".into()
}
fn default_user_url_format() -> String {
"{{host}}/{{user}}".into()
}
fn default_release_commit_message_format() -> String {
"chore(release): {{currentTag}}".into()
}
fn default_line_length() -> usize {
80
}
fn default_issue_prefixes() -> Vec<String> {
vec!["#".into()]
}
fn default_scope_regex() -> String {
"^[[:alnum:]]+(?:[-_/][[:alnum:]]+)*$".to_string()
}
fn default_strip_regex() -> String {
"".to_string()
}
type HostOwnerRepo = (Option<String>, Option<String>, Option<String>);
pub(crate) fn host_info(git: &GitHelper) -> Result<HostOwnerRepo, Error> {
if let Some(mut url) = git.url()? {
if !url.contains("://") {
if let Some(colon) = url.find(':') {
match url.as_bytes()[colon + 1] {
b'0'..=b'9' => url = format!("scheme://{}", url),
_ => url = format!("scheme://{}/{}", &url[..colon], &url[colon + 1..]),
}
}
}
let url = Url::parse(url.as_str())?;
host_info_from_url(url)
} else {
Ok((None, None, None))
}
}
fn host_info_from_url(url: Url) -> Result<HostOwnerRepo, Error> {
let scheme = match url.scheme() {
"scheme" => "https",
scheme => scheme,
};
let host = url.host().map(|h| format!("{scheme}://{}", h));
let (owner, repository) = match url.path().rsplit_once('/') {
Some((owner, repository)) => {
let owner = Some(owner.trim_start_matches('/').to_owned());
let repository = Some(repository.trim_end_matches(".git").to_owned());
(owner, repository)
}
None => (None, None),
};
Ok((host, owner, repository))
}
pub(crate) fn make_cl_config(git: Option<GitHelper>, path: impl AsRef<Path>) -> Config {
let mut config: Config = std::fs::read(path)
.ok()
.and_then(|vec| (serde_norway::from_reader(vec.as_slice())).ok())
.unwrap_or_default();
if let Config {
host: None,
owner: None,
repository: None,
..
} = config
{
if let Some(ref git) = git {
if let Ok((host, owner, repository)) = host_info(git) {
config.host = host;
config.owner = owner;
config.repository = repository;
}
}
}
if config.host.is_none() || config.commit_url_format.is_empty() {
config.link_references = false;
}
config
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_info_from_url() {
fn assert_all(url: &str, host: &str, owner: &str, repo: &str) {
let expected: HostOwnerRepo = (
Some(host.to_string()),
Some(owner.to_string()),
Some(repo.to_string()),
);
let result = host_info_from_url(url.parse().unwrap()).unwrap();
assert_eq!(result, expected);
}
assert_all(
"https://github.com/convco/convco.git",
"https://github.com",
"convco",
"convco",
);
assert_all(
"http://github.com/convco/convco.git",
"http://github.com",
"convco",
"convco",
);
assert_all(
"https://gitlab.com/group/subgroup/repo.git",
"https://gitlab.com",
"group/subgroup",
"repo",
);
assert_all(
"scheme://git@github.com/convco/convco.git",
"https://github.com",
"convco",
"convco",
);
}
#[test]
fn test() {
let json = r#"{
"types": [
{"type": "chore", "section":"Others", "hidden": false},
{"type": "revert", "section":"Reverts", "hidden": false},
{"type": "feat", "section": "Features", "hidden": false},
{"type": "fix", "section": "Bug Fixes", "hidden": false},
{"type": "improvement", "section": "Feature Improvements", "hidden": false},
{"type": "docs", "section":"Docs", "hidden": false},
{"type": "style", "section":"Styling", "hidden": false},
{"type": "refactor", "section":"Code Refactoring", "hidden": false},
{"type": "perf", "section":"Performance Improvements", "hidden": false},
{"type": "test", "section":"Tests", "hidden": false},
{"type": "build", "section":"Build System", "hidden": false},
{"type": "ci", "section":"CI", "hidden":false}
],
}"#;
let value: Config = serde_norway::from_str(json).unwrap();
assert_eq!(
value,
Config {
line_length: 80,
header: "# Changelog\n".to_string(),
types: vec![
Type {
r#type: "chore".into(),
increment: Increment::None,
section: "Others".into(),
hidden: false
},
Type {
r#type: "revert".into(),
increment: Increment::None,
section: "Reverts".into(),
hidden: false
},
Type {
r#type: "feat".into(),
increment: Increment::Minor,
section: "Features".into(),
hidden: false
},
Type {
r#type: "fix".into(),
increment: Increment::Patch,
section: "Bug Fixes".into(),
hidden: false
},
Type {
r#type: "improvement".into(),
increment: Increment::None,
section: "Feature Improvements".into(),
hidden: false
},
Type {
r#type: "docs".into(),
increment: Increment::None,
section: "Docs".into(),
hidden: false
},
Type {
r#type: "style".into(),
increment: Increment::None,
section: "Styling".into(),
hidden: false
},
Type {
r#type: "refactor".into(),
increment: Increment::None,
section: "Code Refactoring".into(),
hidden: false
},
Type {
r#type: "perf".into(),
increment: Increment::None,
section: "Performance Improvements".into(),
hidden: false
},
Type {
r#type: "test".into(),
increment: Increment::None,
section: "Tests".into(),
hidden: false
},
Type {
r#type: "build".into(),
increment: Increment::None,
section: "Build System".into(),
hidden: false
},
Type {
r#type: "ci".into(),
increment: Increment::None,
section: "CI".into(),
hidden: false
}
],
pre_major: false,
commit_url_format: "{{@root.host}}/{{@root.owner}}/{{@root.repository}}/commit/{{hash}}"
.to_string(),
compare_url_format:
"{{@root.host}}/{{@root.owner}}/{{@root.repository}}/compare/{{previousTag}}...{{currentTag}}"
.to_string(),
issue_url_format:
"{{@root.host}}/{{@root.owner}}/{{@root.repository}}/issues/{{issue}}"
.to_string(),
user_url_format: "{{host}}/{{user}}".to_string(),
release_commit_message_format: "chore(release): {{currentTag}}".to_string(),
issue_prefixes: vec!["#".into()],
host: None,
owner: None,
repository: None,
template: None,
commit_template: None,
scope_regex: "^[[:alnum:]]+(?:[-_/][[:alnum:]]+)*$".to_string(),
link_compare: true,
link_references: true,
merges: false,
first_parent: false,
wrap_disabled: false,
strip_regex: "".to_string(),
description: DescriptionConfig { length: DescriptionLengthConfig { min: Some(10), max: None } },
initial_bump_version: Version::new(0, 1, 0),
ignore_message_pattern: vec![],
}
)
}
}