diff --git a/Cargo.lock b/Cargo.lock index 057bcf1642..004c42e387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1013,6 +1013,7 @@ dependencies = [ "secrecy", "serial_test", "shellexpand", + "temp-dir", "tracing", "tracing-indicatif", "tracing-subscriber", diff --git a/git-cliff-core/src/embed.rs b/git-cliff-core/src/embed.rs index a50ac2285a..3e505be28a 100644 --- a/git-cliff-core/src/embed.rs +++ b/git-cliff-core/src/embed.rs @@ -1,5 +1,5 @@ use std::path::Path; -use std::str; +use std::{fs, str}; use rust_embed::RustEmbed; @@ -42,14 +42,22 @@ impl EmbeddedConfig { pub struct BuiltinConfig; impl BuiltinConfig { - /// Extracts the embedded content. - pub fn get_config(mut name: String) -> Result { - 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 { + 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"))), @@ -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 { + 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 { + let mut names: Vec = 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> { + 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(()) + } } diff --git a/git-cliff/Cargo.toml b/git-cliff/Cargo.toml index 884b74d0fe..83bd838421 100644 --- a/git-cliff/Cargo.toml +++ b/git-cliff/Cargo.toml @@ -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 diff --git a/git-cliff/src/args.rs b/git-cliff/src/args.rs index 1c0e516921..366d5d2e9a 100644 --- a/git-cliff/src/args.rs +++ b/git-cliff/src/args.rs @@ -82,6 +82,19 @@ pub struct Opt { required = false )] pub init: Option>, + /// 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, + /// 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, @@ -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"); diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs index f7a8245885..861f2cf50f 100644 --- a/git-cliff/src/lib.rs +++ b/git-cliff/src/lib.rs @@ -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()?, }; @@ -899,3 +903,38 @@ pub fn write_changelog( 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(()) + } +} diff --git a/git-cliff/src/main.rs b/git-cliff/src/main.rs index f7d041b120..64517eecf0 100644 --- a/git-cliff/src/main.rs +++ b/git-cliff/src/main.rs @@ -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. @@ -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(()); } diff --git a/website/docs/usage/args.md b/website/docs/usage/args.md index 20a62acb12..244054012b 100644 --- a/website/docs/usage/args.md +++ b/website/docs/usage/args.md @@ -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 @@ -29,6 +30,7 @@ git-cliff [FLAGS] [OPTIONS] [--] [RANGE] ``` -i, --init [] Writes the default configuration file to cliff.toml + --templates-dir 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 Sets the configuration file [env: GIT_CLIFF_CONFIG=] [default: cliff.toml] --config-url Sets the URL for the configuration file [env: GIT_CLIFF_CONFIG_URL=] diff --git a/website/docs/usage/initializing.md b/website/docs/usage/initializing.md index 3f33f2d938..3e784989f0 100644 --- a/website/docs/usage/initializing.md +++ b/website/docs/usage/initializing.md @@ -53,3 +53,29 @@ git cliff --config-url https://github.com/orhun/git-cliff/blob/main/examples/git ``` ::: + +## User-defined templates + +In addition to the built-in templates, you can keep your own templates in a +directory and use them with `--init` by pointing `git-cliff` at that directory +with `--templates-dir` (or the `GIT_CLIFF_TEMPLATES_DIR` environment variable): + +```bash +# initialize using ~/my-templates/company.toml +git cliff --templates-dir ~/my-templates --init company +``` + +A user-defined template takes precedence over a built-in template of the same +name, so you can override the shipped templates by placing a `.toml` file with +the same name in your templates directory. + +To list every available template — built-in and user-defined — use +`--list-templates` (useful for shell/editor completion): + +```bash +# print built-in template names +git cliff --list-templates + +# also include user-defined templates from a directory +git cliff --list-templates --templates-dir ~/my-templates +```