Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

200 changes: 195 additions & 5 deletions git-cliff-core/src/embed.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::path::Path;
use std::str;
use std::{fs, str};

use rust_embed::RustEmbed;

Expand Down Expand Up @@ -42,14 +42,22 @@ impl EmbeddedConfig {
pub struct BuiltinConfig;

impl BuiltinConfig {
/// Extracts the embedded content.
pub fn get_config(mut name: String) -> Result<String> {
if !Path::new(&name)
/// Normalizes a template name to a file name carrying the `.toml`
/// extension (appending it when absent).
fn with_toml_extension(name: &str) -> String {
if Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
name = format!("{name}.toml");
name.to_string()
} else {
format!("{name}.toml")
}
}

/// Extracts the embedded content.
pub fn get_config(name: String) -> Result<String> {
let name = Self::with_toml_extension(&name);
let contents = match Self::get(&name) {
Some(v) => Ok(str::from_utf8(&v.data)?.to_string()),
None => Err(Error::EmbeddedError(format!("config {name} not found"))),
Expand All @@ -64,4 +72,186 @@ impl BuiltinConfig {
let parsed = Self::get_config(name.clone())?.parse()?;
Ok((parsed, name))
}

/// Extracts the template content for `name`, preferring a user-provided
/// template found in `templates_dir` (if given) over the built-in template
/// of the same name. The `.toml` extension is optional in `name`.
pub fn get_config_from(name: String, templates_dir: Option<&Path>) -> Result<String> {
if let Some(dir) = templates_dir {
let path = dir.join(Self::with_toml_extension(&name));
if path.is_file() {
return Ok(fs::read_to_string(path)?);
}
}
Self::get_config(name)
}

/// Lists the names of the built-in templates, without the `.toml`
/// extension and in sorted order.
pub fn list() -> Vec<String> {
let mut names: Vec<String> = Self::iter()
.filter_map(|file| {
let path = Path::new(file.as_ref());
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
path.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
} else {
None
}
})
.collect();
names.sort();
names
}

/// Lists the names of every available template — built-in templates plus
/// the `.toml` templates found in `templates_dir` (if given) — without the
/// `.toml` extension, sorted and deduplicated.
///
/// Returns an error if `templates_dir` is provided but cannot be read
/// (e.g. it does not exist or is not a directory).
pub fn list_templates(templates_dir: Option<&Path>) -> Result<Vec<String>> {
let mut names = Self::list();
if let Some(dir) = templates_dir {
if !dir.is_dir() {
return Err(Error::ArgumentError(format!(
"templates directory does not exist or is not a directory: {}",
dir.display()
)));
}
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
{
if let Some(stem) = path.file_stem() {
names.push(stem.to_string_lossy().to_string());
}
}
}
}
names.sort();
names.dedup();
Ok(names)
}
}

#[cfg(test)]
mod test {
use std::fs;

use temp_dir::TempDir;

use super::*;

#[test]
fn lists_builtin_templates_sorted_without_extension() {
let names = BuiltinConfig::list();
// a couple of the shipped example templates are expected to be present
assert!(
names.contains(&"github".to_string()),
"expected built-in 'github' in {names:?}"
);
assert!(
names.contains(&"keepachangelog".to_string()),
"expected built-in 'keepachangelog' in {names:?}"
);
// names are reported without the `.toml` extension
assert!(
names.iter().all(|name| !name.ends_with(".toml")),
"names should not carry the .toml extension: {names:?}"
);
// and are returned in sorted order
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "names should be sorted");
}

#[test]
fn list_templates_merges_user_directory() -> Result<()> {
let dir = TempDir::new()?;
fs::write(dir.path().join("my-custom.toml"), "")?;
fs::write(dir.path().join("notes.txt"), "")?; // not a template, ignored

let names = BuiltinConfig::list_templates(Some(dir.path()))?;

assert!(
names.contains(&"my-custom".to_string()),
"user template should be listed: {names:?}"
);
assert!(
names.contains(&"github".to_string()),
"built-in templates should still be listed: {names:?}"
);
assert!(
!names.iter().any(|name| name == "notes"),
"non-.toml files should be ignored: {names:?}"
);

// sorted and deduplicated
let mut expected = names.clone();
expected.sort();
expected.dedup();
assert_eq!(names, expected, "names should be sorted and deduped");
Ok(())
}

#[test]
fn list_templates_without_user_directory_matches_builtin() -> Result<()> {
assert_eq!(BuiltinConfig::list_templates(None)?, BuiltinConfig::list());
Ok(())
}

#[test]
fn list_templates_errors_clearly_when_directory_missing() {
let dir = TempDir::new().expect("temp dir");
let missing = dir.path().join("does-not-exist");
let err = BuiltinConfig::list_templates(Some(&missing))
.expect_err("a missing templates directory should be an error");
let message = err.to_string();
assert!(
message.contains(&missing.display().to_string()),
"error should name the offending directory, got: {message}"
);
}

#[test]
fn get_config_from_prefers_user_template_over_builtin() -> Result<()> {
let dir = TempDir::new()?;
// shadow the built-in "github" template with custom content
fs::write(dir.path().join("github.toml"), "# user override\n")?;
let contents = BuiltinConfig::get_config_from("github".to_string(), Some(dir.path()))?;
assert_eq!(contents, "# user override\n");
Ok(())
}

#[test]
fn get_config_from_normalizes_missing_extension() -> Result<()> {
let dir = TempDir::new()?;
fs::write(dir.path().join("mine.toml"), "# mine\n")?;
// name given without the `.toml` extension still resolves the user file
let contents = BuiltinConfig::get_config_from("mine".to_string(), Some(dir.path()))?;
assert_eq!(contents, "# mine\n");
Ok(())
}

#[test]
fn get_config_from_falls_back_to_builtin() -> Result<()> {
let dir = TempDir::new()?;
// user dir has no "github.toml" → falls back to the embedded built-in
assert_eq!(
BuiltinConfig::get_config_from("github".to_string(), Some(dir.path()))?,
BuiltinConfig::get_config("github".to_string())?
);
// and with no user dir at all it matches the built-in too
assert_eq!(
BuiltinConfig::get_config_from("github".to_string(), None)?,
BuiltinConfig::get_config("github".to_string())?
);
Ok(())
}
}
1 change: 1 addition & 0 deletions git-cliff/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ path = "../git-cliff-core"
[dev-dependencies]
pretty_assertions = "1.4.1"
serial_test = { version = "3.4.0", default-features = false }
temp-dir = "0.1.16"

[lints]
workspace = true
Expand Down
26 changes: 26 additions & 0 deletions git-cliff/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ pub struct Opt {
required = false
)]
pub init: Option<Option<String>>,
/// Sets the directory to look up user-defined templates for `--init`.
///
/// A user-defined template overrides a built-in template of the same name.
#[arg(
long,
env = "GIT_CLIFF_TEMPLATES_DIR",
value_name = "PATH",
value_parser = Opt::parse_dir
)]
pub templates_dir: Option<PathBuf>,
/// Prints the names of the available templates (built-in and user-defined).
#[arg(long, help_heading = Some("FLAGS"))]
pub list_templates: bool,
/// Sets the configuration file.
#[arg(
short,
Expand Down Expand Up @@ -522,6 +535,19 @@ mod tests {
Opt::command().debug_assert();
}

#[test]
fn parses_template_flags() {
let opt = Opt::try_parse_from([
"git-cliff",
"--list-templates",
"--templates-dir",
"/tmp/my-templates",
])
.expect("template flags should parse");
assert!(opt.list_templates);
assert_eq!(opt.templates_dir, Some(PathBuf::from("/tmp/my-templates")));
}

#[test]
fn path_tilde_expansion() {
let home_dir = std::env::home_dir().expect("cannot retrieve home directory");
Expand Down
43 changes: 41 additions & 2 deletions git-cliff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,13 @@ fn process_submodules(
}

/// Initializes the configuration file.
pub fn init_config(name: Option<&str>, config_path: &Path) -> Result<()> {
pub fn init_config(
name: Option<&str>,
templates_dir: Option<&Path>,
config_path: &Path,
) -> Result<()> {
let contents = match name {
Some(name) => BuiltinConfig::get_config(name.to_string())?,
Some(name) => BuiltinConfig::get_config_from(name.to_string(), templates_dir)?,
None => EmbeddedConfig::get_config()?,
};

Expand Down Expand Up @@ -899,3 +903,38 @@ pub fn write_changelog<W: io::Write>(

Ok(())
}

#[cfg(test)]
mod test {
use std::fs;

use temp_dir::TempDir;

use super::*;

#[test]
fn init_config_writes_user_template_over_builtin() -> Result<()> {
let dir = TempDir::new()?;
// a user template shadowing the built-in "github" template
fs::write(dir.path().join("github.toml"), "# user github\n")?;
let out = dir.path().join("cliff.toml");

init_config(Some("github"), Some(dir.path()), &out)?;

assert_eq!(fs::read_to_string(&out)?, "# user github\n");
Ok(())
}

#[test]
fn init_config_falls_back_to_builtin_without_templates_dir() -> Result<()> {
let dir = TempDir::new()?;
let out = dir.path().join("cliff.toml");

init_config(Some("github"), None, &out)?;

let written = fs::read_to_string(&out)?;
let builtin = git_cliff_core::embed::BuiltinConfig::get_config("github".to_string())?;
assert_eq!(written, builtin);
Ok(())
}
}
11 changes: 10 additions & 1 deletion git-cliff/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::{env, io, process};
use clap::Parser;
use git_cliff::args::Opt;
use git_cliff::{init_config, logger};
use git_cliff_core::embed::BuiltinConfig;
use git_cliff_core::error::Result;

/// Profiler.
Expand Down Expand Up @@ -40,9 +41,17 @@ fn main() -> Result<()> {
git_cliff::check_new_version();
}

// Print the available templates if requested.
if args.list_templates {
for name in BuiltinConfig::list_templates(args.templates_dir.as_deref())? {
println!("{name}");
}
return Ok(());
}

// Create the configuration file if init flag is given.
if let Some(path) = &args.init {
init_config(path.as_deref(), &args.config)?;
init_config(path.as_deref(), args.templates_dir.as_deref(), &args.config)?;
return Ok(());
}

Expand Down
2 changes: 2 additions & 0 deletions website/docs/usage/args.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ git-cliff [FLAGS] [OPTIONS] [--] [RANGE]
-h, --help Prints help information
-V, --version Prints version information
-v, --verbose... Increases the logging verbosity
--list-templates Prints the names of the available templates (built-in and user-defined)
--bumped-version Prints bumped version for unreleased changes
-l, --latest Processes the commits starting from the latest tag
--current Processes the commits that belong to the current tag
Expand All @@ -29,6 +30,7 @@ git-cliff [FLAGS] [OPTIONS] [--] [RANGE]

```
-i, --init [<CONFIG>] Writes the default configuration file to cliff.toml
--templates-dir <PATH> Sets the directory to look up user-defined templates for --init [env: GIT_CLIFF_TEMPLATES_DIR=]
--bump Bumps the version for unreleased changes [default: auto] [possible values: auto, major, minor, patch]
-c, --config <PATH> Sets the configuration file [env: GIT_CLIFF_CONFIG=] [default: cliff.toml]
--config-url <URL> Sets the URL for the configuration file [env: GIT_CLIFF_CONFIG_URL=]
Expand Down
Loading
Loading