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