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 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 render(self) -> Result<Cli, clap::Error> {
808 let RawCli {
809 no_exec,
810 prefetch,
811 prefetch_all,
812 list_targets,
813 list_tools,
814 crate_version,
815 source,
816 build_options,
817 cargo,
818 prebuilt,
819 config,
820 http,
821 reporting,
822 invocation,
823 toolchain,
824 } = self;
825
826 let NormalizedInvocation {
827 crate_spec,
828 mut tool_args,
829 cargo_subcommand,
830 } = invocation.map(Invocation::normalize).unwrap_or_default();
831
832 if prefetch_all {
833 let mode = "--prefetch-all";
834 Self::ensure_config_mode_common(
835 mode,
836 &crate_spec,
837 &tool_args,
838 &source,
839 &prebuilt,
840 &crate_version,
841 )?;
842 if cargo.locked || cargo.frozen || cargo.unlocked {
843 return Err(Self::forbidden(mode, "--locked/--frozen/--unlocked"));
844 }
845 if build_options.has_compilation_options() {
846 return Err(Self::forbidden(mode, "build/compilation options"));
847 }
848 return Ok(Cli::PrefetchAll(PrefetchAll {
849 config,
850 http,
851 reporting,
852 offline: cargo.offline,
853 refresh: cargo.refresh,
854 jobs: build_options.jobs,
855 ignore_rust_version: build_options.ignore_rust_version,
856 toolchain,
857 }));
858 }
859
860 if list_tools {
861 let mode = "--list-tools";
862 Self::ensure_config_mode_common(
863 mode,
864 &crate_spec,
865 &tool_args,
866 &source,
867 &prebuilt,
868 &crate_version,
869 )?;
870
871 if cargo.locked || cargo.frozen || cargo.unlocked || cargo.offline || cargo.refresh {
876 return Err(Self::forbidden(mode, "cargo behavior flags"));
877 }
878 if build_options.is_present() {
879 return Err(Self::forbidden(mode, "build options"));
880 }
881 if http.is_present() {
882 return Err(Self::forbidden(mode, "--http-* options"));
883 }
884 if toolchain.is_some() {
885 return Err(Self::forbidden(mode, "+toolchain"));
886 }
887 return Ok(Cli::ListTools(ListTools { config, reporting }));
888 }
889
890 let git_selector = source.git_selector();
893 let source = source.to_source();
894
895 let mode = if prefetch {
896 "--prefetch"
897 } else if list_targets {
898 "--list-targets"
899 } else if no_exec {
900 "--no-exec"
901 } else {
902 "cgx"
903 };
904
905 if crate_spec.is_none() && !source.allows_crate_discovery() {
906 return Err(clap::Error::raw(
907 ErrorKind::MissingRequiredArgument,
908 format!("{mode} requires a crate name, or a discoverable source such as --git or --path\n"),
909 ));
910 }
911
912 let crate_args = CrateArgs {
913 crate_spec,
914 crate_version,
915 source,
916 git_selector,
917 build_options,
918 cargo,
919 prebuilt,
920 config,
921 http,
922 reporting,
923 toolchain,
924 };
925
926 if prefetch {
927 Self::ensure_no_crate_args(&tool_args, "--prefetch")?;
928 Ok(Cli::Prefetch(crate_args))
929 } else if list_targets {
930 Self::ensure_no_crate_args(&tool_args, "--list-targets")?;
931 Ok(Cli::ListTargets(crate_args))
932 } else if no_exec {
933 Self::ensure_no_crate_args(&tool_args, "--no-exec")?;
934 Ok(Cli::NoExec(crate_args))
935 } else {
936 if let Some(subcommand) = cargo_subcommand {
940 tool_args.insert(0, subcommand);
941 }
942 Ok(Cli::Run {
943 args: crate_args,
944 tool_args,
945 })
946 }
947 }
948
949 fn forbidden(mode: &str, what: &str) -> clap::Error {
951 clap::Error::raw(
952 ErrorKind::ArgumentConflict,
953 format!("{what} cannot be used with {mode}\n"),
954 )
955 }
956
957 fn ensure_config_mode_common(
962 mode: &str,
963 crate_spec: &Option<String>,
964 tool_args: &[OsString],
965 source: &SourceArgs,
966 prebuilt: &PrebuiltBinaryArgs,
967 crate_version: &Option<String>,
968 ) -> Result<(), clap::Error> {
969 Self::ensure_no_crate(crate_spec, tool_args, mode)?;
970 if source.is_present() {
971 return Err(Self::forbidden(mode, "source selectors"));
972 }
973 if prebuilt.is_present() {
974 return Err(Self::forbidden(mode, "--prebuilt-binary options"));
975 }
976 if crate_version.is_some() {
977 return Err(Self::forbidden(mode, "--crate-version"));
978 }
979 Ok(())
980 }
981
982 fn ensure_no_crate(
984 crate_spec: &Option<String>,
985 tool_args: &[OsString],
986 mode: &str,
987 ) -> Result<(), clap::Error> {
988 if crate_spec.is_some() || !tool_args.is_empty() {
989 Err(clap::Error::raw(
990 ErrorKind::UnknownArgument,
991 format!("{mode} does not accept a crate or trailing arguments\n"),
992 ))
993 } else {
994 Ok(())
995 }
996 }
997
998 fn ensure_no_crate_args(tool_args: &[OsString], mode: &str) -> Result<(), clap::Error> {
1000 if tool_args.is_empty() {
1001 Ok(())
1002 } else {
1003 Err(clap::Error::raw(
1004 ErrorKind::UnknownArgument,
1005 format!("{mode} cannot be used with trailing tool arguments\n"),
1006 ))
1007 }
1008 }
1009}
1010
1011#[derive(Clone, Debug, Subcommand)]
1023enum Invocation {
1024 #[command(external_subcommand)]
1025 Crate(Vec<OsString>),
1026}
1027
1028#[derive(Default)]
1030struct NormalizedInvocation {
1031 crate_spec: Option<String>,
1033
1034 tool_args: Vec<OsString>,
1037
1038 cargo_subcommand: Option<OsString>,
1044}
1045
1046impl Invocation {
1047 fn normalize(self) -> NormalizedInvocation {
1049 let Self::Crate(mut parts) = self;
1050 if parts.is_empty() {
1051 return NormalizedInvocation::default();
1052 }
1053
1054 let mut cargo_subcommand = None;
1055 let crate_spec = parts.remove(0).to_string_lossy().into_owned();
1056
1057 let (crate_spec, mut tool_args) = if crate_spec == "cargo" {
1060 if matches!(parts.first().and_then(|arg| arg.to_str()), Some("--")) {
1063 parts.remove(0);
1064 }
1065 if parts
1069 .first()
1070 .is_some_and(|arg| !arg.to_string_lossy().starts_with('-'))
1071 {
1072 let subcommand = parts.remove(0).to_string_lossy().into_owned();
1073 let name = subcommand
1074 .split_once('@')
1075 .map_or(subcommand.as_str(), |(name, _)| name);
1076 cargo_subcommand = Some(OsString::from(name));
1077 (format!("cargo-{subcommand}"), parts)
1078 } else {
1079 (crate_spec, parts)
1080 }
1081 } else {
1082 (crate_spec, parts)
1085 };
1086
1087 if matches!(tool_args.first().and_then(|arg| arg.to_str()), Some("--")) {
1091 tool_args.remove(0);
1092 }
1093
1094 NormalizedInvocation {
1095 crate_spec: Some(crate_spec),
1096 tool_args,
1097 cargo_subcommand,
1098 }
1099 }
1100}
1101
1102#[derive(Clone, Debug, Default, Args)]
1110struct SourceArgs {
1111 #[arg(long, group = "source")]
1113 git: Option<String>,
1114
1115 #[arg(long, group = "source")]
1117 registry: Option<String>,
1118
1119 #[arg(long, group = "source")]
1121 path: Option<PathBuf>,
1122
1123 #[arg(long, group = "source")]
1125 github: Option<String>,
1126
1127 #[arg(long, group = "source")]
1129 gitlab: Option<String>,
1130
1131 #[arg(long, group = "source", value_name = "INDEX")]
1133 index: Option<String>,
1134
1135 #[arg(long, requires = "github")]
1137 github_url: Option<String>,
1138
1139 #[arg(long, requires = "gitlab")]
1141 gitlab_url: Option<String>,
1142
1143 #[arg(long, group = "git_ref")]
1145 branch: Option<String>,
1146
1147 #[arg(long, group = "git_ref")]
1149 tag: Option<String>,
1150
1151 #[arg(long, group = "git_ref")]
1153 rev: Option<String>,
1154}
1155
1156impl SourceArgs {
1157 fn is_present(&self) -> bool {
1159 self.git.is_some()
1160 || self.registry.is_some()
1161 || self.path.is_some()
1162 || self.github.is_some()
1163 || self.gitlab.is_some()
1164 || self.index.is_some()
1165 || self.github_url.is_some()
1166 || self.gitlab_url.is_some()
1167 || self.branch.is_some()
1168 || self.tag.is_some()
1169 || self.rev.is_some()
1170 }
1171
1172 fn git_selector(&self) -> GitSelector {
1174 match (&self.branch, &self.tag, &self.rev) {
1175 (Some(branch), None, None) => GitSelector::Branch(branch.clone()),
1176 (None, Some(tag), None) => GitSelector::Tag(tag.clone()),
1177 (None, None, Some(rev)) => GitSelector::Commit(rev.clone()),
1178 (None, None, None) => GitSelector::DefaultBranch,
1179 _ => unreachable!("BUG: the `git_ref` ArgGroup enforces mutual exclusivity"),
1180 }
1181 }
1182
1183 fn to_source(&self) -> Source {
1185 if let Some(url) = &self.git {
1186 Source::Git { url: url.clone() }
1187 } else if let Some(name) = &self.registry {
1188 Source::Registry { name: name.clone() }
1189 } else if let Some(url) = &self.index {
1190 Source::Index { url: url.clone() }
1191 } else if let Some(path) = &self.path {
1192 Source::Path { path: path.clone() }
1193 } else if let Some(repo) = &self.github {
1194 Source::GitHub {
1195 repo: repo.clone(),
1196 custom_url: self.github_url.clone(),
1197 }
1198 } else if let Some(repo) = &self.gitlab {
1199 Source::GitLab {
1200 repo: repo.clone(),
1201 custom_url: self.gitlab_url.clone(),
1202 }
1203 } else {
1204 Source::Default
1205 }
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use assert_matches::assert_matches;
1212 use clap::CommandFactory;
1213
1214 use super::*;
1215 use crate::{
1216 Result,
1217 builder::{BuildOptions, BuildTarget},
1218 config::{Config, ToolConfig, ToolConfigDetailed},
1219 cratespec::{CrateSpec, Forge, RegistrySource},
1220 git::GitSelector,
1221 };
1222
1223 #[test]
1226 fn verify_cli() {
1227 RawCli::command().debug_assert();
1228 }
1229
1230 #[test]
1232 fn verbosity_reflects_repeated_v_flag() {
1233 let cases = [
1234 (vec!["tool"], Verbosity::Normal),
1235 (vec!["-v", "tool"], Verbosity::Verbose),
1236 (vec!["-vv", "tool"], Verbosity::VeryVerbose),
1237 (vec!["-vvv", "tool"], Verbosity::ExtremelyVerbose),
1238 (vec!["-vvvv", "tool"], Verbosity::ExtremelyVerbose),
1239 ];
1240 for (args, expected) in cases {
1241 let verbosity = Cli::parse_from_test_args(args).verbosity();
1242 assert_eq!(verbosity, expected);
1243 }
1244 }
1245
1246 mod cratespec {
1247 use super::*;
1248 fn parse_cratespec_from_args(args: &[&str]) -> Result<CrateSpec> {
1249 let cli = Cli::parse_from_test_args(args);
1250 let config = Config::default();
1251 let request = cli.crate_args().crate_request()?;
1252 CrateSpec::load(&config, &request)
1253 }
1254
1255 #[test]
1256 fn test_simple_crate() {
1257 let cr = parse_cratespec_from_args(&["ripgrep"]).unwrap();
1258 assert_matches!(
1259 cr,
1260 CrateSpec::CratesIo { ref name, version: None } if name == "ripgrep"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_crate_with_at_version() {
1266 let cr = parse_cratespec_from_args(&["ripgrep@14"]).unwrap();
1267 assert_matches!(
1268 cr,
1269 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1270 if name == "ripgrep" && v == &semver::VersionReq::parse("14").unwrap()
1271 );
1272 }
1273
1274 #[test]
1275 fn test_crate_with_flag_version() {
1276 let cr = parse_cratespec_from_args(&["--crate-version", "14", "ripgrep"]).unwrap();
1277 assert_matches!(
1278 cr,
1279 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1280 if name == "ripgrep" && v == &semver::VersionReq::parse("14").unwrap()
1281 );
1282 }
1283
1284 #[test]
1285 fn test_crate_with_conflicting_versions() {
1286 let result = parse_cratespec_from_args(&["--crate-version", "14", "ripgrep@14"]);
1290 assert_matches!(result, Err(crate::error::Error::ConflictingVersions { .. }));
1291
1292 let result = parse_cratespec_from_args(&["--crate-version", "15", "ripgrep@14"]);
1293 assert_matches!(result, Err(crate::error::Error::ConflictingVersions { .. }));
1294 }
1295
1296 #[test]
1297 fn test_at_version_without_crate_name() {
1298 let result = parse_cratespec_from_args(&["@1.0"]);
1299 assert_matches!(result, Err(crate::error::Error::MissingCrateName { .. }));
1300 }
1301
1302 #[test]
1303 fn test_empty_crate_spec() {
1304 let result = parse_cratespec_from_args(&[""]);
1305 assert_matches!(result, Err(crate::error::Error::MissingCrateName { .. }));
1306 }
1307
1308 #[test]
1309 fn test_cargo_subcommand() {
1310 let cr = parse_cratespec_from_args(&["cargo", "deny"]).unwrap();
1311 assert_matches!(
1312 cr,
1313 CrateSpec::CratesIo { ref name, version: None } if name == "cargo-deny"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_cargo_subcommand_with_version() {
1319 let cr = parse_cratespec_from_args(&["cargo", "deny@1"]).unwrap();
1320 assert_matches!(
1321 cr,
1322 CrateSpec::CratesIo { ref name, version: Some(ref v) }
1323 if name == "cargo-deny" && v == &semver::VersionReq::parse("1").unwrap()
1324 );
1325 }
1326
1327 #[test]
1328 fn test_cargo_without_subcommand() {
1329 let result = parse_cratespec_from_args(&["cargo"]);
1330 assert_matches!(result, Err(crate::error::Error::CargoNotRunnable));
1331 }
1332
1333 #[test]
1334 fn test_cargo_with_flag_argument() {
1335 let result = parse_cratespec_from_args(&["cargo", "--help"]);
1336 assert_matches!(result, Err(crate::error::Error::CargoNotRunnable));
1337 }
1338
1339 #[test]
1340 fn test_cargo_with_version() {
1341 let result = parse_cratespec_from_args(&["cargo@1.0"]);
1342 assert_matches!(result, Err(crate::error::Error::CargoNotRunnable));
1343 }
1344
1345 #[test]
1346 fn test_git_source() {
1347 let cr = parse_cratespec_from_args(&["--git", "https://github.com/foo/bar", "mycrate"]).unwrap();
1348 assert_matches!(
1349 cr,
1350 CrateSpec::Forge {
1351 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1352 selector: GitSelector::DefaultBranch,
1353 ref name,
1354 version: None
1355 } if owner == "foo" && repo == "bar" && name.as_deref() == Some("mycrate")
1356 );
1357 }
1358
1359 #[test]
1360 fn test_git_with_branch() {
1361 let cr = parse_cratespec_from_args(&[
1362 "--git",
1363 "https://github.com/foo/bar",
1364 "--branch",
1365 "main",
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::Branch(ref b),
1374 ref name,
1375 version: None
1376 } if owner == "foo" && repo == "bar" && b == "main" && name.as_deref() == Some("mycrate")
1377 );
1378 }
1379
1380 #[test]
1381 fn test_git_with_tag() {
1382 let cr = parse_cratespec_from_args(&[
1383 "--git",
1384 "https://github.com/foo/bar",
1385 "--tag",
1386 "v1.0",
1387 "mycrate",
1388 ])
1389 .unwrap();
1390 assert_matches!(
1391 cr,
1392 CrateSpec::Forge {
1393 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1394 selector: GitSelector::Tag(ref t),
1395 ref name,
1396 version: None
1397 } if owner == "foo" && repo == "bar" && t == "v1.0" && name.as_deref() == Some("mycrate")
1398 );
1399 }
1400
1401 #[test]
1402 fn test_git_with_rev() {
1403 let cr = parse_cratespec_from_args(&[
1404 "--git",
1405 "https://github.com/foo/bar",
1406 "--rev",
1407 "abc123",
1408 "mycrate",
1409 ])
1410 .unwrap();
1411 assert_matches!(
1412 cr,
1413 CrateSpec::Forge {
1414 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1415 selector: GitSelector::Commit(ref c),
1416 ref name,
1417 version: None
1418 } if owner == "foo" && repo == "bar" &&
1419 c == "abc123" &&
1420 name.as_deref() == Some("mycrate")
1421 );
1422 }
1423
1424 #[test]
1425 fn test_git_github_https_url() {
1426 let cr =
1427 parse_cratespec_from_args(&["--git", "https://github.com/owner/repo", "mycrate"]).unwrap();
1428 assert_matches!(
1429 cr,
1430 CrateSpec::Forge {
1431 forge: Forge::GitHub {
1432 custom_url: None,
1433 ref owner,
1434 ref repo
1435 },
1436 selector: GitSelector::DefaultBranch,
1437 ref name,
1438 version: None
1439 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1440 );
1441 }
1442
1443 #[test]
1444 fn test_git_github_https_url_with_git_suffix() {
1445 let cr = parse_cratespec_from_args(&["--git", "https://github.com/owner/repo.git", "mycrate"])
1446 .unwrap();
1447 assert_matches!(
1448 cr,
1449 CrateSpec::Forge {
1450 forge: Forge::GitHub {
1451 custom_url: None,
1452 ref owner,
1453 ref repo
1454 },
1455 selector: GitSelector::DefaultBranch,
1456 ref name,
1457 version: None
1458 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1459 );
1460 }
1461
1462 #[test]
1463 fn test_git_gitlab_https_url() {
1464 let cr =
1465 parse_cratespec_from_args(&["--git", "https://gitlab.com/owner/repo", "mycrate"]).unwrap();
1466 assert_matches!(
1467 cr,
1468 CrateSpec::Forge {
1469 forge: Forge::GitLab {
1470 custom_url: None,
1471 ref owner,
1472 ref repo
1473 },
1474 selector: GitSelector::DefaultBranch,
1475 ref name,
1476 version: None
1477 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1478 );
1479 }
1480
1481 #[test]
1482 fn test_git_scheme_not_transformed() {
1483 let cr = parse_cratespec_from_args(&["--git", "git://github.com/owner/repo", "mycrate"]).unwrap();
1484 assert_matches!(
1485 cr,
1486 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1487 if repo == "git://github.com/owner/repo" && name.as_deref() == Some("mycrate")
1488 );
1489 }
1490
1491 #[test]
1492 fn test_git_custom_domain_not_transformed() {
1493 let cr =
1494 parse_cratespec_from_args(&["--git", "https://github.enterprise.com/owner/repo", "mycrate"])
1495 .unwrap();
1496 assert_matches!(
1497 cr,
1498 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1499 if repo == "https://github.enterprise.com/owner/repo" && name.as_deref() == Some("mycrate")
1500 );
1501 }
1502
1503 #[test]
1504 fn test_git_github_url_with_extra_path_not_transformed() {
1505 let cr =
1506 parse_cratespec_from_args(&["--git", "https://github.com/owner/repo/pull/15", "mycrate"])
1507 .unwrap();
1508 assert_matches!(
1509 cr,
1510 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1511 if repo == "https://github.com/owner/repo/pull/15" && name.as_deref() == Some("mycrate")
1512 );
1513 }
1514
1515 #[test]
1516 fn test_git_github_url_with_tree_path_not_transformed() {
1517 let cr = parse_cratespec_from_args(&[
1518 "--git",
1519 "https://github.com/owner/repo/tree/master/some/path",
1520 "mycrate",
1521 ])
1522 .unwrap();
1523 assert_matches!(
1524 cr,
1525 CrateSpec::Git { ref repo, selector: GitSelector::DefaultBranch, ref name, version: None }
1526 if repo == "https://github.com/owner/repo/tree/master/some/path" &&
1527 name.as_deref() == Some("mycrate")
1528 );
1529 }
1530
1531 #[test]
1532 fn test_registry() {
1533 let cr = parse_cratespec_from_args(&["--registry", "my-registry", "mycrate"]).unwrap();
1534 assert_matches!(
1535 cr,
1536 CrateSpec::Registry {
1537 source: RegistrySource::Named(ref registry),
1538 ref name,
1539 version: None
1540 } if registry == "my-registry" && name == "mycrate"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_index() {
1546 let cr =
1547 parse_cratespec_from_args(&["--index", "https://my-index.com/git/index", "mycrate"]).unwrap();
1548 assert_matches!(
1549 cr,
1550 CrateSpec::Registry {
1551 source: RegistrySource::IndexUrl(ref index),
1552 ref name,
1553 version: None
1554 } if index.as_str() == "https://my-index.com/git/index" && name == "mycrate"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_index_with_version() {
1560 let cr = parse_cratespec_from_args(&["--index", "sparse+https://my-index.com/", "mycrate@1.0"])
1561 .unwrap();
1562 assert_matches!(
1563 cr,
1564 CrateSpec::Registry {
1565 source: RegistrySource::IndexUrl(ref index),
1566 ref name,
1567 version: Some(ref v)
1568 } if index.as_str() == "sparse+https://my-index.com/" &&
1569 name == "mycrate" &&
1570 v == &semver::VersionReq::parse("1.0").unwrap()
1571 );
1572 }
1573
1574 #[test]
1575 fn test_local_path() {
1576 let cr = parse_cratespec_from_args(&["--path", "./my-crate", "mycrate"]).unwrap();
1577 assert_matches!(
1578 cr,
1579 CrateSpec::LocalDir { ref path, ref name, version: None }
1580 if path.to_str().unwrap() == "./my-crate" && name.as_deref() == Some("mycrate")
1581 );
1582 }
1583
1584 #[test]
1585 fn test_github() {
1586 let cr = parse_cratespec_from_args(&["--github", "owner/repo", "mycrate"]).unwrap();
1587 assert_matches!(
1588 cr,
1589 CrateSpec::Forge {
1590 forge: Forge::GitHub {
1591 custom_url: None,
1592 ref owner,
1593 ref repo
1594 },
1595 selector: GitSelector::DefaultBranch,
1596 ref name,
1597 version: None
1598 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1599 );
1600 }
1601
1602 #[test]
1603 fn test_github_with_custom_url() {
1604 let cr = parse_cratespec_from_args(&[
1605 "--github",
1606 "owner/repo",
1607 "--github-url",
1608 "https://github.mycorp.com",
1609 "mycrate",
1610 ])
1611 .unwrap();
1612 assert_matches!(
1613 cr,
1614 CrateSpec::Forge {
1615 forge: Forge::GitHub {
1616 custom_url: Some(ref url),
1617 ref owner,
1618 ref repo
1619 },
1620 selector: GitSelector::DefaultBranch,
1621 ref name,
1622 version: None
1623 } if owner == "owner" &&
1624 repo == "repo" &&
1625 name.as_deref() == Some("mycrate") &&
1626 url.as_str() == "https://github.mycorp.com/"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_github_with_branch() {
1632 let cr = parse_cratespec_from_args(&["--github", "owner/repo", "--branch", "develop", "mycrate"])
1633 .unwrap();
1634 assert_matches!(
1635 cr,
1636 CrateSpec::Forge {
1637 forge: Forge::GitHub {
1638 custom_url: None,
1639 ref owner,
1640 ref repo
1641 },
1642 selector: GitSelector::Branch(ref b),
1643 ref name,
1644 version: None
1645 } if owner == "owner" &&
1646 repo == "repo" &&
1647 b == "develop" &&
1648 name.as_deref() == Some("mycrate")
1649 );
1650 }
1651
1652 #[test]
1653 fn test_gitlab() {
1654 let cr = parse_cratespec_from_args(&["--gitlab", "owner/repo", "mycrate"]).unwrap();
1655 assert_matches!(
1656 cr,
1657 CrateSpec::Forge {
1658 forge: Forge::GitLab {
1659 custom_url: None,
1660 ref owner,
1661 ref repo
1662 },
1663 selector: GitSelector::DefaultBranch,
1664 ref name,
1665 version: None
1666 } if owner == "owner" && repo == "repo" && name.as_deref() == Some("mycrate")
1667 );
1668 }
1669
1670 #[test]
1671 fn test_gitlab_with_custom_url() {
1672 let cr = parse_cratespec_from_args(&[
1673 "--gitlab",
1674 "owner/repo",
1675 "--gitlab-url",
1676 "https://gitlab.mycorp.com",
1677 "mycrate",
1678 ])
1679 .unwrap();
1680 assert_matches!(
1681 cr,
1682 CrateSpec::Forge {
1683 forge: Forge::GitLab {
1684 custom_url: Some(ref url),
1685 ref owner,
1686 ref repo
1687 },
1688 selector: GitSelector::DefaultBranch,
1689 ref name,
1690 version: None
1691 } if owner == "owner" &&
1692 repo == "repo" &&
1693 name.as_deref() == Some("mycrate") &&
1694 url.as_str() == "https://gitlab.mycorp.com/"
1695 );
1696 }
1697
1698 #[test]
1699 fn test_git_selector_without_git_source() {
1700 let result = parse_cratespec_from_args(&["--branch", "main", "mycrate"]);
1701 assert_matches!(result, Err(crate::error::Error::GitSelectorWithoutGitSource));
1702 }
1703
1704 #[test]
1705 fn test_invalid_repo_format() {
1706 let result = parse_cratespec_from_args(&["--github", "invalid-repo", "mycrate"]);
1707 assert_matches!(result, Err(crate::error::Error::InvalidRepoFormat { .. }));
1708 }
1709
1710 #[test]
1711 fn test_invalid_version() {
1712 let result = parse_cratespec_from_args(&["ripgrep@not-a-version"]);
1713 assert_matches!(result, Err(crate::error::Error::InvalidVersionReq { .. }));
1714 }
1715
1716 #[test]
1717 fn test_invalid_index_url() {
1718 let result = parse_cratespec_from_args(&["--index", "not-a-valid-url", "mycrate"]);
1719 assert_matches!(result, Err(crate::error::Error::InvalidUrl { .. }));
1720 }
1721
1722 #[test]
1723 fn test_git_without_crate_name() {
1724 let cr = parse_cratespec_from_args(&["--git", "https://github.com/foo/bar"]).unwrap();
1725 assert_matches!(
1726 cr,
1727 CrateSpec::Forge {
1728 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1729 selector: GitSelector::DefaultBranch,
1730 name: None,
1731 version: None
1732 } if owner == "foo" && repo == "bar"
1733 );
1734 }
1735
1736 #[test]
1737 fn test_github_without_crate_name() {
1738 let cr = parse_cratespec_from_args(&["--github", "owner/repo"]).unwrap();
1739 assert_matches!(
1740 cr,
1741 CrateSpec::Forge {
1742 forge: Forge::GitHub { custom_url: None, ref owner, ref repo },
1743 selector: GitSelector::DefaultBranch,
1744 name: None,
1745 version: None
1746 } if owner == "owner" && repo == "repo"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_gitlab_without_crate_name() {
1752 let cr = parse_cratespec_from_args(&["--gitlab", "owner/repo"]).unwrap();
1753 assert_matches!(
1754 cr,
1755 CrateSpec::Forge {
1756 forge: Forge::GitLab { custom_url: None, ref owner, ref repo },
1757 selector: GitSelector::DefaultBranch,
1758 name: None,
1759 version: None
1760 } if owner == "owner" && repo == "repo"
1761 );
1762 }
1763
1764 #[test]
1765 fn test_path_without_crate_name() {
1766 let cr = parse_cratespec_from_args(&["--path", "./my-crate"]).unwrap();
1767 assert_matches!(
1768 cr,
1769 CrateSpec::LocalDir { ref path, name: None, version: None }
1770 if path.to_str().unwrap() == "./my-crate"
1771 );
1772 }
1773 }
1774
1775 mod build_options {
1776 use super::*;
1777
1778 fn parse_build_options_from_args(args: &[&str]) -> Result<BuildOptions> {
1779 let cli = Cli::parse_from_test_args(args);
1780 let config = Config::default();
1781 BuildOptions::load(&config, &cli.crate_args().to_build_overrides())
1782 }
1783
1784 #[test]
1785 fn test_features_parsing_comma_separated() {
1786 let opts = parse_build_options_from_args(&["--features", "foo,bar,baz", "ripgrep"]).unwrap();
1787 assert_eq!(opts.features, vec!["foo", "bar", "baz"]);
1788 }
1789
1790 #[test]
1791 fn test_features_parsing_space_separated() {
1792 let opts = parse_build_options_from_args(&["--features", "foo bar baz", "ripgrep"]).unwrap();
1793 assert_eq!(opts.features, vec!["foo", "bar", "baz"]);
1794 }
1795
1796 #[test]
1797 fn test_features_parsing_mixed_separators() {
1798 let opts = parse_build_options_from_args(&["--features", "foo, bar baz", "ripgrep"]).unwrap();
1799 assert_eq!(opts.features, vec!["foo", "bar", "baz"]);
1800 }
1801
1802 #[test]
1803 fn test_features_parsing_with_extra_whitespace() {
1804 let opts = parse_build_options_from_args(&["--features", " foo , bar ", "ripgrep"]).unwrap();
1805 assert_eq!(opts.features, vec!["foo", "bar"]);
1806 }
1807
1808 #[test]
1811 fn empty_features_value_is_distinct_from_absent_flag() {
1812 for empty in ["", ","] {
1813 let cli = Cli::parse_from_test_args(["--features", empty, "ripgrep"]);
1814 assert_eq!(cli.crate_args().to_build_overrides().features, Some(vec![]));
1815 }
1816
1817 let cli = Cli::parse_from_test_args(["ripgrep"]);
1818 assert_eq!(cli.crate_args().to_build_overrides().features, None);
1819 }
1820
1821 #[test]
1822 fn test_all_features() {
1823 let opts = parse_build_options_from_args(&["--all-features", "ripgrep"]).unwrap();
1824 assert!(opts.all_features);
1825 }
1826
1827 #[test]
1828 fn test_no_default_features() {
1829 let opts = parse_build_options_from_args(&["--no-default-features", "ripgrep"]).unwrap();
1830 assert!(opts.no_default_features);
1831 }
1832
1833 #[test]
1834 fn test_debug_maps_to_dev_profile() {
1835 let opts = parse_build_options_from_args(&["--debug", "ripgrep"]).unwrap();
1836 assert_eq!(opts.profile, Some("dev".to_string()));
1837 }
1838
1839 #[test]
1840 fn test_profile_custom() {
1841 let opts =
1842 parse_build_options_from_args(&["--profile", "release-with-debug", "ripgrep"]).unwrap();
1843 assert_eq!(opts.profile, Some("release-with-debug".to_string()));
1844 }
1845
1846 #[test]
1847 fn test_config_locked_and_offline_both_true() {
1848 let cli = Cli::parse_from_test_args(["ripgrep"]);
1852 let config = Config {
1853 locked: true,
1854 offline: true,
1855 ..Default::default()
1856 };
1857 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1858 assert!(opts.locked);
1859 assert!(opts.offline);
1860 }
1861
1862 #[test]
1863 fn test_config_locked_without_offline() {
1864 let cli = Cli::parse_from_test_args(["ripgrep"]);
1865 let config = Config {
1866 locked: true,
1867 offline: false,
1868 ..Default::default()
1869 };
1870 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1871 assert!(opts.locked);
1872 assert!(!opts.offline);
1873 }
1874
1875 #[test]
1876 fn test_config_offline_without_locked() {
1877 let cli = Cli::parse_from_test_args(["ripgrep"]);
1878 let config = Config {
1879 locked: false,
1880 offline: true,
1881 ..Default::default()
1882 };
1883 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1884 assert!(!opts.locked);
1885 assert!(opts.offline);
1886 }
1887
1888 #[test]
1889 fn test_target() {
1890 let opts =
1891 parse_build_options_from_args(&["--target", "x86_64-unknown-linux-musl", "ripgrep"]).unwrap();
1892 assert_eq!(
1893 opts.target.as_ref().map(|target| target.as_str()),
1894 Some("x86_64-unknown-linux-musl")
1895 );
1896 }
1897
1898 #[test]
1899 fn test_jobs() {
1900 let opts = parse_build_options_from_args(&["-j", "4", "ripgrep"]).unwrap();
1901 assert_eq!(opts.jobs, Some(4));
1902 }
1903
1904 #[test]
1905 fn test_ignore_rust_version() {
1906 let opts = parse_build_options_from_args(&["--ignore-rust-version", "ripgrep"]).unwrap();
1907 assert!(opts.ignore_rust_version);
1908 }
1909
1910 #[test]
1911 fn test_build_options_defaults() {
1912 let opts = parse_build_options_from_args(&["ripgrep"]).unwrap();
1913 assert_eq!(opts, Default::default());
1914 }
1915
1916 #[test]
1917 fn test_bin_flag() {
1918 let opts = parse_build_options_from_args(&["--bin", "mybinary", "ripgrep"]).unwrap();
1919 assert_eq!(opts.build_target, BuildTarget::Bin("mybinary".to_string()));
1920 assert_eq!(opts.build_target, BuildTarget::Bin("mybinary".to_string()));
1921 }
1922
1923 #[test]
1924 fn test_example_flag() {
1925 let opts = parse_build_options_from_args(&["--example", "myexample", "ripgrep"]).unwrap();
1926 assert_eq!(opts.build_target, BuildTarget::Example("myexample".to_string()));
1927 }
1928 }
1929
1930 mod toolchain_tests {
1931 use super::*;
1932
1933 #[test]
1934 fn test_extract_toolchain_nightly() {
1935 let args = vec!["cgx", "+nightly", "ripgrep"];
1936 let (toolchain, filtered) = Cli::extract_toolchain(args);
1937
1938 assert_eq!(toolchain, Some("nightly".to_string()));
1939 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1940 }
1941
1942 #[test]
1943 fn test_extract_toolchain_specific_version() {
1944 let args = vec!["cgx", "+1.70.0", "ripgrep"];
1945 let (toolchain, filtered) = Cli::extract_toolchain(args);
1946
1947 assert_eq!(toolchain.as_deref(), Some("1.70.0"));
1948 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1949 }
1950
1951 #[test]
1952 fn test_extract_toolchain_stable() {
1953 let args = vec!["cgx", "+stable", "ripgrep"];
1954 let (toolchain, filtered) = Cli::extract_toolchain(args);
1955
1956 assert_eq!(toolchain, Some("stable".to_string()));
1957 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1958 }
1959
1960 #[test]
1961 fn test_extract_toolchain_with_other_flags() {
1962 let args = vec![
1963 "cgx",
1964 "+nightly",
1965 "--git",
1966 "https://github.com/foo/bar",
1967 "mycrate",
1968 ];
1969 let (toolchain, filtered) = Cli::extract_toolchain(args);
1970
1971 assert_eq!(toolchain, Some("nightly".to_string()));
1972 assert_eq!(
1973 filtered,
1974 vec!["cgx", "--git", "https://github.com/foo/bar", "mycrate"]
1975 );
1976 }
1977
1978 #[test]
1979 fn test_no_toolchain() {
1980 let args = vec!["cgx", "ripgrep"];
1981 let (toolchain, filtered) = Cli::extract_toolchain(args);
1982
1983 assert_eq!(toolchain, None);
1984 assert_eq!(filtered, vec!["cgx", "ripgrep"]);
1985 }
1986
1987 #[test]
1988 fn test_bare_plus() {
1989 let args = vec!["cgx", "+", "ripgrep"];
1990 let (toolchain, filtered) = Cli::extract_toolchain(args);
1991
1992 assert_eq!(toolchain, None);
1993 assert_eq!(filtered, vec!["cgx", "+", "ripgrep"]);
1994 }
1995
1996 #[test]
1997 fn test_plus_in_middle_not_toolchain() {
1998 let args = vec!["cgx", "ripgrep", "+something"];
1999 let (toolchain, filtered) = Cli::extract_toolchain(args);
2000
2001 assert_eq!(toolchain, None);
2002 assert_eq!(filtered, vec!["cgx", "ripgrep", "+something"]);
2003 }
2004
2005 #[test]
2006 fn test_toolchain_with_version_flag() {
2007 let args = vec!["+nightly", "ripgrep", "--version", "14"];
2010 let cli = Cli::parse_from_test_args(args);
2011
2012 let invocation = cli.crate_args();
2013 assert_eq!(invocation.toolchain.as_deref(), Some("nightly"));
2014 assert_eq!(invocation.crate_spec.as_deref(), Some("ripgrep"));
2015
2016 let tool_args: Vec<&str> = cli.tool_args().iter().map(|arg| arg.to_str().unwrap()).collect();
2017 assert_eq!(tool_args, ["--version", "14"]);
2018 }
2019
2020 #[test]
2021 fn test_toolchain_propagates_from_config_to_build_options() {
2022 let args = vec!["ripgrep"];
2025 let cli = Cli::parse_from_test_args(args);
2026
2027 let config = Config {
2028 toolchain: Some("nightly".to_string()),
2029 ..Default::default()
2030 };
2031 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
2032 assert_eq!(opts.toolchain, Some("nightly".to_string()));
2033 }
2034
2035 #[test]
2036 fn test_no_toolchain_in_build_options() {
2037 let args = vec!["ripgrep"];
2038 let cli = Cli::parse_from_test_args(args);
2039
2040 let config = Config::default();
2041 let opts = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
2042 assert_eq!(opts.toolchain, None);
2043 }
2044 }
2045
2046 mod config_overrides {
2047 use super::*;
2048
2049 #[test]
2050 fn test_config_overrides_can_combine() {
2051 let inputs = Cli::parse_from_test_args([
2053 "--system-config-dir",
2054 "/tmp/system",
2055 "--app-dir",
2056 "/tmp/app",
2057 "--user-config-dir",
2058 "/tmp/user",
2059 "ripgrep",
2060 ])
2061 .to_config_overrides();
2062 assert_eq!(inputs.system_config_dir, Some(PathBuf::from("/tmp/system")));
2063 assert_eq!(inputs.app_dir, Some(PathBuf::from("/tmp/app")));
2064 assert_eq!(inputs.user_config_dir, Some(PathBuf::from("/tmp/user")));
2065 }
2066 }
2067
2068 mod mode_validation {
2069 use super::*;
2070
2071 #[test]
2072 fn prefetch_accepts_single_tool_build_options() {
2073 let cli = Cli::try_parse_from_test_args([
2074 "--prefetch",
2075 "--features",
2076 "frobnulator",
2077 "--bin",
2078 "timestamp",
2079 "timestamp",
2080 ])
2081 .unwrap();
2082
2083 assert_matches!(&cli, Cli::Prefetch(_));
2084 let invocation = cli.crate_args();
2085 assert_eq!(invocation.crate_spec.as_deref(), Some("timestamp"));
2086 assert_eq!(invocation.build_options.features.as_deref(), Some("frobnulator"));
2087 assert!(cli.tool_args().is_empty());
2088 }
2089
2090 #[test]
2091 fn prefetch_rejects_trailing_args() {
2092 let result = Cli::try_parse_from_test_args(["--prefetch", "timestamp", "--help"]);
2093 assert_matches!(result, Err(_));
2094 }
2095
2096 #[test]
2097 fn no_exec_rejects_trailing_args() {
2098 for args in [
2099 vec!["--no-exec", "timestamp", "--help"],
2100 vec!["--no-exec", "timestamp", "--", "--help"],
2101 ] {
2102 let result = Cli::try_parse_from_test_args(args);
2103 assert_matches!(result, Err(_));
2104 }
2105 }
2106
2107 #[test]
2108 fn prefetch_rejects_no_exec() {
2109 let result = Cli::try_parse_from_test_args(["--prefetch", "--no-exec", "timestamp"]);
2110 assert_matches!(result, Err(_));
2111 }
2112
2113 #[test]
2114 fn prefetch_all_accepts_allowed_controls() {
2115 let cli = Cli::try_parse_from_test_args([
2116 "--prefetch-all",
2117 "--offline",
2118 "--refresh",
2119 "--jobs",
2120 "2",
2121 "--ignore-rust-version",
2122 "--http-timeout",
2123 "5s",
2124 "--system-config-dir",
2125 "/tmp/system",
2126 "--app-dir",
2127 "/tmp/app",
2128 "--user-config-dir",
2129 "/tmp/user",
2130 "--message-format",
2131 "json",
2132 ])
2133 .unwrap();
2134
2135 let Cli::PrefetchAll(prefetch_all) = cli else {
2136 panic!("expected a prefetch-all command");
2137 };
2138 assert!(prefetch_all.offline);
2139 assert!(prefetch_all.refresh);
2140 assert_eq!(prefetch_all.jobs, Some(2));
2141 assert!(prefetch_all.ignore_rust_version);
2142 assert_eq!(prefetch_all.http.http_timeout.as_deref(), Some("5s"));
2143 assert_eq!(
2144 prefetch_all.config.system_config_dir,
2145 Some(PathBuf::from("/tmp/system"))
2146 );
2147 assert_eq!(prefetch_all.config.app_dir, Some(PathBuf::from("/tmp/app")));
2148 assert_eq!(
2149 prefetch_all.config.user_config_dir,
2150 Some(PathBuf::from("/tmp/user"))
2151 );
2152 }
2153
2154 #[test]
2155 fn prefetch_all_controls_reach_loaders() {
2156 let cli = Cli::parse_from_test_args([
2159 "--prefetch-all",
2160 "--offline",
2161 "--refresh",
2162 "--jobs",
2163 "2",
2164 "--ignore-rust-version",
2165 ]);
2166 let Cli::PrefetchAll(prefetch_all) = &cli else {
2167 panic!("expected a prefetch-all command");
2168 };
2169
2170 let build_options =
2172 BuildOptions::load(&Config::default(), &prefetch_all.to_build_overrides()).unwrap();
2173 assert_eq!(build_options.jobs, Some(2));
2174 assert!(build_options.ignore_rust_version);
2175
2176 let temp = tempfile::tempdir().unwrap();
2179 let mut overrides = prefetch_all.to_config_overrides();
2180 overrides.system_config_dir = Some(temp.path().join("system"));
2181 overrides.user_config_dir = Some(temp.path().join("user"));
2182 let config = Config::load_from_dir(temp.path(), &overrides).unwrap();
2183 assert!(config.offline);
2184 assert!(config.refresh);
2185 }
2186
2187 #[test]
2188 fn prefetch_all_verbosity_reaches_config_overrides() {
2189 let cli = Cli::parse_from_test_args(["--prefetch-all", "-vv"]);
2190 let Cli::PrefetchAll(prefetch_all) = &cli else {
2191 panic!("expected a prefetch-all command");
2192 };
2193 assert_eq!(
2194 prefetch_all.to_config_overrides().verbosity,
2195 Verbosity::VeryVerbose
2196 );
2197 }
2198
2199 #[test]
2200 fn prefetch_all_rejects_crate_source_build_and_trailing_args() {
2201 for args in [
2202 vec!["--prefetch-all", "timestamp"],
2203 vec!["--prefetch-all", "--path", "."],
2204 vec!["--prefetch-all", "--features", "frobnulator"],
2205 vec!["--prefetch-all", "--all-features"],
2206 vec!["--prefetch-all", "--locked"],
2207 vec!["--prefetch-all", "--prebuilt-binary", "never"],
2208 vec!["--prefetch-all", "--", "--help"],
2209 ] {
2210 let result = Cli::try_parse_from_test_args(args);
2211 assert_matches!(result, Err(_));
2212 }
2213 }
2214
2215 #[test]
2216 fn list_tools_accepts_config_discovery_and_message_format() {
2217 let cli = Cli::try_parse_from_test_args([
2218 "--list-tools",
2219 "--config-file",
2220 "cgx.toml",
2221 "--message-format",
2222 "json",
2223 ])
2224 .unwrap();
2225
2226 let Cli::ListTools(list_tools) = cli else {
2227 panic!("expected a list-tools command");
2228 };
2229 assert_eq!(list_tools.config.config_file, Some(PathBuf::from("cgx.toml")));
2230 assert_matches!(list_tools.reporting.message_format, Some(MessageFormat::Json));
2231
2232 let cli = Cli::try_parse_from_test_args([
2233 "--list-tools",
2234 "--system-config-dir",
2235 "/tmp/system",
2236 "--app-dir",
2237 "/tmp/app",
2238 "--user-config-dir",
2239 "/tmp/user",
2240 "--message-format",
2241 "json",
2242 ])
2243 .unwrap();
2244
2245 let Cli::ListTools(list_tools) = cli else {
2246 panic!("expected a list-tools command");
2247 };
2248 assert_eq!(
2249 list_tools.config.system_config_dir,
2250 Some(PathBuf::from("/tmp/system"))
2251 );
2252 assert_eq!(list_tools.config.app_dir, Some(PathBuf::from("/tmp/app")));
2253 assert_eq!(
2254 list_tools.config.user_config_dir,
2255 Some(PathBuf::from("/tmp/user"))
2256 );
2257 assert_matches!(list_tools.reporting.message_format, Some(MessageFormat::Json));
2258 }
2259
2260 #[test]
2261 fn list_tools_rejects_operational_flags() {
2262 for args in [
2263 vec!["--list-tools", "timestamp"],
2264 vec!["--list-tools", "--offline"],
2265 vec!["--list-tools", "--features", "frobnulator"],
2266 vec!["--list-tools", "--prefetch"],
2267 ] {
2268 let result = Cli::try_parse_from_test_args(args);
2269 assert_matches!(result, Err(_));
2270 }
2271 }
2272
2273 #[test]
2277 fn config_features_apply_when_cli_features_absent() {
2278 let cli = Cli::parse_from_test_args(["timestamp"]);
2279 let mut config = Config::default();
2280 config.tools.insert(
2281 "timestamp".to_string(),
2282 ToolConfig::Detailed(ToolConfigDetailed {
2283 default_features: true,
2284 version: None,
2285 features: Some(vec!["frobnulator".to_string()]),
2286 registry: None,
2287 git: None,
2288 branch: None,
2289 tag: None,
2290 rev: None,
2291 path: None,
2292 }),
2293 );
2294
2295 let options = BuildOptions::load_for_crate(
2296 &config,
2297 &cli.crate_args().to_build_overrides(),
2298 &CrateSpec::CratesIo {
2299 name: "timestamp".to_string(),
2300 version: None,
2301 },
2302 )
2303 .unwrap();
2304
2305 assert_eq!(options.features, vec!["frobnulator"]);
2306 }
2307
2308 #[test]
2311 fn cli_features_replace_config_features() {
2312 let cli = Cli::parse_from_test_args(["--features", "gonkolator", "timestamp"]);
2313 let mut config = Config::default();
2314 config.tools.insert(
2315 "timestamp".to_string(),
2316 ToolConfig::Detailed(ToolConfigDetailed {
2317 default_features: true,
2318 version: None,
2319 features: Some(vec!["frobnulator".to_string()]),
2320 registry: None,
2321 git: None,
2322 branch: None,
2323 tag: None,
2324 rev: None,
2325 path: None,
2326 }),
2327 );
2328
2329 let options = BuildOptions::load_for_crate(
2330 &config,
2331 &cli.crate_args().to_build_overrides(),
2332 &CrateSpec::CratesIo {
2333 name: "timestamp".to_string(),
2334 version: None,
2335 },
2336 )
2337 .unwrap();
2338
2339 assert_eq!(options.features, vec!["gonkolator"]);
2340 }
2341
2342 #[test]
2345 fn empty_cli_features_override_config_features() {
2346 let cli = Cli::parse_from_test_args(["--features", "", "timestamp"]);
2347 let mut config = Config::default();
2348 config.tools.insert(
2349 "timestamp".to_string(),
2350 ToolConfig::Detailed(ToolConfigDetailed {
2351 default_features: true,
2352 version: None,
2353 features: Some(vec!["frobnulator".to_string()]),
2354 registry: None,
2355 git: None,
2356 branch: None,
2357 tag: None,
2358 rev: None,
2359 path: None,
2360 }),
2361 );
2362
2363 let options = BuildOptions::load_for_crate(
2364 &config,
2365 &cli.crate_args().to_build_overrides(),
2366 &CrateSpec::CratesIo {
2367 name: "timestamp".to_string(),
2368 version: None,
2369 },
2370 )
2371 .unwrap();
2372
2373 assert_eq!(options.features, Vec::<String>::new());
2374 assert!(!options.all_features);
2375 }
2376
2377 #[test]
2380 fn config_default_features_false_disables_defaults() {
2381 let cli = Cli::parse_from_test_args(["timestamp"]);
2382 let mut config = Config::default();
2383 config.tools.insert(
2384 "timestamp".to_string(),
2385 ToolConfig::Detailed(ToolConfigDetailed {
2386 default_features: false,
2387 version: None,
2388 features: None,
2389 registry: None,
2390 git: None,
2391 branch: None,
2392 tag: None,
2393 rev: None,
2394 path: None,
2395 }),
2396 );
2397
2398 let options = BuildOptions::load_for_crate(
2399 &config,
2400 &cli.crate_args().to_build_overrides(),
2401 &CrateSpec::CratesIo {
2402 name: "timestamp".to_string(),
2403 version: None,
2404 },
2405 )
2406 .unwrap();
2407
2408 assert!(options.no_default_features);
2409 }
2410
2411 #[test]
2414 fn config_default_features_true_keeps_defaults() {
2415 let cli = Cli::parse_from_test_args(["timestamp"]);
2416 let mut config = Config::default();
2417 config.tools.insert(
2418 "timestamp".to_string(),
2419 ToolConfig::Detailed(ToolConfigDetailed {
2420 default_features: true,
2421 version: None,
2422 features: None,
2423 registry: None,
2424 git: None,
2425 branch: None,
2426 tag: None,
2427 rev: None,
2428 path: None,
2429 }),
2430 );
2431
2432 let options = BuildOptions::load_for_crate(
2433 &config,
2434 &cli.crate_args().to_build_overrides(),
2435 &CrateSpec::CratesIo {
2436 name: "timestamp".to_string(),
2437 version: None,
2438 },
2439 )
2440 .unwrap();
2441
2442 assert!(!options.no_default_features);
2443 }
2444
2445 #[test]
2449 fn config_default_features_independent_of_cli_features() {
2450 let cli = Cli::parse_from_test_args(["--features", "gonkolator", "timestamp"]);
2451 let mut config = Config::default();
2452 config.tools.insert(
2453 "timestamp".to_string(),
2454 ToolConfig::Detailed(ToolConfigDetailed {
2455 default_features: false,
2456 version: None,
2457 features: Some(vec!["frobnulator".to_string()]),
2458 registry: None,
2459 git: None,
2460 branch: None,
2461 tag: None,
2462 rev: None,
2463 path: None,
2464 }),
2465 );
2466
2467 let options = BuildOptions::load_for_crate(
2468 &config,
2469 &cli.crate_args().to_build_overrides(),
2470 &CrateSpec::CratesIo {
2471 name: "timestamp".to_string(),
2472 version: None,
2473 },
2474 )
2475 .unwrap();
2476
2477 assert_eq!(options.features, vec!["gonkolator"]);
2480 assert!(options.no_default_features);
2481 }
2482
2483 #[test]
2492 fn all_features_yields_same_options_as_unconfigured_tool() {
2493 let cli = Cli::parse_from_test_args(["--all-features", "timestamp"]);
2494 let mut configured = Config::default();
2495 configured.tools.insert(
2496 "timestamp".to_string(),
2497 ToolConfig::Detailed(ToolConfigDetailed {
2498 default_features: false,
2499 version: None,
2500 features: Some(vec!["frobnulator".to_string()]),
2501 registry: None,
2502 git: None,
2503 branch: None,
2504 tag: None,
2505 rev: None,
2506 path: None,
2507 }),
2508 );
2509 let spec = CrateSpec::CratesIo {
2510 name: "timestamp".to_string(),
2511 version: None,
2512 };
2513
2514 let overrides = cli.crate_args().to_build_overrides();
2515 let with_config = BuildOptions::load_for_crate(&configured, &overrides, &spec).unwrap();
2516 let without_config = BuildOptions::load_for_crate(&Config::default(), &overrides, &spec).unwrap();
2517
2518 assert_eq!(with_config, without_config);
2519 }
2520
2521 #[test]
2524 fn all_features_leaves_config_features_unapplied() {
2525 let cli = Cli::parse_from_test_args(["--all-features", "timestamp"]);
2526 let mut config = Config::default();
2527 config.tools.insert(
2528 "timestamp".to_string(),
2529 ToolConfig::Detailed(ToolConfigDetailed {
2530 default_features: true,
2531 version: None,
2532 features: Some(vec!["frobnulator".to_string()]),
2533 registry: None,
2534 git: None,
2535 branch: None,
2536 tag: None,
2537 rev: None,
2538 path: None,
2539 }),
2540 );
2541
2542 let options = BuildOptions::load_for_crate(
2543 &config,
2544 &cli.crate_args().to_build_overrides(),
2545 &CrateSpec::CratesIo {
2546 name: "timestamp".to_string(),
2547 version: None,
2548 },
2549 )
2550 .unwrap();
2551
2552 assert!(options.all_features);
2553 assert_eq!(options.features, Vec::<String>::new());
2554 }
2555
2556 #[test]
2559 fn all_features_leaves_config_default_features_unapplied() {
2560 let cli = Cli::parse_from_test_args(["--all-features", "timestamp"]);
2561 let mut config = Config::default();
2562 config.tools.insert(
2563 "timestamp".to_string(),
2564 ToolConfig::Detailed(ToolConfigDetailed {
2565 default_features: false,
2566 version: None,
2567 features: None,
2568 registry: None,
2569 git: None,
2570 branch: None,
2571 tag: None,
2572 rev: None,
2573 path: None,
2574 }),
2575 );
2576
2577 let options = BuildOptions::load_for_crate(
2578 &config,
2579 &cli.crate_args().to_build_overrides(),
2580 &CrateSpec::CratesIo {
2581 name: "timestamp".to_string(),
2582 version: None,
2583 },
2584 )
2585 .unwrap();
2586
2587 assert!(options.all_features);
2588 assert!(!options.no_default_features);
2589 }
2590 }
2591
2592 mod run_invocation {
2593 use super::*;
2594
2595 fn run(args: &[&str]) -> Cli {
2597 Cli::parse_from_test_args(args)
2598 }
2599
2600 fn tool_args(cli: &Cli) -> Vec<String> {
2602 cli.tool_args()
2603 .iter()
2604 .map(|arg| arg.to_string_lossy().into_owned())
2605 .collect()
2606 }
2607
2608 #[test]
2609 fn crate_then_tool_args_pass_through_without_dashdash() {
2610 let cli = run(&["ripgrep", "--color=always", "-i"]);
2611 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("ripgrep"));
2612 assert_eq!(tool_args(&cli), ["--color=always", "-i"]);
2613 }
2614
2615 #[test]
2616 fn cgx_flags_before_crate_are_not_tool_args() {
2617 let cli = run(&["--features", "foo", "ripgrep", "--color=always", "-i"]);
2618 let invocation = cli.crate_args();
2619 assert_eq!(invocation.crate_spec.as_deref(), Some("ripgrep"));
2620 assert_eq!(invocation.build_options.features.as_deref(), Some("foo"));
2621 assert_eq!(tool_args(&cli), ["--color=always", "-i"]);
2622 }
2623
2624 #[test]
2625 fn same_flag_before_and_after_crate_is_split_by_position() {
2626 let cli = run(&["-F", "x", "ripgrep", "-F", "y"]);
2628 assert_eq!(cli.crate_args().build_options.features.as_deref(), Some("x"));
2629 assert_eq!(tool_args(&cli), ["-F", "y"]);
2630 }
2631
2632 #[test]
2633 fn version_flag_after_crate_is_a_tool_arg() {
2634 let cli = run(&["ripgrep", "--version"]);
2637 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("ripgrep"));
2638 assert_eq!(tool_args(&cli), ["--version"]);
2639
2640 let cli = run(&["ripgrep", "-V"]);
2641 assert_eq!(tool_args(&cli), ["-V"]);
2642 }
2643
2644 #[test]
2645 fn explicit_dashdash_forwards_following_args() {
2646 let cli = run(&["ripgrep", "--", "--version"]);
2647 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("ripgrep"));
2648 assert_eq!(tool_args(&cli), ["--version"]);
2649 }
2650
2651 #[test]
2652 fn at_version_suffix_with_tool_version_flag() {
2653 let cli = run(&["eza@=0.23.1", "--version"]);
2654 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("eza@=0.23.1"));
2655 assert_eq!(tool_args(&cli), ["--version"]);
2656 }
2657
2658 #[test]
2659 fn cargo_subcommand_is_normalized_and_remaining_args_forwarded() {
2660 let cli = run(&["cargo", "deny", "--all"]);
2661 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2662 assert_eq!(tool_args(&cli), ["deny", "--all"]);
2663 }
2664
2665 #[test]
2666 fn cargo_subcommand_without_extra_args_still_forwards_subcommand() {
2667 let cli = run(&["cargo", "deny"]);
2668 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2669 assert_eq!(tool_args(&cli), ["deny"]);
2670 }
2671
2672 #[test]
2673 fn cargo_version_suffix_selects_crate_but_forwards_bare_subcommand() {
2674 let cli = run(&["cargo", "deny@=1.2.3", "--version"]);
2675 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny@=1.2.3"));
2676 let request = cli.crate_args().crate_request().unwrap();
2677 assert_eq!(request.name.as_deref(), Some("cargo-deny"));
2678 assert_eq!(request.version.as_deref(), Some("=1.2.3"));
2679 assert_eq!(tool_args(&cli), ["deny", "--version"]);
2680 }
2681
2682 #[test]
2683 fn cargo_crate_version_flag_does_not_change_forwarded_subcommand() {
2684 let cli = run(&["--crate-version", "1.2.3", "cargo", "deny"]);
2685 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2686 assert_eq!(cli.crate_args().crate_version.as_deref(), Some("1.2.3"));
2687 assert_eq!(tool_args(&cli), ["deny"]);
2688 }
2689
2690 #[test]
2691 fn cargo_subcommand_forwards_cgx_flags_and_argument_boundaries() {
2692 let cli = run(&[
2693 "--features",
2694 "build-feature",
2695 "cargo",
2696 "deny",
2697 "check",
2698 "--features",
2699 "plugin-feature",
2700 "--offline",
2701 "--version",
2702 "two words",
2703 "",
2704 ]);
2705 assert_eq!(
2706 cli.crate_args().build_options.features.as_deref(),
2707 Some("build-feature")
2708 );
2709 assert!(!cli.crate_args().cargo.offline);
2710 assert_eq!(
2711 tool_args(&cli),
2712 [
2713 "deny",
2714 "check",
2715 "--features",
2716 "plugin-feature",
2717 "--offline",
2718 "--version",
2719 "two words",
2720 ""
2721 ]
2722 );
2723 }
2724
2725 #[test]
2728 fn cargo_separator_then_subcommand_is_normalized() {
2729 let cli = run(&["cargo", "--", "deny"]);
2730 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2731 assert_eq!(tool_args(&cli), ["deny"]);
2732 }
2733
2734 #[test]
2737 fn cargo_followed_by_flag_keeps_flag_as_tool_arg() {
2738 let cli = run(&["cargo", "--help"]);
2739 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo"));
2740 assert_eq!(tool_args(&cli), ["--help"]);
2741 }
2742
2743 #[test]
2746 fn cargo_subcommand_separator_then_flags_forwards_flags() {
2747 let cli = run(&["cargo", "deny", "--", "--flag"]);
2748 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2749 assert_eq!(tool_args(&cli), ["deny", "--flag"]);
2750 }
2751
2752 #[test]
2753 fn cargo_separators_are_consumed_but_later_separator_is_forwarded() {
2754 let cli = run(&[
2755 "cargo",
2756 "--",
2757 "deny",
2758 "--",
2759 "--features",
2760 "plugin-feature",
2761 "--",
2762 "literal",
2763 ]);
2764 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("cargo-deny"));
2765 assert_eq!(
2766 tool_args(&cli),
2767 ["deny", "--features", "plugin-feature", "--", "literal"]
2768 );
2769 }
2770
2771 #[test]
2772 fn direct_cargo_plugin_crate_does_not_inject_subcommand() {
2773 for (args, expected) in [
2774 (vec!["cargo-deny"], vec![]),
2775 (vec!["cargo-deny", "--features", "x"], vec!["--features", "x"]),
2776 (vec!["cargo-deny@1.2.3", "--", "--version"], vec!["--version"]),
2777 ] {
2778 let cli = run(&args);
2779 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some(args[0]));
2780 assert_eq!(tool_args(&cli), expected, "args: {args:?}");
2781 }
2782 }
2783
2784 #[test]
2785 fn cargo_plugin_non_run_modes_accept_subcommand_without_tool_args() {
2786 for mode in ["--no-exec", "--prefetch", "--list-targets"] {
2787 for (invocation, expected_spec) in [
2788 (vec!["cargo", "deny"], "cargo-deny"),
2789 (vec!["cargo", "deny@1.2.3"], "cargo-deny@1.2.3"),
2790 (vec!["--crate-version", "1.2.3", "cargo", "deny"], "cargo-deny"),
2791 ] {
2792 let mut args = vec![mode];
2793 args.extend(invocation);
2794 let cli = Cli::try_parse_from_test_args(&args).unwrap();
2795 match mode {
2796 "--no-exec" => assert_matches!(&cli, Cli::NoExec(_)),
2797 "--prefetch" => assert_matches!(&cli, Cli::Prefetch(_)),
2798 "--list-targets" => assert_matches!(&cli, Cli::ListTargets(_)),
2799 _ => unreachable!("the mode table contains only non-run modes"),
2800 }
2801 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some(expected_spec));
2802 assert_eq!(tool_args(&cli), Vec::<String>::new());
2803 }
2804 }
2805 }
2806
2807 #[test]
2808 fn cargo_plugin_non_run_modes_reject_user_tool_args() {
2809 for mode in ["--no-exec", "--prefetch", "--list-targets"] {
2810 for subcommand in ["deny", "deny@1.2.3"] {
2811 let result = Cli::try_parse_from_test_args([mode, "cargo", subcommand, "--help"]);
2812 let err = result.unwrap_err();
2813 assert_eq!(
2814 err.kind(),
2815 ErrorKind::UnknownArgument,
2816 "{mode} {subcommand}: {err}"
2817 );
2818 }
2819 }
2820 }
2821
2822 #[test]
2823 fn crate_named_like_a_mode_flag_runs_that_crate() {
2824 let cli = run(&["prefetch"]);
2826 assert_matches!(&cli, Cli::Run { .. });
2827 assert_eq!(cli.crate_args().crate_spec.as_deref(), Some("prefetch"));
2828 }
2829 }
2830
2831 mod http_args {
2832 use super::*;
2833
2834 #[test]
2835 fn test_http_timeout_cli_arg() {
2836 let cli = Cli::parse_from_test_args(["--http-timeout", "2m", "test-crate"]);
2837 assert_eq!(cli.crate_args().http.http_timeout, Some("2m".to_string()));
2838 }
2839
2840 #[test]
2841 fn test_http_retries_cli_arg() {
2842 let cli = Cli::parse_from_test_args(["--http-retries", "5", "test-crate"]);
2843 assert_eq!(cli.crate_args().http.http_retries, Some(5));
2844 }
2845
2846 #[test]
2847 fn test_http_proxy_cli_arg() {
2848 let cli = Cli::parse_from_test_args(["--http-proxy", "socks5://localhost:1080", "test-crate"]);
2849 assert_eq!(
2850 cli.crate_args().http.http_proxy,
2851 Some("socks5://localhost:1080".to_string())
2852 );
2853 }
2854
2855 #[test]
2856 fn test_http_args_default_none() {
2857 let cli = Cli::parse_from_test_args(["test-crate"]);
2858 assert_eq!(cli.crate_args().http.http_timeout, None);
2859 assert_eq!(cli.crate_args().http.http_retries, None);
2860 assert_eq!(cli.crate_args().http.http_proxy, None);
2861 }
2862
2863 #[test]
2864 fn test_http_args_after_crate_spec_are_binary_args() {
2865 let cli = Cli::parse_from_test_args(["test-crate", "--http-timeout", "5s"]);
2866 assert_eq!(cli.crate_args().http.http_timeout, None);
2867 let tool_args: Vec<&str> = cli.tool_args().iter().map(|arg| arg.to_str().unwrap()).collect();
2868 assert_eq!(tool_args, ["--http-timeout", "5s"]);
2869 }
2870 }
2871
2872 mod strip_cargo_subcommand_arg {
2873 use super::*;
2874
2875 #[test]
2876 fn test_leaves_normal_invocation_unchanged() {
2877 let args = vec!["cgx", "ripgrep", "--help"];
2878 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2879 assert_eq!(result, args);
2880 }
2881
2882 #[test]
2883 fn test_leaves_cargo_without_cgx_unchanged() {
2884 let args = vec!["cargo-cgx", "ripgrep", "--help"];
2885 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2886 assert_eq!(result, args);
2887 }
2888
2889 #[test]
2890 fn test_empty_args() {
2891 let args: Vec<String> = vec![];
2892 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2893 assert_eq!(result, args);
2894 }
2895
2896 #[test]
2897 fn test_single_arg() {
2898 let args = vec!["cargo-cgx"];
2899 let result = Cli::strip_cargo_subcommand_arg(args.clone());
2900 assert_eq!(result, args);
2901 }
2902 }
2903}