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#[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 #[arg(short, long, action = ArgAction::Count, alias = "debug", help_heading = Some("FLAGS"))]
76 pub verbose: u8,
77 #[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 #[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 #[arg(long, env = "GIT_CLIFF_CONFIG_URL", value_name = "URL", hide = !cfg!(feature = "remote"))]
98 pub config_url: Option<Url>,
99 #[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 #[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 #[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 #[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 #[arg(long, env = "GIT_CLIFF_TAG_PATTERN", value_name = "PATTERN")]
138 pub tag_pattern: Option<Regex>,
139 #[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 #[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 #[arg(long, env = "GIT_CLIFF_SKIP_TAGS", value_name = "PATTERN")]
157 pub skip_tags: Option<Regex>,
158 #[arg(long, env = "GIT_CLIFF_IGNORE_TAGS", value_name = "PATTERN")]
160 pub ignore_tags: Option<Regex>,
161 #[arg(long, env = "GIT_CLIFF_COUNT_TAGS", value_name = "PATTERN")]
163 pub count_tags: Option<Regex>,
164 #[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 #[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 #[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 #[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 #[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 #[arg(long, help_heading = Some("FLAGS"))]
213 pub bumped_version: bool,
214 #[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 #[arg(short, long, help_heading = Some("FLAGS"))]
225 pub latest: bool,
226 #[arg(long, help_heading = Some("FLAGS"))]
228 pub current: bool,
229 #[arg(short, long, help_heading = Some("FLAGS"))]
231 pub unreleased: bool,
232 #[arg(long, help_heading = Some("FLAGS"))]
234 pub topo_order: bool,
235 #[arg(long, help_heading = Some("FLAGS"))]
237 pub use_branch_tags: bool,
238 #[arg(long, help_heading = Some("FLAGS"))]
240 pub no_exec: bool,
241 #[arg(short = 'x', long, help_heading = Some("FLAGS"))]
243 pub context: bool,
244 #[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 #[arg(short, long, value_name = "PART", value_enum)]
254 pub strip: Option<Strip>,
255 #[arg(
257 long,
258 value_enum,
259 default_value_t = Sort::Oldest
260 )]
261 pub sort: Sort,
262 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[arg(value_name = "RANGE", help_heading = Some("ARGS"))]
364 pub range: Option<String>,
365 #[arg(long, help_heading = Some("FLAGS"), hide = !cfg!(feature = "remote"))]
367 pub use_native_tls: bool,
368 #[arg(long, env = "GIT_CLIFF_OFFLINE", help_heading = Some("REMOTE OPTIONS"), hide = !cfg!(feature = "remote"))]
370 pub offline: bool,
371}
372
373#[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#[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#[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 #[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 #[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}