Skip to main content

git_cliff/
args.rs

1use std::path::PathBuf;
2
3use clap::builder::styling::{Ansi256Color, AnsiColor};
4use clap::builder::{Styles, TypedValueParser, ValueParserFactory};
5use clap::error::{ContextKind, ContextValue, ErrorKind};
6use clap::{ArgAction, Parser, ValueEnum};
7use git_cliff_core::config::{BumpType, Remote};
8use git_cliff_core::{DEFAULT_CONFIG, DEFAULT_OUTPUT};
9use glob::Pattern;
10use regex::Regex;
11use secrecy::SecretString;
12use url::Url;
13
14#[derive(Debug, Clone, Copy, ValueEnum)]
15pub enum Strip {
16    Header,
17    Footer,
18    All,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
22pub enum Sort {
23    Oldest,
24    Newest,
25}
26
27const STYLES: Styles = Styles::styled()
28    .header(Ansi256Color(208).on_default().bold())
29    .usage(Ansi256Color(208).on_default().bold())
30    .literal(AnsiColor::White.on_default())
31    .placeholder(AnsiColor::Green.on_default());
32
33/// Command-line arguments to parse.
34#[derive(Debug, Parser, Clone)]
35#[command(
36    version,
37    author = clap::crate_authors!("\n"),
38    about,
39    rename_all_env = "screaming-snake",
40	help_template = "\
41{before-help}{name} {version}
42{author-with-newline}{about-with-newline}
43{usage-heading}
44  {usage}
45
46{all-args}{after-help}
47",
48    override_usage = "git-cliff [FLAGS] [OPTIONS] [--] [RANGE]",
49    next_help_heading = Some("OPTIONS"),
50	disable_help_flag = true,
51	disable_version_flag = true,
52    styles(STYLES),
53)]
54#[allow(clippy::struct_excessive_bools)]
55pub struct Opt {
56    #[arg(
57		short,
58		long,
59		action = ArgAction::Help,
60		global = true,
61		help = "Prints help information",
62		help_heading = "FLAGS"
63	)]
64    pub help: Option<bool>,
65    #[arg(
66		short = 'V',
67		long,
68		action = ArgAction::Version,
69		global = true,
70		help = "Prints version information",
71		help_heading = "FLAGS"
72	)]
73    pub version: Option<bool>,
74    /// Increases the logging verbosity.
75    #[arg(short, long, action = ArgAction::Count, alias = "debug", help_heading = Some("FLAGS"))]
76    pub verbose: u8,
77    /// Writes the default configuration file to cliff.toml
78    #[arg(
79	    short,
80	    long,
81	    value_name = "CONFIG",
82	    num_args = 0..=1,
83	    required = false
84	)]
85    pub init: Option<Option<String>>,
86    /// Sets the configuration file.
87    #[arg(
88	    short,
89	    long,
90	    env = "GIT_CLIFF_CONFIG",
91	    value_name = "PATH",
92	    default_value = DEFAULT_CONFIG,
93	    value_parser = Opt::parse_dir
94	)]
95    pub config: PathBuf,
96    /// Sets the URL for the configuration file.
97    #[arg(long, env = "GIT_CLIFF_CONFIG_URL", value_name = "URL", hide = !cfg!(feature = "remote"))]
98    pub config_url: Option<Url>,
99    /// Sets the working directory.
100    #[arg(
101	    short,
102	    long,
103	    env = "GIT_CLIFF_WORKDIR",
104	    value_name = "PATH",
105	    value_parser = Opt::parse_dir
106	)]
107    pub workdir: Option<PathBuf>,
108    /// Sets the git repository.
109    #[arg(
110		short,
111		long,
112		env = "GIT_CLIFF_REPOSITORY",
113		value_name = "PATH",
114		num_args(1..),
115		value_parser = Opt::parse_dir
116	)]
117    pub repository: Option<Vec<PathBuf>>,
118    /// Sets the path to include related commits.
119    #[arg(
120		long,
121		env = "GIT_CLIFF_INCLUDE_PATH",
122		value_name = "PATTERN",
123		value_delimiter = ' ',
124		num_args(1..)
125	)]
126    pub include_path: Option<Vec<Pattern>>,
127    /// Sets the path to exclude related commits.
128    #[arg(
129		long,
130		env = "GIT_CLIFF_EXCLUDE_PATH",
131		value_name = "PATTERN",
132		value_delimiter = ' ',
133		num_args(1..)
134	)]
135    pub exclude_path: Option<Vec<Pattern>>,
136    /// Sets the regex for matching git tags.
137    #[arg(long, env = "GIT_CLIFF_TAG_PATTERN", value_name = "PATTERN")]
138    pub tag_pattern: Option<Regex>,
139    /// Sets custom commit messages to include in the changelog.
140    #[arg(
141		long,
142		env = "GIT_CLIFF_WITH_COMMIT",
143		value_name = "MSG",
144		num_args(1..)
145	)]
146    pub with_commit: Option<Vec<String>>,
147    /// Sets custom message for the latest release.
148    #[arg(
149		long,
150		env = "GIT_CLIFF_WITH_TAG_MESSAGE",
151		value_name = "MSG",
152		num_args = 0..=1,
153	)]
154    pub with_tag_message: Option<String>,
155    /// Sets the tags to skip in the changelog.
156    #[arg(long, env = "GIT_CLIFF_SKIP_TAGS", value_name = "PATTERN")]
157    pub skip_tags: Option<Regex>,
158    /// Sets the tags to ignore in the changelog.
159    #[arg(long, env = "GIT_CLIFF_IGNORE_TAGS", value_name = "PATTERN")]
160    pub ignore_tags: Option<Regex>,
161    /// Sets the tags to count in the changelog.
162    #[arg(long, env = "GIT_CLIFF_COUNT_TAGS", value_name = "PATTERN")]
163    pub count_tags: Option<Regex>,
164    /// Sets commits that will be skipped in the changelog.
165    #[arg(
166		long,
167		env = "GIT_CLIFF_SKIP_COMMIT",
168		value_name = "SHA1",
169		num_args(1..)
170	)]
171    pub skip_commit: Option<Vec<String>>,
172    /// Prepends entries to the given changelog file.
173    #[arg(
174	    short,
175	    long,
176	    env = "GIT_CLIFF_PREPEND",
177	    value_name = "PATH",
178	    value_parser = Opt::parse_dir
179	)]
180    pub prepend: Option<PathBuf>,
181    /// Writes output to the given file.
182    #[arg(
183	    short,
184	    long,
185	    env = "GIT_CLIFF_OUTPUT",
186	    value_name = "PATH",
187	    value_parser = Opt::parse_dir,
188	    num_args = 0..=1,
189	    default_missing_value = DEFAULT_OUTPUT
190	)]
191    pub output: Option<PathBuf>,
192    /// Sets the tag for the latest version.
193    #[arg(
194        short,
195        long,
196        env = "GIT_CLIFF_TAG",
197        value_name = "TAG",
198        allow_hyphen_values = true
199    )]
200    pub tag: Option<String>,
201    /// Bumps the version for unreleased changes. Optionally with specified
202    /// version.
203    #[arg(
204        long,
205        value_name = "BUMP",
206        value_enum,
207        num_args = 0..=1,
208        default_missing_value = "auto",
209        value_parser = clap::value_parser!(BumpOption))]
210    pub bump: Option<BumpOption>,
211    /// Prints bumped version for unreleased changes.
212    #[arg(long, help_heading = Some("FLAGS"))]
213    pub bumped_version: bool,
214    /// Sets the template for the changelog body.
215    #[arg(
216        short,
217        long,
218        env = "GIT_CLIFF_TEMPLATE",
219        value_name = "TEMPLATE",
220        allow_hyphen_values = true
221    )]
222    pub body: Option<String>,
223    /// Processes the commits starting from the latest tag.
224    #[arg(short, long, help_heading = Some("FLAGS"))]
225    pub latest: bool,
226    /// Processes the commits that belong to the current tag.
227    #[arg(long, help_heading = Some("FLAGS"))]
228    pub current: bool,
229    /// Processes the commits that do not belong to a tag.
230    #[arg(short, long, help_heading = Some("FLAGS"))]
231    pub unreleased: bool,
232    /// Sorts the tags topologically.
233    #[arg(long, help_heading = Some("FLAGS"))]
234    pub topo_order: bool,
235    /// Include only the tags that belong to the current branch.
236    #[arg(long, help_heading = Some("FLAGS"))]
237    pub use_branch_tags: bool,
238    /// Disables the external command execution.
239    #[arg(long, help_heading = Some("FLAGS"))]
240    pub no_exec: bool,
241    /// Prints changelog context as JSON.
242    #[arg(short = 'x', long, help_heading = Some("FLAGS"))]
243    pub context: bool,
244    /// Generates changelog from a JSON context.
245    #[arg(
246        long,
247	    value_name = "PATH",
248	    value_parser = Opt::parse_dir,
249		env = "GIT_CLIFF_CONTEXT",
250    )]
251    pub from_context: Option<PathBuf>,
252    /// Strips the given parts from the changelog.
253    #[arg(short, long, value_name = "PART", value_enum)]
254    pub strip: Option<Strip>,
255    /// Sets sorting of the commits inside sections.
256    #[arg(
257		long,
258		value_enum,
259		default_value_t = Sort::Oldest
260	)]
261    pub sort: Sort,
262    /// Sets the GitHub API token.
263    #[arg(
264		long,
265		help_heading = "REMOTE OPTIONS",
266		env = "GITHUB_TOKEN",
267		value_name = "TOKEN",
268		hide_env_values = true,
269		hide = !cfg!(feature = "github"),
270	)]
271    pub github_token: Option<SecretString>,
272    /// Sets the GitHub repository.
273    #[arg(
274		long,
275		help_heading = "REMOTE OPTIONS",
276		env = "GITHUB_REPO",
277		value_parser = clap::value_parser!(RemoteValue),
278		value_name = "OWNER/REPO",
279		hide = !cfg!(feature = "github"),
280	)]
281    pub github_repo: Option<RemoteValue>,
282    /// Sets the GitLab API token.
283    #[arg(
284		long,
285		help_heading = "REMOTE OPTIONS",
286		env = "GITLAB_TOKEN",
287		value_name = "TOKEN",
288		hide_env_values = true,
289		hide = !cfg!(feature = "gitlab"),
290	)]
291    pub gitlab_token: Option<SecretString>,
292    /// Sets the GitLab repository.
293    #[arg(
294		long,
295		help_heading = "REMOTE OPTIONS",
296		env = "GITLAB_REPO",
297		value_parser = clap::value_parser!(RemoteValue),
298		value_name = "OWNER/REPO",
299		hide = !cfg!(feature = "gitlab"),
300	)]
301    pub gitlab_repo: Option<RemoteValue>,
302    /// Sets the Gitea API token.
303    #[arg(
304		long,
305		help_heading = "REMOTE OPTIONS",
306		env = "GITEA_TOKEN",
307		value_name = "TOKEN",
308		hide_env_values = true,
309		hide = !cfg!(feature = "gitea"),
310	)]
311    pub gitea_token: Option<SecretString>,
312    /// Sets the Gitea repository.
313    #[arg(
314		long,
315		help_heading = "REMOTE OPTIONS",
316		env = "GITEA_REPO",
317		value_parser = clap::value_parser!(RemoteValue),
318		value_name = "OWNER/REPO",
319		hide = !cfg!(feature = "gitea"),
320	)]
321    pub gitea_repo: Option<RemoteValue>,
322    /// Sets the Bitbucket API token.
323    #[arg(
324		long,
325		help_heading = "REMOTE OPTIONS",
326		env = "BITBUCKET_TOKEN",
327		value_name = "TOKEN",
328		hide_env_values = true,
329		hide = !cfg!(feature = "bitbucket"),
330	)]
331    pub bitbucket_token: Option<SecretString>,
332    /// Sets the Bitbucket repository.
333    #[arg(
334		long,
335		help_heading = "REMOTE OPTIONS",
336		env = "BITBUCKET_REPO",
337		value_parser = clap::value_parser!(RemoteValue),
338		value_name = "OWNER/REPO",
339		hide = !cfg!(feature = "bitbucket"),
340	)]
341    pub bitbucket_repo: Option<RemoteValue>,
342    /// Sets the Azure DevOps API token.
343    #[arg(
344		long,
345		help_heading = "REMOTE OPTIONS",
346		env = "AZURE_DEVOPS_TOKEN",
347		value_name = "TOKEN",
348		hide_env_values = true,
349		hide = !cfg!(feature = "azure_devops"),
350	)]
351    pub azure_devops_token: Option<SecretString>,
352    /// Sets the Azure DevOps repository.
353    #[arg(
354		long,
355		help_heading = "REMOTE OPTIONS",
356		env = "AZURE_DEVOPS_REPO",
357		value_parser = clap::value_parser!(RemoteValue),
358		value_name = "OWNER/REPO",
359		hide = !cfg!(feature = "azure_devops"),
360	)]
361    pub azure_devops_repo: Option<RemoteValue>,
362    /// Sets the commit range to process.
363    #[arg(value_name = "RANGE", help_heading = Some("ARGS"))]
364    pub range: Option<String>,
365    /// Load TLS certificates from the native certificate store.
366    #[arg(long, help_heading = Some("FLAGS"), hide = !cfg!(feature = "remote"))]
367    pub use_native_tls: bool,
368    /// Disable network access for remote repositories.
369    #[arg(long, env = "GIT_CLIFF_OFFLINE", help_heading = Some("REMOTE OPTIONS"), hide = !cfg!(feature = "remote"))]
370    pub offline: bool,
371}
372
373/// Custom type for the remote value.
374#[derive(Clone, Debug, PartialEq)]
375pub struct RemoteValue(pub Remote);
376
377impl ValueParserFactory for RemoteValue {
378    type Parser = RemoteValueParser;
379    fn value_parser() -> Self::Parser {
380        RemoteValueParser
381    }
382}
383
384/// Parser for the remote value.
385#[derive(Clone, Debug)]
386pub struct RemoteValueParser;
387
388impl TypedValueParser for RemoteValueParser {
389    type Value = RemoteValue;
390    fn parse_ref(
391        &self,
392        cmd: &clap::Command,
393        arg: Option<&clap::Arg>,
394        value: &std::ffi::OsStr,
395    ) -> Result<Self::Value, clap::Error> {
396        let inner = clap::builder::StringValueParser::new();
397        let mut value = inner.parse_ref(cmd, arg, value)?;
398        if let Ok(url) = Url::parse(&value) {
399            value = url.path().trim_start_matches('/').to_string();
400        }
401        let parts = value.rsplit_once('/');
402        if let Some((owner, repo)) = parts {
403            Ok(RemoteValue(Remote::new(
404                owner.to_string(),
405                repo.to_string(),
406            )))
407        } else {
408            let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
409            if let Some(arg) = arg {
410                err.insert(
411                    ContextKind::InvalidArg,
412                    ContextValue::String(arg.to_string()),
413                );
414            }
415            err.insert(ContextKind::InvalidValue, ContextValue::String(value));
416            Err(err)
417        }
418    }
419}
420
421#[derive(Debug, Clone, Eq, PartialEq)]
422pub enum BumpOption {
423    Auto,
424    Specific(BumpType),
425}
426
427impl ValueParserFactory for BumpOption {
428    type Parser = BumpOptionParser;
429    fn value_parser() -> Self::Parser {
430        BumpOptionParser
431    }
432}
433
434/// Parser for bump type.
435#[derive(Clone, Debug)]
436pub struct BumpOptionParser;
437
438impl TypedValueParser for BumpOptionParser {
439    type Value = BumpOption;
440    fn parse_ref(
441        &self,
442        cmd: &clap::Command,
443        arg: Option<&clap::Arg>,
444        value: &std::ffi::OsStr,
445    ) -> Result<Self::Value, clap::Error> {
446        let inner = clap::builder::StringValueParser::new();
447        let value = inner.parse_ref(cmd, arg, value)?;
448        match value.as_str() {
449            "auto" => Ok(BumpOption::Auto),
450            "major" => Ok(BumpOption::Specific(BumpType::Major)),
451            "minor" => Ok(BumpOption::Specific(BumpType::Minor)),
452            "patch" => Ok(BumpOption::Specific(BumpType::Patch)),
453            _ => {
454                let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
455                if let Some(arg) = arg {
456                    err.insert(
457                        ContextKind::InvalidArg,
458                        ContextValue::String(arg.to_string()),
459                    );
460                }
461                err.insert(ContextKind::InvalidValue, ContextValue::String(value));
462                Err(err)
463            }
464        }
465    }
466}
467
468impl Opt {
469    /// Custom string parser for directories.
470    ///
471    /// Expands the tilde (`~`) character in the beginning of the
472    /// input string into contents of the path returned by [`home_dir`].
473    ///
474    /// [`home_dir`]: dirs::home_dir
475    #[allow(clippy::unnecessary_wraps)]
476    fn parse_dir(dir: &str) -> Result<PathBuf, String> {
477        Ok(PathBuf::from(shellexpand::tilde(dir).to_string()))
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use std::env;
484    use std::ffi::OsStr;
485
486    use clap::CommandFactory;
487    use serial_test::serial;
488
489    use super::*;
490
491    struct EnvVarGuard(&'static str);
492
493    impl EnvVarGuard {
494        fn set(key: &'static str, value: &str) -> Self {
495            unsafe { env::set_var(key, value) };
496            Self(key)
497        }
498    }
499
500    impl Drop for EnvVarGuard {
501        fn drop(&mut self) {
502            unsafe { env::remove_var(self.0) };
503        }
504    }
505
506    #[test]
507    fn verify_cli() {
508        Opt::command().debug_assert();
509    }
510
511    #[test]
512    fn path_tilde_expansion() {
513        let home_dir = std::env::home_dir().expect("cannot retrieve home directory");
514        let dir = Opt::parse_dir("~/").expect("cannot expand tilde");
515        assert_eq!(home_dir, dir);
516    }
517
518    #[test]
519    fn remote_value_parser() -> Result<(), clap::Error> {
520        let remote_value_parser = RemoteValueParser;
521        assert_eq!(
522            RemoteValue(Remote::new("test", "repo")),
523            remote_value_parser.parse_ref(&Opt::command(), None, OsStr::new("test/repo"))?
524        );
525        assert_eq!(
526            RemoteValue(Remote::new("gitlab/group/test", "repo")),
527            remote_value_parser.parse_ref(
528                &Opt::command(),
529                None,
530                OsStr::new("gitlab/group/test/repo")
531            )?
532        );
533        assert_eq!(
534            RemoteValue(Remote::new("test", "testrepo")),
535            remote_value_parser.parse_ref(
536                &Opt::command(),
537                None,
538                OsStr::new("https://github.com/test/testrepo")
539            )?
540        );
541        assert_eq!(
542            RemoteValue(Remote::new(
543                "archlinux/packaging/packages",
544                "arch-repro-status"
545            )),
546            remote_value_parser.parse_ref(
547                &Opt::command(),
548                None,
549                OsStr::new(
550                    "https://gitlab.archlinux.org/archlinux/packaging/packages/arch-repro-status"
551                )
552            )?
553        );
554        assert!(
555            remote_value_parser
556                .parse_ref(&Opt::command(), None, OsStr::new("test"))
557                .is_err()
558        );
559        assert!(
560            remote_value_parser
561                .parse_ref(&Opt::command(), None, OsStr::new(""))
562                .is_err()
563        );
564        Ok(())
565    }
566
567    #[test]
568    fn bump_option_parser() -> Result<(), clap::Error> {
569        let bump_option_parser = BumpOptionParser;
570        assert_eq!(
571            BumpOption::Auto,
572            bump_option_parser.parse_ref(&Opt::command(), None, OsStr::new("auto"))?
573        );
574        assert!(
575            bump_option_parser
576                .parse_ref(&Opt::command(), None, OsStr::new("test"))
577                .is_err()
578        );
579        assert_eq!(
580            BumpOption::Specific(BumpType::Major),
581            bump_option_parser.parse_ref(&Opt::command(), None, OsStr::new("major"))?
582        );
583        Ok(())
584    }
585
586    // Environment variables are process-global, so tests that modify them must run exclusively and
587    // restore the original state after execution. For this reason, we use the `serial` macro
588    // from the `serial_test` crate to guarantee exclusive execution. See: https://crates.io/crates/serial_test
589    #[test]
590    #[serial]
591    fn path_env_vars_are_split_into_multiple_patterns() -> Result<(), Box<dyn std::error::Error>> {
592        let _include = EnvVarGuard::set("GIT_CLIFF_INCLUDE_PATH", "website/**/* .github/**/*");
593        let _exclude = EnvVarGuard::set("GIT_CLIFF_EXCLUDE_PATH", "docs/**/* tests/**/*");
594
595        let opt = Opt::try_parse_from(["git-cliff"])?;
596
597        assert_eq!(
598            Some(vec![
599                Pattern::new("website/**/*")?,
600                Pattern::new(".github/**/*")?,
601            ]),
602            opt.include_path
603        );
604        assert_eq!(
605            Some(vec![
606                Pattern::new("docs/**/*")?,
607                Pattern::new("tests/**/*")?
608            ]),
609            opt.exclude_path
610        );
611
612        Ok(())
613    }
614}