Skip to main content

cargo_update/
options.rs

1//! Option parsing and management.
2//!
3//! Use the `Options::parse()` function to get the program's configuration,
4//! as parsed from the commandline.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! # use cargo_update::Options;
10//! let opts = Options::parse();
11//! println!("{:#?}", opts);
12//! ```
13
14
15use clap::builder::{ValueParser, TypedValueParser, NonEmptyStringValueParser, PossibleValue};
16use clap::error::{Error as ClapError, ErrorKind as ClapErrorKind};
17use self::super::ops::{PackageFilterElement, ConfigOperation};
18use semver::{VersionReq as SemverReq, Version as Semver};
19use chrono::{TimeDelta, DateTime, Utc};
20use clap::{Command, Arg, ArgAction};
21use std::ffi::{OsString, OsStr};
22use std::path::{PathBuf, Path};
23use std::fmt::Arguments;
24use whoami::username_os;
25use std::process::exit;
26use std::str::FromStr;
27use std::num::NonZero;
28use std::borrow::Cow;
29use std::{env, fs};
30use home;
31
32
33/// Representation of the application's all configurable values.
34#[derive(Debug, Clone, Hash, PartialEq, Eq)]
35pub struct Options {
36    /// (Additional) packages to update. Default: `[]`
37    pub to_update: Vec<(String, Option<Semver>, Cow<'static, str>)>,
38    /// Whether to update all packages. Default: `false`
39    pub all: bool,
40    /// Whether to update packages or just list them. Default: `true`
41    pub update: bool,
42    /// Whether to allow for just installing packages. Default: `false`
43    pub install: bool,
44    /// Update all packages. Default: `false`
45    pub force: bool,
46    /// Downdate packages to match newest unyanked registry version.
47    pub downdate: bool,
48    /// Update git packages too (it's expensive). Default: `false`
49    pub update_git: bool,
50    /// Don't output messages and pass --quiet to `cargo` subprocesses. Default: `false`
51    pub quiet: bool,
52    /// Enforce packages' embedded `Cargo.lock`. Exactly like `CARGO_INSTALL_OPTS=--locked` (or `--enforce-lock` per package)
53    /// except doesn't disable cargo-binstall. Default: `false`
54    pub locked: bool,
55    /// Only install versions released after this time. Default: `None`
56    pub released_after: Option<DateTime<Utc>>,
57    /// Update all packages. Default: empty
58    pub filter: Vec<PackageFilterElement>,
59    /// The `cargo` home directory; (original, canonicalised). Default: `"$CARGO_INSTALL_ROOT"`, then `"$CARGO_HOME"`,
60    /// then `"$HOME/.cargo"`
61    pub cargo_dir: (PathBuf, PathBuf),
62    /// The temporary directory to clone git repositories to. Default: `"$TEMP/cargo-update"`
63    pub temp_dir: PathBuf,
64    /// Arbitrary arguments to forward to `cargo install`, acquired from `$CARGO_INSTALL_OPTS`. Default: `[]`
65    pub cargo_install_args: Vec<OsString>,
66    /// The cargo to run for installations, and whether it was overridden. Default: `(false, "${CARGO-cargo}")`.
67    pub install_cargo: (bool, Cow<'static, OsStr>),
68    /// `cargo install -j` argument. Default: `None`
69    pub jobs: Option<NonZero<usize>>,
70    /// Start jobserver to fill this many CPUs. Default: `None`
71    pub recursive_jobs: Option<NonZero<usize>>,
72    /// Additional limit of concurrent `cargo install`s. Default: `None`
73    pub concurrent_cargos: Option<NonZero<usize>>,
74}
75
76/// Representation of the config application's all configurable values.
77#[derive(Debug, Clone, Hash, PartialEq, Eq)]
78pub struct ConfigOptions {
79    /// The `cargo` home directory. Default: `"$CARGO_INSTALL_ROOT"`, then `"$CARGO_HOME"`, then `"$HOME/.cargo"`
80    pub cargo_dir: PathBuf,
81    /// Crate to modify config for
82    pub package: String,
83    /// What to do to the config, or display with empty
84    pub ops: Vec<ConfigOperation>,
85}
86
87
88impl Options {
89    /// Parse `env`-wide command-line arguments into an `Options` instance
90    pub fn parse() -> Options {
91        let nproc = std::thread::available_parallelism().unwrap_or(NonZero::new(1).unwrap());
92        let mut matches = Command::new("cargo")
93            .bin_name("cargo")
94            .version(crate_version!())
95            .arg_required_else_help(true)
96            .subcommand_required(true)
97            .args_override_self(true)
98            .subcommand(Command::new("install-update")
99                .version(crate_version!())
100                .author("https://github.com/nabijaczleweli/cargo-update")
101                .about("A cargo subcommand for checking and applying updates to installed executables")
102                .args(&[arg!(-c --"cargo-dir" <CARGO_DIR> "The cargo home directory. Default: $CARGO_HOME or $HOME/.cargo")
103                            .required(false)
104                            .action(ArgAction::Set)
105                            .visible_alias("root")
106                            .value_parser(ExistingDirParser("Cargo")),
107                        arg!(-t --"temp-dir" <TEMP_DIR> "The temporary directory. Default: $TEMP/cargo-update")
108                            .required(false)
109                            .action(ArgAction::Set)
110                            .value_parser(ExistingDirParser("Temporary")),
111                        arg!(-a --"all" "Update all packages").action(ArgAction::SetTrue),
112                        arg!(-l --"list" "Don't update packages, only list and check if they need an update (all packages by default)")
113                            .action(ArgAction::SetTrue),
114                        arg!(-f --"force" "Update all packages regardless if they need updating").action(ArgAction::SetTrue),
115                        arg!(-d --"downdate" "Downdate packages to match latest unyanked registry version").action(ArgAction::SetTrue),
116                        arg!(-i --"allow-no-update" "Allow for fresh-installing packages").action(ArgAction::SetTrue),
117                        arg!(-g --"git" "Also update git packages").action(ArgAction::SetTrue),
118                        arg!(-q --"quiet" "No output printed to stdout").action(ArgAction::SetTrue),
119                        arg!(--"locked" "Enforce packages' embedded Cargo.lock").action(ArgAction::SetTrue),
120                        arg!(--"cooldown" <TIME> "Only consider versions released before (now - TIME). Seconds, [smhdwy] suffix.")
121                            .required(false)
122                            .action(ArgAction::Set)
123                            .num_args(1)
124                            .value_parser(duration_parse),
125                        arg!(-s --"filter" <PACKAGE_FILTER>... "Specify a filter a package must match to be considered")
126                            .required(false)
127                            .action(ArgAction::Append)
128                            .num_args(1)
129                            .value_parser(PackageFilterElement::parse),
130                        arg!(-r --"install-cargo" <EXECUTABLE> "Specify an alternative cargo to run for installations")
131                            .required(false)
132                            .action(ArgAction::Set)
133                            .num_args(1)
134                            .value_parser(ValueParser::os_string()),
135                        arg!(-j --"jobs" <JOBS>)
136                            .help(format!("Limit number of parallel jobs or \"default\" for {}", nproc))
137                            .required(false)
138                            .num_args(1)
139                            .value_parser(JobsParser("default", nproc)),
140                        arg!(-J --"recursive-jobs" [JOBS])
141                            .help(format!("Build up to JOBS crates at once on up to JOBS CPUs. {} if empty.", nproc))
142                            .required(false)
143                            .value_parser(JobsParser("", nproc)),
144                        Arg::new("cargo_install_opts")
145                            .long("__cargo_install_opts")
146                            .env("CARGO_INSTALL_OPTS")
147                            .action(ArgAction::Set)
148                            .value_parser(ValueParser::os_string())
149                            .value_delimiter(' ')
150                            .hide(true),
151                        arg!(<PACKAGE>... "Packages to update")
152                            .action(ArgAction::Append)
153                            .required(false)
154                            .value_parser(package_parse)]))
155            .get_matches_mut();
156        let (_, mut matches) = matches.remove_subcommand().unwrap();
157
158        let all = matches.remove_one("all").unwrap_or(false);
159        let update = !matches.remove_one("list").unwrap_or(false);
160        let jobs_arg = matches.remove_one("jobs");
161        let recursive_jobs = matches.remove_one("recursive-jobs");
162        Options {
163            to_update: match (all || !update, matches.remove_many::<(String, Option<Semver>, Option<String>)>("PACKAGE")) {
164                (_, Some(pkgs)) => {
165                    let mut packages: Vec<_> = pkgs.map(|(package, version, registry)| {
166                            (package, version, registry.map(Cow::from).unwrap_or("https://github.com/rust-lang/crates.io-index".into()))
167                        })
168                        .collect();
169                    packages.sort_by(|l, r| l.0.cmp(&r.0));
170                    packages.dedup_by(|l, r| l.0 == r.0);
171                    packages
172                }
173                (true, None) => vec![],
174                (false, None) => clerror(format_args!("Need at least one PACKAGE without --all")),
175            },
176            all: all,
177            update: update,
178            install: matches.remove_one("allow-no-update").unwrap_or(false),
179            force: matches.remove_one("force").unwrap_or(false),
180            downdate: matches.remove_one("downdate").unwrap_or(false),
181            update_git: matches.remove_one("git").unwrap_or(false),
182            quiet: matches.remove_one("quiet").unwrap_or(false),
183            released_after: matches.get_one::<TimeDelta>("cooldown")
184                .map(|&td| {
185                    Utc::now().checked_sub_signed(td).unwrap_or_else(|| {
186                        let raw = matches.get_raw("cooldown").unwrap_or_default().last().unwrap();
187                        clerror(format_args!("--cooldown {}: (now - {}) out of range", Path::new(raw).display(), td)) // TODO: MSRV 1.87 OsStr::display()
188                    })
189                }),
190            locked: matches.remove_one("locked").unwrap_or(false),
191            filter: matches.remove_many("filter").into_iter().flatten().collect(),
192            cargo_dir: cargo_dir(matches.remove_one("cargo-dir")),
193            temp_dir: matches.remove_one("temp-dir").unwrap_or_else(env::temp_dir).join(Path::new("cargo-update").with_extension(username_os())),
194            cargo_install_args: matches.remove_many("cargo_install_opts").into_iter().flatten().filter(|a: &OsString| !a.is_empty()).collect(),
195            install_cargo: match matches.remove_one("install-cargo") {
196                Some(ic) => (true, Cow::Owned(ic)),
197                None => (false, env::var_os("CARGO").map(Cow::Owned).unwrap_or(OsStr::new("cargo").into())),
198            },
199            jobs: if recursive_jobs.is_some() {
200                None
201            } else {
202                jobs_arg
203            },
204            recursive_jobs: recursive_jobs,
205            concurrent_cargos: match (jobs_arg, recursive_jobs) {
206                (Some(j), Some(rj)) => Some(NonZero::new((rj.get() + (j.get() - 1)) / j).unwrap_or(NonZero::new(1).unwrap())),
207                _ => None,
208            },
209        }
210    }
211}
212
213impl ConfigOptions {
214    /// Parse `env`-wide command-line arguments into a `ConfigOptions` instance
215    pub fn parse() -> ConfigOptions {
216        let mut matches = Command::new("cargo")
217            .bin_name("cargo")
218            .version(crate_version!())
219            // .settings(&[ /* AppSettings::GlobalVersion*/ ])
220            .arg_required_else_help(true)
221            .subcommand_required(true)
222            .subcommand(Command::new("install-update-config")
223                .disable_version_flag(true)
224                .version(crate_version!())
225                .author("https://github.com/nabijaczleweli/cargo-update")
226                .about("A cargo subcommand for checking and applying updates to installed executables -- configuration")
227                .args(&[arg!(-c --"cargo-dir" <CARGO_DIR> "The cargo home directory. Default: $CARGO_HOME or $HOME/.cargo").required(false)
228                            .value_parser(ExistingDirParser("Cargo")),
229                        arg!(-t --"toolchain" <TOOLCHAIN> "Toolchain to use or empty for default")
230                        .num_args(1).required(false).value_parser(ValueParser::string()),
231                        arg!(-f --"feature" <FEATURE>... "Feature to enable").num_args(1).required(false).value_parser(ValueParser::string()),
232                        arg!(-n --"no-feature" <DISABLED_FEATURE>... "Feature to disable").num_args(1).required(false).value_parser(ValueParser::string()),
233                        arg!(-d --"default-features" <DEFAULT_FEATURES> "Whether to allow default features").num_args(1).required(false)
234                            .value_parser(DefaultFeaturesBoolParser)
235                            .hide_possible_values(true),
236                        arg!(--"debug" "Compile the package in debug (\"dev\") mode")
237                        .action(ArgAction::SetTrue).conflicts_with("release").conflicts_with("build-profile"),
238                        arg!(--"release" "Compile the package in release mode")
239                        .action(ArgAction::SetTrue).conflicts_with("debug").conflicts_with("build-profile"),
240                        arg!(--"build-profile" <PROFILE> "Compile the package in the given profile").num_args(1).required(false)
241                            .conflicts_with("debug")
242                            .conflicts_with("release").value_parser(ValueParser::string()),
243                        arg!(--"install-prereleases" "Install prerelease versions").action(ArgAction::SetTrue).conflicts_with("no-install-prereleases"),
244                        arg!(--"no-install-prereleases" "Filter out prerelease versions").action(ArgAction::SetTrue).conflicts_with("install-prereleases"),
245                        arg!(--"enforce-lock" "Require Cargo.lock to be up to date").action(ArgAction::SetTrue).conflicts_with("no-enforce-lock"),
246                        arg!(--"no-enforce-lock" "Don't enforce Cargo.lock").action(ArgAction::SetTrue).conflicts_with("enforce-lock"),
247                        arg!(--"respect-binaries" "Only install already installed binaries").action(ArgAction::SetTrue).conflicts_with("no-respect-binaries"),
248                        arg!(--"no-respect-binaries" "Install all binaries").action(ArgAction::SetTrue).conflicts_with("respect-binaries"),
249                        arg!(-v --"version" <VERSION_REQ> "Require a cargo-compatible version range").num_args(1).required(false)
250                            .value_parser(SemverReq::from_str)
251                            .conflicts_with("any-version"),
252                        arg!(-a --"any-version" "Allow any version").action(ArgAction::SetTrue).conflicts_with("version"),
253                        arg!(-e --"environment" <VARIABLE_EQ_TODO_VALUE>... "Environment variable to set").required(false)
254                            .num_args(1)
255                            .value_parser(|s: &str| if let Some((k,v)) = s.split_once('=') {
256                                Ok((k.to_string(), v.to_string()))
257                            } else {
258                                Err("Missing VALUE")
259                            }),
260                        arg!(-E --"clear-environment" <VARIABLE>... "Environment variable to clear").required(false)
261                            .num_args(1)
262                            .value_parser(|s: &str| if s.contains('=') {
263                                Err("VARIABLE can't contain a =")
264                            } else {
265                                Ok(s.to_string())
266                            }),
267                        arg!(--"inherit-environment" <VARIABLE>... "Environment variable to use from the environment").required(false)
268                            .num_args(1)
269                            .value_parser(|s: &str| if s.contains('=') {
270                                Err("VARIABLE can't contain a =")
271                            } else {
272                                Ok(s.to_string())
273                            }),
274                        arg!(-r --"reset" "Roll back the configuration to the defaults.").action(ArgAction::SetTrue),
275                        arg!(<PACKAGE> "Package to configure").value_parser(NonEmptyStringValueParser ::new())
276                        ]))
277            .get_matches_mut();
278        let (_, mut matches) = matches.remove_subcommand().unwrap();
279
280        ConfigOptions {
281            cargo_dir: cargo_dir(matches.remove_one("cargo-dir")).1,
282            package: matches.remove_one("PACKAGE").unwrap(),
283            ops: matches.remove_one("toolchain")
284                .map(|t: String| if t.is_empty() {
285                    ConfigOperation::RemoveToolchain
286                } else {
287                    ConfigOperation::SetToolchain(t)
288                })
289                .into_iter()
290                .chain(matches.remove_many("feature").into_iter().flatten().map(ConfigOperation::AddFeature))
291                .chain(matches.remove_many("no-feature").into_iter().flatten().map(ConfigOperation::RemoveFeature))
292                .chain(matches.remove_one("default-features").map(ConfigOperation::DefaultFeatures))
293                .chain(match (matches.remove_one("debug").unwrap_or(false),
294                              matches.remove_one("release").unwrap_or(false),
295                              matches.remove_one::<String>("build-profile")) {
296                    (true, _, _) => Some(ConfigOperation::SetBuildProfile("dev".into())),
297                    (_, true, _) => Some(ConfigOperation::SetBuildProfile("release".into())),
298                    (_, _, Some(prof)) => Some(ConfigOperation::SetBuildProfile(prof.into())),
299                    _ => None,
300                })
301                .chain(match (matches.remove_one("install-prereleases").unwrap_or(false), matches.remove_one("no-install-prereleases").unwrap_or(false)) {
302                    (true, _) => Some(ConfigOperation::SetInstallPrereleases(true)),
303                    (_, true) => Some(ConfigOperation::SetInstallPrereleases(false)),
304                    _ => None,
305                })
306                .chain(match (matches.remove_one("enforce-lock").unwrap_or(false), matches.remove_one("no-enforce-lock").unwrap_or(false)) {
307                    (true, _) => Some(ConfigOperation::SetEnforceLock(true)),
308                    (_, true) => Some(ConfigOperation::SetEnforceLock(false)),
309                    _ => None,
310                })
311                .chain(match (matches.remove_one("respect-binaries").unwrap_or(false), matches.remove_one("no-respect-binaries").unwrap_or(false)) {
312                    (true, _) => Some(ConfigOperation::SetRespectBinaries(true)),
313                    (_, true) => Some(ConfigOperation::SetRespectBinaries(false)),
314                    _ => None,
315                })
316                .chain(match (matches.remove_one("any-version").unwrap_or(false), matches.remove_one("version")) {
317                    (true, _) => Some(ConfigOperation::RemoveTargetVersion),
318                    (false, Some(vr)) => Some(ConfigOperation::SetTargetVersion(vr)),
319                    _ => None,
320                })
321                .chain(matches.remove_many("environment")
322                    .into_iter()
323                    .flatten()
324                    .map(|(k, v)| ConfigOperation::SetEnvironment(k, v)))
325                .chain(matches.remove_many("clear-environment").into_iter().flatten().map(ConfigOperation::ClearEnvironment))
326                .chain(matches.remove_many("inherit-environment").into_iter().flatten().map(ConfigOperation::InheritEnvironment))
327                .chain(if matches.remove_one("reset").unwrap_or(false) {
328                    Some(ConfigOperation::ResetConfig)
329                } else {
330                    None
331                })
332                .collect(),
333        }
334    }
335}
336
337fn cargo_dir(opt_cargo_dir: Option<PathBuf>) -> (PathBuf, PathBuf) {
338    if let Some(dir) = opt_cargo_dir {
339        match fs::canonicalize(&dir) {
340            Ok(cdir) => (dir.into(), cdir),
341            Err(_) => clerror(format_args!("--cargo-dir={:?} doesn't exist", dir)),
342        }
343    } else {
344        match env::var_os("CARGO_INSTALL_ROOT")
345            .map(PathBuf::from)
346            .or_else(|| home::cargo_home().ok())
347            .and_then(|ch| fs::canonicalize(&ch).map(|can| (ch, can)).ok()) {
348            Some(cd) => cd,
349            None => {
350                clerror(format_args!("$CARGO_INSTALL_ROOT, $CARGO_HOME, and home directory invalid, please specify the cargo home directory with the -c \
351                                      option"))
352            }
353        }
354    }
355}
356
357#[derive(Copy, Clone)]
358struct ExistingDirParser(&'static str);
359impl TypedValueParser for ExistingDirParser {
360    type Value = PathBuf;
361
362    fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, ClapError> {
363        fs::canonicalize(value).map_err(|_| {
364            ClapError::raw(ClapErrorKind::InvalidValue,
365                           format_args!("{}: {} directory \"{}\" not found", arg.unwrap(), self.0, Path::new(value).display())) // TODO: MSRV 1.87 OsStr::display()
366                .with_cmd(cmd)
367        })
368    }
369}
370
371fn package_parse(mut s: &str) -> Result<(String, Option<Semver>, Option<String>), String> {
372    let mut registry_url = None;
373    if s.starts_with('(') {
374        if let Some(idx) = s.find("):") {
375            registry_url = Some(&s[1..idx]);
376            s = &s[idx + 2..];
377        }
378    }
379
380    if let Some(idx) = s.find(':') {
381        Ok((s[0..idx].to_string(),
382            Some(Semver::parse(&s[idx + 1..]).map_err(|e| format!("Version {} provided for package {} invalid: {}", &s[idx + 1..], &s[0..idx], e))?),
383            registry_url.map(str::to_string)))
384    } else {
385        Ok((s.to_string(), None, registry_url.map(str::to_string)))
386    }
387}
388
389fn duration_parse(s: &str) -> Result<TimeDelta, String> {
390    const MULS_S: [char; 6] = ['y', 'w', 'd', 'h', 'm', 's'];
391    const MULS_V: [f64; 6] = [365.25 / 7., 7., 24., 60., 60., 1.];
392    let (base, mul) = s.strip_suffix(MULS_S).map(|stripped| (stripped, *s.as_bytes().last().unwrap() as _)).unwrap_or((s, 's'));
393    let base = f64::from_str(base).map_err(|e| e.to_string())?;
394    let val = MULS_V[MULS_S.iter().position(|&c| c == mul).unwrap()..].iter().fold(base, |a, e| a * e);
395    let (s, ns) = (val.trunc() as i64, (val.fract() * 1_000_000_000.0) as u32);
396    TimeDelta::new(s, ns).ok_or_else(|| format!("{}.{:09} too big", s, ns))
397}
398
399#[derive(Copy, Clone)]
400struct JobsParser(&'static str, NonZero<usize>);
401impl TypedValueParser for JobsParser {
402    type Value = NonZero<usize>;
403    fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, ClapError> {
404        let value = value.to_str().ok_or(ClapError::new(ClapErrorKind::InvalidValue).with_cmd(cmd))?;
405        let Self(special, default) = *self;
406
407        if value != special {
408            if value.starts_with("-") {
409                    NonZero::from_str(&value[1..]).map(|sub| if sub >= default {
410                        NonZero::new(1).unwrap()
411                    } else {
412                        NonZero::new(default.get() - sub.get()).unwrap()
413                    })
414                } else {
415                    NonZero::from_str(value)
416                }
417                .map_err(|e| ClapError::raw(ClapErrorKind::InvalidValue, format_args!("{}: {}", arg.unwrap(), e)).with_cmd(cmd))
418        } else {
419            Ok(default)
420        }
421    }
422}
423
424#[derive(Copy, Clone)]
425struct DefaultFeaturesBoolParser;
426impl TypedValueParser for DefaultFeaturesBoolParser {
427    type Value = bool;
428
429    fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, ClapError> {
430        match value.to_str().ok_or(ClapError::new(ClapErrorKind::InvalidValue).with_cmd(cmd))? {
431            "1" | "yes" | "true" => Ok(true),
432            "0" | "no" | "false" => Ok(false),
433            value => {
434                Err(ClapError::raw(ClapErrorKind::InvalidValue,
435                                   format_args!("{}: {} not 1|yes|true or 0|no|false", arg.unwrap(), value))
436                    .with_cmd(cmd))
437            }
438        }
439    }
440
441    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue> + '_>> {
442        Some(Box::new(["1", "yes", "true", "0", "no", "false"].iter().map(PossibleValue::new)))
443    }
444}
445
446
447fn clerror(f: Arguments) -> ! {
448    eprintln!("{}", f);
449    exit(1)
450}