1use std::{ffi::OsString, path::PathBuf};
2
3use clap::{
4 ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
5 builder::TypedValueParser, error::ErrorKind,
6};
7use strum::VariantNames;
8
9use crate::{
10 builder::{BuildOverrides, BuildTarget},
11 config::{BinaryProvider, ConfigOverrides, LockMode, UsePrebuiltBinaries, Verbosity},
12 cratespec::{CrateRequest, Source},
13 git::GitSelector,
14};
15
16#[derive(Clone, Debug)]
19pub enum Cli {
20 ListTargets(CrateArgs),
22 ListTools(ListTools),
24 Prefetch(CrateArgs),
26 PrefetchAll(PrefetchAll),
28 NoExec(CrateArgs),
30 Run {
32 args: CrateArgs,
33 tool_args: Vec<OsString>,
34 },
35}
36
37impl Cli {
38 pub fn parse_from_cli_args(version: String) -> Self {
43 let args: Vec<String> = std::env::args().collect();
44 let args = Self::strip_cargo_subcommand_arg(args);
45 Self::parse_from_arg_strings(args, Some(version)).unwrap_or_else(|err| err.exit())
46 }
47
48 pub fn to_config_overrides(&self) -> ConfigOverrides {
55 match self {
56 Cli::ListTargets(args) | Cli::Prefetch(args) | Cli::NoExec(args) | Cli::Run { args, .. } => {
57 args.to_config_overrides()
58 }
59 Cli::ListTools(list_tools) => list_tools.to_config_overrides(),
60 Cli::PrefetchAll(prefetch_all) => prefetch_all.to_config_overrides(),
61 }
62 }
63
64 pub fn message_format(&self) -> Option<MessageFormat> {
66 self.reporting().message_format
67 }
68
69 pub fn verbosity(&self) -> Verbosity {
71 Verbosity::from_count(self.reporting().verbose)
72 }
73
74 fn reporting(&self) -> &ReportingArgs {
76 match self {
77 Cli::ListTargets(args) | Cli::Prefetch(args) | Cli::NoExec(args) | Cli::Run { args, .. } => {
78 &args.reporting
79 }
80 Cli::ListTools(list_tools) => &list_tools.reporting,
81 Cli::PrefetchAll(prefetch_all) => &prefetch_all.reporting,
82 }
83 }
84
85 fn parse_from_arg_strings(args: Vec<String>, version: Option<String>) -> Result<Self, clap::Error> {
88 let (toolchain, filtered_args) = Self::extract_toolchain(args);
89
90 let mut command = RawCli::command();
91 if let Some(version) = version {
92 command = command.version(version);
93 }
94
95 let matches = command.try_get_matches_from(filtered_args)?;
96 let mut raw = RawCli::from_arg_matches(&matches)?;
97 raw.toolchain = toolchain;
98 raw.render()
99 }
100
101 fn strip_cargo_subcommand_arg<I, T>(args: I) -> Vec<String>
127 where
128 I: IntoIterator<Item = T>,
129 T: Into<String>,
130 {
131 let args: Vec<String> = args.into_iter().map(|s| s.into()).collect();
132
133 if args.is_empty() {
134 return args;
135 }
136
137 let is_cargo_subcommand = std::env::current_exe()
138 .ok()
139 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().contains("cargo-cgx")))
140 .unwrap_or(false);
141
142 if is_cargo_subcommand && args.len() > 1 && args[1] == "cgx" {
143 let mut filtered = vec![args[0].clone()];
144 filtered.extend_from_slice(&args[2..]);
145 filtered
146 } else {
147 args
148 }
149 }
150
151 fn extract_toolchain<I, T>(args: I) -> (Option<String>, Vec<String>)
174 where
175 I: IntoIterator<Item = T>,
176 T: Into<String>,
177 {
178 let args = args.into_iter().map(|s| s.into()).collect::<Vec<String>>();
179 if args.len() > 1 && args[1].starts_with('+') && args[1].len() > 1 {
180 #[expect(
181 clippy::string_slice,
182 reason = "guarded by starts_with('+') && len() > 1, so byte 1 is a valid char boundary in \
183 range"
184 )]
185 let toolchain = args[1][1..].to_string();
186
187 let mut filtered = vec![args[0].clone()];
188 filtered.extend_from_slice(&args[2..]);
189
190 (Some(toolchain), filtered)
191 } else {
192 (None, args)
193 }
194 }
195
196 #[cfg(test)]
198 pub fn parse_from_test_args<I, T>(args: I) -> Self
199 where
200 I: IntoIterator<Item = T>,
201 T: Into<OsString> + Clone,
202 {
203 Self::try_parse_from_test_args(args).unwrap()
204 }
205
206 #[cfg(test)]
209 pub fn try_parse_from_test_args<I, T>(args: I) -> Result<Self, clap::Error>
210 where
211 I: IntoIterator<Item = T>,
212 T: Into<OsString> + Clone,
213 {
214 let args = std::iter::once(OsString::from("cgx")).chain(args.into_iter().map(|s| s.into()));
216 let args: Vec<String> = args.map(|s| s.to_string_lossy().to_string()).collect();
217 Self::parse_from_arg_strings(args, None)
218 }
219
220 #[cfg(test)]
225 pub fn crate_args(&self) -> &CrateArgs {
226 match self {
227 Cli::ListTargets(args) | Cli::Prefetch(args) | Cli::NoExec(args) | Cli::Run { args, .. } => args,
228 other @ (Cli::ListTools(_) | Cli::PrefetchAll(_)) => {
229 panic!("expected a crate-level command, got {other:?}")
230 }
231 }
232 }
233
234 #[cfg(test)]
236 pub fn tool_args(&self) -> &[OsString] {
237 match self {
238 Cli::Run { tool_args, .. } => tool_args,
239 _ => &[],
240 }
241 }
242}
243
244#[derive(Clone, Debug)]
249pub struct CrateArgs {
250 pub crate_spec: Option<String>,
253 pub crate_version: Option<String>,
255 pub source: Source,
257 pub git_selector: GitSelector,
259 pub build_options: BuildOptionsArgs,
261 pub cargo: CargoBehaviorArgs,
263 pub prebuilt: PrebuiltBinaryArgs,
265 pub config: ConfigArgs,
267 pub http: HttpArgs,
269 pub reporting: ReportingArgs,
271 pub toolchain: Option<String>,
273}
274
275impl CrateArgs {
276 pub fn crate_request(&self) -> crate::Result<CrateRequest> {
281 let (name, at_version) = match &self.crate_spec {
283 Some(spec) => {
284 let (name, at_version) = match spec.split_once('@') {
285 Some((name, version)) => (name.to_string(), Some(version.to_string())),
286 None => (spec.clone(), None),
287 };
288 if name.is_empty() {
289 return crate::error::MissingCrateNameSnafu { spec: spec.clone() }.fail();
290 }
291 (Some(name), at_version)
292 }
293 None => (None, None),
294 };
295
296 if name.as_deref() == Some("cargo") {
300 return crate::error::CargoNotRunnableSnafu.fail();
301 }
302
303 let version = match (at_version, self.crate_version.clone()) {
308 (Some(at_version), Some(flag_version)) => {
309 return crate::error::ConflictingVersionsSnafu {
310 at_version,
311 flag_version,
312 }
313 .fail();
314 }
315 (Some(version), None) | (None, Some(version)) => Some(version),
316 (None, None) => None,
317 };
318
319 Ok(CrateRequest {
320 name,
321 version,
322 source: self.source.clone(),
323 git_ref: self.git_selector.clone(),
324 })
325 }
326
327 pub fn to_build_overrides(&self) -> BuildOverrides {
330 let build = &self.build_options;
331 BuildOverrides {
332 features: build.features.as_deref().map(Self::parse_features),
333 all_features: build.all_features,
334 no_default_features: build.no_default_features,
335 profile: if build.debug {
336 Some("dev".to_string())
337 } else {
338 build.profile.clone()
339 },
340 target: build.target.clone(),
341 jobs: build.jobs,
342 ignore_rust_version: build.ignore_rust_version,
343 target_selection: build.build_target(),
344 toolchain: self.toolchain.clone(),
345 }
346 }
347
348 pub fn to_config_overrides(&self) -> ConfigOverrides {
350 ConfigOverrides {
351 http_timeout: self.http.http_timeout.clone(),
352 http_retries: self.http.http_retries,
353 http_proxy: self.http.http_proxy.clone(),
354 lockfile: self.cargo.lock_mode(),
355 offline: self.cargo.offline,
356 refresh: self.cargo.refresh,
357 prebuilt_binary: self.prebuilt.prebuilt_binary,
358 prebuilt_binary_sources: self.prebuilt.prebuilt_binary_sources.clone(),
359 prebuilt_binary_no_verify_checksums: self.prebuilt.prebuilt_binary_no_verify_checksums,
360 prebuilt_binary_no_verify_signatures: self.prebuilt.prebuilt_binary_no_verify_signatures,
361 verbosity: Verbosity::from_count(self.reporting.verbose),
362 ..self.config.to_config_overrides()
363 }
364 }
365
366 fn parse_features(features_str: &str) -> Vec<String> {
372 features_str
373 .split(|c: char| c == ',' || c.is_whitespace())
374 .filter(|s| !s.is_empty())
375 .map(|s| s.to_string())
376 .collect()
377 }
378}
379
380#[derive(Clone, Debug, Default)]
382pub struct PrefetchAll {
383 pub config: ConfigArgs,
385 pub http: HttpArgs,
387 pub reporting: ReportingArgs,
389 pub offline: bool,
391 pub refresh: bool,
393 pub jobs: Option<usize>,
395 pub ignore_rust_version: bool,
397 pub toolchain: Option<String>,
399}
400
401impl PrefetchAll {
402 pub fn to_build_overrides(&self) -> BuildOverrides {
407 BuildOverrides {
408 jobs: self.jobs,
409 ignore_rust_version: self.ignore_rust_version,
410 toolchain: self.toolchain.clone(),
411 ..BuildOverrides::default()
412 }
413 }
414
415 pub fn to_config_overrides(&self) -> ConfigOverrides {
417 ConfigOverrides {
418 http_timeout: self.http.http_timeout.clone(),
419 http_retries: self.http.http_retries,
420 http_proxy: self.http.http_proxy.clone(),
421 offline: self.offline,
422 refresh: self.refresh,
423 verbosity: Verbosity::from_count(self.reporting.verbose),
424 ..self.config.to_config_overrides()
425 }
426 }
427}
428
429#[derive(Clone, Debug, Default)]
431pub struct ListTools {
432 pub config: ConfigArgs,
434 pub reporting: ReportingArgs,
436}
437
438impl ListTools {
439 pub fn to_config_overrides(&self) -> ConfigOverrides {
441 ConfigOverrides {
442 verbosity: Verbosity::from_count(self.reporting.verbose),
443 ..self.config.to_config_overrides()
444 }
445 }
446}
447
448#[derive(Clone, Debug, Default, Args)]
454pub struct BuildOptionsArgs {
455 #[arg(short = 'F', long, value_name = "FEATURES")]
457 pub features: Option<String>,
458
459 #[arg(long)]
461 pub all_features: bool,
462
463 #[arg(long)]
465 pub no_default_features: bool,
466
467 #[arg(long, value_name = "PROFILE-NAME", conflicts_with = "debug")]
469 pub profile: Option<String>,
470
471 #[arg(long)]
473 pub debug: bool,
474
475 #[arg(long, value_name = "TRIPLE")]
477 pub target: Option<String>,
478
479 #[arg(short = 'j', long, value_name = "N")]
481 pub jobs: Option<usize>,
482
483 #[arg(long)]
485 pub ignore_rust_version: bool,
486
487 #[arg(long, value_name = "NAME", conflicts_with = "example")]
489 pub bin: Option<String>,
490
491 #[arg(long, value_name = "NAME")]
493 pub example: Option<String>,
494}
495
496impl BuildOptionsArgs {
497 fn has_compilation_options(&self) -> bool {
500 self.features.is_some()
501 || self.all_features
502 || self.no_default_features
503 || self.profile.is_some()
504 || self.debug
505 || self.target.is_some()
506 || self.bin.is_some()
507 || self.example.is_some()
508 }
509
510 fn is_present(&self) -> bool {
512 self.has_compilation_options() || self.jobs.is_some() || self.ignore_rust_version
513 }
514
515 fn build_target(&self) -> BuildTarget {
517 match (&self.bin, &self.example) {
518 (Some(_), Some(_)) => {
519 unreachable!("BUG: clap should enforce mutual exclusivity");
520 }
521 (Some(bin_name), None) => BuildTarget::Bin(bin_name.clone()),
522 (None, Some(example_name)) => BuildTarget::Example(example_name.clone()),
523 (None, None) => BuildTarget::default(),
524 }
525 }
526}
527#[derive(Clone, Debug, Default, Args)]
533pub struct CargoBehaviorArgs {
534 #[arg(long, group = "lockfile")]
536 pub locked: bool,
537
538 #[arg(long, group = "lockfile")]
540 pub frozen: bool,
541
542 #[arg(long, group = "lockfile")]
544 pub unlocked: bool,
545
546 #[arg(long)]
548 pub offline: bool,
549
550 #[arg(long)]
552 pub refresh: bool,
553}
554
555impl CargoBehaviorArgs {
556 fn lock_mode(&self) -> LockMode {
561 if self.unlocked {
562 LockMode::Unlocked
563 } else if self.frozen {
564 LockMode::Frozen
565 } else if self.locked {
566 LockMode::Locked
567 } else {
568 LockMode::Default
569 }
570 }
571}
572
573macro_rules! strum_value_parser {
578 ($t:ty) => {
579 clap::builder::PossibleValuesParser::new(<$t>::VARIANTS).map(|s| s.parse::<$t>().unwrap())
580 };
581}
582
583#[derive(Clone, Debug, Default, Args)]
585pub struct PrebuiltBinaryArgs {
586 #[arg(long, value_name = "WHEN", value_parser = strum_value_parser!(UsePrebuiltBinaries))]
588 pub prebuilt_binary: Option<UsePrebuiltBinaries>,
589
590 #[arg(
592 long,
593 value_name = "SOURCES",
594 value_delimiter = ',',
595 value_parser = strum_value_parser!(BinaryProvider)
596 )]
597 pub prebuilt_binary_sources: Option<Vec<BinaryProvider>>,
598
599 #[arg(long)]
601 pub prebuilt_binary_no_verify_checksums: bool,
602
603 #[arg(long)]
605 pub prebuilt_binary_no_verify_signatures: bool,
606}
607
608impl PrebuiltBinaryArgs {
609 fn is_present(&self) -> bool {
611 self.prebuilt_binary.is_some()
612 || self.prebuilt_binary_sources.is_some()
613 || self.prebuilt_binary_no_verify_checksums
614 || self.prebuilt_binary_no_verify_signatures
615 }
616}
617
618#[derive(Clone, Debug, Default, Args)]
622pub struct ConfigArgs {
623 #[arg(
626 long,
627 value_name = "FILE",
628 conflicts_with_all = ["system_config_dir", "app_dir", "user_config_dir"]
629 )]
630 pub config_file: Option<PathBuf>,
631
632 #[arg(long, value_name = "PATH", env = "CGX_SYSTEM_CONFIG_DIR")]
634 pub system_config_dir: Option<PathBuf>,
635
636 #[arg(long, value_name = "PATH", env = "CGX_APP_DIR")]
638 pub app_dir: Option<PathBuf>,
639
640 #[arg(long, value_name = "PATH", env = "CGX_USER_CONFIG_DIR")]
642 pub user_config_dir: Option<PathBuf>,
643}
644
645impl ConfigArgs {
646 fn to_config_overrides(&self) -> ConfigOverrides {
649 ConfigOverrides {
650 config_file: self.config_file.clone(),
651 system_config_dir: self.system_config_dir.clone(),
652 app_dir: self.app_dir.clone(),
653 user_config_dir: self.user_config_dir.clone(),
654 ..ConfigOverrides::default()
655 }
656 }
657}
658
659#[derive(Clone, Debug, Default, Args)]
661pub struct HttpArgs {
662 #[arg(long, value_name = "DURATION", env = "CGX_HTTP_TIMEOUT")]
664 pub http_timeout: Option<String>,
665
666 #[arg(long, value_name = "N", env = "CGX_HTTP_RETRIES")]
668 pub http_retries: Option<usize>,
669
670 #[arg(long, value_name = "URL", env = "CGX_HTTP_PROXY")]
672 pub http_proxy: Option<String>,
673}
674
675impl HttpArgs {
676 fn is_present(&self) -> bool {
678 self.http_timeout.is_some() || self.http_retries.is_some() || self.http_proxy.is_some()
679 }
680}
681
682#[derive(Clone, Debug, Default, Args)]
684pub struct ReportingArgs {
685 #[arg(short = 'v', long, action = ArgAction::Count)]
687 pub verbose: u8,
688
689 #[arg(short = 'q', long)]
691 pub quiet: bool,
692
693 #[arg(long, value_name = "WHEN")]
695 pub color: Option<String>,
696
697 #[arg(long, value_name = "FMT")]
699 pub message_format: Option<MessageFormat>,
700}
701
702#[derive(Clone, Copy, Debug, ValueEnum)]
704pub enum MessageFormat {
705 Json,
707}
708
709#[derive(Clone, Debug, Parser)]
723#[command(name = "cgx")]
724#[command(about = "Rust equivalent of uvx or npx, for running Rust crates")]
725#[command(
726 after_help = "To run a crate, pass its name (optionally with @VERSION) followed by any arguments for \
727 the tool, e.g. `cgx ripgrep --color=always`. cgx's own options must come before the crate \
728 name; everything after it is forwarded to the tool. Use `cgx cargo <subcommand>` to run a \
729 cargo plugin (e.g. `cgx cargo deny` is equivalent to `cgx cargo-deny`)."
730)]
731struct RawCli {
732 #[arg(long, group = "mode")]
734 no_exec: bool,
735
736 #[arg(long, group = "mode")]
738 prefetch: bool,
739
740 #[arg(long, group = "mode")]
742 prefetch_all: bool,
743
744 #[arg(long, group = "mode")]
746 list_targets: bool,
747
748 #[arg(long, group = "mode")]
750 list_tools: bool,
751
752 #[arg(long, value_name = "REQ")]
758 crate_version: Option<String>,
759
760 #[command(flatten)]
761 source: SourceArgs,
762
763 #[command(flatten)]
764 build_options: BuildOptionsArgs,
765
766 #[command(flatten)]
767 cargo: CargoBehaviorArgs,
768
769 #[command(flatten)]
770 prebuilt: PrebuiltBinaryArgs,
771
772 #[command(flatten)]
773 config: ConfigArgs,
774
775 #[command(flatten)]
776 http: HttpArgs,
777
778 #[command(flatten)]
779 reporting: ReportingArgs,
780
781 #[command(subcommand)]
786 invocation: Option<Invocation>,
787
788 #[arg(skip)]
792 toolchain: Option<String>,
793}
794
795impl RawCli {
796 fn split_invocation(invocation: Option<Invocation>) -> (Option<String>, Vec<OsString>) {
798 let Some(Invocation::Crate(mut parts)) = invocation else {
799 return (None, Vec::new());
800 };
801 if parts.is_empty() {
802 return (None, Vec::new());
803 }
804
805 let crate_spec = parts.remove(0).to_string_lossy().into_owned();
806 let (crate_spec, mut tool_args) = if crate_spec == "cargo" {
807 if matches!(parts.first().and_then(|arg| arg.to_str()), Some("--")) {
810 parts.remove(0);
811 }
812 if parts
816 .first()
817 .is_some_and(|arg| !arg.to_string_lossy().starts_with('-'))
818 {
819 let subcommand = parts.remove(0);
820 (format!("cargo-{}", subcommand.to_string_lossy()), parts)
821 } else {
822 (crate_spec, parts)
823 }
824 } else {
825 (crate_spec, parts)
826 };
827
828 if matches!(tool_args.first().and_then(|arg| arg.to_str()), Some("--")) {
832 tool_args.remove(0);
833 }
834
835 (Some(crate_spec), tool_args)
836 }
837
838 fn render(self) -> Result<Cli, clap::Error> {
850 let RawCli {
851 no_exec,
852 prefetch,
853 prefetch_all,
854 list_targets,
855 list_tools,
856 crate_version,
857 source,
858 build_options,
859 cargo,
860 prebuilt,
861 config,
862 http,
863 reporting,
864 invocation,
865 toolchain,
866 } = self;
867
868 let (crate_spec, tool_args) = Self::split_invocation(invocation);
869
870 if prefetch_all {
871 let mode = "--prefetch-all";
872 Self::ensure_config_mode_common(
873 mode,
874 &crate_spec,
875 &tool_args,
876 &source,
877 &prebuilt,
878 &crate_version,
879 )?;
880 if cargo.locked || cargo.frozen || cargo.unlocked {
881 return Err(Self::forbidden(mode, "--locked/--frozen/--unlocked"));
882 }
883 if build_options.has_compilation_options() {
884 return Err(Self::forbidden(mode, "build/compilation options"));
885 }
886 return Ok(Cli::PrefetchAll(PrefetchAll {
887 config,
888 http,
889 reporting,
890 offline: cargo.offline,
891 refresh: cargo.refresh,
892 jobs: build_options.jobs,
893 ignore_rust_version: build_options.ignore_rust_version,
894 toolchain,
895 }));
896 }
897
898 if list_tools {
899 let mode = "--list-tools";
900 Self::ensure_config_mode_common(
901 mode,
902 &crate_spec,
903 &tool_args,
904 &source,
905 &prebuilt,
906 &crate_version,
907 )?;
908
909 if cargo.locked || cargo.frozen || cargo.unlocked || cargo.offline || cargo.refresh {
914 return Err(Self::forbidden(mode, "cargo behavior flags"));
915 }
916 if build_options.is_present() {
917 return Err(Self::forbidden(mode, "build options"));
918 }
919 if http.is_present() {
920 return Err(Self::forbidden(mode, "--http-* options"));
921 }
922 if toolchain.is_some() {
923 return Err(Self::forbidden(mode, "+toolchain"));
924 }
925 return Ok(Cli::ListTools(ListTools { config, reporting }));
926 }
927
928 let git_selector = source.git_selector();
931 let source = source.to_source();
932
933 let mode = if prefetch {
934 "--prefetch"
935 } else if list_targets {
936 "--list-targets"
937 } else if no_exec {
938 "--no-exec"
939 } else {
940 "cgx"
941 };
942
943 if crate_spec.is_none() && !source.allows_crate_discovery() {
944 return Err(clap::Error::raw(
945 ErrorKind::MissingRequiredArgument,
946 format!("{mode} requires a crate name, or a discoverable source such as --git or --path\n"),
947 ));
948 }
949
950 let crate_args = CrateArgs {
951 crate_spec,
952 crate_version,
953 source,
954 git_selector,
955 build_options,
956 cargo,
957 prebuilt,
958 config,
959 http,
960 reporting,
961 toolchain,
962 };
963
964 if prefetch {
965 Self::ensure_no_crate_args(&tool_args, "--prefetch")?;
966 Ok(Cli::Prefetch(crate_args))
967 } else if list_targets {
968 Self::ensure_no_crate_args(&tool_args, "--list-targets")?;
969 Ok(Cli::ListTargets(crate_args))
970 } else if no_exec {
971 Self::ensure_no_crate_args(&tool_args, "--no-exec")?;
972 Ok(Cli::NoExec(crate_args))
973 } else {
974 Ok(Cli::Run {
975 args: crate_args,
976 tool_args,
977 })
978 }
979 }
980
981 fn forbidden(mode: &str, what: &str) -> clap::Error {
983 clap::Error::raw(
984 ErrorKind::ArgumentConflict,
985 format!("{what} cannot be used with {mode}\n"),
986 )
987 }
988
989 fn ensure_config_mode_common(
994 mode: &str,
995 crate_spec: &Option<String>,
996 tool_args: &[OsString],
997 source: &SourceArgs,
998 prebuilt: &PrebuiltBinaryArgs,
999 crate_version: &Option<String>,
1000 ) -> Result<(), clap::Error> {
1001 Self::ensure_no_crate(crate_spec, tool_args, mode)?;
1002 if source.is_present() {
1003 return Err(Self::forbidden(mode, "source selectors"));
1004 }
1005 if prebuilt.is_present() {
1006 return Err(Self::forbidden(mode, "--prebuilt-binary options"));
1007 }
1008 if crate_version.is_some() {
1009 return Err(Self::forbidden(mode, "--crate-version"));
1010 }
1011 Ok(())
1012 }
1013
1014 fn ensure_no_crate(
1016 crate_spec: &Option<String>,
1017 tool_args: &[OsString],
1018 mode: &str,
1019 ) -> Result<(), clap::Error> {
1020 if crate_spec.is_some() || !tool_args.is_empty() {
1021 Err(clap::Error::raw(
1022 ErrorKind::UnknownArgument,
1023 format!("{mode} does not accept a crate or trailing arguments\n"),
1024 ))
1025 } else {
1026 Ok(())
1027 }
1028 }
1029
1030 fn ensure_no_crate_args(tool_args: &[OsString], mode: &str) -> Result<(), clap::Error> {
1032 if tool_args.is_empty() {
1033 Ok(())
1034 } else {
1035 Err(clap::Error::raw(
1036 ErrorKind::UnknownArgument,
1037 format!("{mode} cannot be used with trailing tool arguments\n"),
1038 ))
1039 }
1040 }
1041}
1042
1043#[derive(Clone, Debug, Subcommand)]
1055enum Invocation {
1056 #[command(external_subcommand)]
1057 Crate(Vec<OsString>),
1058}
1059
1060#[derive(Clone, Debug, Default, Args)]
1068struct SourceArgs {
1069 #[arg(long, group = "source")]
1071 git: Option<String>,
1072
1073 #[arg(long, group = "source")]
1075 registry: Option<String>,
1076
1077 #[arg(long, group = "source")]
1079 path: Option<PathBuf>,
1080
1081 #[arg(long, group = "source")]
1083 github: Option<String>,
1084
1085 #[arg(long, group = "source")]
1087 gitlab: Option<String>,
1088
1089 #[arg(long, group = "source", value_name = "INDEX")]
1091 index: Option<String>,
1092
1093 #[arg(long, requires = "github")]
1095 github_url: Option<String>,
1096
1097 #[arg(long, requires = "gitlab")]
1099 gitlab_url: Option<String>,
1100
1101 #[arg(long, group = "git_ref")]
1103 branch: Option<String>,
1104
1105 #[arg(long, group = "git_ref")]
1107 tag: Option<String>,
1108
1109 #[arg(long, group = "git_ref")]
1111 rev: Option<String>,
1112}
1113
1114impl SourceArgs {
1115 fn is_present(&self) -> bool {
1117 self.git.is_some()
1118 || self.registry.is_some()
1119 || self.path.is_some()
1120 || self.github.is_some()
1121 || self.gitlab.is_some()
1122 || self.index.is_some()
1123 || self.github_url.is_some()
1124 || self.gitlab_url.is_some()
1125 || self.branch.is_some()
1126 || self.tag.is_some()
1127 || self.rev.is_some()
1128 }
1129
1130 fn git_selector(&self) -> GitSelector {
1132 match (&self.branch, &self.tag, &self.rev) {
1133 (Some(branch), None, None) => GitSelector::Branch(branch.clone()),
1134 (None, Some(tag), None) => GitSelector::Tag(tag.clone()),
1135 (None, None, Some(rev)) => GitSelector::Commit(rev.clone()),
1136 (None, None, None) => GitSelector::DefaultBranch,
1137 _ => unreachable!("BUG: the `git_ref` ArgGroup enforces mutual exclusivity"),
1138 }
1139 }
1140
1141 fn to_source(&self) -> Source {
1143 if let Some(url) = &self.git {
1144 Source::Git { url: url.clone() }
1145 } else if let Some(name) = &self.registry {
1146 Source::Registry { name: name.clone() }
1147 } else if let Some(url) = &self.index {
1148 Source::Index { url: url.clone() }
1149 } else if let Some(path) = &self.path {
1150 Source::Path { path: path.clone() }
1151 } else if let Some(repo) = &self.github {
1152 Source::GitHub {
1153 repo: repo.clone(),
1154 custom_url: self.github_url.clone(),
1155 }
1156 } else if let Some(repo) = &self.gitlab {
1157 Source::GitLab {
1158 repo: repo.clone(),
1159 custom_url: self.gitlab_url.clone(),
1160 }
1161 } else {
1162 Source::Default
1163 }
1164 }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use assert_matches::assert_matches;
1170 use clap::CommandFactory;
1171
1172 use super::*;
1173 use crate::{
1174 Result,
1175 builder::{BuildOptions, BuildTarget},
1176 config::{Config, ToolConfig, ToolConfigDetailed},
1177 cratespec::{CrateSpec, Forge, RegistrySource},
1178 git::GitSelector,
1179 };
1180
1181 #[test]
1184 fn verify_cli() {
1185 RawCli::command().debug_assert();
1186 }
1187
1188 #[test]
1190 fn verbosity_reflects_repeated_v_flag() {
1191 let cases = [
1192 (vec!["tool"], Verbosity::Normal),
1193 (vec!["-v", "tool"], Verbosity::Verbose),
1194 (vec!["-vv", "tool"], Verbosity::VeryVerbose),
1195 (vec!["-vvv", "tool"], Verbosity::ExtremelyVerbose),
1196 (vec!["-vvvv", "tool"], Verbosity::ExtremelyVerbose),
1197 ];
1198 for (args, expected) in cases {
1199 let verbosity = Cli::parse_from_test_args(args).verbosity();
1200 assert_eq!(verbosity, expected);
1201 }
1202 }
1203
1204 mod cratespec {
1205 use super::*;
1206 fn parse_cratespec_from_args(args: &[&str]) -> Result<CrateSpec> {
1207 let cli = Cli::parse_from_test_args(args);
1208 let config = Config::default();
1209 let request = cli.crate_args().crate_request()?;
1210 CrateSpec::load(&config, &request)
1211 }
1212
1213 #[test]
1214 fn test_simple_crate() {
1215 let cr = parse_cratespec_from_args(&["ripgrep"]).unwrap();
1216 assert_matches!(
1217 cr,
1218 CrateSpec::CratesIo { ref name, version: None } if name == "ripgrep"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_crate_with_at_version() {
1224 let cr = parse_cratespec_from_args(&["ripgrep@14"]).unwrap();
1225 assert_matches!(
1226 cr,
1227 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1228 if name == "ripgrep" && v == &semver::VersionReq::parse("14").unwrap()
1229 );
1230 }
1231
1232 #[test]
1233 fn test_crate_with_flag_version() {
1234 let cr = parse_cratespec_from_args(&["--crate-version", "14", "ripgrep"]).unwrap();
1235 assert_matches!(
1236 cr,
1237 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1238 if name == "ripgrep" && v == &semver::VersionReq::parse("14").unwrap()
1239 );
1240 }
1241
1242 #[test]
1243 fn test_crate_with_conflicting_versions() {
1244 let result = parse_cratespec_from_args(&["--crate-version", "14", "ripgrep@14"]);
1248 assert_matches!(result, Err(crate::error::Error::ConflictingVersions { .. }));
1249
1250 let result = parse_cratespec_from_args(&["--crate-version", "15", "ripgrep@14"]);
1251 assert_matches!(result, Err(crate::error::Error::ConflictingVersions { .. }));
1252 }
1253
1254 #[test]
1255 fn test_at_version_without_crate_name() {
1256 let result = parse_cratespec_from_args(&["@1.0"]);
1257 assert_matches!(result, Err(crate::error::Error::MissingCrateName { .. }));
1258 }
1259
1260 #[test]
1261 fn test_empty_crate_spec() {
1262 let result = parse_cratespec_from_args(&[""]);
1263 assert_matches!(result, Err(crate::error::Error::MissingCrateName { .. }));
1264 }
1265
1266 #[test]
1267 fn test_cargo_subcommand() {
1268 let cr = parse_cratespec_from_args(&["cargo", "deny"]).unwrap();
1269 assert_matches!(
1270 cr,
1271 CrateSpec::CratesIo { ref name, version: None } if name == "cargo-deny"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_cargo_subcommand_with_version() {
1277 let cr = parse_cratespec_from_args(&["cargo", "deny@1"]).unwrap();
1278 assert_matches!(
1279 cr,
1280 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1281 if name == "cargo-deny" && v == &semver::VersionReq::parse("1").unwrap()
1282 );
1283 }
1284
1285 #[test]
1286 fn test_cargo_without_subcommand() {
1287 let result = parse_cratespec_from_args(&["cargo"]);
1288 assert_matches!(result, Err(crate::error::Error::CargoNotRunnable));
1289 }
1290
1291 #[test]
1292 fn test_cargo_with_flag_argument() {
1293 let result = parse_cratespec_from_args(&["cargo", "--help"]);
1294 assert_matches!(result, Err(crate::error::Error::CargoNotRunnable));
1295 }
1296
1297 #[test]
1298 fn test_cargo_with_version() {
1299 let result = parse_cratespec_from_args(&["cargo@1.0"]);
1300 assert_matches!(result, Err(crate::error::Error::CargoNotRunnable));
1301 }
1302
1303 #[test]
1304 fn test_git_source() {
1305 let cr = parse_cratespec_from_args(&["--git", "https://github.com/foo/bar", "mycrate"]).unwrap();
1306 assert_matches!(
1307 cr,
1308 CrateSpec::Forge {
1309 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1310 selector: GitSelector::DefaultBranch,
1311 ref name,
1312 version: None
1313 } if owner == "foo" && repo == "bar" && name.as_deref() == Some("mycrate")
1314 );
1315 }
1316
1317 #[test]
1318 fn test_git_with_branch() {
1319 let cr = parse_cratespec_from_args(&[
1320 "--git",
1321 "https://github.com/foo/bar",
1322 "--branch",
1323 "main",
1324 "mycrate",
1325 ])
1326 .unwrap();
1327 assert_matches!(
1328 cr,
1329 CrateSpec::Forge {
1330 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1331 selector: GitSelector::Branch(ref b),
1332 ref name,
1333 version: None
1334 } if owner == "foo" && repo == "bar" && b == "main" && name.as_deref() == Some("mycrate")
1335 );
1336 }
1337
1338 #[test]
1339 fn test_git_with_tag() {
1340 let cr = parse_cratespec_from_args(&[
1341 "--git",
1342 "https://github.com/foo/bar",
1343 "--tag",
1344 "v1.0",
1345 "mycrate",
1346 ])
1347 .unwrap();
1348 assert_matches!(
1349 cr,
1350 CrateSpec::Forge {
1351 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1352 selector: GitSelector::Tag(ref t),
1353 ref name,
1354 version: None
1355 } if owner == "foo" && repo == "bar" && t == "v1.0" && name.as_deref() == Some("mycrate")
1356 );
1357 }
1358
1359 #[test]
1360 fn test_git_with_rev() {
1361 let cr = parse_cratespec_from_args(&[
1362 "--git",
1363 "https://github.com/foo/bar",
1364 "--rev",
1365 "abc123",
1366 "mycrate",
1367 ])
1368 .unwrap();
1369 assert_matches!(
1370 cr,
1371 CrateSpec::Forge {
1372 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1373 selector: GitSelector::Commit(ref c),
1374 ref name,
1375 version: None
1376 } if owner == "foo" && repo == "bar" &&
1377 c == "abc123" &&
1378 name.as_deref() == Some("mycrate")
1379 );
1380 }
1381
1382 #[test]
1383 fn test_git_github_https_url() {
1384 let cr =
1385 parse_cratespec_from_args(&["--git", "https://github.com/owner/repo", "mycrate"]).unwrap();
1386 assert_matches!(
1387 cr,
1388 CrateSpec::Forge {
1389 forge: Forge::GitHub {
1390 custom_url: None,
1391 ref owner,
1392 ref repo
1393 },
1394 selector: GitSelector::DefaultBranch,
1395 ref name,
1396 version: None
1397 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1398 );
1399 }
1400
1401 #[test]
1402 fn test_git_github_https_url_with_git_suffix() {
1403 let cr = parse_cratespec_from_args(&["--git", "https://github.com/owner/repo.git", "mycrate"])
1404 .unwrap();
1405 assert_matches!(
1406 cr,
1407 CrateSpec::Forge {
1408 forge: Forge::GitHub {
1409 custom_url: None,
1410 ref owner,
1411 ref repo
1412 },
1413 selector: GitSelector::DefaultBranch,
1414 ref name,
1415 version: None
1416 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1417 );
1418 }
1419
1420 #[test]
1421 fn test_git_gitlab_https_url() {
1422 let cr =
1423 parse_cratespec_from_args(&["--git", "https://gitlab.com/owner/repo", "mycrate"]).unwrap();
1424 assert_matches!(
1425 cr,
1426 CrateSpec::Forge {
1427 forge: Forge::GitLab {
1428 custom_url: None,
1429 ref owner,
1430 ref repo
1431 },
1432 selector: GitSelector::DefaultBranch,
1433 ref name,
1434 version: None
1435 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1436 );
1437 }
1438
1439 #[test]
1440 fn test_git_scheme_not_transformed() {
1441 let cr = parse_cratespec_from_args(&["--git", "git://github.com/owner/repo", "mycrate"]).unwrap();
1442 assert_matches!(
1443 cr,
1444 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1445 if repo == "git://github.com/owner/repo" && name.as_deref() == Some("mycrate")
1446 );
1447 }
1448
1449 #[test]
1450 fn test_git_custom_domain_not_transformed() {
1451 let cr =
1452 parse_cratespec_from_args(&["--git", "https://github.enterprise.com/owner/repo", "mycrate"])
1453 .unwrap();
1454 assert_matches!(
1455 cr,
1456 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1457 if repo == "https://github.enterprise.com/owner/repo" && name.as_deref() == Some("mycrate")
1458 );
1459 }
1460
1461 #[test]
1462 fn test_git_github_url_with_extra_path_not_transformed() {
1463 let cr =
1464 parse_cratespec_from_args(&["--git", "https://github.com/owner/repo/pull/15", "mycrate"])
1465 .unwrap();
1466 assert_matches!(
1467 cr,
1468 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1469 if repo == "https://github.com/owner/repo/pull/15" && name.as_deref() == Some("mycrate")
1470 );
1471 }
1472
1473 #[test]
1474 fn test_git_github_url_with_tree_path_not_transformed() {
1475 let cr = parse_cratespec_from_args(&[
1476 "--git",
1477 "https://github.com/owner/repo/tree/master/some/path",
1478 "mycrate",
1479 ])
1480 .unwrap();
1481 assert_matches!(
1482 cr,
1483 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1484 if repo == "https://github.com/owner/repo/tree/master/some/path" &&
1485 name.as_deref() == Some("mycrate")
1486 );
1487 }
1488
1489 #[test]
1490 fn test_registry() {
1491 let cr = parse_cratespec_from_args(&["--registry", "my-registry", "mycrate"]).unwrap();
1492 assert_matches!(
1493 cr,
1494 CrateSpec::Registry {
1495 source: RegistrySource::Named(ref registry),
1496 ref name,
1497 version: None
1498 } if registry == "my-registry" && name == "mycrate"
1499 );
1500 }
1501
1502 #[test]
1503 fn test_index() {
1504 let cr =
1505 parse_cratespec_from_args(&["--index", "https://my-index.com/git/index", "mycrate"]).unwrap();
1506 assert_matches!(
1507 cr,
1508 CrateSpec::Registry {
1509 source: RegistrySource::IndexUrl(ref index),
1510 ref name,
1511 version: None
1512 } if index.as_str() == "https://my-index.com/git/index" && name == "mycrate"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_index_with_version() {
1518 let cr = parse_cratespec_from_args(&["--index", "sparse+https://my-index.com/", "mycrate@1.0"])
1519 .unwrap();
1520 assert_matches!(
1521 cr,
1522 CrateSpec::Registry {
1523 source: RegistrySource::IndexUrl(ref index),
1524 ref name,
1525 version: Some(ref v)
1526 } if index.as_str() == "sparse+https://my-index.com/" &&
1527 name == "mycrate" &&
1528 v == &semver::VersionReq::parse("1.0").unwrap()
1529 );
1530 }
1531
1532 #[test]
1533 fn test_local_path() {
1534 let cr = parse_cratespec_from_args(&["--path", "./my-crate", "mycrate"]).unwrap();
1535 assert_matches!(
1536 cr,
1537 CrateSpec::LocalDir { ref path, ref name, version: None }
1538 if path.to_str().unwrap() == "./my-crate" && name.as_deref() == Some("mycrate")
1539 );
1540 }
1541
1542 #[test]
1543 fn test_github() {
1544 let cr = parse_cratespec_from_args(&["--github", "owner/repo", "mycrate"]).unwrap();
1545 assert_matches!(
1546 cr,
1547 CrateSpec::Forge {
1548 forge: Forge::GitHub {
1549 custom_url: None,
1550 ref owner,
1551 ref repo
1552 },
1553 selector: GitSelector::DefaultBranch,
1554 ref name,
1555 version: None
1556 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1557 );
1558 }
1559
1560 #[test]
1561 fn test_github_with_custom_url() {
1562 let cr = parse_cratespec_from_args(&[
1563 "--github",
1564 "owner/repo",
1565 "--github-url",
1566 "https://github.mycorp.com",
1567 "mycrate",
1568 ])
1569 .unwrap();
1570 assert_matches!(
1571 cr,
1572 CrateSpec::Forge {
1573 forge: Forge::GitHub {
1574 custom_url: Some(ref url),
1575 ref owner,
1576 ref repo
1577 },
1578 selector: GitSelector::DefaultBranch,
1579 ref name,
1580 version: None
1581 } if owner == "owner" &&
1582 repo == "repo" &&
1583 name.as_deref() == Some("mycrate") &&
1584 url.as_str() == "https://github.mycorp.com/"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_github_with_branch() {
1590 let cr = parse_cratespec_from_args(&["--github", "owner/repo", "--branch", "develop", "mycrate"])
1591 .unwrap();
1592 assert_matches!(
1593 cr,
1594 CrateSpec::Forge {
1595 forge: Forge::GitHub {
1596 custom_url: None,
1597 ref owner,
1598 ref repo
1599 },
1600 selector: GitSelector::Branch(ref b),
1601 ref name,
1602 version: None
1603 } if owner == "owner" &&
1604 repo == "repo" &&
1605 b == "develop" &&
1606 name.as_deref() == Some("mycrate")
1607 );
1608 }
1609
1610 #[test]
1611 fn test_gitlab() {
1612 let cr = parse_cratespec_from_args(&["--gitlab", "owner/repo", "mycrate"]).unwrap();
1613 assert_matches!(
1614 cr,
1615 CrateSpec::Forge {
1616 forge: Forge::GitLab {
1617 custom_url: None,
1618 ref owner,
1619 ref repo
1620 },
1621 selector: GitSelector::DefaultBranch,
1622 ref name,
1623 version: None
1624 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1625 );
1626 }
1627
1628 #[test]
1629 fn test_gitlab_with_custom_url() {
1630 let cr = parse_cratespec_from_args(&[
1631 "--gitlab",
1632 "owner/repo",
1633 "--gitlab-url",
1634 "https://gitlab.mycorp.com",
1635 "mycrate",
1636 ])
1637 .unwrap();
1638 assert_matches!(
1639 cr,
1640 CrateSpec::Forge {
1641 forge: Forge::GitLab {
1642 custom_url: Some(ref url),
1643 ref owner,
1644 ref repo
1645 },
1646 selector: GitSelector::DefaultBranch,
1647 ref name,
1648 version: None
1649 } if owner == "owner" &&
1650 repo == "repo" &&
1651 name.as_deref() == Some("mycrate") &&
1652 url.as_str() == "https://gitlab.mycorp.com/"
1653 );
1654 }
1655
1656 #[test]
1657 fn test_git_selector_without_git_source() {
1658 let result = parse_cratespec_from_args(&["--branch", "main", "mycrate"]);
1659 assert_matches!(result, Err(crate::error::Error::GitSelectorWithoutGitSource));
1660 }
1661
1662 #[test]
1663 fn test_invalid_repo_format() {
1664 let result = parse_cratespec_from_args(&["--github", "invalid-repo", "mycrate"]);
1665 assert_matches!(result, Err(crate::error::Error::InvalidRepoFormat { .. }));
1666 }
1667
1668 #[test]
1669 fn test_invalid_version() {
1670 let result = parse_cratespec_from_args(&["ripgrep@not-a-version"]);
1671 assert_matches!(result, Err(crate::error::Error::InvalidVersionReq { .. }));
1672 }
1673
1674 #[test]
1675 fn test_invalid_index_url() {
1676 let result = parse_cratespec_from_args(&["--index", "not-a-valid-url", "mycrate"]);
1677 assert_matches!(result, Err(crate::error::Error::InvalidUrl { .. }));
1678 }
1679
1680 #[test]
1681 fn test_git_without_crate_name() {
1682 let cr = parse_cratespec_from_args(&["--git", "https://github.com/foo/bar"]).unwrap();
1683 assert_matches!(
1684 cr,
1685 CrateSpec::Forge {
1686 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1687 selector: GitSelector::DefaultBranch,
1688 name: None,
1689 version: None
1690 } if owner == "foo" && repo == "bar"
1691 );
1692 }
1693
1694 #[test]
1695 fn test_github_without_crate_name() {
1696 let cr = parse_cratespec_from_args(&["--github", "owner/repo"]).unwrap();
1697 assert_matches!(
1698 cr,
1699 CrateSpec::Forge {
1700 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1701 selector: GitSelector::DefaultBranch,
1702 name: None,
1703 version: None
1704 } if owner == "owner" && repo == "repo"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_gitlab_without_crate_name() {
1710 let cr = parse_cratespec_from_args(&["--gitlab", "owner/repo"]).unwrap();
1711 assert_matches!(
1712 cr,
1713 CrateSpec::Forge {
1714 forge: Forge::GitLab { custom_url: None, ref owner, ref repo },
1715 selector: GitSelector::DefaultBranch,
1716 name: None,
1717 version: None
1718 } if owner == "owner" && repo == "repo"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_path_without_crate_name() {
1724 let cr = parse_cratespec_from_args(&["--path", "./my-crate"]).unwrap();
1725 assert_matches!(
1726 cr,
1727 CrateSpec::LocalDir { ref path, name: None, version: None }
1728 if path.to_str().unwrap() == "./my-crate"
1729 );
1730 }
1731 }
1732
1733 mod build_options {
1734 use super::*;
1735
1736 fn parse_build_options_from_args(args: &[&str]) -> Result<BuildOptions> {
1737 let cli = Cli::parse_from_test_args(args);
1738 let config = Config::default();
1739 BuildOptions::load(&config, &cli.crate_args().to_build_overrides())
1740 }
1741
1742 #[test]
1743 fn test_features_parsing_comma_separated() {
1744 let opts = parse_build_options_from_args(&["--features", "foo,bar,baz", "ripgrep"]).unwrap();
1745 assert_eq!(opts.features, vec!["foo", "bar", "baz"]);
1746 }
1747
1748 #[test]
1749 fn test_features_parsing_space_separated() {
1750 let opts = parse_build_options_from_args(&["--features", "foo bar baz", "ripgrep"]).unwrap();
1751 assert_eq!(opts.features, vec!["foo", "bar", "baz"]);
1752 }
1753
1754 #[test]
1755 fn test_features_parsing_mixed_separators() {
1756 let opts = parse_build_options_from_args(&["--features", "foo, bar baz", "ripgrep"]).unwrap();
1757 assert_eq!(opts.features, vec!["foo", "bar", "baz"]);
1758 }
1759
1760 #[test]
1761 fn test_features_parsing_with_extra_whitespace() {
1762 let opts = parse_build_options_from_args(&["--features", " foo , bar ", "ripgrep"]).unwrap();
1763 assert_eq!(opts.features, vec!["foo", "bar"]);
1764 }
1765
1766 #[test]
1769 fn empty_features_value_is_distinct_from_absent_flag() {
1770 for empty in ["", ","] {
1771 let cli = Cli::parse_from_test_args(["--features", empty, "ripgrep"]);
1772 assert_eq!(cli.crate_args().to_build_overrides().features, Some(vec![]));
1773 }
1774
1775 let cli = Cli::parse_from_test_args(["ripgrep"]);
1776 assert_eq!(cli.crate_args().to_build_overrides().features, None);
1777 }
1778
1779 #[test]
1780 fn test_all_features() {
1781 let opts = parse_build_options_from_args(&["--all-features", "ripgrep"]).unwrap();
1782 assert!(opts.all_features);
1783 }
1784
1785 #[test]
1786 fn test_no_default_features() {
1787 let opts = parse_build_options_from_args(&["--no-default-features", "ripgrep"]).unwrap();
1788 assert!(opts.no_default_features);
1789 }
1790
1791 #[test]
1792 fn test_debug_maps_to_dev_profile() {
1793 let opts = parse_build_options_from_args(&["--debug", "ripgrep"]).unwrap();
1794 assert_eq!(opts.profile, Some("dev".to_string()));
1795 }
1796
1797 #[test]
1798 fn test_profile_custom() {
1799 let opts =
1800 parse_build_options_from_args(&["--profile", "release-with-debug", "ripgrep"]).unwrap();
1801 assert_eq!(opts.profile, Some("release-with-debug".to_string()));
1802 }
1803
1804 #[test]
1805 fn test_config_locked_and_offline_both_true() {
1806 let cli = Cli::parse_from_test_args(["ripgrep"]);
1810 let config = Config {
1811 locked: true,
1812 offline: true,
1813 ..Default::default()
1814 };
1815 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1816 assert!(opts.locked);
1817 assert!(opts.offline);
1818 }
1819
1820 #[test]
1821 fn test_config_locked_without_offline() {
1822 let cli = Cli::parse_from_test_args(["ripgrep"]);
1823 let config = Config {
1824 locked: true,
1825 offline: false,
1826 ..Default::default()
1827 };
1828 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1829 assert!(opts.locked);
1830 assert!(!opts.offline);
1831 }
1832
1833 #[test]
1834 fn test_config_offline_without_locked() {
1835 let cli = Cli::parse_from_test_args(["ripgrep"]);
1836 let config = Config {
1837 locked: false,
1838 offline: true,
1839 ..Default::default()
1840 };
1841 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1842 assert!(!opts.locked);
1843 assert!(opts.offline);
1844 }
1845
1846 #[test]
1847 fn test_target() {
1848 let opts =
1849 parse_build_options_from_args(&["--target", "x86_64-unknown-linux-musl", "ripgrep"]).unwrap();
1850 assert_eq!(
1851 opts.target.as_ref().map(|target| target.as_str()),
1852 Some("x86_64-unknown-linux-musl")
1853 );
1854 }
1855
1856 #[test]
1857 fn test_jobs() {
1858 let opts = parse_build_options_from_args(&["-j", "4", "ripgrep"]).unwrap();
1859 assert_eq!(opts.jobs, Some(4));
1860 }
1861
1862 #[test]
1863 fn test_ignore_rust_version() {
1864 let opts = parse_build_options_from_args(&["--ignore-rust-version", "ripgrep"]).unwrap();
1865 assert!(opts.ignore_rust_version);
1866 }
1867
1868 #[test]
1869 fn test_build_options_defaults() {
1870 let opts = parse_build_options_from_args(&["ripgrep"]).unwrap();
1871 assert_eq!(opts, Default::default());
1872 }
1873
1874 #[test]
1875 fn test_bin_flag() {
1876 let opts = parse_build_options_from_args(&["--bin", "mybinary", "ripgrep"]).unwrap();
1877 assert_eq!(opts.build_target, BuildTarget::Bin("mybinary".to_string()));
1878 assert_eq!(opts.build_target, BuildTarget::Bin("mybinary".to_string()));
1879 }
1880
1881 #[test]
1882 fn test_example_flag() {
1883 let opts = parse_build_options_from_args(&["--example", "myexample", "ripgrep"]).unwrap();
1884 assert_eq!(opts.build_target, BuildTarget::Example("myexample".to_string()));
1885 }
1886 }
1887
1888 mod toolchain_tests {
1889 use super::*;
1890
1891 #[test]
1892 fn test_extract_toolchain_nightly() {
1893 let args = vec!["cgx", "+nightly", "ripgrep"];
1894 let (toolchain, filtered) = Cli::extract_toolchain(args);
1895
1896 assert_eq!(toolchain, Some("nightly".to_string()));
1897 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1898 }
1899
1900 #[test]
1901 fn test_extract_toolchain_specific_version() {
1902 let args = vec!["cgx", "+1.70.0", "ripgrep"];
1903 let (toolchain, filtered) = Cli::extract_toolchain(args);
1904
1905 assert_eq!(toolchain.as_deref(), Some("1.70.0"));
1906 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1907 }
1908
1909 #[test]
1910 fn test_extract_toolchain_stable() {
1911 let args = vec!["cgx", "+stable", "ripgrep"];
1912 let (toolchain, filtered) = Cli::extract_toolchain(args);
1913
1914 assert_eq!(toolchain, Some("stable".to_string()));
1915 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1916 }
1917
1918 #[test]
1919 fn test_extract_toolchain_with_other_flags() {
1920 let args = vec![
1921 "cgx",
1922 "+nightly",
1923 "--git",
1924 "https://github.com/foo/bar",
1925 "mycrate",
1926 ];
1927 let (toolchain, filtered) = Cli::extract_toolchain(args);
1928
1929 assert_eq!(toolchain, Some("nightly".to_string()));
1930 assert_eq!(
1931 filtered,
1932 vec!["cgx", "--git", "https://github.com/foo/bar", "mycrate"]
1933 );
1934 }
1935
1936 #[test]
1937 fn test_no_toolchain() {
1938 let args = vec!["cgx", "ripgrep"];
1939 let (toolchain, filtered) = Cli::extract_toolchain(args);
1940
1941 assert_eq!(toolchain, None);
1942 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1943 }
1944
1945 #[test]
1946 fn test_bare_plus() {
1947 let args = vec!["cgx", "+", "ripgrep"];
1948 let (toolchain, filtered) = Cli::extract_toolchain(args);
1949
1950 assert_eq!(toolchain, None);
1951 assert_eq!(filtered, vec!["cgx", "+", "ripgrep"]);
1952 }
1953
1954 #[test]
1955 fn test_plus_in_middle_not_toolchain() {
1956 let args = vec!["cgx", "ripgrep", "+something"];
1957 let (toolchain, filtered) = Cli::extract_toolchain(args);
1958
1959 assert_eq!(toolchain, None);
1960 assert_eq!(filtered, vec!["cgx", "ripgrep", "+something"]);
1961 }
1962
1963 #[test]
1964 fn test_toolchain_with_version_flag() {
1965 let args = vec!["+nightly", "ripgrep", "--version", "14"];
1968 let cli = Cli::parse_from_test_args(args);
1969
1970 let invocation = cli.crate_args();
1971 assert_eq!(invocation.toolchain.as_deref(), Some("nightly"));
1972 assert_eq!(invocation.crate_spec.as_deref(), Some("ripgrep"));
1973
1974 let tool_args: Vec<&str> = cli.tool_args().iter().map(|arg| arg.to_str().unwrap()).collect();
1975 assert_eq!(tool_args, ["--version", "14"]);
1976 }
1977
1978 #[test]
1979 fn test_toolchain_propagates_from_config_to_build_options() {
1980 let args = vec!["ripgrep"];
1983 let cli = Cli::parse_from_test_args(args);
1984
1985 let config = Config {
1986 toolchain: Some("nightly".to_string()),
1987 ..Default::default()
1988 };
1989 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1990 assert_eq!(opts.toolchain, Some("nightly".to_string()));
1991 }
1992
1993 #[test]
1994 fn test_no_toolchain_in_build_options() {
1995 let args = vec!["ripgrep"];
1996 let cli = Cli::parse_from_test_args(args);
1997
1998 let config = Config::default();
1999 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
2000 assert_eq!(opts.toolchain, None);
2001 }
2002 }
2003
2004 mod config_overrides {
2005 use super::*;
2006
2007 #[test]
2008 fn test_config_overrides_can_combine() {
2009 let inputs = Cli::parse_from_test_args([
2011 "--system-config-dir",
2012 "/tmp/system",
2013 "--app-dir",
2014 "/tmp/app",
2015 "--user-config-dir",
2016 "/tmp/user",
2017 "ripgrep",
2018 ])
2019 .to_config_overrides();
2020 assert_eq!(inputs.system_config_dir, Some(PathBuf::from("/tmp/system")));
2021 assert_eq!(inputs.app_dir, Some(PathBuf::from("/tmp/app")));
2022 assert_eq!(inputs.user_config_dir, Some(PathBuf::from("/tmp/user")));
2023 }
2024 }
2025
2026 mod mode_validation {
2027 use super::*;
2028
2029 #[test]
2030 fn prefetch_accepts_single_tool_build_options() {
2031 let cli = Cli::try_parse_from_test_args([
2032 "--prefetch",
2033 "--features",
2034 "frobnulator",
2035 "--bin",
2036 "timestamp",
2037 "timestamp",
2038 ])
2039 .unwrap();
2040
2041 assert_matches!(&cli, Cli::Prefetch(_));
2042 let invocation = cli.crate_args();
2043 assert_eq!(invocation.crate_spec.as_deref(), Some("timestamp"));
2044 assert_eq!(invocation.build_options.features.as_deref(), Some("frobnulator"));
2045 assert!(cli.tool_args().is_empty());
2046 }
2047
2048 #[test]
2049 fn prefetch_rejects_trailing_args() {
2050 let result = Cli::try_parse_from_test_args(["--prefetch", "timestamp", "--help"]);
2051 assert_matches!(result, Err(_));
2052 }
2053
2054 #[test]
2055 fn no_exec_rejects_trailing_args() {
2056 for args in [
2057 vec!["--no-exec", "timestamp", "--help"],
2058 vec!["--no-exec", "timestamp", "--", "--help"],
2059 ] {
2060 let result = Cli::try_parse_from_test_args(args);
2061 assert_matches!(result, Err(_));
2062 }
2063 }
2064
2065 #[test]
2066 fn prefetch_rejects_no_exec() {
2067 let result = Cli::try_parse_from_test_args(["--prefetch", "--no-exec", "timestamp"]);
2068 assert_matches!(result, Err(_));
2069 }
2070
2071 #[test]
2072 fn prefetch_all_accepts_allowed_controls() {
2073 let cli = Cli::try_parse_from_test_args([
2074 "--prefetch-all",
2075 "--offline",
2076 "--refresh",
2077 "--jobs",
2078 "2",
2079 "--ignore-rust-version",
2080 "--http-timeout",
2081 "5s",
2082 "--system-config-dir",
2083 "/tmp/system",
2084 "--app-dir",
2085 "/tmp/app",
2086 "--user-config-dir",
2087 "/tmp/user",
2088 "--message-format",
2089 "json",
2090 ])
2091 .unwrap();
2092
2093 let Cli::PrefetchAll(prefetch_all) = cli else {
2094 panic!("expected a prefetch-all command");
2095 };
2096 assert!(prefetch_all.offline);
2097 assert!(prefetch_all.refresh);
2098 assert_eq!(prefetch_all.jobs, Some(2));
2099 assert!(prefetch_all.ignore_rust_version);
2100 assert_eq!(prefetch_all.http.http_timeout.as_deref(), Some("5s"));
2101 assert_eq!(
2102 prefetch_all.config.system_config_dir,
2103 Some(PathBuf::from("/tmp/system"))
2104 );
2105 assert_eq!(prefetch_all.config.app_dir, Some(PathBuf::from("/tmp/app")));
2106 assert_eq!(
2107 prefetch_all.config.user_config_dir,
2108 Some(PathBuf::from("/tmp/user"))
2109 );
2110 }
2111
2112 #[test]
2113 fn prefetch_all_controls_reach_loaders() {
2114 let cli = Cli::parse_from_test_args([
2117 "--prefetch-all",
2118 "--offline",
2119 "--refresh",
2120 "--jobs",
2121 "2",
2122 "--ignore-rust-version",
2123 ]);
2124 let Cli::PrefetchAll(prefetch_all) = &cli else {
2125 panic!("expected a prefetch-all command");
2126 };
2127
2128 let build_options =
2130 BuildOptions::load(&Config::default(), &prefetch_all.to_build_overrides()).unwrap();
2131 assert_eq!(build_options.jobs, Some(2));
2132 assert!(build_options.ignore_rust_version);
2133
2134 let temp = tempfile::tempdir().unwrap();
2137 let mut overrides = prefetch_all.to_config_overrides();
2138 overrides.system_config_dir = Some(temp.path().join("system"));
2139 overrides.user_config_dir = Some(temp.path().join("user"));
2140 let config = Config::load_from_dir(temp.path(), &overrides).unwrap();
2141 assert!(config.offline);
2142 assert!(config.refresh);
2143 }
2144
2145 #[test]
2146 fn prefetch_all_verbosity_reaches_config_overrides() {
2147 let cli = Cli::parse_from_test_args(["--prefetch-all", "-vv"]);
2148 let Cli::PrefetchAll(prefetch_all) = &cli else {
2149 panic!("expected a prefetch-all command");
2150 };
2151 assert_eq!(
2152 prefetch_all.to_config_overrides().verbosity,
2153 Verbosity::VeryVerbose
2154 );
2155 }
2156
2157 #[test]
2158 fn prefetch_all_rejects_crate_source_build_and_trailing_args() {
2159 for args in [
2160 vec!["--prefetch-all", "timestamp"],
2161 vec!["--prefetch-all", "--path", "."],
2162 vec!["--prefetch-all", "--features", "frobnulator"],
2163 vec!["--prefetch-all", "--all-features"],
2164 vec!["--prefetch-all", "--locked"],
2165 vec!["--prefetch-all", "--prebuilt-binary", "never"],
2166 vec!["--prefetch-all", "--", "--help"],
2167 ] {
2168 let result = Cli::try_parse_from_test_args(args);
2169 assert_matches!(result, Err(_));
2170 }
2171 }
2172
2173 #[test]
2174 fn list_tools_accepts_config_discovery_and_message_format() {
2175 let cli = Cli::try_parse_from_test_args([
2176 "--list-tools",
2177 "--config-file",
2178 "cgx.toml",
2179 "--message-format",
2180 "json",
2181 ])
2182 .unwrap();
2183
2184 let Cli::ListTools(list_tools) = cli else {
2185 panic!("expected a list-tools command");
2186 };
2187 assert_eq!(list_tools.config.config_file, Some(PathBuf::from("cgx.toml")));
2188 assert_matches!(list_tools.reporting.message_format, Some(MessageFormat::Json));
2189
2190 let cli = Cli::try_parse_from_test_args([
2191 "--list-tools",
2192 "--system-config-dir",
2193 "/tmp/system",
2194 "--app-dir",
2195 "/tmp/app",
2196 "--user-config-dir",
2197 "/tmp/user",
2198 "--message-format",
2199 "json",
2200 ])
2201 .unwrap();
2202
2203 let Cli::ListTools(list_tools) = cli else {
2204 panic!("expected a list-tools command");
2205 };
2206 assert_eq!(
2207 list_tools.config.system_config_dir,
2208 Some(PathBuf::from("/tmp/system"))
2209 );
2210 assert_eq!(list_tools.config.app_dir, Some(PathBuf::from("/tmp/app")));
2211 assert_eq!(
2212 list_tools.config.user_config_dir,
2213 Some(PathBuf::from("/tmp/user"))
2214 );
2215 assert_matches!(list_tools.reporting.message_format, Some(MessageFormat::Json));
2216 }
2217
2218 #[test]
2219 fn list_tools_rejects_operational_flags() {
2220 for args in [
2221 vec!["--list-tools", "timestamp"],
2222 vec!["--list-tools", "--offline"],
2223 vec!["--list-tools", "--features", "frobnulator"],
2224 vec!["--list-tools", "--prefetch"],
2225 ] {
2226 let result = Cli::try_parse_from_test_args(args);
2227 assert_matches!(result, Err(_));
2228 }
2229 }
2230
2231 #[test]
2235 fn config_features_apply_when_cli_features_absent() {
2236 let cli = Cli::parse_from_test_args(["timestamp"]);
2237 let mut config = Config::default();
2238 config.tools.insert(
2239 "timestamp".to_string(),
2240 ToolConfig::Detailed(ToolConfigDetailed {
2241 default_features: true,
2242 version: None,
2243 features: Some(vec!["frobnulator".to_string()]),
2244 registry: None,
2245 git: None,
2246 branch: None,
2247 tag: None,
2248 rev: None,
2249 path: None,
2250 }),
2251 );
2252
2253 let options = BuildOptions::load_for_crate(
2254 &config,
2255 &cli.crate_args().to_build_overrides(),
2256 &CrateSpec::CratesIo {
2257 name: "timestamp".to_string(),
2258 version: None,
2259 },
2260 )
2261 .unwrap();
2262
2263 assert_eq!(options.features, vec!["frobnulator"]);
2264 }
2265
2266 #[test]
2269 fn cli_features_replace_config_features() {
2270 let cli = Cli::parse_from_test_args(["--features", "gonkolator", "timestamp"]);
2271 let mut config = Config::default();
2272 config.tools.insert(
2273 "timestamp".to_string(),
2274 ToolConfig::Detailed(ToolConfigDetailed {
2275 default_features: true,
2276 version: None,
2277 features: Some(vec!["frobnulator".to_string()]),
2278 registry: None,
2279 git: None,
2280 branch: None,
2281 tag: None,
2282 rev: None,
2283 path: None,
2284 }),
2285 );
2286
2287 let options = BuildOptions::load_for_crate(
2288 &config,
2289 &cli.crate_args().to_build_overrides(),
2290 &CrateSpec::CratesIo {
2291 name: "timestamp".to_string(),
2292 version: None,
2293 },
2294 )
2295 .unwrap();
2296
2297 assert_eq!(options.features, vec!["gonkolator"]);
2298 }
2299
2300 #[test]
2303 fn empty_cli_features_override_config_features() {
2304 let cli = Cli::parse_from_test_args(["--features", "", "timestamp"]);
2305 let mut config = Config::default();
2306 config.tools.insert(
2307 "timestamp".to_string(),
2308 ToolConfig::Detailed(ToolConfigDetailed {
2309 default_features: true,
2310 version: None,
2311 features: Some(vec!["frobnulator".to_string()]),
2312 registry: None,
2313 git: None,
2314 branch: None,
2315 tag: None,
2316 rev: None,
2317 path: None,
2318 }),
2319 );
2320
2321 let options = BuildOptions::load_for_crate(
2322 &config,
2323 &cli.crate_args().to_build_overrides(),
2324 &CrateSpec::CratesIo {
2325 name: "timestamp".to_string(),
2326 version: None,
2327 },
2328 )
2329 .unwrap();
2330
2331 assert_eq!(options.features, Vec::<String>::new());
2332 assert!(!options.all_features);
2333 }
2334
2335 #[test]
2338 fn config_default_features_false_disables_defaults() {
2339 let cli = Cli::parse_from_test_args(["timestamp"]);
2340 let mut config = Config::default();
2341 config.tools.insert(
2342 "timestamp".to_string(),
2343 ToolConfig::Detailed(ToolConfigDetailed {
2344 default_features: false,
2345 version: None,
2346 features: None,
2347 registry: None,
2348 git: None,
2349 branch: None,
2350 tag: None,
2351 rev: None,
2352 path: None,
2353 }),
2354 );
2355
2356 let options = BuildOptions::load_for_crate(
2357 &config,
2358 &cli.crate_args().to_build_overrides(),
2359 &CrateSpec::CratesIo {
2360 name: "timestamp".to_string(),
2361 version: None,
2362 },
2363 )
2364 .unwrap();
2365
2366 assert!(options.no_default_features);
2367 }
2368
2369 #[test]
2372 fn config_default_features_true_keeps_defaults() {
2373 let cli = Cli::parse_from_test_args(["timestamp"]);
2374 let mut config = Config::default();
2375 config.tools.insert(
2376 "timestamp".to_string(),
2377 ToolConfig::Detailed(ToolConfigDetailed {
2378 default_features: true,
2379 version: None,
2380 features: None,
2381 registry: None,
2382 git: None,
2383 branch: None,
2384 tag: None,
2385 rev: None,
2386 path: None,
2387 }),
2388 );
2389
2390 let options = BuildOptions::load_for_crate(
2391 &config,
2392 &cli.crate_args().to_build_overrides(),
2393 &CrateSpec::CratesIo {
2394 name: "timestamp".to_string(),
2395 version: None,
2396 },
2397 )
2398 .unwrap();
2399
2400 assert!(!options.no_default_features);
2401 }
2402
2403 #[test]
2407 fn config_default_features_independent_of_cli_features() {
2408 let cli = Cli::parse_from_test_args(["--features", "gonkolator", "timestamp"]);
2409 let mut config = Config::default();
2410 config.tools.insert(
2411 "timestamp".to_string(),
2412 ToolConfig::Detailed(ToolConfigDetailed {
2413 default_features: false,
2414 version: None,
2415 features: Some(vec!["frobnulator".to_string()]),
2416 registry: None,
2417 git: None,
2418 branch: None,
2419 tag: None,
2420 rev: None,
2421 path: None,
2422 }),
2423 );
2424
2425 let options = BuildOptions::load_for_crate(
2426 &config,
2427 &cli.crate_args().to_build_overrides(),
2428 &CrateSpec::CratesIo {
2429 name: "timestamp".to_string(),
2430 version: None,
2431 },
2432 )
2433 .unwrap();
2434
2435 assert_eq!(options.features, vec!["gonkolator"]);
2438 assert!(options.no_default_features);
2439 }
2440
2441 #[test]
2450 fn all_features_yields_same_options_as_unconfigured_tool() {
2451 let cli = Cli::parse_from_test_args(["--all-features", "timestamp"]);
2452 let mut configured = Config::default();
2453 configured.tools.insert(
2454 "timestamp".to_string(),
2455 ToolConfig::Detailed(ToolConfigDetailed {
2456 default_features: false,
2457 version: None,
2458 features: Some(vec!["frobnulator".to_string()]),
2459 registry: None,
2460 git: None,
2461 branch: None,
2462 tag: None,
2463 rev: None,
2464 path: None,
2465 }),
2466 );
2467 let spec = CrateSpec::CratesIo {
2468 name: "timestamp".to_string(),
2469 version: None,
2470 };
2471
2472 let overrides = cli.crate_args().to_build_overrides();
2473 let with_config = BuildOptions::load_for_crate(&configured, &overrides, &spec).unwrap();
2474 let without_config = BuildOptions::load_for_crate(&Config::default(), &overrides, &spec).unwrap();
2475
2476 assert_eq!(with_config, without_config);
2477 }
2478
2479 #[test]
2482 fn all_features_leaves_config_features_unapplied() {
2483 let cli = Cli::parse_from_test_args(["--all-features", "timestamp"]);
2484 let mut config = Config::default();
2485 config.tools.insert(
2486 "timestamp".to_string(),
2487 ToolConfig::Detailed(ToolConfigDetailed {
2488 default_features: true,
2489 version: None,
2490 features: Some(vec!["frobnulator".to_string()]),
2491 registry: None,
2492 git: None,
2493 branch: None,
2494 tag: None,
2495 rev: None,
2496 path: None,
2497 }),
2498 );
2499
2500 let options = BuildOptions::load_for_crate(
2501 &config,
2502 &cli.crate_args().to_build_overrides(),
2503 &CrateSpec::CratesIo {
2504 name: "timestamp".to_string(),
2505 version: None,
2506 },
2507 )
2508 .unwrap();
2509
2510 assert!(options.all_features);
2511 assert_eq!(options.features, Vec::<String>::new());
2512 }
2513
2514 #[test]
2517 fn all_features_leaves_config_default_features_unapplied() {
2518 let cli = Cli::parse_from_test_args(["--all-features", "timestamp"]);
2519 let mut config = Config::default();
2520 config.tools.insert(
2521 "timestamp".to_string(),
2522 ToolConfig::Detailed(ToolConfigDetailed {
2523 default_features: false,
2524 version: None,
2525 features: None,
2526 registry: None,
2527 git: None,
2528 branch: None,
2529 tag: None,
2530 rev: None,
2531 path: None,
2532 }),
2533 );
2534
2535 let options = BuildOptions::load_for_crate(
2536 &config,
2537 &cli.crate_args().to_build_overrides(),
2538 &CrateSpec::CratesIo {
2539 name: "timestamp".to_string(),
2540 version: None,
2541 },
2542 )
2543 .unwrap();
2544
2545 assert!(options.all_features);
2546 assert!(!options.no_default_features);
2547 }
2548 }
2549
2550 mod run_invocation {
2551 use super::*;
2552
2553 fn run(args: &[&str]) -> Cli {
2555 Cli::parse_from_test_args(args)
2556 }
2557
2558 fn tool_args(cli: &Cli) -> Vec<String> {
2560 cli.tool_args()
2561 .iter()
2562 .map(|arg| arg.to_string_lossy().into_owned())
2563 .collect()
2564 }
2565
2566 #[test]
2567 fn crate_then_tool_args_pass_through_without_dashdash() {
2568 let cli = run(&["ripgrep", "--color=always", "-i"]);
2569 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("ripgrep"));
2570 assert_eq!(tool_args(&cli), ["--color=always", "-i"]);
2571 }
2572
2573 #[test]
2574 fn cgx_flags_before_crate_are_not_tool_args() {
2575 let cli = run(&["--features", "foo", "ripgrep", "--color=always", "-i"]);
2576 let invocation = cli.crate_args();
2577 assert_eq!(invocation.crate_spec.as_deref(), Some("ripgrep"));
2578 assert_eq!(invocation.build_options.features.as_deref(), Some("foo"));
2579 assert_eq!(tool_args(&cli), ["--color=always", "-i"]);
2580 }
2581
2582 #[test]
2583 fn same_flag_before_and_after_crate_is_split_by_position() {
2584 let cli = run(&["-F", "x", "ripgrep", "-F", "y"]);
2586 assert_eq!(cli.crate_args().build_options.features.as_deref(), Some("x"));
2587 assert_eq!(tool_args(&cli), ["-F", "y"]);
2588 }
2589
2590 #[test]
2591 fn version_flag_after_crate_is_a_tool_arg() {
2592 let cli = run(&["ripgrep", "--version"]);
2595 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("ripgrep"));
2596 assert_eq!(tool_args(&cli), ["--version"]);
2597
2598 let cli = run(&["ripgrep", "-V"]);
2599 assert_eq!(tool_args(&cli), ["-V"]);
2600 }
2601
2602 #[test]
2603 fn explicit_dashdash_forwards_following_args() {
2604 let cli = run(&["ripgrep", "--", "--version"]);
2605 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("ripgrep"));
2606 assert_eq!(tool_args(&cli), ["--version"]);
2607 }
2608
2609 #[test]
2610 fn at_version_suffix_with_tool_version_flag() {
2611 let cli = run(&["eza@=0.23.1", "--version"]);
2612 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("eza@=0.23.1"));
2613 assert_eq!(tool_args(&cli), ["--version"]);
2614 }
2615
2616 #[test]
2617 fn cargo_subcommand_is_normalized_and_remaining_args_forwarded() {
2618 let cli = run(&["cargo", "deny", "--all"]);
2619 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2620 assert_eq!(tool_args(&cli), ["--all"]);
2621 }
2622
2623 #[test]
2626 fn cargo_separator_then_subcommand_is_normalized() {
2627 let cli = run(&["cargo", "--", "deny"]);
2628 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2629 assert_eq!(tool_args(&cli), Vec::<String>::new());
2630 }
2631
2632 #[test]
2635 fn cargo_followed_by_flag_keeps_flag_as_tool_arg() {
2636 let cli = run(&["cargo", "--help"]);
2637 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo"));
2638 assert_eq!(tool_args(&cli), ["--help"]);
2639 }
2640
2641 #[test]
2644 fn cargo_subcommand_separator_then_flags_forwards_flags() {
2645 let cli = run(&["cargo", "deny", "--", "--flag"]);
2646 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2647 assert_eq!(tool_args(&cli), ["--flag"]);
2648 }
2649
2650 #[test]
2651 fn crate_named_like_a_mode_flag_runs_that_crate() {
2652 let cli = run(&["prefetch"]);
2654 assert_matches!(&cli, Cli::Run { .. });
2655 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("prefetch"));
2656 }
2657 }
2658
2659 mod http_args {
2660 use super::*;
2661
2662 #[test]
2663 fn test_http_timeout_cli_arg() {
2664 let cli = Cli::parse_from_test_args(["--http-timeout", "2m", "test-crate"]);
2665 assert_eq!(cli.crate_args().http.http_timeout, Some("2m".to_string()));
2666 }
2667
2668 #[test]
2669 fn test_http_retries_cli_arg() {
2670 let cli = Cli::parse_from_test_args(["--http-retries", "5", "test-crate"]);
2671 assert_eq!(cli.crate_args().http.http_retries, Some(5));
2672 }
2673
2674 #[test]
2675 fn test_http_proxy_cli_arg() {
2676 let cli = Cli::parse_from_test_args(["--http-proxy", "socks5://localhost:1080", "test-crate"]);
2677 assert_eq!(
2678 cli.crate_args().http.http_proxy,
2679 Some("socks5://localhost:1080".to_string())
2680 );
2681 }
2682
2683 #[test]
2684 fn test_http_args_default_none() {
2685 let cli = Cli::parse_from_test_args(["test-crate"]);
2686 assert_eq!(cli.crate_args().http.http_timeout, None);
2687 assert_eq!(cli.crate_args().http.http_retries, None);
2688 assert_eq!(cli.crate_args().http.http_proxy, None);
2689 }
2690
2691 #[test]
2692 fn test_http_args_after_crate_spec_are_binary_args() {
2693 let cli = Cli::parse_from_test_args(["test-crate", "--http-timeout", "5s"]);
2694 assert_eq!(cli.crate_args().http.http_timeout, None);
2695 let tool_args: Vec<&str> = cli.tool_args().iter().map(|arg| arg.to_str().unwrap()).collect();
2696 assert_eq!(tool_args, ["--http-timeout", "5s"]);
2697 }
2698 }
2699
2700 mod strip_cargo_subcommand_arg {
2701 use super::*;
2702
2703 #[test]
2704 fn test_leaves_normal_invocation_unchanged() {
2705 let args = vec!["cgx", "ripgrep", "--help"];
2706 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2707 assert_eq!(result, args);
2708 }
2709
2710 #[test]
2711 fn test_leaves_cargo_without_cgx_unchanged() {
2712 let args = vec!["cargo-cgx", "ripgrep", "--help"];
2713 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2714 assert_eq!(result, args);
2715 }
2716
2717 #[test]
2718 fn test_empty_args() {
2719 let args: Vec<String> = vec![];
2720 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2721 assert_eq!(result, args);
2722 }
2723
2724 #[test]
2725 fn test_single_arg() {
2726 let args = vec!["cargo-cgx"];
2727 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2728 assert_eq!(result, args);
2729 }
2730 }
2731}