Skip to main content

cgx_core/
cli.rs

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/// A fully validated command, produced by parsing the CLI args with
17/// [`Self::parse_from_cli_args`]
18#[derive(Clone, Debug)]
19pub enum Cli {
20    /// Resolve a crate and list its runnable targets without building or executing.
21    ListTargets(CrateArgs),
22    /// Render the merged configured tools and aliases without resolving them.
23    ListTools(ListTools),
24    /// Prepare a crate without executing it or printing its path.
25    Prefetch(CrateArgs),
26    /// Prefetch every tool and alias configured in the `cgx` configuration.
27    PrefetchAll(PrefetchAll),
28    /// Prepare a crate without executing it; print the resolved binary path to stdout.
29    NoExec(CrateArgs),
30    /// Prepare a crate and execute it, forwarding `tool_args` to the executed binary.
31    Run {
32        args: CrateArgs,
33        tool_args: Vec<OsString>,
34    },
35}
36
37impl Cli {
38    /// Parse the process command line into a validated [`Cli`] command.
39    ///
40    /// `version` is the string shown by `-V`/`--version`; clap prints it to stdout and exits. This
41    /// uses clap, which will exit the process on `--help`, `--version`, or invalid arguments.
42    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    /// Translate the configuration-affecting arguments this command supplies into a
49    /// [`ConfigOverrides`], leaving as default any options that the command doesn't accept.
50    ///
51    /// Not all commands even take all config options, but by translating each into a single
52    /// [`ConfigOverrides`] representation of the sum total of CLI-based config settings it makes it
53    /// easier to load and render the config in a single shared code path.
54    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    /// The structured-message format requested for this command, if any.
65    pub fn message_format(&self) -> Option<MessageFormat> {
66        self.reporting().message_format
67    }
68
69    /// The requested verbosity level for this command.
70    pub fn verbosity(&self) -> Verbosity {
71        Verbosity::from_count(self.reporting().verbose)
72    }
73
74    /// The reporting controls carried by this command's variant.
75    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    /// Shared parse pipeline: extract a leading `+toolchain`, invoke clap (with an optional version
86    /// string for the `--version` action), and validate into a [`Cli`] via [`RawCli::render`].
87    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    /// Strip the cargo subcommand argument when invoked as `cargo-cgx`.
102    ///
103    /// When cgx is invoked as a cargo subcommand (via the `cargo-cgx` binary),
104    /// cargo invokes it with argv like: `["cargo-cgx", "cgx", ...user_args]`.
105    /// This function detects that pattern and removes the redundant "cgx" argument.
106    ///
107    /// This pre-processing happens before all other argument parsing to ensure
108    /// that subsequent parsing logic sees the same argument structure regardless
109    /// of whether the user invoked `cgx` or `cargo cgx`.
110    ///
111    /// The function checks if the binary name (argv\[0\] or `std::env::current_exe()`)
112    /// contains "cargo-cgx". If so, and if argv\[1\] equals "cgx", then argv\[1\] is removed.
113    ///
114    /// # Examples
115    ///
116    /// ```text
117    /// Input:  ["cargo-cgx", "cgx", "ripgrep", "--help"]
118    /// Output: ["cargo-cgx", "ripgrep", "--help"]
119    ///
120    /// Input:  ["cgx", "ripgrep", "--help"]
121    /// Output: ["cgx", "ripgrep", "--help"]
122    ///
123    /// Input:  ["/usr/bin/cargo-cgx", "cgx", "+nightly", "just"]
124    /// Output: ["/usr/bin/cargo-cgx", "+nightly", "just"]
125    /// ```
126    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    /// Extract `+toolchain` syntax from the first positional argument.
152    ///
153    /// This method performs pre-processing to extract cargo/rustup-style toolchain overrides
154    /// before clap parses the arguments. This is necessary because:
155    ///
156    /// 1. The `+toolchain` syntax must appear as the first argument (after the binary name)
157    /// 2. It uses a `+` prefix which conflicts with clap's normal argument parsing
158    /// 3. It's a modifier that applies globally, not a flag or positional argument
159    /// 4. This matches how rustup handles toolchain selection for cargo
160    ///
161    /// clap has no native support for this pattern, so we extract it manually and then
162    /// pass the filtered arguments to clap for normal parsing.
163    ///
164    /// # Arguments
165    ///
166    /// * `args` - The raw command line arguments including the binary name at position 0
167    ///
168    /// # Returns
169    ///
170    /// A tuple of `(Option<String>, Vec<String>)` where:
171    /// - The first element is `Some(toolchain)` if `+toolchain` was found, `None` otherwise
172    /// - The second element is the filtered argument list with `+toolchain` removed
173    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    /// Parse an arbitrary argument iterator for tests, panicking on parse or validation errors.
197    #[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    /// Try to parse an arbitrary argument iterator for tests, running the same preprocessing and
207    /// [`RawCli::render`] validation as the real entry point.
208    #[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        // Prepend the executable name, as clap expects, so callers don't have to.
215        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    /// Borrow the [`CrateArgs`] of a crate-level command, panicking on commands that don't
221    /// take crate args.
222    ///
223    /// Test helper for asserting on parsed crate arguments.
224    #[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    /// The trailing tool arguments of a [`Cli::Run`] command (empty for any other). Test helper.
235    #[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/// The parsed command-line arguments for preparing or running a single crate.
245///
246/// Not all possible commands involve running a single crate so not all commands will include
247/// these crate args.
248#[derive(Clone, Debug)]
249pub struct CrateArgs {
250    /// The crate spec (name, optionally with an `@VERSION` suffix), or `None` when the name is
251    /// discovered from the source (e.g. `--git`/`--path`).
252    pub crate_spec: Option<String>,
253    /// Version requirement from `--crate-version`.
254    pub crate_version: Option<String>,
255    /// Where to obtain the crate.
256    pub source: Source,
257    /// Which git ref to use, for git-backed sources.
258    pub git_selector: GitSelector,
259    /// Build options passed through to cargo.
260    pub build_options: BuildOptionsArgs,
261    /// Cargo behavior flags (lockfile / offline / refresh).
262    pub cargo: CargoBehaviorArgs,
263    /// Pre-built binary lookup options.
264    pub prebuilt: PrebuiltBinaryArgs,
265    /// Config file discovery options.
266    pub config: ConfigArgs,
267    /// HTTP tuning options.
268    pub http: HttpArgs,
269    /// Output and diagnostic controls.
270    pub reporting: ReportingArgs,
271    /// Toolchain from a leading `+toolchain` token.
272    pub toolchain: Option<String>,
273}
274
275impl CrateArgs {
276    /// Translate these arguments into a [`CrateRequest`] for [`crate::cratespec::CrateSpec::load`].
277    ///
278    /// This also performs some validation checking for conflicting args that `clap` is not
279    /// expressive enough to handle on its own, therefore it's fallible.
280    pub fn crate_request(&self) -> crate::Result<CrateRequest> {
281        // Split the CLI-only `name@version` convention into name and version-suffix string.
282        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        // `cgx cargo <subcommand>` is normalized to the `cargo-<subcommand>` plugin crate during
297        // invocation splitting, so a crate spec still named `cargo` means the user is trying to
298        // run cargo itself, which cgx cannot do.
299        if name.as_deref() == Some("cargo") {
300            return crate::error::CargoNotRunnableSnafu.fail();
301        }
302
303        // Users can either specify a semver version req with the `name@version` syntax in the
304        // crate name, or they can specify the version with `--crate-version`, but they cannot
305        // provide both. (of course they can also not specify any version at all to default to the
306        // latest suitable version)
307        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    /// Translate a subset of these arguments into [`BuildOverrides`] for
328    /// [`crate::builder::BuildOptions::load`].
329    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    /// Translate a subset of these arguments into [`ConfigOverrides`]
349    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    /// Tokenize a `--features` string into individual feature names.
367    ///
368    /// Features may be separated by commas or whitespace; empty tokens are dropped, so an empty
369    /// input yields an empty vector (distinct from `--features` being absent, which the caller
370    /// represents as `None`).
371    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/// Arguments for `--prefetch-all`
381#[derive(Clone, Debug, Default)]
382pub struct PrefetchAll {
383    /// Config file discovery overrides.
384    pub config: ConfigArgs,
385    /// HTTP tuning options.
386    pub http: HttpArgs,
387    /// Output and diagnostic controls.
388    pub reporting: ReportingArgs,
389    /// Run without accessing the network.
390    pub offline: bool,
391    /// Force refresh of all cached data.
392    pub refresh: bool,
393    /// Number of parallel jobs.
394    pub jobs: Option<usize>,
395    /// Ignore `rust-version` specifications in packages.
396    pub ignore_rust_version: bool,
397    /// Toolchain from a leading `+toolchain` token.
398    pub toolchain: Option<String>,
399}
400
401impl PrefetchAll {
402    /// The build overrides applied to every tool prefetched by `--prefetch-all`.
403    ///
404    /// `--prefetch-all` doesn't take per-crate compilation options, so only the generic build knobs
405    /// it does accept are set; the rest take the defaults or can be set in the config file.
406    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    /// The config overrides applied when loading config for the `--prefetch-all` run.
416    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/// Arguments for `--list-tools`
430#[derive(Clone, Debug, Default)]
431pub struct ListTools {
432    /// Config file discovery overrides.
433    pub config: ConfigArgs,
434    /// Output and diagnostic controls.
435    pub reporting: ReportingArgs,
436}
437
438impl ListTools {
439    /// The config overrides applied when loading config for the `--list-tools` run.
440    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/// CLI arguments that are crate-specific and passed through to cargo build.
449///
450/// These args are segreated from the other CLI args to make the semantic distinction more
451/// explicit.  Most CLI args can also be set in config files to apply globally, but these are
452/// always crate-specific.
453#[derive(Clone, Debug, Default, Args)]
454pub struct BuildOptionsArgs {
455    /// Space or comma separated list of features to activate
456    #[arg(short = 'F', long, value_name = "FEATURES")]
457    pub features: Option<String>,
458
459    /// Activate all available features
460    #[arg(long)]
461    pub all_features: bool,
462
463    /// Do not activate the default features
464    #[arg(long)]
465    pub no_default_features: bool,
466
467    /// Build with the specified profile
468    #[arg(long, value_name = "PROFILE-NAME", conflicts_with = "debug")]
469    pub profile: Option<String>,
470
471    /// Build in debug mode (with the 'dev' profile) instead of release mode
472    #[arg(long)]
473    pub debug: bool,
474
475    /// Build for the target triple
476    #[arg(long, value_name = "TRIPLE")]
477    pub target: Option<String>,
478
479    /// Number of parallel jobs, defaults to # of CPUs
480    #[arg(short = 'j', long, value_name = "N")]
481    pub jobs: Option<usize>,
482
483    /// Ignore `rust-version` specification in packages
484    #[arg(long)]
485    pub ignore_rust_version: bool,
486
487    /// Install only the specified binary
488    #[arg(long, value_name = "NAME", conflicts_with = "example")]
489    pub bin: Option<String>,
490
491    /// Install only the specified example
492    #[arg(long, value_name = "NAME")]
493    pub example: Option<String>,
494}
495
496impl BuildOptionsArgs {
497    /// True if any compilation-affecting option is set, such that a pre-built binary is not
498    /// suitable for this request.
499    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    /// True if any build option at all is set.
511    fn is_present(&self) -> bool {
512        self.has_compilation_options() || self.jobs.is_some() || self.ignore_rust_version
513    }
514
515    /// Resolve the mutually-exclusive `--bin`/`--example` flags into a [`BuildTarget`].
516    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/// Lockfile and cache behavior flags that affect dependency resolution and cache identity for one
528/// tool.
529///
530/// The mutually-exclusive `--locked`/`--frozen`/`--unlocked` flags share a `lockfile`
531/// arg-group
532#[derive(Clone, Debug, Default, Args)]
533pub struct CargoBehaviorArgs {
534    /// Honor Cargo.lock from the crate, equivalent to passing `--locked` to `cargo install`
535    #[arg(long, group = "lockfile")]
536    pub locked: bool,
537
538    /// Equivalent to specifying both --locked and --offline
539    #[arg(long, group = "lockfile")]
540    pub frozen: bool,
541
542    /// Ignore Cargo.lock and resolve dependencies fresh
543    #[arg(long, group = "lockfile")]
544    pub unlocked: bool,
545
546    /// Run without accessing the network
547    #[arg(long)]
548    pub offline: bool,
549
550    /// Force refresh of all cached data for this crate.
551    #[arg(long)]
552    pub refresh: bool,
553}
554
555impl CargoBehaviorArgs {
556    /// Collapse the mutually-exclusive `--locked`/`--frozen`/`--unlocked` flags into a
557    /// [`LockMode`].
558    ///
559    /// The `lockfile` clap arg-group guarantees at most one is set.
560    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
573/// Creates a clap value parser that uses strum's [`VariantNames`] for possible values
574/// and strum's [`FromStr`](std::str::FromStr) for parsing. This ensures:
575/// - `--help` shows valid values (from `VARIANTS`)
576/// - Parsing uses the same logic as config files (strum's [`EnumString`](strum::EnumString))
577macro_rules! strum_value_parser {
578    ($t:ty) => {
579        clap::builder::PossibleValuesParser::new(<$t>::VARIANTS).map(|s| s.parse::<$t>().unwrap())
580    };
581}
582
583/// CLI config overrides for prebuilt binaries
584#[derive(Clone, Debug, Default, Args)]
585pub struct PrebuiltBinaryArgs {
586    /// Control use of pre-built binaries: never, always, or auto.
587    #[arg(long, value_name = "WHEN", value_parser = strum_value_parser!(UsePrebuiltBinaries))]
588    pub prebuilt_binary: Option<UsePrebuiltBinaries>,
589
590    /// Override the binary providers to check for pre-built binaries.
591    #[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    /// Disable checksum verification when downloading pre-built binaries.
600    #[arg(long)]
601    pub prebuilt_binary_no_verify_checksums: bool,
602
603    /// Disable signature verification when downloading pre-built binaries.
604    #[arg(long)]
605    pub prebuilt_binary_no_verify_signatures: bool,
606}
607
608impl PrebuiltBinaryArgs {
609    /// True if any prebuilt-binary override was given on the command line.
610    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/// Options that control which configuration files are loaded before executing a command.
619///
620/// Every command that respects config files accepts these args.
621#[derive(Clone, Debug, Default, Args)]
622pub struct ConfigArgs {
623    /// Read configuration options from the given TOML file only, bypassing the usual config search
624    /// paths.
625    #[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    /// Override the system config directory location.
633    #[arg(long, value_name = "PATH", env = "CGX_SYSTEM_CONFIG_DIR")]
634    pub system_config_dir: Option<PathBuf>,
635
636    /// Override the base application directory.
637    #[arg(long, value_name = "PATH", env = "CGX_APP_DIR")]
638    pub app_dir: Option<PathBuf>,
639
640    /// Override the user config directory location.
641    #[arg(long, value_name = "PATH", env = "CGX_USER_CONFIG_DIR")]
642    pub user_config_dir: Option<PathBuf>,
643}
644
645impl ConfigArgs {
646    /// Create a new [`ConfigOverrides`] consisting of default values except those that are
647    /// overridden by options specified in this struct.
648    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/// Network tuning options used by commands that may resolve, download, or build crates.
660#[derive(Clone, Debug, Default, Args)]
661pub struct HttpArgs {
662    /// HTTP request timeout (e.g., "30s", "2m").
663    #[arg(long, value_name = "DURATION", env = "CGX_HTTP_TIMEOUT")]
664    pub http_timeout: Option<String>,
665
666    /// Maximum number of retries for transient HTTP failures (0 = no retries).
667    #[arg(long, value_name = "N", env = "CGX_HTTP_RETRIES")]
668    pub http_retries: Option<usize>,
669
670    /// HTTP or SOCKS5 proxy URL for all HTTP requests.
671    #[arg(long, value_name = "URL", env = "CGX_HTTP_PROXY")]
672    pub http_proxy: Option<String>,
673}
674
675impl HttpArgs {
676    /// True if any HTTP tuning option was given on the command line.
677    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/// Output and diagnostic controls for commands that may produce operational messages.
683#[derive(Clone, Debug, Default, Args)]
684pub struct ReportingArgs {
685    /// Use verbose output (-vv very verbose/build.rs output)
686    #[arg(short = 'v', long, action = ArgAction::Count)]
687    pub verbose: u8,
688
689    /// Do not print cargo log messages
690    #[arg(short = 'q', long)]
691    pub quiet: bool,
692
693    /// Coloring: auto, always, never
694    #[arg(long, value_name = "WHEN")]
695    pub color: Option<String>,
696
697    /// Output structured messages in the specified format.
698    #[arg(long, value_name = "FMT")]
699    pub message_format: Option<MessageFormat>,
700}
701
702/// Output format for structured messages.
703#[derive(Clone, Copy, Debug, ValueEnum)]
704pub enum MessageFormat {
705    /// JSON format, one message per line
706    Json,
707}
708
709// Raw clap parse result.
710//
711// Private on purpose: the public surface is [`Cli`], produced by [`Self::resolve`], which enforces
712// the per-mode rules clap cannot express structurally and collapses the flat flags into typed
713// command variants. The mutually-exclusive mode flags share a `mode` ArgGroup, so clap rejects two
714// modes natively without a `conflicts_with` explosion.
715//
716// This is how we can treat things like `--prefetch` and `--list-tools` as if they were clap
717// subcommands, and still properly handle `cgx foo` for literally any `foo` as an invocation of
718// crate `foo`.
719//
720// This uses a regular comment, not a doc comment, so the explanation does not leak into `--help`
721// output as the command's long description.
722#[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    /// Build the binary but do not execute it; print its path to stdout instead.
733    #[arg(long, group = "mode")]
734    no_exec: bool,
735
736    /// Prepare the crate binary and exit without printing or executing it.
737    #[arg(long, group = "mode")]
738    prefetch: bool,
739
740    /// Prefetch all tools configured in the `cgx` configuration.
741    #[arg(long, group = "mode")]
742    prefetch_all: bool,
743
744    /// List the crate's executable targets (bins and examples) without building or executing.
745    #[arg(long, group = "mode")]
746    list_targets: bool,
747
748    /// List all configured tools and aliases in the `cgx` configuration.
749    #[arg(long, group = "mode")]
750    list_tools: bool,
751
752    /// Version requirement of the crate to run (alternative to the `@VERSION` suffix).
753    ///
754    /// Must appear before the crate name (e.g. `cgx --crate-version 1.0 ripgrep`); a
755    /// `--crate-version` after the crate name is passed through to the tool. The `@VERSION` suffix
756    /// (`cgx ripgrep@1.0`) is the preferred form.
757    #[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    /// The crate to run plus any trailing tool arguments, captured raw via an external subcommand.
782    ///
783    /// This is the trick we use to be able to capture anything other than a recognized argument as
784    /// the name of a crate followed by args to that crate.
785    #[command(subcommand)]
786    invocation: Option<Invocation>,
787
788    /// Toolchain extracted from a leading `+toolchain` token before clap parsing.
789    ///
790    /// Populated by [`Cli::extract_toolchain`], not parsed directly from the command line.
791    #[arg(skip)]
792    toolchain: Option<String>,
793}
794
795impl RawCli {
796    /// Split the captured external-subcommand vector into a crate spec and trailing tool arguments.
797    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            // A `--` may also separate `cargo` from its subcommand; drop it before deciding
808            // whether a subcommand follows.
809            if matches!(parts.first().and_then(|arg| arg.to_str()), Some("--")) {
810                parts.remove(0);
811            }
812            // Glue only a plausible subcommand name into the `cargo-<subcommand>` plugin crate
813            // name. A flag (or nothing) leaves the spec as bare `cargo`, which
814            // [`CrateArgs::crate_request`] rejects as unrunnable.
815            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        // A leading `--` immediately after the crate is the conventional argument separator; drop it
829        // so `cgx rg -- --flag` forwards `--flag` to the tool, matching the no-separator
830        // `cgx rg --flag`.
831        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    /// Validate the raw parse and render it into a typed [`Cli`] command.
839    ///
840    /// This is the single location where advanced CLI validation (which `clap` is not expressive
841    /// enough to represent/enforce itself) happens: the mode arg-group already guarantees at most
842    /// one mode flag, and here we enforce which other flags each mode permits, whether a crate is
843    /// required or forbidden, and whether trailing tool arguments are allowed.
844    ///
845    /// It would be nice if more of the rules about which args are allowed with which modes could
846    /// be expressed using `clap` proc macros, but we're already pushing the limits of clap as it
847    /// is.  An earlier attempt at CLI parsing was even more horrifyingly manual and
848    /// stringly-typed.
849    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            // `--list-tools` is purely local: it only reads the merged config, never resolving,
910            // downloading, or building. It therefore additionally forbids everything network- or
911            // toolchain-related (offline/refresh, --http-*, +toolchain) that `--prefetch-all`
912            // legitimately permits.
913            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        // By this point we know that the command is one that operates on a specific crate, so
929        // crate-level args are supported as well
930        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    /// Build a clap error reporting that `what` is not allowed in the `mode` command.
982    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    /// Run the flag guards shared by the config-driven modes (`--prefetch-all` and `--list-tools`):
990    /// they accept no crate or trailing arguments, no source selectors, no `--prebuilt-binary`
991    /// options, and no `--crate-version`. Each mode then layers its own additional, intentionally
992    /// differing guards on top of these.
993    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    /// Reject a crate spec or trailing arguments for the config-level commands that take neither.
1015    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    /// Reject trailing arguments for crate-level commands that prepare but never execute a crate.
1031    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// External-subcommand capture for when the user has not specified one of the subcommands and is
1044// just running a crate
1045//
1046// Modeling the crate-and-arguments case as an external subcommand makes clap split the crate spec
1047// from the trailing tool arguments natively, with no `--` separator required and no reserved
1048// words.  It's a clever hack to get around the fact that we want `cgx foo` for literally any `foo`
1049// to refer to running crate `foo` but we also have some options like `--prefetch` and the like
1050// that really work like clap subcommands.
1051//
1052// NOTE: This deliberately is a regular comment, not a doc comment, so it does not become the
1053// command's `--help` text.
1054#[derive(Clone, Debug, Subcommand)]
1055enum Invocation {
1056    #[command(external_subcommand)]
1057    Crate(Vec<OsString>),
1058}
1059
1060/// Source selectors for commands that operate on one concrete crate invocation, to specify the
1061/// source of a crate.
1062///
1063/// The mutually-exclusive selectors share clap arg-groups (`source` for the
1064/// forge/registry/path selectors, `git_ref` for the branch/tag/rev selectors) so clap rejects
1065/// invalid combinations natively, and [`Self::to_source`] collapses the flat flags into the
1066/// [`Source`] enum.
1067#[derive(Clone, Debug, Default, Args)]
1068struct SourceArgs {
1069    /// Find crate in git repository at the given URL
1070    #[arg(long, group = "source")]
1071    git: Option<String>,
1072
1073    /// Name of registry (configured in .cargo/config.toml) in which to find crate
1074    #[arg(long, group = "source")]
1075    registry: Option<String>,
1076
1077    /// Filesystem path to local crate to install from
1078    #[arg(long, group = "source")]
1079    path: Option<PathBuf>,
1080
1081    /// Find crate in GitHub repository (format: owner/repo)
1082    #[arg(long, group = "source")]
1083    github: Option<String>,
1084
1085    /// Find crate in GitLab repository (format: owner/repo)
1086    #[arg(long, group = "source")]
1087    gitlab: Option<String>,
1088
1089    /// Registry index URL to use
1090    #[arg(long, group = "source", value_name = "INDEX")]
1091    index: Option<String>,
1092
1093    /// Custom GitHub instance URL (for GitHub Enterprise)
1094    #[arg(long, requires = "github")]
1095    github_url: Option<String>,
1096
1097    /// Custom GitLab instance URL (for self-hosted GitLab)
1098    #[arg(long, requires = "gitlab")]
1099    gitlab_url: Option<String>,
1100
1101    /// Branch to use when installing from a git repo
1102    #[arg(long, group = "git_ref")]
1103    branch: Option<String>,
1104
1105    /// Tag to use when installing from a git repo
1106    #[arg(long, group = "git_ref")]
1107    tag: Option<String>,
1108
1109    /// Specific commit to use when installing from a git repo
1110    #[arg(long, group = "git_ref")]
1111    rev: Option<String>,
1112}
1113
1114impl SourceArgs {
1115    /// True when any source or git-ref selector was given on the command line.
1116    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    /// Collapse the mutually-exclusive `--branch`/`--tag`/`--rev` flags into a [`GitSelector`].
1131    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    /// Collapse the mutually-exclusive source selectors into the typed [`Source`] enum.
1142    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    /// Using `clap`'s built in afforance, assert that the `clap` definition is valid and won't
1182    /// panic at runtime when attempting to parse arguments
1183    #[test]
1184    fn verify_cli() {
1185        RawCli::command().debug_assert();
1186    }
1187
1188    /// The repeated `-v` flag maps to a [`Verbosity`] level, saturating at the most verbose.
1189    #[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            // Users can only specify the crate semver version req one way, either
1245            // `--crate-version` or as part of the crate name.  It doesn't matter if they specify
1246            // the same version in both, or different versions, it's not allowed.
1247            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        /// An explicit empty `--features` value (which cargo itself accepts) is an empty feature
1767        /// list, distinct from the flag being absent entirely.
1768        #[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            // BuildOptions reads locked/offline from Config, not CLI args.
1807            // CLI override tests (--locked, --unlocked, --frozen, --offline) belong in config.rs
1808            // since that's where CLI-to-Config override logic lives.
1809            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            // `--version` after the crate name is a tool argument, not cgx's: the crate is ripgrep
1966            // and `--version 14` is forwarded to it.
1967            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            // BuildOptions reads toolchain from Config, not from CLI args directly.
1981            // CLI toolchain override (+nightly) is applied by Config::load_from_dir().
1982            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            // Verify that system-config-dir, app-dir, and user-config-dir work together.
2010            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            // The parse test above checks the raw flags properly map to `PrefetchAll`; this checks they
2115            // actually flow through into `BuildOptions` and `Config`
2116            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            // Build knobs reach BuildOptions.
2129            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            // Network/refresh knobs reach Config, loaded from an isolated tree so host config
2135            // cannot interfere with the assertion.
2136            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        /// If `--features` isn't present on the command line and the config TOML has an entry for
2232        /// the crate in the `[tools]` section that specifies features, those features are applied
2233        /// to the build options for that crate.
2234        #[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        /// When the config TOML contains a crate in the `[tools]` section that specifies features,
2267        /// that is overridden by any features specified on the CLI.
2268        #[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        /// An explicit empty `--features` value (accepted by cargo as "no features") is still an
2301        /// explicit CLI override, so configured `[tools]` features are not applied.
2302        #[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        /// `default-features = false` in the `[tools]` config disables default features, the same
2336        /// as passing `--no-default-features`.
2337        #[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        /// The default `default-features = true` (the same as omitting the key) leaves default
2370        /// features enabled.
2371        #[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        /// Config `default-features` is independent of the feature list: overriding `--features` on
2404        /// the CLI replaces the configured features but leaves the configured `default-features =
2405        /// false` in effect.
2406        #[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            // CLI replaced the feature list, but the configured `default-features = false` still
2436            // applies.
2437            assert_eq!(options.features, vec!["gonkolator"]);
2438            assert!(options.no_default_features);
2439        }
2440
2441        /// With `--all-features`, configured `[tools]` features and `default-features` are
2442        /// overridden (cargo is invoked with `--all-features` alone), so the loaded options are
2443        /// identical to those of a tool with no config entry and the two builds can share a cache
2444        /// entry.
2445        ///
2446        /// This test simply verifies that the `--all-features` flag sufficiently overrides the
2447        /// [`tools`] config that the resulting `BuildOptions` has the same hash as it does when
2448        /// there is not `[tools]` entry for the crate.
2449        #[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        /// `--all-features` supersedes a configured `[tools]` feature list, which cannot affect a
2480        /// build that already enables every feature.
2481        #[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        /// `--all-features` supersedes configured `default-features = false`, which has no effect
2515        /// when every feature is enabled anyway.
2516        #[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        /// Parse run-path args (without the leading executable name) into a [`Cli`].
2554        fn run(args: &[&str]) -> Cli {
2555            Cli::parse_from_test_args(args)
2556        }
2557
2558        /// The forwarded tool arguments, as owned strings for convenient comparison.
2559        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            // `-F x` is cgx's features flag; `-F y` after the crate is forwarded to the tool.
2585            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            // The hard requirement: `cgx <crate> --version` runs the tool with `--version` and must
2593            // NOT print cgx's own version.
2594            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        /// A `--` separator between `cargo` and the subcommand is permitted; the subcommand is
2624        /// still normalized into the plugin crate name.
2625        #[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        /// A flag after `cargo` is not a subcommand name; it stays a tool argument for the
2633        /// (unrunnable) `cargo` crate spec rather than being glued into the crate name.
2634        #[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        /// The `--` separator between the cargo subcommand and the tool's own flags is dropped,
2642        /// matching the behavior for ordinary crate specs.
2643        #[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            // `prefetch` (no dashes) is a crate name, not the `--prefetch` mode.
2653            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}