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